import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { ledgerEvents, eq, type Db } from "@parking/db"; import { EventLog, canonicalize, hashEvent } from "./event-log.js"; import { SoftwareSigner, buildVerifier } from "./signer.js"; // The append-only, hash-chained, signed event log is THE anti-fraud primitive // (threat model: the operator at the booth). These tests pin every integrity rule: // monotonic index, prevHash linkage, payload-in-signature, and that verifyChain() // catches each class of tamper (content edit, reorder, deletion gap, forged sig, // missing key). No live DB is touched — a fresh in-memory SQLite per test. const SECRET = "test-event-signing-key-0123456789"; let db: Db; let close: () => void; let log: EventLog; beforeEach(() => { const t = createTestDb(); db = t.db; close = t.close; log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier); }); afterEach(() => close()); describe("EventLog.append — chain construction", () => { it("assigns a monotonic index starting at 1", async () => { const a = await log.append({ type: "vehicle_entry", identity: "T1" }); const b = await log.append({ type: "vehicle_exit", identity: "T1" }); expect(a.index).toBe(1); expect(b.index).toBe(2); }); it("genesis event has a null prevHash; the next chains to it", async () => { const a = await log.append({ type: "vehicle_entry", identity: "T1" }); const b = await log.append({ type: "vehicle_exit", identity: "T1" }); expect(a.prevHash).toBeNull(); expect(b.prevHash).toBe(hashEvent(canonicalize(a))); }); it("signs each row under the active keyId", async () => { const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } }); expect(row.keyId).toBe("sw-hmac-v2"); expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true); }); it("serializes concurrent appends without index collisions", async () => { const rows = await Promise.all( Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })), ); const indices = rows.map((r) => r.index).sort((a, b) => a - b); expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1)); }); }); describe("EventLog.verifyChain — integrity", () => { async function seed() { await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" }); await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } }); await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" }); } it("accepts an untampered chain", async () => { await seed(); expect(log.verifyChain()).toEqual({ ok: true }); }); it("accepts an empty chain", () => { expect(log.verifyChain()).toEqual({ ok: true }); }); it("detects a tampered payload (the money amount)", async () => { await seed(); // Rewrite the payment amount directly in the DB — exactly the booth-operator // fraud the signed payload defends against. db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run(); const r = log.verifyChain(); expect(r.ok).toBe(false); if (!r.ok) { expect(r.index).toBe(2); expect(r.reason).toMatch(/signature invalid/); } }); it("detects a deleted row as an index gap", async () => { await seed(); db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run(); const r = log.verifyChain(); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toMatch(/index gap/); }); it("detects a broken prevHash link (reordering / re-chaining)", async () => { await seed(); db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run(); const r = log.verifyChain(); expect(r.ok).toBe(false); if (!r.ok) { expect(r.index).toBe(3); expect(r.reason).toMatch(/prevHash/); } }); it("detects an event signed under a key that is no longer configured", async () => { await seed(); // Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it. db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run(); const r = log.verifyChain(); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/); }); }); describe("canonicalize — byte-stability", () => { it("is independent of payload key order (sorted recursively)", () => { const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null }; const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } }); const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } }); expect(a).toBe(b); }); it("changes when any signed field changes", () => { const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null }; expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } })); expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" })); }); });