test(server): add fresh-SQLite test harness + anti-fraud core suites

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
This commit is contained in:
2026-06-21 15:20:38 +02:00
parent 3ed785c33e
commit 0985b86fa7
9 changed files with 268 additions and 4 deletions
+4 -2
View File
@@ -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"
}
}
+129
View File
@@ -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" }));
});
});
+77
View File
@@ -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();
});
});
+2 -1
View File
@@ -9,5 +9,6 @@
{ "path": "../../packages/db" },
{ "path": "../../packages/devices" }
],
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+15
View File
@@ -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",
},
},
});
+4
View File
@@ -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",
+32
View File
@@ -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() };
}
+2 -1
View File
@@ -5,5 +5,6 @@
"outDir": "./dist",
"composite": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+3
View File
@@ -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: {}