Files
parking_solution/apps/server/src/exit-flow.test.ts
T
julian 33c4ea1e91
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 12:17:52 +02:00

193 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
import { randomUUID } from "node:crypto";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
// the DECISION (refuse vs. sign the exit), not the hardware open.
let db: Db;
let close: () => void;
let log: EventLog;
let exit: ExitFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
exit = new ExitFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
}
function exitsSigned(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
}
function anomalies(reason?: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
}
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
* plate-reconciliation check can see this identity's plate against open sessions. */
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
db.insert(deviceEventsTable).values({
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
detail: { identity, direction: "entry", plate, confidence },
}).run();
}
describe("exitForBooth — refusal gates", () => {
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
const r = await exit.exitForBooth("ghost");
expect(r).toMatchObject({ ok: false, status: "no_session" });
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
expect(anomalies).toHaveLength(1);
expect(exitsSigned("ghost")).toHaveLength(0);
});
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
});
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(200));
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
await log.append({
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
});
const r = await exit.exitForBooth("T1");
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
expect(exitsSigned("T1")).toHaveLength(0);
});
});
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash"); // fresh payment → within grace
const r = await exit.exitForBooth("T1");
expect(r.ok).toBe(true);
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
expect(log.verifyChain()).toEqual({ ok: true });
});
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.exitForBooth("SUBSESS-1");
expect(r).toMatchObject({ ok: false, status: "unpaid" });
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
});
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
expect(r.ok).toBe(true);
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
});
});
describe("reopenBarrier — no unpaid re-open", () => {
it("refuses to re-open an unpaid transient session", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000 });
await enter("T1", minutesAgo(90));
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(false);
expect(exitsSigned("T1")).toHaveLength(0);
});
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("T1", minutesAgo(90));
await pay.pay("T1", "cash");
const r = await exit.reopenBarrier("T1", "op1");
expect(r.ok).toBe(true);
// The open session is closed by the human-intervention exit so it leaves the list.
expect(exitsSigned("T1")).toHaveLength(1);
});
});
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
});
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
expect(r.ok).toBe(true);
expect(exitsSigned("1237")).toHaveLength(1); // released
const ov = anomalies("released a suspected ticket-swap");
expect(ov.length).toBe(1);
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
});
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1234", minutesAgo(120));
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
await enter("1237", minutesAgo(1));
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r.ok).toBe(true); // no warning — exits normally
expect(exitsSigned("1237")).toHaveLength(1);
});
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
await enter("1237", minutesAgo(90));
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
await pay.pay("1237", "cash");
const r = await exit.exitForBooth("1237");
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
expect(exitsSigned("1237")).toHaveLength(1);
});
});