test: full Playwright + API test suite for stage
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m52s
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m52s
159 tests across three Playwright projects (api, chromium, mobile): - tests/api/security.test.ts: security headers, CORS on /api/supabase-config (origin allowlist enforced), auth gate on /api/demo, Stripe webhook signature enforcement, source file leakage, path traversal, input validation (8000-char message cap) - tests/api/endpoints.test.ts: /api/health, /api/founding-count shape invariants, /api/supabase-config JWT shape, sitemap.xml, robots.txt, /llms.txt, /api/soul-health internal gate, 404 for unknown routes - tests/e2e/landing.spec.ts: title, h1 count, meta description, OG tags, canonical (no stage leak), JSON-LD schema, demo widget DOM presence, JS error filtering (known GTM/CSP noise excluded) - tests/e2e/seo.spec.ts: per-page title patterns, noindex on checkout, canonical URLs, sitemap production-URL enforcement - tests/e2e/checkout.spec.ts: all three plan variants, auth section, payment element, canonical - tests/e2e/chat.spec.ts: widget DOM structure, auth gate (send button disabled without session), API-level auth rejection - tests/e2e/navigation.spec.ts: all public routes return 200, 404s for removed/old paths (/terms, /enterprise-terms, /gallery), static files All 159 pass against stage. CI step added to stage.yaml after smoke test.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app';
|
||||
|
||||
const get = (path: string, headers: Record<string, string> = {}) =>
|
||||
fetch(`${BASE}${path}`, { headers });
|
||||
|
||||
// ── /api/health ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('/api/health — returns 200 with status:ok', async () => {
|
||||
const r = await get('/api/health');
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json() as Record<string, string>;
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.service).toBe('neuron-web');
|
||||
});
|
||||
|
||||
test('/api/health — content-type is application/json', async () => {
|
||||
const r = await get('/api/health');
|
||||
expect(r.headers.get('content-type')).toContain('application/json');
|
||||
});
|
||||
|
||||
// ── /api/founding-count ───────────────────────────────────────────────────────
|
||||
|
||||
test('/api/founding-count — returns numeric fields', async () => {
|
||||
const r = await get('/api/founding-count');
|
||||
expect(r.status).toBe(200);
|
||||
const body = await r.json() as Record<string, number>;
|
||||
expect(typeof body.sold).toBe('number');
|
||||
expect(typeof body.total).toBe('number');
|
||||
expect(typeof body.remaining).toBe('number');
|
||||
// Invariants
|
||||
expect(body.total).toBe(1000);
|
||||
expect(body.sold).toBeGreaterThanOrEqual(0);
|
||||
expect(body.remaining).toBe(body.total - body.sold);
|
||||
});
|
||||
|
||||
// ── /api/supabase-config ──────────────────────────────────────────────────────
|
||||
// Requires a permitted Origin. See security.test.ts for CORS tests.
|
||||
|
||||
test('/api/supabase-config — returns url and anon_key for allowed 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<string, string>;
|
||||
expect(body.url).toMatch(/supabase\.co/);
|
||||
expect(typeof body.anon_key).toBe('string');
|
||||
expect(body.anon_key.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
test('/api/supabase-config — anon_key is a valid JWT shape', async () => {
|
||||
const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' });
|
||||
const body = await r.json() as Record<string, string>;
|
||||
// Supabase anon key is a JWT: three base64 segments separated by dots
|
||||
const parts = body.anon_key.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
// ── /sitemap.xml ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('/sitemap.xml — returns valid XML with production URLs', async () => {
|
||||
const r = await get('/sitemap.xml');
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get('content-type')).toContain('xml');
|
||||
const text = await r.text();
|
||||
expect(text).toContain('<urlset');
|
||||
expect(text).toContain('neurontechnologies.ai');
|
||||
// Must not leak stage URL
|
||||
expect(text).not.toContain('run.app');
|
||||
expect(text).not.toContain('stage');
|
||||
});
|
||||
|
||||
test('/sitemap.xml — includes all major pages', async () => {
|
||||
const r = await get('/sitemap.xml');
|
||||
const text = await r.text();
|
||||
expect(text).toContain('neurontechnologies.ai/');
|
||||
expect(text).toContain('neurontechnologies.ai/about');
|
||||
expect(text).toContain('neurontechnologies.ai/legal/terms');
|
||||
expect(text).toContain('neurontechnologies.ai/legal/enterprise-terms');
|
||||
});
|
||||
|
||||
// ── /robots.txt ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('/robots.txt — accessible with correct directives', async () => {
|
||||
const r = await get('/robots.txt');
|
||||
expect(r.status).toBe(200);
|
||||
const text = await r.text();
|
||||
expect(text).toContain('User-agent');
|
||||
// Private paths are disallowed
|
||||
expect(text).toContain('Disallow: /checkout');
|
||||
expect(text).toContain('Disallow: /account');
|
||||
expect(text).toContain('Disallow: /api/');
|
||||
// Sitemap link points to production
|
||||
expect(text).toContain('Sitemap: https://neurontechnologies.ai/sitemap.xml');
|
||||
});
|
||||
|
||||
// ── /llms.txt ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('/llms.txt — accessible', async () => {
|
||||
const r = await get('/llms.txt');
|
||||
expect(r.status).toBe(200);
|
||||
const text = await r.text();
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── 404 handling ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('Unknown route returns 404', async () => {
|
||||
const r = await get('/this-route-xyz-does-not-exist-abc123');
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
|
||||
// ── /api/webhooks/stripe — POST-only, requires valid signature ────────────────
|
||||
|
||||
test('/api/webhooks/stripe — rejects missing Stripe-Signature with 400', async () => {
|
||||
const r = await fetch(`${BASE}/api/webhooks/stripe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'payment_intent.succeeded' }),
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
|
||||
// ── /api/demo — POST only, auth-gated ────────────────────────────────────────
|
||||
|
||||
test('/api/demo — missing access_token returns auth_required', async () => {
|
||||
const r = await fetch(`${BASE}/api/demo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'hello' }),
|
||||
});
|
||||
const body = await r.json() as Record<string, unknown>;
|
||||
expect(body.auth_required).toBe(true);
|
||||
});
|
||||
|
||||
// ── /api/soul-health — internal gate ─────────────────────────────────────────
|
||||
// The probe responses embedded in the JSON body may contain literal newlines
|
||||
// (control characters), so we test via text matching, not JSON.parse.
|
||||
|
||||
test('/api/soul-health — 404 without X-Internal header', async () => {
|
||||
const r = await get('/api/soul-health');
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
|
||||
test('/api/soul-health — 200 with X-Internal: true, body contains soul_url', async () => {
|
||||
const r = await get('/api/soul-health', { 'X-Internal': 'true' });
|
||||
expect(r.status).toBe(200);
|
||||
const text = await r.text();
|
||||
expect(text).toContain('"soul_url"');
|
||||
expect(text).toMatch(/soul_url.*https?:\/\//);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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<string, string> = {}) {
|
||||
return fetch(`${BASE}${path}`, { headers, redirect: 'manual' });
|
||||
}
|
||||
|
||||
async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
|
||||
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('Rejects requests with no Origin header', async () => {
|
||||
// No Origin = not from a browser context — the server treats this as
|
||||
// an unknown caller and returns 403 to prevent server-side exfiltration.
|
||||
const r = await get('/api/supabase-config');
|
||||
expect(r.status).toBe(403);
|
||||
});
|
||||
|
||||
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<string, string>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user