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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -9,5 +9,6 @@
|
||||
{ "path": "../../packages/db" },
|
||||
{ "path": "../../packages/devices" }
|
||||
],
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user