diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index e29288d..5021eac 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -226,56 +226,39 @@ jobs: echo "SERVER_PID=$!" >> $GITHUB_ENV sleep 5 - - name: Create Axe accessibility test - working-directory: ./frontend - run: | - mkdir -p tests/accessibility - cat > tests/accessibility/axe.spec.ts << 'EOF' - import { test, expect } from '@playwright/test'; - import AxeBuilder from '@axe-core/playwright'; - - test('should not have accessibility violations', async ({ page }) => { - await page.goto('http://localhost:3001'); - - const accessibilityScanResults = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa']) - .analyze(); - - // Log violations for debugging but don't fail the test - if (accessibilityScanResults.violations.length > 0) { - console.log('Accessibility violations found:', accessibilityScanResults.violations.length); - accessibilityScanResults.violations.forEach((violation, index) => { - console.log(`Violation ${index + 1}: ${violation.id} - ${violation.description}`); - }); - } - - // We expect no violations, but test continues even if there are some - expect(accessibilityScanResults.violations.length).toBeLessThanOrEqual(10); - }); - EOF - - name: Run Axe accessibility tests working-directory: ./frontend run: | - cat > playwright.config.ts << 'EOF' + # Create a specific config for axe tests + cat > playwright.axe.config.ts << 'EOF' import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests/accessibility', + testMatch: '**/axe.spec.ts', fullyParallel: false, forbidOnly: !!process.env.CI, - retries: 1, + retries: 2, workers: 1, - reporter: [['html'], ['json', { outputFile: 'test-results.json' }]], + reporter: [ + ['html', { outputFolder: 'playwright-report-axe' }], + ['json', { outputFile: 'axe-results.json' }], + ['list'] + ], use: { baseURL: 'http://localhost:3001', trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + timeout: 60000, + expect: { + timeout: 10000, }, - timeout: 30000, }); EOF - npx playwright test || echo "Axe tests completed with violations" + echo "Running Axe accessibility tests..." + npx playwright test --config=playwright.axe.config.ts || echo "Axe tests completed with violations" continue-on-error: true - name: Upload Axe results @@ -284,8 +267,10 @@ jobs: with: name: axe-results-${{ github.run_number }} path: | - frontend/test-results.json - frontend/playwright-report/ + frontend/axe-results.json + frontend/playwright-report-axe/ + frontend/playwright.axe.config.ts + frontend/test-results/ retention-days: 7 - name: Stop frontend server @@ -323,7 +308,7 @@ jobs: run: | echo "Installing workspace dependencies..." npm install - npm install puppeteer + npm install puppeteer serve - name: Build frontend working-directory: ./frontend @@ -369,7 +354,18 @@ jobs: run: | npx serve -s dist -l 3002 & echo "SERVER_PID=$!" >> $GITHUB_ENV - sleep 5 + echo "Waiting for server to start..." + sleep 10 + + # Check if server is running + for i in {1..5}; do + if curl -f http://localhost:3002 > /dev/null 2>&1; then + echo "Server is ready!" + break + fi + echo "Waiting for server... attempt $i" + sleep 2 + done - name: Run WAVE-style tests run: | @@ -391,7 +387,24 @@ jobs: try { const page = await browser.newPage(); - await page.goto('http://localhost:3002', { waitUntil: 'networkidle2', timeout: 30000 }); + + // Retry logic for connection + let connected = false; + let retries = 3; + while (retries > 0 && !connected) { + try { + await page.goto('http://localhost:3002', { waitUntil: 'networkidle2', timeout: 30000 }); + connected = true; + } catch (error) { + console.log(`Connection attempt failed. Retries left: ${retries - 1}`); + retries--; + if (retries > 0) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } else { + throw error; + } + } + } const pageResults = await page.evaluate(() => { const errors = []; @@ -519,7 +532,7 @@ jobs: run: | echo "Installing workspace dependencies..." npm install - npm install puppeteer + npm install puppeteer serve - name: Build frontend working-directory: ./frontend @@ -565,7 +578,18 @@ jobs: run: | npx serve -s dist -l 3003 & echo "SERVER_PID=$!" >> $GITHUB_ENV - sleep 5 + echo "Waiting for server to start..." + sleep 10 + + # Check if server is running + for i in {1..5}; do + if curl -f http://localhost:3003 > /dev/null 2>&1; then + echo "Server is ready!" + break + fi + echo "Waiting for server... attempt $i" + sleep 2 + done - name: Run color contrast tests run: | @@ -587,7 +611,24 @@ jobs: try { const page = await browser.newPage(); - await page.goto('http://localhost:3003', { waitUntil: 'networkidle2', timeout: 30000 }); + + // Retry logic for connection + let connected = false; + let retries = 3; + while (retries > 0 && !connected) { + try { + await page.goto('http://localhost:3003', { waitUntil: 'networkidle2', timeout: 30000 }); + connected = true; + } catch (error) { + console.log(`Connection attempt failed. Retries left: ${retries - 1}`); + retries--; + if (retries > 0) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } else { + throw error; + } + } + } const contrastResults = await page.evaluate(() => { const elements = document.querySelectorAll('*'); @@ -751,64 +792,14 @@ jobs: echo "SERVER_PID=$!" >> $GITHUB_ENV sleep 5 - - name: Create keyboard navigation test - working-directory: ./frontend - run: | - mkdir -p tests/accessibility - cat > tests/accessibility/keyboard.spec.ts << 'EOF' - import { test, expect } from '@playwright/test'; - - test('keyboard navigation should work', async ({ page }) => { - await page.goto('http://localhost:3004'); - - // Find all interactive elements - const interactiveElements = await page.locator('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])').all(); - - console.log(`Found ${interactiveElements.length} interactive elements`); - - // Test Tab navigation through first few elements - const elementsToTest = Math.min(interactiveElements.length, 5); - - for (let i = 0; i < elementsToTest; i++) { - await page.keyboard.press('Tab'); - await page.waitForTimeout(100); - - // Verify an element is focused - const focusedElement = await page.locator(':focus').first(); - const isVisible = await focusedElement.isVisible().catch(() => false); - - if (isVisible) { - console.log(`Element ${i + 1} is focusable`); - } - } - - // Test should pass even if some elements aren't perfectly focusable - expect(elementsToTest).toBeGreaterThan(0); - }); - EOF - - name: Run keyboard navigation tests working-directory: ./frontend run: | - cat > playwright.config.ts << 'EOF' - import { defineConfig } from '@playwright/test'; - - export default defineConfig({ - testDir: './tests/accessibility', - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: 1, - workers: 1, - reporter: [['html'], ['json', { outputFile: 'keyboard-results.json' }]], - use: { - baseURL: 'http://localhost:3004', - trace: 'on-first-retry', - }, - timeout: 30000, - }); - EOF - - npx playwright test keyboard.spec.ts || echo "Keyboard tests completed" + echo "Running keyboard navigation tests using existing CI config..." + npx playwright test --config=playwright.keyboard.ci.config.ts || { + echo "Keyboard navigation tests completed with issues" + echo "Check the report for details" + } continue-on-error: true - name: Upload keyboard navigation results @@ -818,7 +809,10 @@ jobs: name: keyboard-results-${{ github.run_number }} path: | frontend/keyboard-results.json - frontend/playwright-report/ + frontend/playwright-report-keyboard/ + frontend/playwright.keyboard.ci.config.ts + frontend/test-results/ + frontend/keyboard-ci-results.json retention-days: 7 - name: Stop frontend server diff --git a/.github/workflows/api-testing.yml b/.github/workflows/api-testing.yml new file mode 100644 index 0000000..d602e79 --- /dev/null +++ b/.github/workflows/api-testing.yml @@ -0,0 +1,698 @@ +name: API Testing + +on: + pull_request: + branches: [main, develop] + paths: + - "backend/**" + - ".github/workflows/api-testing.yml" + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + actions: read + checks: write + +jobs: + postman-newman-tests: + name: Postman/Newman API Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin123 + POSTGRES_DB: connectkit + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install backend dependencies + run: | + cd backend + npm ci --include=dev + + - name: Start backend + run: | + cd backend + npm run dev & + sleep 5 + timeout 60 bash -c 'until curl -f http://localhost:3001/health; do sleep 2; done' + echo "βœ… Backend is ready" + env: + NODE_ENV: test + PORT: 3001 + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: admin + DB_PASSWORD: admin123 + DB_NAME: connectkit + REDIS_HOST: localhost + REDIS_PORT: 6379 + JWT_SECRET: test-jwt-secret-that-is-long-enough-for-validation + JWT_REFRESH_SECRET: test-refresh-secret-that-is-long-enough-for-validation + ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + + - name: Install Newman + run: npm install -g newman newman-reporter-htmlextra newman-reporter-json-summary + + - name: Create Postman collection + run: | + mkdir -p tests/api + cat > tests/api/connectkit-api.postman_collection.json << 'EOF' + { + "info": { + "name": "ConnectKit API Tests", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Health Check", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response time is less than 500ms', () => {", + " pm.expect(pm.response.responseTime).to.be.below(500);", + "});", + "", + "pm.test('Health check returns status', () => {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('status');", + " pm.expect(response.status).to.equal('healthy');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{API_URL}}/health" + } + }, + { + "name": "Authentication", + "item": [ + { + "name": "Register User", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const randomEmail = `test${Date.now()}@example.com`;", + "pm.collectionVariables.set('testEmail', randomEmail);" + ] + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('User registration successful', () => {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test('Response contains user data', () => {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('user');", + " pm.expect(response.user).to.have.property('email');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "url": "{{API_URL}}/auth/register", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \\"email\\": \\"{{testEmail}}\\",\\n \\"password\\": \\"TestPassword123!\\",\\n \\"name\\": \\"Test User\\"\\n}" + } + } + }, + { + "name": "Login User", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Login successful', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response contains token', () => {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('token');", + " pm.collectionVariables.set('authToken', response.token);", + "});", + "", + "pm.test('Token is valid JWT', () => {", + " const response = pm.response.json();", + " const tokenParts = response.token.split('.');", + " pm.expect(tokenParts).to.have.lengthOf(3);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "url": "{{API_URL}}/auth/login", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \\"email\\": \\"{{testEmail}}\\",\\n \\"password\\": \\"TestPassword123!\\"\\n}" + } + } + } + ] + }, + { + "name": "Contacts", + "item": [ + { + "name": "Create Contact", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Contact created successfully', () => {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test('Response contains contact ID', () => {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('id');", + " pm.collectionVariables.set('contactId', response.id);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "url": "{{API_URL}}/contacts", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \\"firstName\\": \\"John\\",\\n \\"lastName\\": \\"Doe\\",\\n \\"email\\": \\"john.doe@example.com\\",\\n \\"phone\\": \\"+1234567890\\"\\n}" + } + } + }, + { + "name": "Get Contacts List", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Contacts retrieved successfully', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response is paginated', () => {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('data');", + " pm.expect(response).to.have.property('pagination');", + " pm.expect(response.data).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{API_URL}}/contacts", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ] + } + }, + { + "name": "Update Contact", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Contact updated successfully', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Updated fields are correct', () => {", + " const response = pm.response.json();", + " pm.expect(response.firstName).to.equal('Jane');", + "});" + ] + } + } + ], + "request": { + "method": "PUT", + "url": "{{API_URL}}/contacts/{{contactId}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \\"firstName\\": \\"Jane\\"\\n}" + } + } + }, + { + "name": "Delete Contact", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Contact deleted successfully', () => {", + " pm.response.to.have.status(204);", + "});" + ] + } + } + ], + "request": { + "method": "DELETE", + "url": "{{API_URL}}/contacts/{{contactId}}", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}" + } + ] + } + } + ] + } + ], + "variable": [ + { + "key": "API_URL", + "value": "http://localhost:3001/api", + "type": "string" + } + ] + } + EOF + + - name: Run Newman tests + run: | + newman run tests/api/connectkit-api.postman_collection.json \ + --environment-var API_URL=http://localhost:3001/api \ + --reporters cli,json,htmlextra \ + --reporter-json-export results/newman-results.json \ + --reporter-htmlextra-export results/newman-report.html + continue-on-error: true + + - name: Upload Newman results + if: always() + uses: actions/upload-artifact@v4 + with: + name: newman-results-${{ github.run_number }} + path: results/ + retention-days: 30 + + rest-assured-tests: + name: REST Assured API Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin123 + POSTGRES_DB: connectkit + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install backend dependencies + run: | + cd backend + npm ci --include=dev + + - name: Start backend + run: | + cd backend + npm run dev & + sleep 5 + timeout 60 bash -c 'until curl -f http://localhost:3001/health; do sleep 2; done' + echo "βœ… Backend is ready" + env: + NODE_ENV: test + PORT: 3001 + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: admin + DB_PASSWORD: admin123 + DB_NAME: connectkit + REDIS_HOST: localhost + REDIS_PORT: 6379 + JWT_SECRET: test-jwt-secret-that-is-long-enough-for-validation + JWT_REFRESH_SECRET: test-refresh-secret-that-is-long-enough-for-validation + ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + + - name: Install test dependencies + run: npm install axios + + - name: Create REST Assured test script + run: | + mkdir -p tests/api + cat > tests/api/rest-assured-test.js << 'EOF' + const axios = require('axios'); + const assert = require('assert'); + + const API_URL = process.env.API_URL || 'http://localhost:3001/api'; + + async function runTests() { + console.log('πŸ§ͺ Running REST Assured style API tests...\n'); + + try { + // Test 1: Health Check + console.log('Test 1: Health Check'); + const healthRes = await axios.get(`${API_URL}/health`); + assert.strictEqual(healthRes.status, 200, 'Health check should return 200'); + assert.strictEqual(healthRes.data.status, 'healthy', 'Status should be healthy'); + console.log('βœ… Health check passed\n'); + + // Test 2: Authentication Flow + console.log('Test 2: Authentication Flow'); + const email = `test${Date.now()}@example.com`; + + // Register + const registerRes = await axios.post(`${API_URL}/auth/register`, { + email, + password: 'TestPassword123!', + name: 'Test User' + }); + assert.strictEqual(registerRes.status, 201, 'Registration should return 201'); + console.log('βœ… Registration passed'); + + // Login + const loginRes = await axios.post(`${API_URL}/auth/login`, { + email, + password: 'TestPassword123!' + }); + assert.strictEqual(loginRes.status, 200, 'Login should return 200'); + assert(loginRes.data.token, 'Login should return token'); + const token = loginRes.data.token; + console.log('βœ… Login passed\n'); + + // Test 3: Contact CRUD + console.log('Test 3: Contact CRUD Operations'); + + // Create + const createRes = await axios.post(`${API_URL}/contacts`, { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890' + }, { + headers: { 'Authorization': `Bearer ${token}` } + }); + assert.strictEqual(createRes.status, 201, 'Create contact should return 201'); + const contactId = createRes.data.id; + console.log('βœ… Create contact passed'); + + // Read + const getRes = await axios.get(`${API_URL}/contacts/${contactId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + assert.strictEqual(getRes.status, 200, 'Get contact should return 200'); + console.log('βœ… Get contact passed'); + + // Update + const updateRes = await axios.put(`${API_URL}/contacts/${contactId}`, { + firstName: 'Jane' + }, { + headers: { 'Authorization': `Bearer ${token}` } + }); + assert.strictEqual(updateRes.status, 200, 'Update contact should return 200'); + console.log('βœ… Update contact passed'); + + // Delete + const deleteRes = await axios.delete(`${API_URL}/contacts/${contactId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + assert.strictEqual(deleteRes.status, 204, 'Delete contact should return 204'); + console.log('βœ… Delete contact passed\n'); + + console.log('πŸŽ‰ All REST Assured tests passed!'); + } catch (error) { + console.error('❌ Test failed:', error.message); + if (error.response) { + console.error('Response status:', error.response.status); + console.error('Response data:', error.response.data); + } + process.exit(1); + } + } + + runTests(); + EOF + + - name: Run REST Assured style tests + run: node tests/api/rest-assured-test.js + env: + API_URL: http://localhost:3001/api + continue-on-error: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: rest-assured-results-${{ github.run_number }} + path: tests/api/ + retention-days: 30 + + api-security-tests: + name: API Security Testing + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin123 + POSTGRES_DB: connectkit + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install backend dependencies + run: | + cd backend + npm ci --include=dev + + - name: Start backend + run: | + cd backend + npm run dev & + sleep 5 + timeout 60 bash -c 'until curl -f http://localhost:3001/health; do sleep 2; done' + echo "βœ… Backend is ready" + env: + NODE_ENV: test + PORT: 3001 + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: admin + DB_PASSWORD: admin123 + DB_NAME: connectkit + REDIS_HOST: localhost + REDIS_PORT: 6379 + JWT_SECRET: test-jwt-secret-that-is-long-enough-for-validation + JWT_REFRESH_SECRET: test-refresh-secret-that-is-long-enough-for-validation + ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + + - name: Run API security tests + run: | + echo "## πŸ”’ API Security Tests" >> $GITHUB_STEP_SUMMARY + API_URL="http://localhost:3001/api" + + # Test 1: SQL Injection + echo "### SQL Injection Test" >> $GITHUB_STEP_SUMMARY + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/contacts?search=' OR '1'='1") + if [ "$RESPONSE" = "400" ] || [ "$RESPONSE" = "401" ]; then + echo "βœ… Protected against SQL injection" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ Potential SQL injection vulnerability (status: $RESPONSE)" >> $GITHUB_STEP_SUMMARY + fi + + # Test 2: XSS Prevention + echo "### XSS Prevention Test" >> $GITHUB_STEP_SUMMARY + XSS_PAYLOAD='' + RESPONSE=$(curl -s -X POST "$API_URL/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$XSS_PAYLOAD\",\"password\":\"test\"}") + if echo "$RESPONSE" | grep -q "