diff --git a/apps/server/package.json b/apps/server/package.json index 4bf0185..c20dff6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,7 +9,8 @@ "start": "node --env-file-if-exists=.env dist/index.js", "seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs", "typecheck": "tsc --noEmit", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@fastify/cookie": "^11.0.2", @@ -28,6 +29,7 @@ "@types/bcrypt": "6.0.0", "@types/node": "25.9.3", "tsx": "4.22.4", - "typescript": "6.0.3" + "typescript": "6.0.3", + "vitest": "^4.1.9" } } diff --git a/apps/server/src/event-log.test.ts b/apps/server/src/event-log.test.ts new file mode 100644 index 0000000..389e0db --- /dev/null +++ b/apps/server/src/event-log.test.ts @@ -0,0 +1,129 @@ +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" })); + }); +}); diff --git a/apps/server/src/signer.test.ts b/apps/server/src/signer.test.ts new file mode 100644 index 0000000..fd636c9 --- /dev/null +++ b/apps/server/src/signer.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { SoftwareSigner, buildSigner, buildVerifier } from "./signer.js"; + +// The signer is half of the anti-fraud chain (the other half is event-log's hashing). +// These tests pin: a sign/verify round-trip, rejection of any tamper, constant-time +// length handling, and the keyId rotation contract that lets one chain span keys. + +describe("SoftwareSigner", () => { + it("verifies its own signature (round-trip)", () => { + const s = new SoftwareSigner("a-test-secret-key"); + const sig = s.sign("hello world"); + expect(s.verify("hello world", sig)).toBe(true); + }); + + it("rejects a signature over different content (tamper-evidence)", () => { + const s = new SoftwareSigner("a-test-secret-key"); + const sig = s.sign("amount=100"); + // Flip the signed content — the whole point of signing the payload. + expect(s.verify("amount=9999", sig)).toBe(false); + }); + + it("rejects a signature made under a different key (forgery)", () => { + const real = new SoftwareSigner("the-real-host-key"); + const forger = new SoftwareSigner("an-attacker-guess"); + const forged = forger.sign("amount=100"); + expect(real.verify("amount=100", forged)).toBe(false); + }); + + it("rejects a malformed / wrong-length signature without throwing", () => { + const s = new SoftwareSigner("a-test-secret-key"); + // timingSafeEqual throws on length mismatch; verify() must guard it. + expect(() => s.verify("x", "deadbeef")).not.toThrow(); + expect(s.verify("x", "deadbeef")).toBe(false); + expect(s.verify("x", "")).toBe(false); + }); + + it("is deterministic — same key + payload yields the same signature", () => { + const a = new SoftwareSigner("k").sign("p"); + const b = new SoftwareSigner("k").sign("p"); + expect(a).toBe(b); + }); + + it("defaults to the v2 keyId", () => { + expect(new SoftwareSigner("k").keyId).toBe("sw-hmac-v2"); + }); +}); + +describe("buildSigner", () => { + // vitest.config.ts sets EVENT_SIGNING_KEY + JWT_SECRET for the whole run. + it("prefers EVENT_SIGNING_KEY (keyId sw-hmac-v2)", () => { + const s = buildSigner(); + expect(s.keyId).toBe("sw-hmac-v2"); + const sig = s.sign("x"); + expect(s.verify("x", sig)).toBe(true); + }); +}); + +describe("buildVerifier (key rotation)", () => { + it("returns a working verifier for the configured v2 key", () => { + const v = buildVerifier("sw-hmac-v2"); + expect(v).toBeDefined(); + const signer = new SoftwareSigner(process.env.EVENT_SIGNING_KEY!, "sw-hmac-v2"); + expect(v!.verify("x", signer.sign("x"))).toBe(true); + }); + + it("resolves the jwtfallback key when present", () => { + const v = buildVerifier("sw-hmac-jwtfallback"); + expect(v).toBeDefined(); + const signer = new SoftwareSigner(process.env.JWT_SECRET!, "sw-hmac-jwtfallback"); + expect(v!.verify("x", signer.sign("x"))).toBe(true); + }); + + it("returns undefined for an unknown keyId (key gone, not a false tamper)", () => { + expect(buildVerifier("atecc608-slot0")).toBeUndefined(); + expect(buildVerifier("nonsense")).toBeUndefined(); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index c432d9e..f68c250 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -9,5 +9,6 @@ { "path": "../../packages/db" }, { "path": "../../packages/devices" } ], - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] } diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..66aa278 --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +// Server tests live next to the code under test (src/**/*.test.ts). They run against +// a fresh in-memory SQLite from @parking/db/testing — never the live parking.sqlite. +// A test signing key is set here so the SoftwareSigner/buildSigner path works without +// a real .env (the value is irrelevant — tests assert self-consistency, not secrecy). +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + env: { + EVENT_SIGNING_KEY: "test-event-signing-key-0123456789", + JWT_SECRET: "test-jwt-secret-0123456789abcdef", + }, + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json index 13903fc..f9b0b8c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -11,6 +11,10 @@ "./schema": { "types": "./dist/schema.d.ts", "default": "./dist/schema.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" } }, "main": "./dist/index.js", diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts new file mode 100644 index 0000000..05bbae2 --- /dev/null +++ b/packages/db/src/testing.ts @@ -0,0 +1,32 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import * as schema from "./schema.js"; +import type { Db } from "./index.js"; + +// Test-only helper: a fresh, fully-migrated SQLite database with NO live-DB risk. +// Every server/integration test spins one of these so suites are isolated and +// deterministic — never the real parking.sqlite. Not exported from the package +// root (`@parking/db`); import it from `@parking/db/testing` in test code only. + +// The migrations live next to this package's compiled output. From dist/testing.js +// that's ../drizzle; resolve it off import.meta.url so it works regardless of the +// caller's cwd (tests run from apps/server, packages/devices, etc.). +const MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle"); + +/** + * Open an in-memory SQLite (or a temp file if `url` is given), apply every Drizzle + * migration in order, and return a typed Drizzle handle plus the raw better-sqlite3 + * connection (so a test can assert raw rows or `.close()` it). The schema matches + * production exactly because it's the SAME migration set, not a hand-rolled DDL. + */ +export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Database; close: () => void } { + const sqlite = new Database(url); + sqlite.pragma("journal_mode = WAL"); + sqlite.pragma("foreign_keys = ON"); + const db = drizzle(sqlite, { schema }) as Db; + migrate(db, { migrationsFolder: MIGRATIONS_DIR }); + return { db, sqlite, close: () => sqlite.close() }; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index bfdb609..1d607f2 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -5,5 +5,6 @@ "outDir": "./dist", "composite": true }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22ade16..635801e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: typescript: specifier: 6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)) apps/vision: {}