import { test, expect, request as playwrightRequest } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080'; // Helper: Create logs directory function ensureLogDir() { const logDir = path.join(process.cwd(), 'logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } return logDir; } // Helper: Extract token expiry from JWT function getTokenExpiry(token: string): string { try { const parts = token.split('.'); if (parts.length !== 3) return 'invalid'; const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); if (payload.exp) { return new Date(payload.exp * 1000).toISOString(); } } catch (e) { return 'parse_error'; } return 'unknown'; } // Helper: Log heartbeat for 60-minute session test function logHeartbeat(heartbeatNum: number, minuteElapsed: number, context: string, tokenExpiry: string) { const logDir = ensureLogDir(); const heartbeatMsg = `✓ [Heartbeat ${heartbeatNum}] Min ${minuteElapsed}: ${context}. Token expires: ${tokenExpiry}`; fs.appendFileSync(path.join(logDir, 'session-heartbeat.log'), heartbeatMsg + '\n'); console.log(heartbeatMsg); } test.describe('Security Enforcement', () => { let baseContext: any; test.beforeAll(async () => { baseContext = await playwrightRequest.newContext(); }); test.afterAll(async () => { await baseContext?.close(); }); // ========================================================================= // Test Suite: Bearer Token Validation // ========================================================================= test.describe('Bearer Token Validation', () => { test('should reject request with missing bearer token (401)', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`); expect(response.status()).toBe(401); const data = await response.json(); expect(data).toHaveProperty('error'); }); test('should reject request with invalid bearer token (401)', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'Bearer invalid.token.here', }, }); expect(response.status()).toBe(401); }); test('should reject request with malformed authorization header (401)', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'InvalidFormat token_without_bearer', }, }); expect(response.status()).toBe(401); }); test('should reject request with empty bearer token (401)', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'Bearer ', }, }); expect(response.status()).toBe(401); }); test('should reject request with NULL bearer token (401)', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'Bearer null', }, }); expect(response.status()).toBe(401); }); test('should reject request with uppercase "bearer" keyword (case-sensitive)', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'BEARER validtoken123', }, }); // Should be 401 (strict case sensitivity) expect(response.status()).toBe(401); }); }); // ========================================================================= // Test Suite: JWT Expiration & Refresh // ========================================================================= test.describe('JWT Expiration & Auto-Refresh', () => { test('should handle expired JWT gracefully', async () => { // Simulate an expired token (issued in the past with short TTL) // The app should return 401, prompting client to refresh const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDAwMDAwMDB9.invalidSignature'; const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: `Bearer ${expiredToken}`, }, }); expect(response.status()).toBe(401); }); test('should return 401 for JWT with invalid signature', async () => { const invalidJWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.wrongSignature'; const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: `Bearer ${invalidJWT}`, }, }); expect(response.status()).toBe(401); }); test('should return 401 for token missing required claims (sub, exp)', async () => { // Token with missing required claims const incompletJWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoibm9jbGFpbXMifQ.wrong'; const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: `Bearer ${incompletJWT}`, }, }); expect(response.status()).toBe(401); }); }); // ========================================================================= // Test Suite: CSRF Token Validation // ========================================================================= test.describe('CSRF Token Validation', () => { test('POST request should include CSRF protection headers', async () => { // This tests that the API enforces CSRF on mutating operations // A POST without CSRF token should be rejected or require X-CSRF-Token header const response = await baseContext.post(`${BASE_URL}/api/v1/proxy-hosts`, { data: { domain: 'test.example.com', forward_host: '127.0.0.1', forward_port: 8000, }, headers: { Authorization: 'Bearer invalid_token', 'Content-Type': 'application/json', }, }); // Should fail at auth layer before CSRF check (401) expect(response.status()).toBe(401); }); test('PUT request should validate CSRF token', async () => { const response = await baseContext.put(`${BASE_URL}/api/v1/proxy-hosts/test-id`, { data: { domain: 'updated.example.com', }, headers: { Authorization: 'Bearer invalid_token', 'Content-Type': 'application/json', }, }); expect(response.status()).toBe(401); }); test('DELETE request without auth should return 401', async () => { const response = await baseContext.delete(`${BASE_URL}/api/v1/proxy-hosts/test-id`); expect(response.status()).toBe(401); }); }); // ========================================================================= // Test Suite: Request Timeout & Handling // ========================================================================= test.describe('Request Timeout Handling', () => { test('should handle slow endpoint with reasonable timeout', async ({}, testInfo) => { // Create new context with timeout const timeoutContext = await playwrightRequest.newContext({ baseURL: BASE_URL, httpClient: true, }); try { // Request to health endpoint (should be fast) const response = await timeoutContext.get('/api/v1/health', { timeout: 5000, // 5 second timeout }); expect(response.status()).toBe(200); } finally { await timeoutContext.close(); } }); test('should return proper error for unreachable endpoint', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/nonexistent-endpoint`); expect(response.status()).toBe(404); }); }); // ========================================================================= // Test Suite: Middleware Load Order & Precedence // ========================================================================= test.describe('Middleware Execution Order', () => { test('authentication should be checked before authorization', async () => { // Request without token should fail at auth (401) before authz check const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`); expect(response.status()).toBe(401); // If it was 403, authz was checked, indicating middleware order is wrong expect(response.status()).not.toBe(403); }); test('malformed request should be validated before processing', async () => { const response = await baseContext.post(`${BASE_URL}/api/v1/proxy-hosts`, { data: 'invalid non-json body', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token', }, }); // Should return 400 (malformed) or 401 (auth) depending on order expect([400, 401, 415]).toContain(response.status()); }); test('rate limiting should be applied after authentication', async () => { // Even with invalid auth, rate limit should track the request let codes = []; for (let i = 0; i < 3; i++) { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'Bearer invalid', }, }); codes.push(response.status()); } // Should all be 401, or we might see 429 if rate limit kicks in expect(codes.every(code => [401, 429].includes(code))).toBe(true); }); }); // ========================================================================= // Test Suite: Header Validation // ========================================================================= test.describe('HTTP Header Validation', () => { test('should accept valid Content-Type application/json', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/health`, { headers: { 'Content-Type': 'application/json', }, }); expect(response.status()).toBe(200); }); test('should handle requests with no User-Agent header', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/health`); expect(response.status()).toBe(200); }); test('response should include security headers', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/health`); const headers = response.headers(); // Check for common security headers // Note: These may not all be present depending on app configuration expect(response.status()).toBe(200); // Verify response is valid JSON const data = await response.json(); expect(data).toHaveProperty('status'); }); }); // ========================================================================= // Test Suite: Method Validation // ========================================================================= test.describe('HTTP Method Validation', () => { test('GET request should be allowed for read operations', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'Bearer invalid', }, }); // Should fail auth (401), not method (405) expect(response.status()).toBe(401); }); test('unsupported methods should return 405 or 401', async () => { const response = await baseContext.fetch(`${BASE_URL}/api/v1/proxy-hosts`, { method: 'PATCH', headers: { Authorization: 'Bearer invalid', }, }); // Should be 401 (auth fail) or 405 (method not allowed) expect([401, 405]).toContain(response.status()); }); }); // ========================================================================= // Test Suite: Error Response Format // ========================================================================= test.describe('Error Response Format', () => { test('401 error should include error message', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`); expect(response.status()).toBe(401); const data = await response.json().catch(() => ({})); // Should have some error indication expect(response.status()).toBe(401); }); test('error response should not expose internal details', async () => { const response = await baseContext.get(`${BASE_URL}/api/v1/proxy-hosts`, { headers: { Authorization: 'Bearer malformed.token.here', }, }); expect(response.status()).toBe(401); const text = await response.text(); // Should not expose stack traces or internal file paths expect(text).not.toContain('stack trace'); expect(text).not.toContain('/app/'); }); }); });