/** * Layer 1 — bus-mediated ensure-interview flow. * * Drives `interviewForEnsureInputs` against a real sqlite bus + real * encrypted store + a programmatic test responder. Mirrors what * `bus-secret-flow.test.ts` does for the secret interview. * * Covers what stage 3 introduced: * - `append_to_array` inputs apply deterministic without a bus event. * - `set_in_object` inputs go through one bulk `ensure.required` event. * - Config-target values arrive in `reply.values`, applied by the * deploy via the read-merge-write helper. * - Secret-target values are written out-of-band by the responder * (never on the bus); the reply carries `acknowledged: true` only. * - Idempotent re-runs skip already-populated inputs and don't fire * bus events at all. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import { and, eq } from 'drizzle-orm'; import { type DbClient, getDb } from '../db/client'; import { moduleConfigs, modules, secrets } from '../db/schema'; import type { Ensure } from '../manifest/schema'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { resetTestDbPath } from '../test-utils/db-path'; import type { EnsureRequiredPayload } from './bus-interview'; import { interviewForEnsureInputs, writeModuleSecretKey } from './config-interview'; const NO_SCHEMAS = defineEvents({}); const ENSURE: Ensure = { id: 'managed_domain', description: 'Add a domain.', inputs: [ { kind: 'append_to_array', target: 'config.domains' }, { kind: 'set_in_object', target: 'config.zone_ids', key: '{{value}}', prompt: 'Zone id for {{value}}', }, { kind: 'set_in_object', target: 'secret.ddns_passwords', key: '{{value}}', prompt: 'DDNS password for {{value}}', hint: 'Advanced DNS panel', }, ], post: 'redeploy_self', }; interface EnsureResponderOpts { /** Value to "type" for config-target inputs, keyed by target. */ configValues: Record; /** Value to "type" for secret-target inputs, keyed by target. */ secretValues: Record; } /** * In-process test responder for ensure.required. Mirrors what the * terminal-responder does: write secret values out-of-band via the * read-merge-write path, return config values in `reply.values`, * tag the reply with `acknowledged: true` if any secrets were touched. */ function startEnsureResponder( bus: Bus, pattern: string, opts: EnsureResponderOpts, db: DbClient, ): { seen: Array<{ payload: EnsureRequiredPayload; type: string }>; close: () => void } { const seen: Array<{ payload: EnsureRequiredPayload; type: string }> = []; const handle = bus.watch(pattern, async (event) => { if (event.replyFor !== null) return; const payload = event.payload as EnsureRequiredPayload; seen.push({ payload, type: event.type }); const values: Record = {}; let acknowledged = false; let masterKey: Buffer | null = null; for (const input of payload.inputs) { if (input.target.startsWith('config.')) { values[input.target] = opts.configValues[input.target]; continue; } // Secret target — read-merge-write the secret object, never // include the value in `values`. const name = input.target.slice('secret.'.length); if (!masterKey) masterKey = await getOrCreateMasterKey(); const { readModuleSecretKey } = await import('./config-interview'); let obj: Record = {}; const currentRaw = await readModuleSecretKey(payload.provider, name, db, masterKey); if (currentRaw) { try { const parsed = JSON.parse(currentRaw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { obj = parsed as Record; } } catch { /* overwrite */ } } obj[input.objectKey] = opts.secretValues[input.target]; await writeModuleSecretKey(payload.provider, name, JSON.stringify(obj), db, masterKey); acknowledged = true; } bus.emitRaw(`${event.type}.reply`, acknowledged ? { values, acknowledged: true } : { values }, { replyFor: event.id, emittedBy: 'test-responder', }); }); // Real responders answer `responder.probe` so the deploy's fail-fast guard // (ISS-0025) knows a responder is listening. A bespoke fixture must too, or // busInterviewGuarded throws before the ensure prompt is ever emitted. const probeHandle = bus.watch('responder.probe', (event) => { if (event.replyFor !== null) return; bus.emitRaw( `${event.type}.reply`, { kind: 'programmatic', emittedBy: 'test-responder' }, { replyFor: event.id, emittedBy: 'test-responder' }, ); }); return { seen, close: () => { handle.close(); probeHandle.close(); }, }; } describe('bus-mediated interviewForEnsureInputs', () => { let tempDir: string; let testDb: DbClient; let bus: Bus; let busDbPath: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-bus-ensure-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); busDbPath = join(tempDir, 'events.db'); process.env.EVENT_BUS_DB = busDbPath; testDb = getDb(); testDb .insert(modules) .values({ id: 'namecheap', name: 'Namecheap', sourcePath: tempDir, version: '2.0.0', manifestData: { provides: { capabilities: [{ name: 'dns_registrar', version: '3.0.0', ensures: [ENSURE] }], }, }, }) .run(); bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS }); }); afterEach(() => { bus.close(); rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.EVENT_BUS_DB; }); test('mixed inputs: array applied deterministic, config + secret routed through bus', async () => { const { seen, close } = startEnsureResponder( bus, 'ensure.required.namecheap.managed_domain', { configValues: { 'config.zone_ids': 'zone-abc-123' }, secretValues: { 'secret.ddns_passwords': 'super-secret-pw' }, }, testDb, ); const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb); expect(result.success).toBe(true); expect(result.alreadyApplied).toBe(false); // Exactly one bus event for the whole ensure (set_in_object inputs // bundled together; append_to_array stayed off the bus). expect(seen.length).toBe(1); expect(seen[0].type).toBe('ensure.required.namecheap.managed_domain'); expect(seen[0].payload.provider).toBe('namecheap'); expect(seen[0].payload.ensureId).toBe('managed_domain'); expect(seen[0].payload.triggerValue).toBe('celilo.computer'); // Only set_in_object inputs reach the responder. expect(seen[0].payload.inputs).toHaveLength(2); expect(seen[0].payload.inputs.map((i) => i.target).sort()).toEqual([ 'config.zone_ids', 'secret.ddns_passwords', ]); // The bus payload must NEVER carry the secret value. expect(JSON.stringify(seen[0].payload)).not.toContain('super-secret-pw'); // append_to_array applied without a bus event. const domains = testDb .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'domains'))) .get(); expect(JSON.parse(domains?.valueJson ?? '[]')).toEqual(['celilo.computer']); // set_in_object config applied via reply.values. const zoneIds = testDb .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'zone_ids'))) .get(); expect(JSON.parse(zoneIds?.valueJson ?? '{}')).toEqual({ 'celilo.computer': 'zone-abc-123', }); // set_in_object secret applied out-of-band by the responder. const secretRow = testDb .select() .from(secrets) .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'ddns_passwords'))) .get(); expect(secretRow).toBeTruthy(); if (!secretRow) return; const masterKey = await getOrCreateMasterKey(); const decoded = decryptSecret( { encryptedValue: secretRow.encryptedValue, iv: secretRow.iv, authTag: secretRow.authTag, }, masterKey, ); expect(JSON.parse(decoded)).toEqual({ 'celilo.computer': 'super-secret-pw' }); close(); }); test('idempotent: re-run with same trigger value fires no bus event', async () => { // First run populates everything. { const { close } = startEnsureResponder( bus, 'ensure.required.namecheap.managed_domain', { configValues: { 'config.zone_ids': 'z1' }, secretValues: { 'secret.ddns_passwords': 'pw1' }, }, testDb, ); const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb); expect(result.success).toBe(true); close(); } // Second run: nothing should fire on the bus. const fired: string[] = []; const handle = bus.watch('ensure.required.namecheap.managed_domain', (event) => { if (event.replyFor !== null) return; fired.push(event.type); }); const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb); expect(result.success).toBe(true); expect(result.alreadyApplied).toBe(true); expect(fired).toEqual([]); handle.close(); }); test('multiple trigger values: bus-mediated ensure preserves prior entries', async () => { { const { close } = startEnsureResponder( bus, 'ensure.required.namecheap.managed_domain', { configValues: { 'config.zone_ids': 'z1' }, secretValues: { 'secret.ddns_passwords': 'pw1' }, }, testDb, ); await interviewForEnsureInputs('namecheap', ENSURE, 'first.com', testDb); close(); } { const { close } = startEnsureResponder( bus, 'ensure.required.namecheap.managed_domain', { configValues: { 'config.zone_ids': 'z2' }, secretValues: { 'secret.ddns_passwords': 'pw2' }, }, testDb, ); await interviewForEnsureInputs('namecheap', ENSURE, 'second.com', testDb); close(); } const domains = testDb .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'domains'))) .get(); expect(JSON.parse(domains?.valueJson ?? '[]')).toEqual(['first.com', 'second.com']); const zoneIds = testDb .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'zone_ids'))) .get(); expect(JSON.parse(zoneIds?.valueJson ?? '{}')).toEqual({ 'first.com': 'z1', 'second.com': 'z2', }); const secretRow = testDb .select() .from(secrets) .where(and(eq(secrets.moduleId, 'namecheap'), eq(secrets.name, 'ddns_passwords'))) .get(); if (!secretRow) throw new Error('expected secret row'); const masterKey = await getOrCreateMasterKey(); const decoded = decryptSecret( { encryptedValue: secretRow.encryptedValue, iv: secretRow.iv, authTag: secretRow.authTag, }, masterKey, ); expect(JSON.parse(decoded)).toEqual({ 'first.com': 'pw1', 'second.com': 'pw2' }); }); test('append_to_array-only ensure fires no bus event', async () => { const arrayOnly: Ensure = { id: 'simple_append', inputs: [{ kind: 'append_to_array', target: 'config.allowed_hosts' }], }; // Wildcard catches any ensure event (this test's `arrayOnly` // ensure id is `simple_append`, not `managed_domain`). const fired: string[] = []; const handle = bus.watch('ensure.required.namecheap.*', (event) => { if (event.replyFor !== null) return; fired.push(event.type); }); const result = await interviewForEnsureInputs('namecheap', arrayOnly, 'newhost.local', testDb); expect(result.success).toBe(true); expect(fired).toEqual([]); const row = testDb .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, 'namecheap'), eq(moduleConfigs.key, 'allowed_hosts'))) .get(); expect(JSON.parse(row?.valueJson ?? '[]')).toEqual(['newhost.local']); handle.close(); }); test('promptOverride still works (legacy test escape hatch)', async () => { // The promptOverride path bypasses the bus entirely so existing // unit tests don't have to set up a responder. Verify it still // works. const fired: string[] = []; const handle = bus.watch('ensure.required.namecheap.managed_domain', (event) => { if (event.replyFor !== null) return; fired.push(event.type); }); const result = await interviewForEnsureInputs('namecheap', ENSURE, 'celilo.computer', testDb, { promptOverride: async () => 'overridden', }); expect(result.success).toBe(true); expect(fired).toEqual([]); // override path skipped the bus handle.close(); }); });