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 { defineEvents, openBus } from '@celilo/event-bus'; import type { DbClient } from '../../db/client'; import { type Route, modules } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import { type NotificationResponderHandle, startNotificationResponder, } from './notification-responder'; import { createPerson, createRoute } from './people'; import { consumeDelivery, findLiveDelivery } from './tokens'; const NO_SCHEMAS = defineEvents({}); const NOW = new Date('2026-07-28T12:00:00Z'); describe('notification responder', () => { let dir: string; let db: DbClient; let busPath: string; let route: Route; let sent: { address: string; body: string }[]; let declined: { type: string; reason: string }[]; let sendFailures: string[]; let sendFails: boolean; let handle: NotificationResponderHandle | undefined; function start(hasTty = false, routes?: Route[]) { handle = startNotificationResponder({ db, busDbPath: busPath, routes: routes ?? [route], transportFor: () => ({ send: async (r) => { if (sendFails) throw new Error('unlinked account cannot send'); sent.push({ address: r.address, body: r.body }); return { messageId: 'm1' }; }, }), hasTty: () => hasTty, // Real time, not the fixed NOW: the bus stamps `emitted_at` with the // wall clock, so a fake clock in the past makes every question look // like it has not been emitted yet. now: () => new Date(), ttlMs: 86_400_000, // Most tests are about the delivery decision, not the grace — a // question emitted microseconds ago is otherwise always too fresh. // The grace itself gets its own test below. askAfterMs: 0, onDeclined: (type, reason) => declined.push({ type, reason }), onSendFailed: (type, error) => sendFailures.push(`${type}: ${error}`), }); return handle; } beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'nresp-')); const dbPath = join(dir, 'celilo.db'); busPath = join(dir, 'events.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); sent = []; declined = []; sendFailures = []; sendFails = false; db.insert(modules) .values({ id: 'signal', name: 'Signal', version: '0.1.0', manifestData: {}, sourcePath: '/tmp/signal', }) .run(); const peter = createPerson(db, { name: 'peter', timezone: 'UTC' }); route = createRoute(db, { personId: peter.id, transportModuleId: 'signal', address: '+15550001', canAck: true, }); }); afterEach(() => { handle?.stop(); handle = undefined; db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); const emit = (type: string, payload: Record) => { const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS }); try { return bus.emitRaw(type, payload, { emittedBy: 'test' }).id; } finally { bus.close(); } }; const repliesFor = (eventId: number) => { const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS }); try { return bus.db.query('SELECT type, payload FROM events WHERE reply_for = ?').all(eventId) as { type: string; payload: string; }[]; } finally { bus.close(); } }; test('a non-secret question is delivered to a route', async () => { const responder = start(); emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); expect(sent).toHaveLength(1); expect(sent[0].address).toBe('+15550001'); expect(sent[0].body).toContain('caddy needs: hostname'); expect(sent[0].body).toMatch(/reply [0-9A-Z]{6} /); }); // THE security property. A secret must never reach a chat, even headlessly. test('a secret question is never delivered', async () => { const responder = start(); emit('secret.required.forgejo.admin_token', { scope: 'forgejo', key: 'admin_token' }); await responder.poll(); expect(sent).toEqual([]); expect(declined[0].type).toBe('secret.required.forgejo.admin_token'); expect(declined[0].reason).toContain('terminal'); }); test('nothing is delivered when a terminal is attached', async () => { const responder = start(true); emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); expect(sent).toEqual([]); expect(declined[0].reason).toContain('terminal responder'); }); test('nothing is delivered when no route can receive replies', async () => { const responder = start(false, []); emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); expect(sent).toEqual([]); expect(declined[0].reason).toContain('never be answered'); }); /** * Resolve a token the way the inbound poller does — through the DELIVERY * row, not through anything the responder remembers. That is what lets a * reply arriving minutes later, in a different process, still land. */ function eventIdForToken(token: string): string | null { const delivery = findLiveDelivery(db, token, NOW); return delivery?.kind === 'interview' ? delivery.targetId : null; } const tokenIn = (body: string) => /reply ([0-9A-Z]{6})/.exec(body)?.[1] as string; // The unification: a reply from a phone unblocks a waiting deploy. test('an inbound reply publishes the answer against the waiting question', async () => { const responder = start(); const eventId = emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); const resolved = eventIdForToken(tokenIn(sent[0].body)) as string; expect(resolved).toBe(String(eventId)); responder.answer(resolved, 'www.example.com'); const replies = repliesFor(eventId); expect(replies).toHaveLength(1); expect(replies[0].type).toBe('config.required.caddy.hostname.reply'); expect(JSON.parse(replies[0].payload).value).toBe('www.example.com'); }); /** * A responder that is actually attached — a terminal, or the wire bridge in * api-serve — answers within seconds. Paging a phone about a question * somebody is already looking at is noise, and both responders would reply * to the same question. */ test('a fresh question is left for whatever responder is attached', async () => { const responder = startNotificationResponder({ db, busDbPath: busPath, routes: [route], transportFor: () => ({ send: async (r) => { sent.push({ address: r.address, body: r.body }); return { messageId: 'm1' }; }, }), hasTty: () => false, now: () => new Date(), ttlMs: 86_400_000, askAfterMs: 90_000, }); handle = responder; emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); expect((await responder.poll()).asked).toBe(0); expect(sent).toEqual([]); }); /** * The CLI runs one process per invocation and polls every few seconds, so * "asked already" cannot live in memory — it has to be the delivery row. */ test('a question is not re-asked by a fresh responder process', async () => { const first = start(); emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); expect((await first.poll()).asked).toBe(1); first.stop(); const second = start(); expect((await second.poll()).asked).toBe(0); expect(sent).toHaveLength(1); }); // Found by the e2e: the delivery row is minted BEFORE the send (the token has // to be in the body), so a failed send would otherwise leave a record saying // the question was asked — suppressing every retry while the deploy waits // forever on a question nobody received. test('a failed send is reported and does NOT count as asked', async () => { sendFails = true; const responder = start(); emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); const first = await responder.poll(); expect(first.asked).toBe(0); expect(first.failed).toBe(1); expect(sendFailures[0]).toContain('config.required.caddy.hostname'); // The question is still outstanding, so a later poll retries it. sendFails = false; expect((await responder.poll()).asked).toBe(1); }); test('a token nobody issued resolves to nothing', async () => { const responder = start(); const eventId = emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); expect(eventIdForToken('ZZZZZZ')).toBeNull(); expect(repliesFor(eventId)).toEqual([]); }); // Two questions outstanding must not cross-answer. test('each question is answered by its own token', async () => { const responder = start(); const first = emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); const second = emit('config.required.forgejo.domain', { scope: 'forgejo', key: 'domain' }); await responder.poll(); expect(sent).toHaveLength(2); responder.answer(eventIdForToken(tokenIn(sent[1].body)) as string, 'git.example.com'); expect(repliesFor(first)).toEqual([]); expect(JSON.parse(repliesFor(second)[0].payload).value).toBe('git.example.com'); }); // Single-use lives in the token layer, which is where the alert path // enforces it too — one mechanism, not two. test('a consumed token cannot be replayed', async () => { const responder = start(); emit('config.required.caddy.hostname', { scope: 'caddy', key: 'hostname' }); await responder.poll(); const token = tokenIn(sent[0].body); const delivery = findLiveDelivery(db, token, NOW); expect(delivery).toBeDefined(); consumeDelivery(db, (delivery as { id: string }).id, NOW); expect(eventIdForToken(token)).toBeNull(); expect(responder).toBeDefined(); }); });