/** * The delivery loop, end to end, against the signal-cli simulator. * * Everything from "an alert is firing" to "the operator's reply acknowledged * it" runs here against a real database and a real HTTP server speaking * signal-cli's JSON-RPC dialect — including its awkward parts. * * What this proves: escalation selection, quiet-hours deferral, token minting, * the send itself, sender authentication on the way back, and the all-clear * reaching only those who were paged. * * What it does NOT prove: that signal-cli's real wire contract matches the * simulator's. Both encode the same assumption, so a wrong assumption passes. * That needs one verification against a real daemon. */ import { afterAll, afterEach, beforeAll, 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 { DbClient } from '../../db/client'; import { type Alert, type Route, modules } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import type { EscalationStep, RouteForEscalation } from './escalation'; import { interpretInbound } from './inbound'; import { type NotificationTransport, type NotifyDeps, notifyAlert, notifyResolved, } from './notifier'; import { createPerson, createRoute } from './people'; import { deliveriesForAlert, findLiveDelivery, mintDelivery } from './tokens'; const PETER = '+15550001111'; const WIFE = '+15550002222'; const PORT = 18101; const BASE = `http://127.0.0.1:${PORT}`; // Verified: the real daemon serves JSON-RPC only at this path (404 otherwise). const RPC = `${BASE}/api/v1/rpc`; const NOW = new Date('2026-07-28T12:00:00Z'); const at = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000); let simulator: ReturnType; async function control(path: string, body?: unknown) { const response = await fetch(`${BASE}/_control/${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); return response.json(); } async function sentMessages(): Promise<{ recipient: string; message: string }[]> { const response = await fetch(`${BASE}/_control/sent`); return ((await response.json()) as { sent: { recipient: string; message: string }[] }).sent; } beforeAll(async () => { const simulatorPath = join( import.meta.dir, '../../../../../packages/e2e/simulators/signal-cli/server.ts', ); simulator = Bun.spawn(['bun', simulatorPath], { env: { ...process.env, SIGNAL_RPC_PORT: String(PORT), SIGNAL_KNOWN_RECIPIENTS: `${PETER},${WIFE}`, // The flag the module's systemd unit must pass. The simulator defaults // to signal-cli's real default (`on-start`), where the daemon drains // replies into its own SSE stream and REFUSES `receive` — so a loop test // may only read replies from a daemon started the way celilo deploys it. SIGNAL_RECEIVE_MODE: 'manual', }, stdout: 'pipe', stderr: 'pipe', }); // Wait for it to bind rather than sleeping a guessed interval. for (let i = 0; i < 100; i++) { try { await fetch(`${BASE}/_control/sent`); return; } catch { await new Promise((r) => setTimeout(r, 50)); } } throw new Error('signal simulator did not start'); }); afterAll(() => { simulator?.kill(); }); describe('delivery loop against the signal-cli simulator', () => { let dir: string; let db: DbClient; let peterRoute: Route; let wifeRoute: Route; /** * A minimal JSON-RPC client for the simulator. * * Deliberately NOT the signal module's own client: importing a module script * here would pull it into apps/celilo's typecheck, where @celilo/capabilities * resolves to the vendored npm copy rather than the workspace source. The * module's client is driven against this same simulator from the module's own * test, where it resolves correctly. */ const notifier: NotificationTransport & { receive(): Promise<{ from: string; body: string }[]> } = { async send({ address, body, token }) { const message = token ? `${body}\n\nreply ${token}` : body; const response = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'send', params: { recipient: [address], message }, }), }); const parsed = (await response.json()) as { result?: { timestamp: number }; error?: { message: string }; }; // signal-cli reports failures inside an HTTP 200 — checking response.ok // would record a rejected page as delivered. if (parsed.error) throw new Error(parsed.error.message); return { messageId: String(parsed.result?.timestamp) }; }, async receive() { const response = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'receive', params: {} }), }); const parsed = (await response.json()) as { result?: { envelope?: { sourceNumber?: string; dataMessage?: { message?: string } }; }[]; }; // Receipts and typing indicators are not replies. return (parsed.result ?? []) .filter((e) => e.envelope?.dataMessage?.message) .map((e) => ({ from: e.envelope?.sourceNumber as string, body: e.envelope?.dataMessage?.message as string, })); }, }; function alert(over: Partial = {}): Alert { return { id: 'alert-1', key: 'module:caddy/check:disk-space', activeKey: 'module:caddy/check:disk-space', monitorId: 'mon-1', state: 'firing', severity: 'critical', firstFiredAt: NOW, lastSeenAt: NOW, graceUntil: NOW, suppressedByAlertId: null, suppressedByWindowId: null, unsuppressedAt: null, awaitingConfirmation: false, ackedBy: null, ackedAt: null, silencedUntil: null, escalationStep: 0, nextEscalationAt: null, message: '/var 94% used', details: null, resolvedAt: null, ...over, } as Alert; } function deps(over: Partial = {}): NotifyDeps { const steps: EscalationStep[] = [ { stepIndex: 0, routeId: peterRoute.id, delayMinutes: 0 }, { stepIndex: 1, routeId: wifeRoute.id, delayMinutes: 10 }, ]; const routes = new Map([ [peterRoute.id, { id: peterRoute.id, severityFloor: 'warning', enabled: true }], [wifeRoute.id, { id: wifeRoute.id, severityFloor: 'critical', enabled: true }], ]); return { steps, routes, routeDetails: new Map([ [peterRoute.id, peterRoute], [wifeRoute.id, wifeRoute], ]), quietHoursByPerson: new Map(), bypassQuietHours: false, transportFor: () => notifier, mintToken: (alertId, routeId) => mintDelivery(db, { kind: 'alert', targetId: alertId, routeId, now: NOW, ttlMs: 86_400_000, }).token, now: NOW, ...over, }; } beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'delivery-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); await control('reset'); db.insert(modules) .values({ id: 'signal', name: 'Signal', version: '0.1.0', manifestData: {}, sourcePath: '/tmp/signal', }) .run(); const peter = createPerson(db, { name: 'peter', timezone: 'America/Los_Angeles' }); const wife = createPerson(db, { name: 'wife', timezone: 'America/Los_Angeles' }); peterRoute = createRoute(db, { personId: peter.id, transportModuleId: 'signal', address: PETER, canAck: true, }); wifeRoute = createRoute(db, { personId: wife.id, transportModuleId: 'signal', address: WIFE, severityFloor: 'critical', canAck: true, }); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); test('a firing alert pages the primary with a reply token', async () => { const outcome = await notifyAlert(alert(), deps()); expect(outcome).toMatchObject({ result: 'sent', routeId: peterRoute.id, stepIndex: 0 }); const messages = await sentMessages(); expect(messages).toHaveLength(1); expect(messages[0].recipient).toBe(PETER); // Key first: on a phone, the first line is the notification preview. expect(messages[0].message).toContain('module:caddy/check:disk-space'); expect(messages[0].message).toMatch(/reply [0-9A-Z]{6}$/); }); test('the token in the message is the one recorded for that delivery', async () => { await notifyAlert(alert(), deps()); const [message] = await sentMessages(); const token = /reply ([0-9A-Z]{6})/.exec(message.message)?.[1] as string; const delivery = findLiveDelivery(db, token, NOW); expect(delivery?.routeId).toBe(peterRoute.id); expect(delivery?.targetId).toBe('alert-1'); }); // The whole point of the unification: the reply comes back over the same // transport and identifies both the alert and the person. test('the operator replies with the token and it authenticates', async () => { await notifyAlert(alert(), deps()); const [message] = await sentMessages(); const token = /reply ([0-9A-Z]{6})/.exec(message.message)?.[1] as string; await control('inbound', { from: PETER, body: token }); const received = await notifier.receive(); // Receipts and typing indicators the simulator interleaves are not replies. expect(received).toHaveLength(1); expect(received[0].from).toBe(PETER); const outcome = interpretInbound(db, { senderAddress: received[0].from, body: received[0].body, now: NOW, outstandingForRoute: () => [], }); expect(outcome).toMatchObject({ action: 'ack' }); expect(outcome.action === 'ack' && outcome.route.id).toBe(peterRoute.id); }); // A token replayed from another phone is refused even though the token is // perfectly valid. test("the wife replying with peter's token is refused", async () => { await notifyAlert(alert(), deps()); const [message] = await sentMessages(); const token = /reply ([0-9A-Z]{6})/.exec(message.message)?.[1] as string; await control('inbound', { from: WIFE, body: token }); const received = await notifier.receive(); const outcome = interpretInbound(db, { senderAddress: received[0].from, body: received[0].body, now: NOW, outstandingForRoute: () => [], }); expect(outcome).toEqual({ action: 'rejected', reason: 'wrong_sender' }); }); test('escalation reaches the secondary once the delay elapses', async () => { const escalated = alert({ escalationStep: 1 }); const outcome = await notifyAlert(escalated, deps({ now: at(10) })); expect(outcome).toMatchObject({ result: 'sent', routeId: wifeRoute.id, stepIndex: 1 }); expect((await sentMessages())[0].recipient).toBe(WIFE); }); test('quiet hours defer delivery — nothing is sent', async () => { // 04:00 Pacific, inside a 22:00-07:00 window. The alert fired an hour // earlier so its grace window has long elapsed — otherwise the run skips // on grace and never reaches the quiet-hours branch at all. const insideWindow = new Date('2026-07-28T11:00:00Z'); const fired = new Date(insideWindow.getTime() - 60 * 60_000); const outcome = await notifyAlert( alert({ firstFiredAt: fired, graceUntil: fired }), deps({ quietHoursByPerson: new Map([ [ peterRoute.personId, { personId: peterRoute.personId, start: '22:00', end: '07:00', timezone: 'America/Los_Angeles', }, ], ]), now: insideWindow, }), ); expect(outcome).toMatchObject({ result: 'deferred', reason: 'quiet_hours' }); // Deferred means deferred: the transport was never called. expect(await sentMessages()).toEqual([]); expect(outcome.result === 'deferred' && outcome.until.toISOString()).toBe( '2026-07-28T14:00:00.000Z', ); }); // A transport failure must surface. The operator believes they are covered. test('a send to an unregistered number is reported as failed, not sent', async () => { const strangerRoute = { ...peterRoute, address: '+15559998888' } as Route; const outcome = await notifyAlert( alert(), deps({ routeDetails: new Map([[peterRoute.id, strangerRoute]]) }), ); expect(outcome).toMatchObject({ result: 'failed' }); expect(outcome.result === 'failed' && outcome.error).toContain('Unregistered user'); }); test('a revoked device link surfaces as a failure rather than silence', async () => { await control('unlink'); const outcome = await notifyAlert(alert(), deps()); expect(outcome).toMatchObject({ result: 'failed' }); expect(outcome.result === 'failed' && outcome.error).toMatch(/not registered|link/i); await control('relink'); }); test('the all-clear goes only to routes that were paged', async () => { await notifyAlert(alert(), deps()); await control('reset'); const paged = deliveriesForAlert(db, 'alert-1'); expect(paged).toHaveLength(1); await notifyResolved({ id: 'alert-1', key: 'module:caddy/check:disk-space' }, [peterRoute], { transportFor: () => notifier, }); const messages = await sentMessages(); expect(messages).toHaveLength(1); expect(messages[0].recipient).toBe(PETER); expect(messages[0].message).toContain('RESOLVED'); }); });