feat(entry): admin bypass of the presence gate for faulty radar/camera
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:
- Granular: bypass radar and camera independently (Setup → controller
section). A dead camera drops only the camera check; a dead radar only
radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
site:update) appends a signed config_change {setting, value, prev,
operator} per actually-changed signal — new ledger type. No-op toggles
sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
booth entry light treats a bypassed signal as satisfied (server
re-checks authoritatively). Physical-button path falls through to the
cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).
Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).
10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
|
||||
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
|
||||
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
|
||||
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
|
||||
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
|
||||
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
|
||||
// (it then fails at printing — a different reason — which is exactly "the gate opened").
|
||||
|
||||
let db: Db;
|
||||
let flow: EntryFlow;
|
||||
|
||||
const CTL = "ctl-entry";
|
||||
const PRESENCE_INPUT = 2;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
|
||||
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 1, role: "button", relay: 1 },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||
});
|
||||
|
||||
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
|
||||
db.insert(siteConfig)
|
||||
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
|
||||
.onConflictDoUpdate({
|
||||
target: siteConfig.id,
|
||||
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
|
||||
async function setRadarPresent(present: boolean) {
|
||||
await flow.onInput({
|
||||
driverId: "stub-access",
|
||||
deviceId: CTL,
|
||||
input: PRESENCE_INPUT,
|
||||
edge: present ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("entry presence-gate bypass", () => {
|
||||
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(anomalies()).toHaveLength(1);
|
||||
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
|
||||
});
|
||||
|
||||
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
|
||||
setBypass({ camera: true });
|
||||
await setRadarPresent(true);
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
|
||||
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
|
||||
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
|
||||
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
|
||||
setBypass({ radar: true });
|
||||
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
|
||||
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
|
||||
setBypass({ camera: true });
|
||||
await setRadarPresent(false); // radar required (not bypassed) and clear
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||
expect(res.ok).toBe(false);
|
||||
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusal, "the still-required radar gates the button").toBeTruthy();
|
||||
// The refusal records which signal was bypassed (audit).
|
||||
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
|
||||
});
|
||||
|
||||
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
|
||||
setBypass({ radar: true, camera: true });
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user