/** * Layer 1 — bus-mediated secret-interview flow. * * Drives `interviewForMissingSecrets` against a real sqlite bus + real * encrypted store + a programmatic test responder. No fixture modules, * no machines, no terminal — the responder is just a `bus.watch` that * mimics what `terminal-responder.ts` does. * * Covers what stage 3 introduced: * - `user_provided` / `user_password` / `generated_optional` go through * the bus; the responder writes the secret out-of-band; the deploy * reads from the store after ack. * - `generated` and derived secrets bypass the bus entirely. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type Bus, defineEvents, openBus } from '@celilo/event-bus'; import { type DbClient, getDb } from '../db/client'; import { modules } from '../db/schema'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { resetTestDbPath } from '../test-utils/db-path'; import type { SecretRequiredPayload } from './bus-interview'; import { interviewForMissingSecrets, readModuleSecretKey, writeModuleSecretKey, } from './config-interview'; const NO_SCHEMAS = defineEvents({}); interface ResponderReply { /** Value the responder "types" — written out-of-band before ack. */ value: string; /** * If true, fall back to auto-generate using the payload's `generate` * hint (mimics what terminal-responder does on empty input for * `generated_optional`). Overrides `value` when set. */ autoGenerate?: boolean; } /** * In-process test responder. Watches a single secret.required event * type and replies with a fixed value (or auto-generates) — mirrors * the writeModuleSecretKey + emit-ack flow that terminal-responder * does on a TTY. * * Returns a `seen` ref that captures the payloads the responder saw, * so tests can assert on what the deploy emitted. */ function startTestResponder( bus: Bus, pattern: string, reply: ResponderReply, db: DbClient, ): { seen: Array<{ payload: SecretRequiredPayload; type: string }>; close: () => void } { const seen: Array<{ payload: SecretRequiredPayload; type: string }> = []; const handle = bus.watch(pattern, async (event) => { if (event.replyFor !== null) return; const payload = event.payload as SecretRequiredPayload; seen.push({ payload, type: event.type }); let value = reply.value; if (reply.autoGenerate && payload.generate) { const { generateSecret } = await import('../secrets/generators'); value = generateSecret(payload.generate); } const masterKey = await getOrCreateMasterKey(); await writeModuleSecretKey(payload.module, payload.key, value, db, masterKey); bus.emitRaw( `${event.type}.reply`, { acknowledged: true }, { 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 secret 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(); }, }; } function writeSecretsSchema( dir: string, properties: Record< string, { source: 'generated' | 'user_provided' | 'user_password' | 'generated_optional'; format?: string; length?: number; derive_from?: string; derive_method?: string; description?: string; } >, ): void { mkdirSync(join(dir, 'schema'), { recursive: true }); const schema = { type: 'object', properties: Object.fromEntries( Object.entries(properties).map(([name, props]) => [name, { type: 'string', ...props }]), ), }; writeFileSync(join(dir, 'schema', 'secrets.json'), JSON.stringify(schema)); } describe('bus-mediated interviewForMissingSecrets', () => { let tempDir: string; let testDb: DbClient; let bus: Bus; let busDbPath: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-bus-secret-')); 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: 'testmod', name: 'Test Module', sourcePath: tempDir, version: '1.0.0', manifestData: {}, }) .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('user_provided: emits secret.required, responder writes, value lands in store', async () => { // No schema file → defaults to user_provided per // interviewForMissingSecrets's `metadata?.source || 'user_provided'`. // Use a snake_case secret name to also exercise the pattern // compiler's LIKE escape — pre-0.1.4 this would have thrown. const { seen, close } = startTestResponder( bus, 'secret.required.testmod.api_key', { value: 'sk-12345' }, testDb, ); const result = await interviewForMissingSecrets( 'testmod', [{ name: 'api_key', source: 'secret', description: 'API key for upstream' }], testDb, ); expect(result.success).toBe(true); expect(result.configured).toContain('api_key (secret)'); expect(seen.length).toBe(1); expect(seen[0].type).toBe('secret.required.testmod.api_key'); expect(seen[0].payload.module).toBe('testmod'); expect(seen[0].payload.key).toBe('api_key'); expect(seen[0].payload.style).toBe('user_provided'); expect(seen[0].payload.description).toBe('API key for upstream'); // The bus payload must NEVER carry the secret value. expect(seen[0].payload).not.toHaveProperty('value'); const masterKey = await getOrCreateMasterKey(); const stored = await readModuleSecretKey('testmod', 'api_key', testDb, masterKey); expect(stored).toBe('sk-12345'); close(); }); test('user_password: payload carries style:user_password', async () => { writeSecretsSchema(tempDir, { admin_password: { source: 'user_password', description: 'Admin login' }, }); const { seen, close } = startTestResponder( bus, 'secret.required.testmod.admin_password', { value: 'hunter2' }, testDb, ); const result = await interviewForMissingSecrets( 'testmod', [{ name: 'admin_password', source: 'secret' }], testDb, ); expect(result.success).toBe(true); expect(seen.length).toBe(1); expect(seen[0].payload.style).toBe('user_password'); const masterKey = await getOrCreateMasterKey(); const stored = await readModuleSecretKey('testmod', 'admin_password', testDb, masterKey); expect(stored).toBe('hunter2'); close(); }); test('generated_optional: payload carries generate hints + style', async () => { writeSecretsSchema(tempDir, { session_key: { source: 'generated_optional', format: 'hex', length: 16 }, }); const { seen, close } = startTestResponder( bus, 'secret.required.testmod.session_key', { value: 'user-typed-it' }, testDb, ); const result = await interviewForMissingSecrets( 'testmod', [{ name: 'session_key', source: 'secret' }], testDb, ); expect(result.success).toBe(true); expect(seen[0].payload.style).toBe('generated_optional'); expect(seen[0].payload.generate).toEqual({ format: 'hex', length: 16 }); const masterKey = await getOrCreateMasterKey(); const stored = await readModuleSecretKey('testmod', 'session_key', testDb, masterKey); expect(stored).toBe('user-typed-it'); close(); }); test('generated_optional + responder auto-generates: secret in store has the generated shape', async () => { writeSecretsSchema(tempDir, { session_key: { source: 'generated_optional', format: 'hex', length: 16 }, }); const { close } = startTestResponder( bus, 'secret.required.testmod.session_key', { value: '', autoGenerate: true }, testDb, ); const result = await interviewForMissingSecrets( 'testmod', [{ name: 'session_key', source: 'secret' }], testDb, ); expect(result.success).toBe(true); const masterKey = await getOrCreateMasterKey(); const stored = await readModuleSecretKey('testmod', 'session_key', testDb, masterKey); // hex format with 16 bytes → 32 hex chars. expect(stored).toMatch(/^[0-9a-f]{32}$/); close(); }); test('source=generated bypasses the bus (no event fired, no responder needed)', async () => { writeSecretsSchema(tempDir, { auto_token: { source: 'generated', format: 'base64', length: 32 }, }); // Wildcard catches any spurious user-input bus event. const fired: string[] = []; const handle = bus.watch('secret.required.testmod.*', (event) => { if (event.replyFor !== null) return; fired.push(event.type); }); const result = await interviewForMissingSecrets( 'testmod', [{ name: 'auto_token', source: 'secret' }], testDb, ); expect(result.success).toBe(true); expect(fired).toEqual([]); const masterKey = await getOrCreateMasterKey(); const stored = await readModuleSecretKey('testmod', 'auto_token', testDb, masterKey); expect(stored).toBeTruthy(); expect(stored?.length).toBeGreaterThan(0); handle.close(); }); test('manifest generate field overrides schema source — also bypasses bus', async () => { writeSecretsSchema(tempDir, { // Schema says user_provided, but the missing-variable carries an // explicit `generate` block. The latter wins per the // `hasManifestGenerate` priority in interviewForMissingSecrets. override_secret: { source: 'user_provided' }, }); const fired: string[] = []; const handle = bus.watch('secret.required.testmod.*', (event) => { if (event.replyFor !== null) return; fired.push(event.type); }); const result = await interviewForMissingSecrets( 'testmod', [ { name: 'override_secret', source: 'secret', generate: { method: 'random', length: 16, encoding: 'hex' }, }, ], testDb, ); expect(result.success).toBe(true); expect(fired).toEqual([]); handle.close(); }); // string-map secrets: the responder collects key/value pairs via an // add-loop and JSON-stringifies the result before writing. The bus // payload signals the type so the responder picks the right UX. test('string-map: payload carries type=string-map + labels; stored value is JSON-stringified record', async () => { const { seen, close } = startTestResponder( bus, 'secret.required.testmod.ddns_passwords', { value: JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }) }, testDb, ); const result = await interviewForMissingSecrets( 'testmod', [ { name: 'ddns_passwords', source: 'secret', type: 'string-map', description: 'Namecheap Dynamic DNS password per managed domain', key_label: 'Domain', value_label: 'Password', }, ], testDb, ); expect(result.success).toBe(true); expect(seen.length).toBe(1); expect(seen[0].payload.type).toBe('string-map'); expect(seen[0].payload.key_label).toBe('Domain'); expect(seen[0].payload.value_label).toBe('Password'); // Bus must NOT carry the secret value, even for string-map. expect(seen[0].payload).not.toHaveProperty('value'); const masterKey = await getOrCreateMasterKey(); const stored = await readModuleSecretKey('testmod', 'ddns_passwords', testDb, masterKey); expect(stored).toBe(JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' })); close(); }); test('string-map: enrichment from manifest.yml makes its way to the bus payload', async () => { // Drop a manifest.yml in tempDir (the module's sourcePath) declaring // the secret as string-map with custom labels. validateModuleSecrets // reads the manifest and threads the metadata through to the bus. writeSecretsSchema(tempDir, { ddns_passwords: { source: 'user_provided' }, }); writeFileSync( join(tempDir, 'manifest.yml'), ` celilo_contract: "1.0" id: testmod name: Test Module version: 1.0.0 secrets: declares: - name: ddns_passwords type: string-map required: true description: Per-domain DDNS password sensitive: true key_label: Domain value_label: Password `, ); const { seen, close } = startTestResponder( bus, 'secret.required.testmod.ddns_passwords', { value: JSON.stringify({ 'a.test': 'pw' }) }, testDb, ); const { interviewForMissingSecrets, validateModuleSecrets } = await import( './config-interview' ); const missing = await validateModuleSecrets('testmod', testDb); expect(missing).toHaveLength(1); expect(missing[0].name).toBe('ddns_passwords'); expect(missing[0].type).toBe('string-map'); expect(missing[0].key_label).toBe('Domain'); expect(missing[0].value_label).toBe('Password'); const result = await interviewForMissingSecrets('testmod', missing, testDb); expect(result.success).toBe(true); expect(seen[0].payload.type).toBe('string-map'); expect(seen[0].payload.key_label).toBe('Domain'); expect(seen[0].payload.value_label).toBe('Password'); close(); }); });