0985b86fa7
Foundation for testing every service. Adds @parking/db/testing — createTestDb() spins a fresh in-memory SQLite and applies the real Drizzle migrations, so server tests run against the production schema with zero live-DB risk. Wires Vitest into apps/server (test script + config; test signing keys via env) and adds the first Phase-1 suites against the anti-fraud core: - signer.test.ts (10): sign/verify round-trip, tamper + forgery rejection, malformed-signature guard, determinism, keyId rotation (buildVerifier). - event-log.test.ts (12): monotonic index, prevHash linkage, payload-in-signature, append serialization, and verifyChain() catching every tamper class — edited payload, deleted row (index gap), broken prevHash, unknown keyId — plus canonicalize byte-stability. Also stops *.test.ts leaking into shipped dist/ (tsconfig exclude in server + shared; shared had been emitting compiled tests all along). server 22/22, shared 87/87 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
130 lines
5.3 KiB
TypeScript
130 lines
5.3 KiB
TypeScript
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" }));
|
|
});
|
|
});
|