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 type { DbClient } from '../../db/client'; import { type NotificationDelivery, modules, people, routes } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import { interpretInbound, parseInbound } from './inbound'; import { mintDelivery } from './tokens'; const NOW = new Date('2026-07-28T03:00:00Z'); const TTL_MS = 24 * 60 * 60_000; const PETER = '+15550001'; const WIFE = '+15550002'; const STRANGER = '+15559999'; describe('parseInbound', () => { test.each(['ack', 'ACK', 'ok', 'K', ' ack ', '👍'])('%p is a bare ack', (body) => { expect(parseInbound(body)).toEqual({ kind: 'bare_ack' }); }); test('a bare token acknowledges', () => { expect(parseInbound('K7QM2X')).toEqual({ kind: 'ack', token: 'K7QM2X' }); }); test('a token typed in lower case still parses', () => { expect(parseInbound('k7qm2x')).toEqual({ kind: 'ack', token: 'K7QM2X' }); }); // The verb is optional in v1 — people will type it anyway. test('a trailing verb is accepted and ignored', () => { expect(parseInbound('K7QM2X ack')).toEqual({ kind: 'ack', token: 'K7QM2X' }); }); test.each(['', ' ', 'what is going on', 'K7QM2XY', 'ack now please'])( '%p is unrecognised', (body) => { expect(parseInbound(body)).toEqual({ kind: 'unrecognised' }); }, ); // The bug that started this: the page says "reply BPRJEH", and a human types // "ack BPRJEH". The old grammar read the VERB as the token, failed the length // check, and rejected a real acknowledgement over word order. test.each([ ['ack K7QM2X', 'K7QM2X'], ['ACK K7QM2X', 'K7QM2X'], ['ok k7qm2x', 'K7QM2X'], ['K7QM2X ack', 'K7QM2X'], ['k7qm2x OK', 'K7QM2X'], ['👍 K7QM2X', 'K7QM2X'], [' ack k7qm2x ', 'K7QM2X'], ])('%p yields token %p — the verb may lead, trail, or be absent', (body, token) => { expect(parseInbound(body)).toEqual({ kind: 'ack', token }); }); // A prefix is legal INPUT; whether it identifies anything is the delivery // table's question, not the parser's. test.each([ ['bp', 'BP'], ['ack bp', 'BP'], ['BP ack', 'BP'], ['b', 'B'], ])('%p parses as the prefix %p', (body, token) => { expect(parseInbound(body)).toEqual({ kind: 'ack', token }); }); }); describe('interpretInbound', () => { let dir: string; let db: DbClient; const outstanding: NotificationDelivery[] = []; const context = (senderAddress: string, body: string) => ({ senderAddress, body, now: NOW, outstandingForRoute: (routeId: string) => outstanding.filter((d) => d.routeId === routeId), }); beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'inbound-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); outstanding.length = 0; db.insert(modules) .values({ id: 'signal', name: 'Signal', version: '1.0.0', manifestData: {}, sourcePath: '/tmp/signal', }) .run(); db.insert(people).values({ id: 'p1', name: 'peter', timezone: 'UTC' }).run(); db.insert(people).values({ id: 'p2', name: 'wife', timezone: 'UTC' }).run(); db.insert(routes) .values({ id: 'r-peter', personId: 'p1', transportModuleId: 'signal', address: PETER }) .run(); db.insert(routes) .values({ id: 'r-wife', personId: 'p2', transportModuleId: 'signal', address: WIFE }) .run(); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); const mintFor = (routeId: string, targetId = 'alert-1') => mintDelivery(db, { kind: 'alert', targetId, routeId, now: NOW, ttlMs: TTL_MS }); test('a valid token from the right sender acknowledges', () => { const delivery = mintFor('r-peter'); const outcome = interpretInbound(db, context(PETER, delivery.token)); expect(outcome).toMatchObject({ action: 'ack' }); expect(outcome.action === 'ack' && outcome.route.id).toBe('r-peter'); }); // Factor two. A token glimpsed over a shoulder is useless from another phone. test('a valid token from the WRONG sender is refused', () => { const delivery = mintFor('r-peter'); expect(interpretInbound(db, context(WIFE, delivery.token))).toEqual({ action: 'rejected', reason: 'wrong_sender', }); }); // Silence, not a reply — a reply confirms the number is live and that this // is a celilo instance. test('a message from an unknown number is ignored, not answered', () => { const delivery = mintFor('r-peter'); expect(interpretInbound(db, context(STRANGER, delivery.token))).toEqual({ action: 'ignored', reason: 'unknown_sender', }); }); test('an unknown token from a known sender is rejected', () => { expect(interpretInbound(db, context(PETER, 'ZZZZZZ'))).toEqual({ action: 'rejected', reason: 'unknown_token', }); }); test('an expired token is rejected', () => { const delivery = mintFor('r-peter'); const late = { ...context(PETER, delivery.token), now: new Date(NOW.getTime() + TTL_MS + 1) }; expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' }); }); describe('an abbreviated token', () => { // How much an operator must type depends on what is actually outstanding. test('resolves when the prefix is unique among live deliveries', () => { const delivery = mintFor('r-peter'); const prefix = delivery.token.slice(0, 2); const outcome = interpretInbound(db, context(PETER, `ack ${prefix.toLowerCase()}`)); expect(outcome).toMatchObject({ action: 'ack' }); expect(outcome.action === 'ack' && outcome.delivery.id).toBe(delivery.id); }); test('a single character is enough when only one alert is live', () => { const delivery = mintFor('r-peter'); // One character in 32 is `K`, which is ALSO an ack synonym — so that // token parses as a bare ack rather than as a prefix. Either reading // must land on the single live delivery, so the harness reflects it as // outstanding as well; without this the test failed ~3% of runs with // `unknown_token`, because the bare-ack path saw nothing outstanding. outstanding.push(delivery); const outcome = interpretInbound(db, context(PETER, delivery.token.slice(0, 1))); expect(outcome).toMatchObject({ action: 'ack' }); }); // Never a guess: acknowledging the wrong alert is worse than asking again. test('is refused as ambiguous when two live deliveries share the prefix', () => { const a = mintFor('r-peter', 'alert-a'); let b = mintFor('r-peter', 'alert-b'); // Force a shared prefix rather than hoping two random tokens collide. for (let i = 0; i < 40 && b.token[0] !== a.token[0]; i++) { b = mintFor('r-peter', `alert-b-${i}`); } if (b.token[0] !== a.token[0]) return; // vanishingly unlikely; do not flake // As above: a shared first character of `K` parses as a bare ack, and // that path reads `outstandingForRoute` rather than the prefix search. // Both readings must answer `ambiguous` here, so both are given two // candidates to be ambiguous between. outstanding.push(a, b); expect(interpretInbound(db, context(PETER, a.token[0]))).toEqual({ action: 'rejected', reason: 'ambiguous', }); }); // Uniqueness is scoped to what is LIVE, so yesterday's tokens cannot make // today's prefix ambiguous. test('an expired delivery does not make a prefix ambiguous', () => { const delivery = mintFor('r-peter'); const late = { ...context(PETER, delivery.token.slice(0, 2)), now: new Date(NOW.getTime() + TTL_MS + 1), }; expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' }); }); // The second factor still holds for a prefix. test('a prefix from the WRONG sender is still refused', () => { const delivery = mintFor('r-peter'); expect(interpretInbound(db, context(WIFE, delivery.token.slice(0, 3)))).toEqual({ action: 'rejected', reason: 'wrong_sender', }); }); }); test('gibberish from a known sender is rejected as unrecognised', () => { expect(interpretInbound(db, context(PETER, 'what is going on'))).toEqual({ action: 'rejected', reason: 'unrecognised', }); }); describe('bare ack', () => { test('works when exactly one delivery is outstanding', () => { outstanding.push(mintFor('r-peter')); const outcome = interpretInbound(db, context(PETER, 'ack')); expect(outcome).toMatchObject({ action: 'ack' }); }); // Guessing which alert they meant would ack the wrong one. test('is refused as ambiguous when several are outstanding', () => { outstanding.push(mintFor('r-peter', 'alert-1')); outstanding.push(mintFor('r-peter', 'alert-2')); expect(interpretInbound(db, context(PETER, 'ack'))).toEqual({ action: 'rejected', reason: 'ambiguous', }); }); test('is refused when nothing is outstanding', () => { expect(interpretInbound(db, context(PETER, 'ack'))).toEqual({ action: 'rejected', reason: 'unknown_token', }); }); // Another person's outstanding page is not this person's to ack blindly. test('only considers the sender own outstanding deliveries', () => { outstanding.push(mintFor('r-wife')); expect(interpretInbound(db, context(PETER, 'ack'))).toEqual({ action: 'rejected', reason: 'unknown_token', }); }); }); /** * Answering a deploy question. * * These ran against `parseInterviewAnswer` in isolation and all passed while * the feature was dead in production (#533) — the alert grammar rejected * every one of these bodies before the answer path was ever reached. They * live here now, against `interpretInbound`, because the wiring is the part * that was broken and the part worth guarding. */ describe('a deploy question', () => { const askFor = (routeId: string, targetId = 'evt-1') => mintDelivery(db, { kind: 'interview', targetId, routeId, now: NOW, ttlMs: TTL_MS }); test('takes everything after the token as the answer', () => { const q = askFor('r-peter'); expect(interpretInbound(db, context(PETER, `${q.token} www.example.com`))).toMatchObject({ action: 'answer', value: 'www.example.com', }); }); test('is case-insensitive on the token', () => { const q = askFor('r-peter'); expect( interpretInbound(db, context(PETER, `${q.token.toLowerCase()} www.example.com`)), ).toMatchObject({ action: 'answer', value: 'www.example.com' }); }); // An answer may legitimately contain spaces and punctuation. Second-guessing // it would corrupt exactly the values that are painful to retype. This is // the shape that was refused as `unrecognised` for months. test('preserves an answer containing spaces and punctuation verbatim', () => { const q = askFor('r-peter'); expect( interpretInbound(db, context(PETER, `${q.token} my value: with, punctuation`)), ).toMatchObject({ action: 'answer', value: 'my value: with, punctuation' }); }); // The word that must NOT acknowledge an alert is a perfectly good answer to // a question. That the same text means different things is the whole reason // the token is resolved before the grammar is chosen. test('an answer that looks like a terminal verb is still an answer', () => { const q = askFor('r-peter'); expect(interpretInbound(db, context(PETER, `${q.token} resolve`))).toMatchObject({ action: 'answer', value: 'resolve', }); }); test('a token with no value says so, rather than being unrecognised', () => { const q = askFor('r-peter'); for (const body of [q.token, `${q.token} `]) { expect(interpretInbound(db, context(PETER, body))).toEqual({ action: 'rejected', reason: 'needs_value', }); } }); // A verb-led message names no value, so it is not an answer. test('a leading ack verb is not an answer', () => { const q = askFor('r-peter'); expect(interpretInbound(db, context(PETER, `ack ${q.token}`))).toEqual({ action: 'rejected', reason: 'unrecognised', }); }); // Both factors still apply — a question is no less sensitive than a page. test('an answer from the wrong number is refused', () => { const q = askFor('r-peter'); expect(interpretInbound(db, context(WIFE, `${q.token} www.example.com`))).toEqual({ action: 'rejected', reason: 'wrong_sender', }); }); // Reported as `unrecognised` rather than `unknown_token`, and that is // deliberate: `ZZZZZZ value` and `what is going on` are the same shape to // celilo — a token-shaped word followed by text — because the token // alphabet is most of the Latin one. Nothing corroborates that the leading // word was meant as a token, so claiming the operator got a TOKEN wrong // would send them hunting for a typo they may not have made. test('an unknown leading token is not answerable', () => { askFor('r-peter'); expect(interpretInbound(db, context(PETER, 'ZZZZZZ value'))).toEqual({ action: 'rejected', reason: 'unrecognised', }); }); }); /** * The two grammars share a table, so the same body must mean different * things depending on what the token names. Asserting both readings side by * side is the guard against one of them quietly swallowing the other again. */ describe('the two grammars do not bleed into each other', () => { test('` resolve` answers a question and REFUSES an alert', () => { const question = mintDelivery(db, { kind: 'interview', targetId: 'evt-1', routeId: 'r-peter', now: NOW, ttlMs: TTL_MS, }); const alert = mintFor('r-peter'); expect(interpretInbound(db, context(PETER, `${question.token} resolve`))).toMatchObject({ action: 'answer', value: 'resolve', }); expect(interpretInbound(db, context(PETER, `${alert.token} resolve`))).toEqual({ action: 'rejected', reason: 'unrecognised', }); }); }); }); /** * An earlier cut ignored any verb after the token, so ` resolve` * silently ACKNOWLEDGED — the operator believes they cleared the alert while it * is still firing. Raised in review of #420. */ describe('reply verbs', () => { test('a bare token acknowledges', () => { expect(parseInbound('K7QM2X')).toEqual({ kind: 'ack', token: 'K7QM2X' }); }); test('an explicit ack synonym acknowledges', () => { for (const verb of ['ack', 'ok', 'k', '👍']) { expect(parseInbound(`K7QM2X ${verb}`)).toEqual({ kind: 'ack', token: 'K7QM2X' }); } }); // Doing nothing and saying so beats doing the wrong thing quietly. test('resolve is REJECTED rather than treated as an ack', () => { expect(parseInbound('K7QM2X resolve')).toEqual({ kind: 'unrecognised' }); }); test('silence is REJECTED rather than treated as an ack', () => { expect(parseInbound('K7QM2X silence 2h')).toEqual({ kind: 'unrecognised' }); }); test('a typo after the token is rejected, not guessed at', () => { expect(parseInbound('K7QM2X akc')).toEqual({ kind: 'unrecognised' }); }); }); /** * celilo#500. The accepted grammar, in one table, because it was previously * knowable only by reading the parser. * * The headline case is `reply K7QM2X`: every page ends with that literal line, * and an operator who did exactly what it said was told `unrecognised` while * the alert kept firing. Two consecutive real operator replies were lost this * way. The instruction the system gives was the one input it refused. * * The trailing-period case is the same shape of unkindness. iOS inserts a full * stop on a double space by default, and a one-word message is precisely where * that fires. * * Being liberal here costs nothing: authentication is the token plus the sender * check (see the header of inbound.ts), never punctuation strictness. */ describe('the grammar accepts what a phone actually sends (#500)', () => { const ACKNOWLEDGES: Array<[string, string]> = [ ['K7QM2X', 'the bare token'], ['k7qm2x', 'lower case'], ['K7-QM2X', 'a separator the operator kept'], ['K7QM2X ack', 'a trailing verb'], ['ack K7QM2X', 'the natural spoken order'], ['reply K7QM2X', 'WHAT THE PAGE ITSELF INSTRUCTS'], ['K7QM2X.', 'iOS double-space autocorrect'], ['Ack K7QM2X.', 'both at once, capitalised'], ['reply k7-qm2x.', 'everything at once'], ]; test.each(ACKNOWLEDGES)('%p acknowledges (%s)', (body) => { expect(parseInbound(body)).toEqual({ kind: 'ack', token: 'K7QM2X' }); }); /** * The guard that must survive. ` resolve` silently acknowledging an * alert the operator meant to escalate is the worst outcome available here, * so widening the grammar must not widen THIS. */ const REFUSED: Array<[string, string]> = [ ['K7QM2X resolve', 'a verb celilo does not implement'], ['K7QM2X silence 2h', 'a request with an argument'], ['K7QM2X akc', 'a typo, not guessed at'], ['reply K7QM2X resolve', 'the new verb does not smuggle a sentence through'], ['what is going on', 'no token-shaped word anywhere'], ['K7QM2XY', 'too long to be a token'], ['reply', 'the verb alone names nothing'], ]; test.each(REFUSED)('%p is refused (%s)', (body) => { expect(parseInbound(body)).toEqual({ kind: 'unrecognised' }); }); /** * The instruction and the parser are pinned to the same literal from both * sides, so they cannot drift apart again. `composeBody` in * `modules/signal/scripts/notification.ts` emits `\n\nreply `, and * `signal-rpc.test.ts:273` asserts that exact output. This asserts the parser * accepts it. Change the wording and one of the two goes red. */ test('the exact line composeBody emits is accepted', () => { const asSent = 'caddy is down\n\nreply K7QM2X'.split('\n\n')[1]; expect(asSent).toBe('reply K7QM2X'); expect(parseInbound(asSent)).toEqual({ kind: 'ack', token: 'K7QM2X' }); }); });