Repository Public
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