SPB Git

Repository Public

harness.ts 12.5 KB 272 lines typescript

1/**2 * Test harness: a throwaway Postgres database per test file (`aos_test_<random>`, migrated,3 * dropped afterwards), the SQL-backed fake kernel, a fixture model catalogue, and a small4 * cookie-aware client on top of `app.inject`.5 */6import { execFileSync } from 'node:child_process';7import fs from 'node:fs';8import os from 'node:os';9import path from 'node:path';10import { randomBytes } from 'node:crypto';11import type { AddressInfo } from 'node:net';12import { Writable } from 'node:stream';13import type { InjectOptions, LightMyRequestResponse } from 'fastify';14import WebSocket from 'ws';15import type { ModelInfo, Provider } from '@aos/shared';16import { buildServer, type BuildDeps, type Server } from '../app.js';17import { loadConfig, type ServerConfig } from '../config.js';18import { createDb, type Db } from '../db.js';19import type { ProvidersPort } from '../providers-port.js';20import { base32Decode, totpAt } from '../security/totp.js';21import { migrate } from '@aos/kernel';22import { FakeKernel } from './fake-kernel.js';2324export const ORIGIN = 'http://localhost:8640';25export const PASSWORD = 'correct horse battery staple';26/** Fake model API keys: configured in every harness so tests can assert they never leave the server. */27export const FAKE_ANTHROPIC_KEY = 'sk-ant-test-FAKEKEY-do-not-leak-0123456789abcdef';28export const FAKE_OPENAI_KEY = 'sk-proj-test-FAKEKEY-do-not-leak-fedcba9876543210';2930/** TEST FIXTURE catalogue (ids of CONTRACT §3; prices are fixture values, not a price list). */31export const FIXTURE_CATALOGUE: ModelInfo[] = [32  { provider: 'anthropic', id: 'claude-opus-5-5', label: 'Opus (fixture)', tier: 'main', contextTokens: 200000, maxOutputTokens: 64000, inputUsd: 4, outputUsd: 20, efforts: ['low', 'medium', 'high', 'xhigh', 'max'], available: true },33  { provider: 'anthropic', id: 'claude-haiku-5-5', label: 'Haiku (fixture)', tier: 'volume', contextTokens: 200000, maxOutputTokens: 64000, inputUsd: 0.1, outputUsd: 0.5, efforts: ['low', 'medium'], available: true },34  { provider: 'openai', id: 'gpt-6.1-sol', label: 'Sol (fixture)', tier: 'main', contextTokens: null, maxOutputTokens: null, inputUsd: 2, outputUsd: 10, efforts: ['low', 'medium', 'high'], available: true },35];36export class FakeProviders implements ProvidersPort {37  calls: Provider[] = [];38  fail: Partial<Record<Provider, boolean>> = {};39  hang: Partial<Record<Provider, boolean>> = {};40  catalogue(): ModelInfo[] {41    return FIXTURE_CATALOGUE.map((m) => ({ ...m }));42  }43  async listModels(provider: Provider): Promise<ModelInfo[]> {44    this.calls.push(provider);45    if (this.hang[provider]) return new Promise(() => undefined);46    // The message deliberately quotes a key: the server must not echo provider error text.47    if (this.fail[provider]) throw new Error(`401 invalid x-api-key ${FAKE_ANTHROPIC_KEY}`);48    return this.catalogue().filter((m) => m.provider === provider);49  }50}5152export interface Harness {53  server: Server;54  config: ServerConfig;55  kernel: FakeKernel;56  providers: FakeProviders;57  db: Db;58  dbName: string;59  databaseUrl: string;60  dataDir: string;61  workspace: string;62  logs: string[];63  setupToken(): string;64  /** Advances the clock used by the server (TOTP windows, challenge expiry). */65  clock: { offsetMs: number };66  listen(): Promise<string>;67  close(): Promise<void>;68}6970export function createTestDb(): { dbName: string; databaseUrl: string } {71  const dbName = `aos_test_${randomBytes(6).toString('hex')}`;72  execFileSync('createdb', [dbName], { stdio: 'pipe' });73  return { dbName, databaseUrl: `postgres://localhost/${dbName}` };74}75export function dropTestDb(dbName: string): void {76  if (!/^aos_test_[0-9a-f]+$/.test(dbName)) throw new Error('refusing to drop a non-test database');77  execFileSync('dropdb', ['--if-exists', '--force', dbName], { stdio: 'pipe' });78}7980export async function createHarness(81  o: { env?: Record<string, string>; deps?: Partial<BuildDeps>; tweak?: (c: ServerConfig) => void } = {},82): Promise<Harness> {83  const { dbName, databaseUrl } = createTestDb();84  let dataDir = '';85  let workspace = '';86  let db: Db | null = null;87  const cleanup = (): void => {88    dropTestDb(dbName);89    if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });90    if (workspace) fs.rmSync(workspace, { recursive: true, force: true });91  };92  try {93    await migrate(databaseUrl);94    dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-data-'));95    workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-ws-')));96    // Also in the process environment: anything that reads it directly sees the same fake values.97    process.env.ANTHROPIC_API_KEY = FAKE_ANTHROPIC_KEY;98    process.env.OPENAI_API_KEY = FAKE_OPENAI_KEY;99    const config = loadConfig({100      NODE_ENV: 'test',101      AOS_PUBLIC_ORIGIN: ORIGIN,102      AOS_DATA_DIR: dataDir,103      AOS_WORKSPACE_ROOT: workspace,104      DATABASE_URL: databaseUrl,105      AOS_SECRET_KEY: 'ab'.repeat(32),106      AOS_LOG_LEVEL: 'info',107      AOS_WEB_DIR: path.join(dataDir, 'no-web'),108      ANTHROPIC_API_KEY: FAKE_ANTHROPIC_KEY,109      OPENAI_API_KEY: FAKE_OPENAI_KEY,110      ...o.env,111    });112    config.passwordCost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };113    config.rateLimit = { api: 100_000, preAuth: 100_000, status: 100_000, health: 100_000 };114    o.tweak?.(config);115    const logs: string[] = [];116    const clock = { offsetMs: 0 };117    const logStream = new Writable({118      write(chunk: Buffer, _enc, cb) {119        logs.push(chunk.toString());120        cb();121      },122    });123    const pool = createDb(databaseUrl);124    db = pool;125    const kernel = new FakeKernel(pool, dataDir);126    const providers = new FakeProviders();127    const server = await buildServer(config, { now: () => Date.now() + clock.offsetMs, logStream, db: pool, kernel, providers, ...o.deps });128    await server.startBackground({ timers: false });129    return {130      server, config, kernel, providers, db: pool, dbName, databaseUrl, dataDir, workspace, logs, clock,131      setupToken: () => fs.readFileSync(path.join(dataDir, 'setup-token'), 'utf8'),132      async listen() {133        await server.app.listen({ port: 0, host: '127.0.0.1' });134        return `127.0.0.1:${(server.app.server.address() as AddressInfo).port}`;135      },136      async close() {137        try {138          await server.close();139          kernel.stop();140          await pool.end();141        } finally {142          cleanup();143        }144      },145    };146  } catch (err) {147    // Never leave a test database behind when the harness itself fails to start.148    await db?.end().catch(() => undefined);149    cleanup();150    throw err;151  }152}153154/** Cookie jar + CSRF-aware wrapper around `app.inject`. */155export class Client {156  cookies: Record<string, string> = {};157  csrf: string | null = null;158  origin: string | null = ORIGIN;159  constructor(private readonly h: Harness) {}160161  get cookieHeader(): string {162    return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');163  }164  async req(method: string, url: string, body?: unknown, extra: { headers?: Record<string, string>; raw?: Buffer | string } = {}): Promise<LightMyRequestResponse> {165    const headers: Record<string, string> = { ...extra.headers };166    if (this.cookieHeader) headers.cookie = this.cookieHeader;167    if (method !== 'GET' && method !== 'HEAD') {168      if (this.origin && headers.origin === undefined) headers.origin = this.origin;169      if (this.csrf && headers['x-csrf-token'] === undefined) headers['x-csrf-token'] = this.csrf;170    }171    const opts: InjectOptions = { method: method as 'GET', url: url.startsWith('/api') || url.startsWith('/_') || !url.startsWith('/') ? url : `/api/v1${url}`, headers };172    if (extra.raw !== undefined) opts.payload = extra.raw;173    else if (body !== undefined) {174      opts.payload = JSON.stringify(body);175      headers['content-type'] ??= 'application/json';176    } else if (method !== 'GET' && method !== 'HEAD' && method !== 'DELETE') {177      opts.payload = '{}';178      headers['content-type'] ??= 'application/json';179    }180    const res = await this.h.server.app.inject(opts);181    for (const c of res.cookies) {182      if (c.value === '' || (c.maxAge !== undefined && c.maxAge <= 0)) delete this.cookies[c.name];183      else this.cookies[c.name] = c.value;184    }185    return res;186  }187  get = (url: string): Promise<LightMyRequestResponse> => this.req('GET', url);188  post = (url: string, body?: unknown): Promise<LightMyRequestResponse> => this.req('POST', url, body);189  /** Refreshes the CSRF token from /auth/status. */190  async sync(): Promise<Record<string, unknown>> {191    const s = (await this.get('/auth/status')).json<Record<string, unknown>>();192    this.csrf = (s.csrfToken as string | null) ?? null;193    return s;194  }195}196197export const codeFor = (secretB32: string, h: Harness, stepOffset = 0): string =>198  totpAt(base32Decode(secretB32), Date.now() + h.clock.offsetMs + stepOffset * 30_000);199200/** Runs setup + TOTP enrolment; returns a fully authenticated client and the TOTP secret. */201export async function setupAdmin(h: Harness, username = 'admin'): Promise<{ client: Client; secret: string }> {202  const client = new Client(h);203  const res = await client.post('/auth/setup', { setupToken: h.setupToken(), username, password: PASSWORD });204  if (res.statusCode !== 200) throw new Error(`setup failed: ${res.statusCode} ${res.body}`);205  await client.sync();206  const start = (await client.post('/auth/totp/enroll/start')).json<{ secret: string }>();207  const fin = await client.post('/auth/totp/enroll/finish', { code: codeFor(start.secret, h) });208  if (fin.statusCode !== 200) throw new Error(`enrol failed: ${fin.statusCode} ${fin.body}`);209  await client.sync();210  return { client, secret: start.secret };211}212213/** Password + TOTP login on a fresh client. Moves the clock one step so the code is never a replay. */214export async function login(h: Harness, secret: string, username = 'admin'): Promise<Client> {215  h.clock.offsetMs += 30_000;216  const client = new Client(h);217  const res = await client.post('/auth/login', { username, password: PASSWORD, totp: codeFor(secret, h) });218  if (res.statusCode !== 200) throw new Error(`login failed: ${res.statusCode} ${res.body}`);219  await client.sync();220  return client;221}222223export function openWs(addr: string, pathAndQuery: string, o: { cookie?: string; origin?: string | null } = {}): WebSocket {224  const headers: Record<string, string> = {};225  if (o.cookie) headers.cookie = o.cookie;226  if (o.origin !== null) headers.origin = o.origin ?? ORIGIN;227  return new WebSocket(`ws://${addr}/api/v1${pathAndQuery}`, { headers, perMessageDeflate: false, maxPayload: 32 * 1024 * 1024 });228}229/** Resolves with the HTTP status of a refused upgrade, or 101 when the socket opens. */230export function upgradeStatus(ws: WebSocket): Promise<number> {231  return new Promise((resolve, reject) => {232    ws.once('open', () => resolve(101));233    ws.once('unexpected-response', (_req, res) => {234      res.resume();235      resolve(res.statusCode ?? 0);236    });237    ws.once('error', (err) => {238      const m = /Unexpected server response: (\d+)/.exec(err.message);239      if (m) resolve(Number(m[1]));240      else reject(err);241    });242  });243}244export const until = async (cond: () => boolean | Promise<boolean>, ms = 5000): Promise<void> => {245  const end = Date.now() + ms;246  while (!(await cond())) {247    if (Date.now() > end) throw new Error('condition not met in time');248    await new Promise((r) => setTimeout(r, 20));249  }250};251252/** A second account inserted directly (the product has one administrator; this proves owner scoping). */253export async function insertOtherUser(h: Harness, username = 'other'): Promise<string> {254  const row = await h.db.one<{ id: string }>("INSERT INTO users (username, password_hash) VALUES ($1, 'x') RETURNING id", [username]);255  return row?.id as string;256}257/** Project + mission created through the API; returns their ids and the root run id. */258export async function seedMission(259  h: Harness,260  client: Client,261  o: { project?: string; mission?: Record<string, unknown> } = {},262): Promise<{ projectId: string; missionId: string; runId: string }> {263  const name = o.project ?? `p-${randomBytes(4).toString('hex')}`;264  const p = await client.post('/projects', { name, mode: 'create' });265  if (p.statusCode !== 201) throw new Error(`project create failed: ${p.statusCode} ${p.body}`);266  const projectId = p.json<{ id: string }>().id;267  const m = await client.post('/missions', { projectId, objective: 'Fix the failing test', ...o.mission });268  if (m.statusCode !== 201) throw new Error(`mission create failed: ${m.statusCode} ${m.body}`);269  const mission = m.json<{ id: string; rootRunId: string }>();270  return { projectId, missionId: mission.id, runId: mission.rootRunId };271}272
Files main
Menu

Theme