/** * Regression: the programmatic responder (`celilo events respond`) must handle * `aspect.required.*` so a HEADLESS deploy of a module with a base_module_aspect * can be approved without a TTY. * * Before the fix it watched only config/secret/ensure/interview — never aspect — * so a headless deploy whose aspect consent wasn't pre-recorded emitted * `aspect.required..` and hung forever (busInterview uses timeoutMs:0; * no responder ever replied). This reproduced the ISS-0156 cutover hang and is * the core of #262. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defineEvents, openBus } from '@celilo/event-bus'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { resetTestDbPath } from '../test-utils/db-path'; import { type AspectRequiredPayload, EVENT_TYPES } from './bus-interview'; import { startProgrammaticResponder } from './programmatic-responder'; const NO_SCHEMAS = defineEvents({}); const ASPECT_PAYLOAD: AspectRequiredPayload = { module: 'technitium', role: 'dns-client-config', zones: ['dmz', 'app', 'secure', 'internal'], triggers: ['on_install'], trigger: 'on_install', reason: 'no_approval', }; /** Emit the aspect-consent query and return the responder's decision, or null. */ async function askAspectConsent( busPath: string, module: string, role: string, payload: AspectRequiredPayload, ): Promise { const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS }); try { const replies = await bus.query( EVENT_TYPES.aspectRequired(module, role) as never, payload as never, { timeoutMs: 3000, pollIntervalMs: 100, expect: 'first', }, ); if (replies.length === 0) return null; return (replies[0].payload as { consented?: boolean }).consented ?? null; } finally { bus.close(); } } describe('programmatic responder — aspect.required consent (#262)', () => { let dir: string; let busPath: string; let db: ReturnType; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-resp-aspect-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); busPath = join(dir, 'bus.db'); await runMigrations(process.env.CELILO_DB_PATH); db = getDb(); }); afterEach(() => { closeDb(); rmSync(dir, { recursive: true, force: true }); resetTestDbPath(); }); it('approves (consented=true) when the policy approves the module', async () => { const handle = startProgrammaticResponder({ busDbPath: busPath, db, onMissing: 'skip', values: { aspects: { technitium: true } }, }); try { const decision = await askAspectConsent( busPath, 'technitium', 'dns-client-config', ASPECT_PAYLOAD, ); expect(decision).toBe(true); } finally { handle.close(); } }); it("approves via the '*' wildcard policy", async () => { const handle = startProgrammaticResponder({ busDbPath: busPath, db, onMissing: 'skip', values: { aspects: { '*': true } }, }); try { const decision = await askAspectConsent( busPath, 'technitium', 'dns-client-config', ASPECT_PAYLOAD, ); expect(decision).toBe(true); } finally { handle.close(); } }); it('refuses (consented=false) when the policy denies the module', async () => { const handle = startProgrammaticResponder({ busDbPath: busPath, db, onMissing: 'skip', values: { aspects: { technitium: false } }, }); try { const decision = await askAspectConsent( busPath, 'technitium', 'dns-client-config', ASPECT_PAYLOAD, ); expect(decision).toBe(false); } finally { handle.close(); } }); it('does not reply when no aspect policy is provided (onMissing: skip)', async () => { const handle = startProgrammaticResponder({ busDbPath: busPath, db, onMissing: 'skip', values: {}, }); try { const decision = await askAspectConsent( busPath, 'technitium', 'dns-client-config', ASPECT_PAYLOAD, ); expect(decision).toBeNull(); } finally { handle.close(); } }); });