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 { modules, people, routes } from '../../db/schema'; import { setupTestDatabaseAt } from '../../test-utils/database'; import { resetTestDbPath } from '../../test-utils/db-path'; import { consumeDelivery, deliveriesForAlert, findLiveDelivery, generateToken, mintDelivery, normaliseToken, } from './tokens'; const NOW = new Date('2026-07-28T03:00:00Z'); const TTL_MS = 24 * 60 * 60_000; const ROUTE = 'route-1'; describe('token generation', () => { // These are read off a phone screen and typed back. Characters people // reliably confuse are excluded on purpose. test('never contains I, L, O, or U', () => { for (let i = 0; i < 500; i++) { expect(generateToken()).not.toMatch(/[ILOU]/); } }); test('is six characters of the expected alphabet', () => { expect(generateToken()).toMatch(/^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{6}$/); }); test('is not obviously constant', () => { const seen = new Set(Array.from({ length: 200 }, () => generateToken())); expect(seen.size).toBeGreaterThan(150); }); }); describe('normaliseToken — forgiving of how people actually type', () => { test.each([ ['k7qm2x', 'K7QM2X'], [' K7QM2X ', 'K7QM2X'], ['K7Q-M2X', 'K7QM2X'], ['K7Q M2X', 'K7QM2X'], ])('%p → %p', (input, expected) => { expect(normaliseToken(input)).toBe(expected); }); }); describe('delivery records', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'tokens-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); 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(routes) .values({ id: ROUTE, personId: 'p1', transportModuleId: 'signal', address: '+15550000' }) .run(); }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); const mint = (targetId = 'alert-1', now = NOW) => mintDelivery(db, { kind: 'alert', targetId, routeId: ROUTE, now, ttlMs: TTL_MS }); test('a minted delivery is findable by its token', () => { const delivery = mint(); expect(findLiveDelivery(db, delivery.token, NOW)?.id).toBe(delivery.id); }); test('lookup is case-insensitive and separator-tolerant', () => { const delivery = mint(); const typed = `${delivery.token.slice(0, 3)}-${delivery.token.slice(3)}`.toLowerCase(); expect(findLiveDelivery(db, typed, NOW)?.id).toBe(delivery.id); }); test('an unknown token finds nothing', () => { expect(findLiveDelivery(db, 'ZZZZZZ', NOW)).toBeUndefined(); }); test('an expired token is not live', () => { const delivery = mint(); const afterExpiry = new Date(NOW.getTime() + TTL_MS + 1000); expect(findLiveDelivery(db, delivery.token, afterExpiry)).toBeUndefined(); }); // A token is single-use: replaying it must not re-acknowledge, and must not // let someone who saw the message once act on it repeatedly. test('a consumed token is no longer live', () => { const delivery = mint(); consumeDelivery(db, delivery.id, NOW); expect(findLiveDelivery(db, delivery.token, NOW)).toBeUndefined(); }); test('tokens are unique across concurrent outstanding deliveries', () => { const tokens = new Set(); for (let i = 0; i < 50; i++) { tokens.add(mint(`alert-${i}`).token); } expect(tokens.size).toBe(50); }); // The all-clear must reach everyone who was told, and only them. test('deliveriesForAlert finds every delivery made for that alert', () => { mint('alert-A'); mint('alert-A'); mint('alert-B'); expect(deliveriesForAlert(db, 'alert-A')).toHaveLength(2); expect(deliveriesForAlert(db, 'alert-B')).toHaveLength(1); }); test('a delivery records which route it went to, so a reply identifies who', () => { const delivery = mint(); expect(delivery.routeId).toBe(ROUTE); }); test('interview deliveries share the table but not the kind', () => { mintDelivery(db, { kind: 'interview', targetId: 'bus-event-42', routeId: ROUTE, now: NOW, ttlMs: TTL_MS, }); // An interview delivery is not an alert delivery, so an all-clear for an // alert with a colliding id must not reach it. expect(deliveriesForAlert(db, 'bus-event-42')).toEqual([]); }); });