SPB Git

Repository Public

http.test.ts 10.5 KB 177 lines typescript

1import fs from 'node:fs';2import path from 'node:path';3import { afterAll, beforeAll, describe, expect, it } from 'vitest';4import { type Client, 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, 'missions'), { 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, 'missions', 'index.html'), '<html><script>window.T=1</script>missions-page</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(45);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-z0-9]+/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:8640', '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:8640', cookie: 'aos_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:8640' } });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, agent}', async () => {77    const res = await h.server.app.inject({ method: 'GET', url: '/api/v1/health' });78    expect(res.statusCode).toBe(200);79    expect(res.json()).toEqual({ ok: true, version: h.config.version, agent: 'aos' });80  });81  it('auth status carries the single Agent OS brand, also for anonymous callers', async () => {82    const anon = await h.server.app.inject({ method: 'GET', url: '/api/v1/auth/status' });83    expect(anon.json().branding).toEqual({ agent: 'aos', productName: 'Agent OS', agentName: 'Agent' });84    expect((await admin.get('/auth/status')).json().branding).toEqual({ agent: 'aos', productName: 'Agent OS', agentName: 'Agent' });85  });86  it('every mutating route refuses a missing CSRF token and a foreign Origin, even with a full session', async () => {87    const mutating = h.server.ctx.routes.filter((r) => r.url.startsWith('/api/') && r.auth === 'full' && !['GET', 'HEAD', 'OPTIONS'].includes(r.method));88    expect(mutating.length).toBeGreaterThan(12);89    for (const r of mutating) {90      const url = r.url.replace(/:[A-Za-z0-9]+/g, UUID);91      const key = `${r.method} ${r.url}`;92      const base = { method: r.method as 'POST', url, payload: r.method === 'DELETE' ? undefined : '{}' };93      const noCsrf = await h.server.app.inject({ ...base, headers: { cookie: admin.cookieHeader, origin: 'http://localhost:8640', 'content-type': 'application/json' } });94      expect([key, noCsrf.statusCode, noCsrf.json().error.code]).toEqual([key, 403, 'bad_csrf']);95      const badOrigin = await h.server.app.inject({ ...base, headers: { cookie: admin.cookieHeader, origin: 'https://evil.example', 'content-type': 'application/json', 'x-csrf-token': admin.csrf as string } });96      expect([key, badOrigin.statusCode, badOrigin.json().error.code]).toEqual([key, 403, 'bad_origin']);97      const noOrigin = await h.server.app.inject({ ...base, headers: { cookie: admin.cookieHeader, 'content-type': 'application/json', 'x-csrf-token': admin.csrf as string } });98      expect([key, noOrigin.statusCode]).toEqual([key, 403]);99    }100  });101});102103describe('response headers', () => {104  it('API responses are no-store and carry the hardening headers', async () => {105    const res = await admin.get('/settings');106    expect(res.headers['cache-control']).toBe('no-store');107    expect(res.headers['x-content-type-options']).toBe('nosniff');108    expect(res.headers['referrer-policy']).toBe('no-referrer');109    expect(res.headers['strict-transport-security']).toContain('max-age=63072000');110    expect(res.headers['x-frame-options']).toBe('DENY');111    expect(String(res.headers['permissions-policy'])).toContain('camera=()');112    const csp = String(res.headers['content-security-policy']);113    const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';114    expect(script).toBe("script-src 'self' 'wasm-unsafe-eval'");115    expect(csp).toContain("frame-ancestors 'none'");116    const unauth = await h.server.app.inject({ method: 'GET', url: '/api/v1/missions' });117    expect(unauth.headers['cache-control']).toBe('no-store');118    expect(unauth.headers['content-security-policy']).toBeDefined();119    expect(unauth.statusCode).toBe(401);120    const forbidden = await admin.req('PUT', '/settings', {}, { headers: { origin: 'https://evil.example' } });121    expect(forbidden.statusCode).toBe(403);122    expect(forbidden.headers['content-security-policy']).toBeDefined();123    expect(forbidden.headers['x-content-type-options']).toBe('nosniff');124  });125  it('JSON bodies above 1 MiB are refused', async () => {126    const big = JSON.stringify({ projectId: UUID, objective: 'x'.repeat(1024 * 1024 + 10) });127    const res = await admin.req('POST', '/missions', undefined, { raw: big, headers: { 'content-type': 'application/json' } });128    expect(res.statusCode).toBe(413);129  });130});131132describe('static web app', () => {133  it('serves trailing-slash routes with a per-document CSP that hashes its inline scripts', async () => {134    const root = await h.server.app.inject({ method: 'GET', url: '/' });135    expect(root.statusCode).toBe(200);136    expect(root.headers['cache-control']).toBe('no-cache');137    const script = String(root.headers['content-security-policy']).split('; ').find((d) => d.startsWith('script-src')) ?? '';138    expect(script).toMatch(/^script-src 'self' 'wasm-unsafe-eval' 'sha256-[A-Za-z0-9+/=]+'$/);139    expect(script).not.toContain('unsafe-inline');140    const term = await h.server.app.inject({ method: 'GET', url: '/missions/' });141    expect(term.statusCode).toBe(200);142    expect(term.body).toContain('missions-page');143    expect(term.headers['content-security-policy']).not.toBe(root.headers['content-security-policy']);144    const redirect = await h.server.app.inject({ method: 'GET', url: '/missions' });145    expect(redirect.statusCode).toBe(301);146    expect(redirect.headers.location).toBe('/missions/');147  });148  it('immutable caching only under /_next/static, and 404.html for unknown pages', async () => {149    const asset = await h.server.app.inject({ method: 'GET', url: '/_next/static/a.js' });150    expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');151    expect((await h.server.app.inject({ method: 'GET', url: '/robots.txt' })).headers['cache-control']).not.toContain('immutable');152    const missing = await h.server.app.inject({ method: 'GET', url: '/nope/' });153    expect(missing.statusCode).toBe(404);154    expect(missing.body).toContain('missing');155    expect(missing.headers['content-type']).toContain('text/html');156    const traversal = await h.server.app.inject({ method: 'GET', url: '/..%2f..%2fsetup-token' });157    expect(traversal.statusCode).toBeGreaterThanOrEqual(400);158    expect(traversal.body).not.toMatch(/^[A-Za-z0-9_-]{43}$/);159  });160});161162describe('OpenAPI', () => {163  it('lists every registered route with its auth requirement', async () => {164    const doc = (await admin.get('/openapi.json')).json<{ paths: Record<string, Record<string, { 'x-auth': string; requestBody?: unknown }>>; components: { schemas: Record<string, unknown> } }>();165    for (const r of h.server.ctx.routes.filter((x) => x.url.startsWith('/api/v1') && x.method !== 'HEAD' && x.method !== 'OPTIONS')) {166      const op = doc.paths[r.url.replace(/:([A-Za-z0-9_]+)/g, '{$1}')]?.[r.method.toLowerCase()];167      expect(op, `${r.method} ${r.url}`).toBeDefined();168      expect(op?.['x-auth']).toBe(r.auth);169    }170    expect(doc.paths['/api/v1/missions']?.post?.requestBody).toBeDefined();171    expect((doc as unknown as { info: { title: string } }).info.title).toBe('Agent OS API');172    for (const name of ['CreateMission', 'Mission', 'RunList', 'StepList', 'ResolveWait', 'PolicyRule', 'SpendSummary', 'SystemStatus', 'ModelInfoList', 'AosSettings', 'ApiError', 'LoginStep']) {173      expect(doc.components.schemas[name], name).toBeDefined();174    }175  });176});177
Files main
Menu

Theme