Repository Public
1import fs from 'node:fs';2import path from 'node:path';3import { afterAll, beforeAll, describe, expect, it } from 'vitest';4import { Client, PASSWORD, createHarness, setupAdmin, type Harness } from './testkit/harness.js';56/** The ONLY routes reachable without a session. Adding one here must be a deliberate decision. */7const DOCUMENTED_PUBLIC = [8 'GET /api/v1/health',9 'GET /api/v1/auth/status',10 'POST /api/v1/auth/setup',11 'POST /api/v1/auth/login',12 'POST /api/v1/auth/passkey/login/options',13 'POST /api/v1/auth/passkey/login/verify',14];15const UUID = '00000000-0000-4000-8000-000000000000';1617let h: Harness;18let admin: Client;1920beforeAll(async () => {21 h = await createHarness({22 tweak: (c) => {23 c.webDir = path.join(c.dataDir, 'web');24 fs.mkdirSync(path.join(c.webDir, 'terminal'), { recursive: true });25 fs.mkdirSync(path.join(c.webDir, '_next', 'static'), { recursive: true });26 fs.writeFileSync(path.join(c.webDir, 'index.html'), '<html><script>self.__next_f=[]</script><script src="/_next/static/a.js"></script></html>');27 fs.writeFileSync(path.join(c.webDir, 'terminal', 'index.html'), '<html><script>window.T=1</script>terminal</html>');28 fs.writeFileSync(path.join(c.webDir, '404.html'), '<html>missing</html>');29 fs.writeFileSync(path.join(c.webDir, '_next', 'static', 'a.js'), 'console.log(1)');30 fs.writeFileSync(path.join(c.webDir, 'robots.txt'), 'User-agent: *\nDisallow: /\n');31 },32 });33 ({ client: admin } = await setupAdmin(h));34});35afterAll(async () => {36 await h.close();37});3839describe('route table', () => {40 it('every registered API route refuses unauthenticated callers, except the documented public ones', async () => {41 const routes = h.server.ctx.routes.filter((r) => r.url.startsWith('/api/'));42 expect(routes.length).toBeGreaterThan(60);43 const open: string[] = [];44 for (const r of routes) {45 if (r.method === 'HEAD' || r.method === 'OPTIONS') continue;46 const url = r.url.replace(/:[A-Za-z]+/g, UUID);47 const key = `${r.method} ${r.url}`;48 // No cookie; a correct Origin and JSON body so that ONLY authentication can refuse it.49 const res = await h.server.app.inject({50 method: r.method as 'GET', url,51 headers: { origin: 'http://localhost:8620', 'content-type': 'application/json', 'x-csrf-token': 'x' },52 payload: r.method === 'GET' || r.method === 'DELETE' ? undefined : '{}',53 });54 if (res.statusCode !== 401) open.push(key);55 else expect(res.json().error.code).toBe('unauthenticated');56 // A garbage cookie is no better than none.57 const forged = await h.server.app.inject({ method: r.method as 'GET', url, headers: { origin: 'http://localhost:8620', cookie: 'cr_sid=' + 'A'.repeat(43) } });58 if (!DOCUMENTED_PUBLIC.includes(key)) expect([key, forged.statusCode]).toEqual([key, 401]);59 }60 expect(open.sort()).toEqual([...DOCUMENTED_PUBLIC].sort());61 });62 it('the declared auth levels match the documented public list', () => {63 const declared = h.server.ctx.routes64 .filter((r) => r.url.startsWith('/api/') && (r.auth === 'public' || r.auth === 'preauth') && r.method !== 'HEAD')65 .map((r) => `${r.method} ${r.url}`);66 expect(declared.sort()).toEqual([...DOCUMENTED_PUBLIC].sort());67 });68 it('unknown API paths are 404 JSON (401 first when the static wildcard would match)', async () => {69 const res = await h.server.app.inject({ method: 'POST', url: '/api/v1/nope', headers: { origin: 'http://localhost:8620' } });70 expect(res.statusCode).toBe(404);71 expect(res.json().error.code).toBe('not_found');72 const get = await h.server.app.inject({ method: 'GET', url: '/api/v1/nope' });73 expect([401, 404]).toContain(get.statusCode);74 expect((await admin.get('/nope')).statusCode).toBe(404);75 });76 it('health is public and discloses only {ok, version}', async () => {77 const res = await h.server.app.inject({ method: 'GET', url: '/api/v1/health' });78 expect(res.statusCode).toBe(200);79 expect(Object.keys(res.json()).sort()).toEqual(['ok', 'version']);80 });81});8283describe('response headers', () => {84 it('API responses are no-store and carry the hardening headers', async () => {85 const res = await admin.get('/settings');86 expect(res.headers['cache-control']).toBe('no-store');87 expect(res.headers['x-content-type-options']).toBe('nosniff');88 expect(res.headers['referrer-policy']).toBe('no-referrer');89 expect(res.headers['strict-transport-security']).toContain('max-age=63072000');90 expect(res.headers['x-frame-options']).toBe('DENY');91 expect(String(res.headers['permissions-policy'])).toContain('camera=()');92 const csp = String(res.headers['content-security-policy']);93 const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';94 expect(script).toBe("script-src 'self' 'wasm-unsafe-eval'");95 expect(csp).toContain("frame-ancestors 'none'");96 const unauth = await h.server.app.inject({ method: 'GET', url: '/api/v1/sessions' });97 expect(unauth.headers['cache-control']).toBe('no-store');98 expect(unauth.headers['content-security-policy']).toBeDefined();99 expect(unauth.statusCode).toBe(401);100 const forbidden = await admin.req('PUT', '/settings', {}, { headers: { origin: 'https://evil.example' } });101 expect(forbidden.statusCode).toBe(403);102 expect(forbidden.headers['content-security-policy']).toBeDefined();103 expect(forbidden.headers['x-content-type-options']).toBe('nosniff');104 });105 it('JSON bodies above 6 MiB are refused', async () => {106 const big = JSON.stringify({ path: 'a.txt', content: 'x'.repeat(6 * 1024 * 1024 + 10) });107 const res = await admin.req('PUT', `/projects/${UUID}/file`, undefined, { raw: big, headers: { 'content-type': 'application/json' } });108 expect(res.statusCode).toBe(413);109 });110});111112describe('static web app', () => {113 it('serves trailing-slash routes with a per-document CSP that hashes its inline scripts', async () => {114 const root = await h.server.app.inject({ method: 'GET', url: '/' });115 expect(root.statusCode).toBe(200);116 expect(root.headers['cache-control']).toBe('no-cache');117 const script = String(root.headers['content-security-policy']).split('; ').find((d) => d.startsWith('script-src')) ?? '';118 expect(script).toMatch(/^script-src 'self' 'wasm-unsafe-eval' 'sha256-[A-Za-z0-9+/=]+'$/);119 expect(script).not.toContain('unsafe-inline');120 const term = await h.server.app.inject({ method: 'GET', url: '/terminal/' });121 expect(term.statusCode).toBe(200);122 expect(term.body).toContain('terminal');123 expect(term.headers['content-security-policy']).not.toBe(root.headers['content-security-policy']);124 const redirect = await h.server.app.inject({ method: 'GET', url: '/terminal' });125 expect(redirect.statusCode).toBe(301);126 expect(redirect.headers.location).toBe('/terminal/');127 });128 it('immutable caching only under /_next/static, and 404.html for unknown pages', async () => {129 const asset = await h.server.app.inject({ method: 'GET', url: '/_next/static/a.js' });130 expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');131 expect((await h.server.app.inject({ method: 'GET', url: '/robots.txt' })).headers['cache-control']).not.toContain('immutable');132 const missing = await h.server.app.inject({ method: 'GET', url: '/nope/' });133 expect(missing.statusCode).toBe(404);134 expect(missing.body).toContain('missing');135 expect(missing.headers['content-type']).toContain('text/html');136 const traversal = await h.server.app.inject({ method: 'GET', url: '/..%2f..%2fsetup-token' });137 expect(traversal.statusCode).toBeGreaterThanOrEqual(400);138 expect(traversal.body).not.toMatch(/^[A-Za-z0-9_-]{43}$/);139 });140});141142describe('OpenAPI', () => {143 it('lists every registered route with its auth requirement', async () => {144 const doc = (await admin.get('/openapi.json')).json<{ paths: Record<string, Record<string, { 'x-auth': string; requestBody?: unknown }>>; components: { schemas: Record<string, unknown> } }>();145 for (const r of h.server.ctx.routes.filter((x) => x.url.startsWith('/api/v1') && x.method !== 'HEAD' && x.method !== 'OPTIONS')) {146 const op = doc.paths[r.url.replace(/:([A-Za-z0-9_]+)/g, '{$1}')]?.[r.method.toLowerCase()];147 expect(op, `${r.method} ${r.url}`).toBeDefined();148 expect(op?.['x-auth']).toBe(r.auth);149 }150 expect(doc.paths['/api/v1/sessions']?.post?.requestBody).toBeDefined();151 for (const name of ['CreateSession', 'TerminalSession', 'SystemStatus', 'ClusterOverview', 'ApiError', 'LoginStep']) {152 expect(doc.components.schemas[name], name).toBeDefined();153 }154 });155});156