/* Test helpers for the stage 7 suites: fake Technitium servers that answer by path and record every call, and a hub started against two of them with one user per role. Not used by the hub itself. */ import http from 'node:http' import type { AddressInfo } from 'node:net' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { parseConfig } from './config.ts' import { createHub } from './server.ts' import { addUser, writeUsersFile } from './users.ts' export interface Call { path: string method: string token: string query: URLSearchParams form: URLSearchParams /* A text/plain body (a zone file). */ text: string } /* What a handler answers: the envelope's `response`, an error message, or a file. */ export type Reply = { response?: unknown } | { error: string } | { text: string } export interface Fake { server: http.Server url: string calls: Call[] down: boolean handlers: Map Reply> /** Answers one path; later calls replace earlier ones. */ on: (apiPath: string, handler: (call: Call) => Reply) => void } export function fake(): Promise { return new Promise((resolve) => { const f: Fake = { server: http.createServer(), url: '', calls: [], down: false, handlers: new Map(), on(apiPath, handler) { f.handlers.set(apiPath, handler) }, } f.server.on('request', (req, res) => { const chunks: Buffer[] = [] req.on('data', (c: Buffer) => chunks.push(c)) req.on('end', () => { if (f.down) return req.socket.destroy() const url = new URL(req.url ?? '/', 'http://fake') const type = String(req.headers['content-type'] ?? '') const raw = Buffer.concat(chunks).toString('utf8') const call: Call = { path: url.pathname.replace(/^\/api\//, ''), method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form: new URLSearchParams(type.includes('x-www-form-urlencoded') ? raw : ''), text: type.includes('text/plain') ? raw : '', } f.calls.push(call) const reply: Reply = f.handlers.get(call.path)?.(call) ?? { response: {} } if ('text' in reply) { res.setHeader('content-type', 'text/plain') return res.end(reply.text) } res.setHeader('content-type', 'application/json') res.end(JSON.stringify('error' in reply ? { status: 'error', errorMessage: reply.error } : { status: 'ok', response: reply.response ?? {} })) }) }) f.server.listen(0, '127.0.0.1', () => { f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}` resolve(f) }) }) } export const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] as const export interface TestHub { base: string tokens: Record dataDir: string /** Calls the hub with a role's session. */ call: (role: string, apiPath: string, params?: Record, method?: 'GET' | 'POST') => Promise<{ code: number; body: Record }> audit: () => Promise[]> close: () => void } export async function startHub(a: Fake, b: Fake, extra: Record = {}): Promise { const dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-data-')) const staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s7-static-')) let users = addUser([], 'viewer', 'viewer', 'password-viewer') users = addUser(users, 'operator', 'operator', 'password-operator') users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin') users = addUser(users, 'admin', 'admin', 'password-admin') writeUsersFile(dataDir, users) const hub = createHub( parseConfig({ HUB_SERVERS: `a=${a.url},b=${b.url}`, A_TOKEN: 'read-a', B_TOKEN: 'read-b', A_ADMIN_TOKEN: 'admin-a', B_ADMIN_TOKEN: 'admin-b', HUB_DATA: dataDir, HUB_STATIC: staticDir, HUB_CACHE_SECONDS: '0', HUB_TIMEOUT_MS: '2000', HUB_DRIFT_MINUTES: '0', ...extra, }), ) await new Promise((resolve) => hub.listen(0, '127.0.0.1', resolve)) const base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}` const tokens: Record = {} for (const role of ROLES) { const res = await fetch(`${base}/api/user/login`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `user=${role}&pass=password-${role}`, }) tokens[role] = ((await res.json()) as { token: string }).token } const call: TestHub['call'] = async (role, apiPath, params = {}, method = 'POST') => { const query = new URLSearchParams(method === 'GET' ? params : {}) const res = await fetch(`${base}/api/${apiPath}${query.size > 0 ? `?${query}` : ''}`, { method, headers: { authorization: `Bearer ${tokens[role]}`, ...(method === 'POST' ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) }, ...(method === 'POST' ? { body: new URLSearchParams(params).toString() } : {}), }) return { code: res.status, body: (await res.json()) as Record } } return { base, tokens, dataDir, call, audit: async () => { const res = await fetch(`${base}/api/hub/audit/list?limit=500`, { headers: { authorization: `Bearer ${tokens.admin}` } }) return ((await res.json()) as { response: { entries: Record[] } }).response.entries }, close: () => { hub.close() rmSync(dataDir, { recursive: true, force: true }) rmSync(staticDir, { recursive: true, force: true }) }, } }