Repository Public
1/**2 * Test harness: a throwaway Postgres database per test file (`cr_test_<random>`, migrated,3 * dropped afterwards), a fake runtime on a temp unix socket, and a small cookie-aware client4 * 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 { buildServer, type BuildDeps, type Server } from '../app.js';16import { loadConfig, type ServerConfig } from '../config.js';17import { migrate } from '../migrate.js';18import { base32Decode, totpAt } from '../security/totp.js';19import { FAKE_RT_TOKEN, FakeRuntime } from './fake-runtime.js';2021export const ORIGIN = 'http://localhost:8620';22export const PASSWORD = 'correct horse battery staple';2324export interface Harness {25 server: Server;26 config: ServerConfig;27 rt: FakeRuntime;28 dbName: string;29 databaseUrl: string;30 dataDir: string;31 workspace: string;32 logs: string[];33 setupToken(): string;34 /** Advances the clock used by the server (TOTP windows, challenge expiry). */35 clock: { offsetMs: number };36 listen(): Promise<string>;37 close(): Promise<void>;38}3940export function createTestDb(): { dbName: string; databaseUrl: string } {41 const dbName = `cr_test_${randomBytes(6).toString('hex')}`;42 execFileSync('createdb', [dbName], { stdio: 'pipe' });43 return { dbName, databaseUrl: `postgres://localhost/${dbName}` };44}45export function dropTestDb(dbName: string): void {46 if (!/^cr_test_[0-9a-f]+$/.test(dbName)) throw new Error('refusing to drop a non-test database');47 execFileSync('dropdb', ['--if-exists', '--force', dbName], { stdio: 'pipe' });48}4950export async function createHarness(51 o: { env?: Record<string, string>; deps?: BuildDeps; tweak?: (c: ServerConfig) => void; runtime?: boolean } = {},52): Promise<Harness> {53 const { dbName, databaseUrl } = createTestDb();54 await migrate(databaseUrl);55 const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-data-'));56 const workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cr-ws-')));57 const rt = new FakeRuntime();58 if (o.runtime !== false) await rt.start();59 rt.mkdir(workspace);60 const config = loadConfig({61 NODE_ENV: 'test',62 CR_PUBLIC_ORIGIN: ORIGIN,63 CR_DATA_DIR: dataDir,64 CR_WORKSPACE_ROOTS: `ws=${workspace}`,65 CR_RUNTIME_SOCKET: rt.socketPath,66 CR_RUNTIME_TOKEN: FAKE_RT_TOKEN,67 DATABASE_URL: databaseUrl,68 CR_SECRET_KEY: 'ab'.repeat(32),69 CR_LOG_LEVEL: 'info',70 CR_WEB_DIR: path.join(dataDir, 'no-web'),71 ...o.env,72 });73 config.passwordCost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };74 config.rateLimit = { api: 100_000, preAuth: 100_000, status: 100_000, health: 100_000 };75 o.tweak?.(config);76 const logs: string[] = [];77 const clock = { offsetMs: 0 };78 const logStream = new Writable({79 write(chunk: Buffer, _enc, cb) {80 logs.push(chunk.toString());81 cb();82 },83 });84 const server = await buildServer(config, { now: () => Date.now() + clock.offsetMs, logStream, clusterSource: null, skillsRegistry: null, ...o.deps });85 return {86 server, config, rt, dbName, databaseUrl, dataDir, workspace, logs, clock,87 setupToken: () => fs.readFileSync(path.join(dataDir, 'setup-token'), 'utf8'),88 async listen() {89 await server.app.listen({ port: 0, host: '127.0.0.1' });90 return `127.0.0.1:${(server.app.server.address() as AddressInfo).port}`;91 },92 async close() {93 await server.close();94 await rt.destroy();95 dropTestDb(dbName);96 fs.rmSync(dataDir, { recursive: true, force: true });97 fs.rmSync(workspace, { recursive: true, force: true });98 },99 };100}101102/** Cookie jar + CSRF-aware wrapper around `app.inject`. */103export class Client {104 cookies: Record<string, string> = {};105 csrf: string | null = null;106 origin: string | null = ORIGIN;107 constructor(private readonly h: Harness) {}108109 get cookieHeader(): string {110 return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');111 }112 async req(method: string, url: string, body?: unknown, extra: { headers?: Record<string, string>; raw?: Buffer | string } = {}): Promise<LightMyRequestResponse> {113 const headers: Record<string, string> = { ...extra.headers };114 if (this.cookieHeader) headers.cookie = this.cookieHeader;115 if (method !== 'GET' && method !== 'HEAD') {116 if (this.origin && headers.origin === undefined) headers.origin = this.origin;117 if (this.csrf && headers['x-csrf-token'] === undefined) headers['x-csrf-token'] = this.csrf;118 }119 const opts: InjectOptions = { method: method as 'GET', url: url.startsWith('/api') || url.startsWith('/_') || !url.startsWith('/') ? url : `/api/v1${url}`, headers };120 if (extra.raw !== undefined) opts.payload = extra.raw;121 else if (body !== undefined) {122 opts.payload = JSON.stringify(body);123 headers['content-type'] ??= 'application/json';124 } else if (method !== 'GET' && method !== 'HEAD' && method !== 'DELETE') {125 opts.payload = '{}';126 headers['content-type'] ??= 'application/json';127 }128 const res = await this.h.server.app.inject(opts);129 for (const c of res.cookies) {130 if (c.value === '' || (c.maxAge !== undefined && c.maxAge <= 0)) delete this.cookies[c.name];131 else this.cookies[c.name] = c.value;132 }133 return res;134 }135 get = (url: string): Promise<LightMyRequestResponse> => this.req('GET', url);136 post = (url: string, body?: unknown): Promise<LightMyRequestResponse> => this.req('POST', url, body);137 /** Refreshes the CSRF token from /auth/status. */138 async sync(): Promise<Record<string, unknown>> {139 const s = (await this.get('/auth/status')).json<Record<string, unknown>>();140 this.csrf = (s.csrfToken as string | null) ?? null;141 return s;142 }143}144145export const codeFor = (secretB32: string, h: Harness, stepOffset = 0): string =>146 totpAt(base32Decode(secretB32), Date.now() + h.clock.offsetMs + stepOffset * 30_000);147148/** Runs setup + TOTP enrolment; returns a fully authenticated client and the TOTP secret. */149export async function setupAdmin(h: Harness, username = 'admin'): Promise<{ client: Client; secret: string }> {150 const client = new Client(h);151 const res = await client.post('/auth/setup', { setupToken: h.setupToken(), username, password: PASSWORD });152 if (res.statusCode !== 200) throw new Error(`setup failed: ${res.statusCode} ${res.body}`);153 await client.sync();154 const start = (await client.post('/auth/totp/enroll/start')).json<{ secret: string }>();155 const fin = await client.post('/auth/totp/enroll/finish', { code: codeFor(start.secret, h) });156 if (fin.statusCode !== 200) throw new Error(`enrol failed: ${fin.statusCode} ${fin.body}`);157 await client.sync();158 return { client, secret: start.secret };159}160161/** Password + TOTP login on a fresh client. Moves the clock one step so the code is never a replay. */162export async function login(h: Harness, secret: string, username = 'admin'): Promise<Client> {163 h.clock.offsetMs += 30_000;164 const client = new Client(h);165 const res = await client.post('/auth/login', { username, password: PASSWORD, totp: codeFor(secret, h) });166 if (res.statusCode !== 200) throw new Error(`login failed: ${res.statusCode} ${res.body}`);167 await client.sync();168 return client;169}170171export function openWs(addr: string, pathAndQuery: string, o: { cookie?: string; origin?: string | null } = {}): WebSocket {172 const headers: Record<string, string> = {};173 if (o.cookie) headers.cookie = o.cookie;174 if (o.origin !== null) headers.origin = o.origin ?? ORIGIN;175 return new WebSocket(`ws://${addr}/api/v1${pathAndQuery}`, { headers, perMessageDeflate: false, maxPayload: 32 * 1024 * 1024 });176}177/** Resolves with the HTTP status of a refused upgrade, or 101 when the socket opens. */178export function upgradeStatus(ws: WebSocket): Promise<number> {179 return new Promise((resolve, reject) => {180 ws.once('open', () => resolve(101));181 ws.once('unexpected-response', (_req, res) => {182 res.resume();183 resolve(res.statusCode ?? 0);184 });185 ws.once('error', (err) => {186 const m = /Unexpected server response: (\d+)/.exec(err.message);187 if (m) resolve(Number(m[1]));188 else reject(err);189 });190 });191}192export const until = async (cond: () => boolean | Promise<boolean>, ms = 5000): Promise<void> => {193 const end = Date.now() + ms;194 while (!(await cond())) {195 if (Date.now() > end) throw new Error('condition not met in time');196 await new Promise((r) => setTimeout(r, 20));197 }198};199