/** * Bus responder test fixture. * * Stage 4 dropped the `--no-interactive` deploy flag, so missing * config now fires bus events instead of aborting the deploy. * Integration tests that previously asserted on the abort behavior * need to attach a responder to the same bus DB the celilo CLI * subprocess uses, then assert on what the responder saw + the * deploy's final state. * * The fixture is in-process, the deploy runs in a subprocess — * they share the bus via the file path passed in. * * Implementation: thin wrapper around `services/programmatic- * responder.ts`, which is the production-grade responder that also * powers `celilo events respond --values `. The fixture sets * `onMissing: 'throw'` so a forgotten value fails the test loudly * instead of letting the deploy hang waiting for a reply. * * Usage: * * const responder = startBusResponderFixture({ * busDbPath, * config: { 'mymod.host': 'example.com' }, * secrets: { 'mymod.api_key': 'sk-fake' }, * ensures: { * 'namecheap.managed_domain': { * configValues: { 'config.zone_ids': 'zone-1' }, * secretValues: { 'secret.ddns_passwords': 'pw' }, * }, * }, * }); * try { * ... * expect(responder.seenConfigKeys()).toContain('mymod.host'); * } finally { * responder.close(); * } */ import { type DbClient, createDbClient } from '../db/client'; import type { ConfigRequiredPayload, EnsureRequiredPayload, InterviewRequiredPayload, SecretRequiredPayload, } from '../services/bus-interview'; import { type ProgrammaticResponderHandle, type ResponderValues, startProgrammaticResponder, } from '../services/programmatic-responder'; import { resetTestDbPath } from './db-path'; export interface BusResponderFixtureOptions extends ResponderValues { /** Path to the bus sqlite db (shared with the celilo subprocess). */ busDbPath: string; /** * Path to the celilo sqlite db. The responder reaches into this DB * directly to write secret values out-of-band — same pattern the * terminal-responder uses when running in-process inside the deploy. */ celiloDbPath: string; /** * Path to the celilo data dir (where master.key lives). The * responder needs the master key to encrypt secret values. */ dataDir: string; } export interface BusResponderFixture { /** Module.key strings the responder fielded on `config.required`. */ seenConfigKeys(): string[]; /** Module.key strings the responder fielded on `secret.required`. */ seenSecretKeys(): string[]; /** `.` strings the responder fielded. */ seenEnsureKeys(): string[]; /** Snapshot of every config.required payload the responder saw. */ seenConfigPayloads(): ConfigRequiredPayload[]; /** Snapshot of every secret.required payload the responder saw. */ seenSecretPayloads(): SecretRequiredPayload[]; /** Snapshot of every ensure.required payload the responder saw. */ seenEnsurePayloads(): EnsureRequiredPayload[]; /** Snapshot of every generic interview.required payload the responder saw. */ seenInterviewPayloads(): InterviewRequiredPayload[]; /** Stop watching and close the bus + db connections. */ close(): void; } /** * Start a programmatic responder against the given bus DB. Opens * its own bus + db client in the test process so the celilo * subprocess and the responder share state via file storage. */ export function startBusResponderFixture(opts: BusResponderFixtureOptions): BusResponderFixture { // Set CELILO_DATA_DIR for getOrCreateMasterKey so the responder // reads the subprocess's master.key. The fixture's process inherits // these env vars; restore on close so other tests aren't affected. const prevDataDir = process.env.CELILO_DATA_DIR; process.env.CELILO_DATA_DIR = opts.dataDir; process.env.CELILO_DB_PATH = opts.celiloDbPath; const db: DbClient = createDbClient(); const handle: ProgrammaticResponderHandle = startProgrammaticResponder({ busDbPath: opts.busDbPath, db, // Every family the fixture's options type accepts must be forwarded, or a // test supplies an answer that silently never arrives and the deploy hangs // on a question nobody is listening for. values: { config: opts.config, secrets: opts.secrets, ensures: opts.ensures, interview: opts.interview, aspects: opts.aspects, }, onMissing: 'throw', emittedBy: 'test-bus-responder', }); return { seenConfigKeys: () => handle.seenConfigPayloads().map((p) => `${p.module}.${p.key}`), seenSecretKeys: () => handle.seenSecretPayloads().map((p) => `${p.module}.${p.key}`), seenEnsureKeys: () => handle.seenEnsurePayloads().map((p) => `${p.provider}.${p.ensureId}`), seenConfigPayloads: () => handle.seenConfigPayloads(), seenSecretPayloads: () => handle.seenSecretPayloads(), seenEnsurePayloads: () => handle.seenEnsurePayloads(), seenInterviewPayloads: () => handle.seenInterviewPayloads(), close: () => { handle.close(); db.$client.close(); if (prevDataDir === undefined) delete process.env.CELILO_DATA_DIR; else process.env.CELILO_DATA_DIR = prevDataDir; // Reset to the scratch path, never restore the previous value: it may // be another suite's temp database, or unset — which sends the next // var-less reader to the operator's real celilo.db (celilo#1315). resetTestDbPath(); }, }; }