feat(entry): admin bypass of the presence gate for faulty radar/camera
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 41s

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:
2026-07-04 16:52:34 +02:00
parent 8b65e199a3
commit 6505a4a73b
16 changed files with 497 additions and 20 deletions
@@ -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/);
});
});