SPB Git

Repository Public

http.test.ts 8.5 KB 156 lines typescript

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
Files main
Menu

Theme