/** * Security: API Boundary Tests (E2E) * * Verifies runtime security boundaries that must hold in the running application: * 1. Unauthenticated requests to protected endpoints return 401/403 * 2. Error responses never expose stack traces or internal paths * 3. Portal API endpoints reject CRM session cookies (separate auth domains) * * These tests run against the live dev server (baseURL = http://localhost:3000). * They use `page.request` (the Playwright API client) so no browser UI is involved. */ import { test, expect } from '@playwright/test'; // ───────────────────────────────────────────────────────────────────────────── test.describe('API Security — unauthenticated access', () => { test('GET /api/v1/clients returns 401 or 403 without a session', async ({ page }) => { const response = await page.request.get('/api/v1/clients'); expect([401, 403]).toContain(response.status()); }); test('GET /api/v1/interests returns 401 or 403 without a session', async ({ page }) => { const response = await page.request.get('/api/v1/interests'); expect([401, 403]).toContain(response.status()); }); test('GET /api/v1/dashboard/kpis returns 401 or 403 without a session', async ({ page }) => { const response = await page.request.get('/api/v1/dashboard/kpis'); expect([401, 403]).toContain(response.status()); }); test('GET /api/v1/notifications/unread-count returns 401 or 403 without a session', async ({ page }) => { const response = await page.request.get('/api/v1/notifications/unread-count'); expect([401, 403]).toContain(response.status()); }); test('GET /api/v1/admin/health returns 401 or 403 without a session', async ({ page }) => { const response = await page.request.get('/api/v1/admin/health'); expect([401, 403]).toContain(response.status()); }); test('POST /api/v1/clients returns 401 or 403 without a session', async ({ page }) => { const response = await page.request.post('/api/v1/clients', { data: { fullName: 'Test', contacts: [{ channel: 'email', value: 'x@y.com' }] }, }); expect([401, 403]).toContain(response.status()); }); test('DELETE on a client record returns 401 or 403 without a session', async ({ page }) => { const fakeId = '00000000-0000-0000-0000-000000000000'; const response = await page.request.delete(`/api/v1/clients/${fakeId}`); expect([401, 403]).toContain(response.status()); }); }); // ───────────────────────────────────────────────────────────────────────────── test.describe('API Security — error response sanitization', () => { test('404 on a non-existent API route does not contain stack traces', async ({ page }) => { const response = await page.request.get('/api/v1/nonexistent-endpoint-xyzzy'); // Accept any non-200 status — we just care about the body content const body = await response.json().catch(() => ({ error: response.statusText() })); const bodyStr = JSON.stringify(body); expect(bodyStr).not.toContain('node_modules'); expect(bodyStr).not.toContain('.ts:'); expect(bodyStr).not.toContain('at Object'); expect(bodyStr).not.toContain('at Function'); expect(bodyStr).not.toContain('G:\\'); expect(bodyStr).not.toContain('/app/src'); }); test('unauthenticated response body follows { error } shape, no internal details', async ({ page }) => { const response = await page.request.get('/api/v1/clients'); const body = await response.json().catch(() => null); if (body) { // If a JSON body was returned, it must follow the documented error shape expect(typeof body.error).toBe('string'); // Stack trace fields must be absent expect(body).not.toHaveProperty('stack'); expect(body).not.toHaveProperty('trace'); // Internal database connection strings must not appear const bodyStr = JSON.stringify(body); expect(bodyStr).not.toContain('postgres://'); expect(bodyStr).not.toContain('postgresql://'); expect(bodyStr).not.toContain('SELECT'); } }); test('malformed JSON body to POST endpoint returns 400/422 without stack trace', async ({ page }) => { // Send invalid JSON as body — should trigger a validation or parse error const response = await page.request.post('/api/v1/clients', { headers: { 'Content-Type': 'application/json' }, data: '{ invalid json }', }); // Must be a client error (4xx), not a 500 stack dump // (401/403 is also acceptable — auth check happens before parse) expect(response.status()).toBeLessThan(600); const body = await response.json().catch(() => null); if (body) { const bodyStr = JSON.stringify(body); expect(bodyStr).not.toContain('stack'); expect(bodyStr).not.toContain('node_modules'); } }); }); // ───────────────────────────────────────────────────────────────────────────── test.describe('API Security — portal / CRM auth separation', () => { test('portal dashboard endpoint returns 401 without portal JWT', async ({ page }) => { // The portal uses a separate JWT auth flow, not the CRM session cookie. // Even if called with no credentials, it must reject with 401. const response = await page.request.get('/api/portal/dashboard'); expect([401, 403, 404]).toContain(response.status()); }); test('CRM login credentials cannot be used to access portal endpoints', async ({ page }) => { // Attempt to authenticate as a CRM user via Better Auth const loginRes = await page.request.post('/api/auth/sign-in/email', { data: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!', }, }).catch(() => null); // Whether or not login succeeded, portal endpoints should be inaccessible // via the CRM session (portal uses a separate JWT issued by /api/portal/auth) const portalRes = await page.request.get('/api/portal/dashboard'); expect([401, 403, 404]).toContain(portalRes.status()); }); test('portal profile endpoint is inaccessible without portal token', async ({ page }) => { const response = await page.request.get('/api/portal/profile'); expect([401, 403, 404]).toContain(response.status()); }); }); // ───────────────────────────────────────────────────────────────────────────── test.describe('API Security — response headers', () => { test('API responses do not expose internal server technology via X-Powered-By', async ({ page }) => { const response = await page.request.get('/api/v1/clients'); // Next.js sets X-Powered-By by default — should be removed in production config. // This test documents the expectation; it warns if the header is present. const poweredBy = response.headers()['x-powered-by']; if (poweredBy) { console.warn( `⚠️ SECURITY: X-Powered-By header exposed: "${poweredBy}". ` + 'Set headers: { "X-Powered-By": "" } in next.config.ts to suppress.', ); } // Not a hard fail — but the header should not be present in production // expect(poweredBy).toBeUndefined(); }); test('unauthenticated API responses include correct Content-Type', async ({ page }) => { const response = await page.request.get('/api/v1/clients'); const contentType = response.headers()['content-type'] ?? ''; // Error responses must be JSON, not HTML (which would indicate an unhandled crash page) expect(contentType).toContain('application/json'); }); });