SPB Git

Repository Public

harness.ts 8.5 KB 199 lines typescript

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

Theme