import { test, expect } from '@playwright/test'; const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app'; async function get(path: string, headers: Record = {}) { return fetch(`${BASE}${path}`, { headers, redirect: 'manual' }); } async function post(path: string, body: unknown, headers: Record = {}) { return fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); } // ── Security headers ────────────────────────────────────────────────────────── // All HTML pages and API responses carry the full security header suite. // The El runtime's handle_request wrapper applies sec_headers_json() to every // response, so we can assert the same set on both HTML pages and JSON APIs. test.describe('Security headers', () => { const htmlPages = ['/', '/about', '/checkout?plan=professional']; for (const path of htmlPages) { test(`HTML ${path} — required security headers present`, async () => { const r = await get(path); expect(r.headers.get('x-content-type-options')).toBe('nosniff'); expect(r.headers.get('x-frame-options')).toMatch(/DENY|SAMEORIGIN/i); expect(r.headers.get('referrer-policy')).toBeTruthy(); expect(r.headers.get('content-security-policy')).toBeTruthy(); }); } test('API responses carry x-content-type-options', async () => { const r = await get('/api/health'); expect(r.headers.get('x-content-type-options')).toBe('nosniff'); }); test('permissions-policy header is present', async () => { const r = await get('/'); expect(r.headers.get('permissions-policy')).toBeTruthy(); }); }); // ── CORS enforcement on /api/supabase-config ────────────────────────────────── // This endpoint enforces an explicit origin allowlist: // - empty Origin (server-side / curl): BLOCKED (403) // - https://neurontechnologies.ai: ALLOWED // - https://www.neurontechnologies.ai: ALLOWED // - http://localhost:*: ALLOWED (dev) // - anything else (e.g. evil.com): BLOCKED (403) test.describe('CORS enforcement — /api/supabase-config', () => { test('Allows requests with no Origin header (same-origin browser fetches)', async () => { // Same-origin browser fetches (e.g. checkout page fetching supabase-config on // the same domain) do not send an Origin header. The server must pass these // through — blocking them would break the checkout flow on production. // Server-side exfiltration is prevented by the evil-origin 403 below. const r = await get('/api/supabase-config'); expect(r.status).toBe(200); }); test('Rejects evil origin', async () => { const r = await get('/api/supabase-config', { Origin: 'https://evil.com' }); expect(r.status).toBe(403); }); test('Allows neurontechnologies.ai origin', async () => { const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' }); expect(r.status).toBe(200); const body = await r.json() as Record; expect(body.url).toMatch(/supabase\.co/); expect(typeof body.anon_key).toBe('string'); expect(body.anon_key.length).toBeGreaterThan(20); }); test('Allows www.neurontechnologies.ai origin', async () => { const r = await get('/api/supabase-config', { Origin: 'https://www.neurontechnologies.ai' }); expect(r.status).toBe(200); }); test('Allows localhost origin (dev)', async () => { const r = await get('/api/supabase-config', { Origin: 'http://localhost:3001' }); expect(r.status).toBe(200); }); }); // ── Auth enforcement on /api/demo ───────────────────────────────────────────── // All requests require a valid Supabase access_token. // Missing or invalid tokens return {"auth_required":true}. test.describe('Auth enforcement — /api/demo', () => { test('Rejects POST with no access_token', async () => { const r = await post('/api/demo', { message: 'hello' }); const body = await r.json() as Record; expect(body.auth_required).toBe(true); }); test('Rejects POST with invalid access_token', async () => { const r = await post('/api/demo', { message: 'hello', access_token: 'invalid.token.here' }); const body = await r.json() as Record; expect(body.auth_required).toBe(true); }); test('Rejects empty message (length guard fires after auth check)', async () => { // With no token, auth check fires first const r = await post('/api/demo', { message: '', access_token: 'invalid' }); const body = await r.json() as Record; expect(body.auth_required || body.error).toBeTruthy(); }); }); // ── Stripe webhook signature enforcement ────────────────────────────────────── test.describe('Stripe webhook security', () => { test('Rejects POST with no Stripe-Signature header', async () => { const r = await post('/api/webhooks/stripe', { type: 'payment_intent.succeeded', data: { object: { amount: 9900 } }, }); expect(r.status).toBe(400); }); test('Rejects POST with malformed Stripe-Signature', async () => { const r = await post( '/api/webhooks/stripe', { type: 'payment_intent.succeeded', data: { object: {} } }, { 'Stripe-Signature': 't=1234,v1=fakesignature' }, ); expect(r.status).toBe(400); }); }); // ── Information leakage — source and build files must not be exposed ────────── // The Docker image copies only compiled artifacts and static assets into // /srv/landing/. Source files (.el, Makefile, Dockerfile) never land there, // so all these paths should 404. test.describe('Information leakage — source files not served', () => { const leakyPaths = [ '/src/main.el', '/.env', '/Dockerfile.stage', '/runtime/el_runtime.c', '/.gitea/workflows/stage.yaml', '/dist/neuron-landing', ]; for (const path of leakyPaths) { test(`${path} returns 404`, async () => { const r = await get(path); expect(r.status).toBe(404); }); } }); // ── /api/soul-health — internal-only diagnostic ─────────────────────────────── // Returns 404 without the X-Internal: true header. // Returns 200 with the header (allows in-container health probing). test.describe('Soul health — internal gate', () => { test('Returns 404 without X-Internal header', async () => { const r = await get('/api/soul-health'); expect(r.status).toBe(404); }); test('Returns 200 with X-Internal: true and includes soul_url', async () => { const r = await get('/api/soul-health', { 'X-Internal': 'true' }); expect(r.status).toBe(200); // The response embeds raw probe output which may contain literal newlines // inside JSON strings (invalid JSON). Check via text search to avoid // JSON.parse failure on the control characters. const text = await r.text(); expect(text).toContain('"soul_url"'); expect(text).toMatch(/soul_url.*https?:\/\//); }); }); // ── Path traversal ──────────────────────────────────────────────────────────── // The El runtime only serves files from whitelisted paths (src/assets/, // src/shares/, src/js/). Any traversal attempt resolves to 404 — the // runtime never reads outside its served directories. test.describe('Path traversal blocked', () => { const traversals = [ '/assets/../../../etc/passwd', '/assets/%2e%2e%2f%2e%2e%2fetc%2fpasswd', '/js/../../../etc/passwd', ]; for (const path of traversals) { test(`Traversal blocked: ${path}`, async () => { const r = await get(path); expect(r.status).toBe(404); const text = await r.text(); // Must not contain any /etc/passwd content expect(text).not.toContain('root:'); }); } }); // ── Input validation — /api/demo message length cap ────────────────────────── // Messages over 8000 chars are rejected before any auth or LLM call. test.describe('Input validation', () => { test('Oversized message (>8000 chars) is rejected with error', async () => { const r = await post('/api/demo', { message: 'A'.repeat(10000), access_token: 'test', }); const body = await r.json() as Record; // Length guard fires before auth check in server code expect(typeof body.error).toBe('string'); expect((body.error as string).toLowerCase()).toMatch(/long|length|8000/i); }); });