/** * Tests for the set-time interview gate on `system config set` * (slice 3 of hook-jail-config-surface, design D5 + peba's --force addition). * * Writing `hooks.jail_policy off` is a fleet-wide security posture change, so * the write asks first over the bus interview and only proceeds on an explicit * confirm. `auto` and `required` write directly. `--force` skips the ask per * the house flags.force convention. * * Isolation: CELILO_DB_PATH / CELILO_DATA_DIR / EVENT_BUS_DB are set before the * SUT is imported, so nothing touches the production database or event bus. * bun test runs non-TTY, so `withInterviewSession` starts no terminal responder * and the interview guard probes the isolated bus — a successful direct write * with no responder running is itself proof the command never asked. */ import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; const testRoot = mkdtempSync(join(tmpdir(), 'celilo-jailgate-')); const busDbPath = join(testRoot, 'bus.db'); process.env.CELILO_DB_PATH = join(testRoot, 'celilo.db'); process.env.CELILO_DATA_DIR = join(testRoot, 'data'); process.env.EVENT_BUS_DB = busDbPath; const { getDb, closeDb } = await import('../../db/client'); const { systemConfig } = await import('../../db/schema'); const { handleSystemConfigSet } = await import('./system-config'); const { startProgrammaticResponder } = await import('../../services/programmatic-responder'); const KEY = 'hooks.jail_policy'; const CONFIRM_KEY = 'hooks-jail-policy.set-off'; function storedJailPolicy(): string | undefined { return getDb().select().from(systemConfig).where(eq(systemConfig.key, KEY)).get()?.value; } afterAll(() => { closeDb(); rmSync(testRoot, { recursive: true, force: true }); }); describe('system config set — jail_policy value validation', () => { beforeEach(() => { getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run(); }); test('a value outside the enum is refused and names the allowed values', async () => { const result = await handleSystemConfigSet([KEY, 'alwayssafe']); expect(result.success).toBe(false); if (result.success) throw new Error('expected a refusal'); expect(result.error).toContain('auto'); expect(result.error).toContain('required'); expect(storedJailPolicy()).toBeUndefined(); }); }); describe('system config set — jail_policy values that never ask', () => { beforeEach(() => { getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run(); }); test('auto writes directly with no responder listening', async () => { const result = await handleSystemConfigSet([KEY, 'auto']); expect(result.success).toBe(true); expect(storedJailPolicy()).toBe('auto'); }); test('required writes directly with no responder listening', async () => { const result = await handleSystemConfigSet([KEY, 'required']); expect(result.success).toBe(true); expect(storedJailPolicy()).toBe('required'); }); }); describe('system config set — the off interview gate', () => { beforeEach(() => { getDb().delete(systemConfig).where(eq(systemConfig.key, KEY)).run(); }); test('off fails fast with no responder listening and writes nothing', async () => { let threw: unknown; try { await handleSystemConfigSet([KEY, 'off']); } catch (error) { threw = error; } expect(threw).toBeInstanceOf(Error); expect(String((threw as Error).message)).toContain('No responder is listening'); expect(storedJailPolicy()).toBeUndefined(); }); test('off writes after a confirming reply over non-TTY, and the question was asked', async () => { const responder = startProgrammaticResponder({ busDbPath, db: getDb(), onMissing: 'skip', values: { interview: { [CONFIRM_KEY]: true } }, }); try { const result = await handleSystemConfigSet([KEY, 'off']); expect(result.success).toBe(true); expect(storedJailPolicy()).toBe('off'); const asked = responder.seenInterviewPayloads(); expect(asked.length).toBe(1); expect(asked[0].scope).toBe('hooks-jail-policy'); expect(asked[0].key).toBe('set-off'); expect(asked[0].message).toContain('unjailed'); } finally { responder.close(); } }); test('off is cancelled when the reply declines, and writes nothing', async () => { const responder = startProgrammaticResponder({ busDbPath, db: getDb(), onMissing: 'skip', values: { interview: { [CONFIRM_KEY]: false } }, }); try { const result = await handleSystemConfigSet([KEY, 'off']); expect(result.success).toBe(false); if (result.success) throw new Error('expected cancellation'); expect(result.error).toBe('Cancelled by user'); expect(storedJailPolicy()).toBeUndefined(); } finally { responder.close(); } }); test('--force skips the ask and writes off with no responder listening', async () => { const result = await handleSystemConfigSet([KEY, 'off'], { force: true }); expect(result.success).toBe(true); expect(storedJailPolicy()).toBe('off'); }); });