33c4ea1e91
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
193 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|