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,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
|
||||
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
|
||||
// the ledger (attributed), and it persists to site_config. Admin-only.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||
|
||||
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/site-config/presence-bypass",
|
||||
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
|
||||
payload: body as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
describe("PUT /api/site-config/presence-bypass", () => {
|
||||
it("is admin-only: a non-site:update user is 403", async () => {
|
||||
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||
const auth = await login(app, "op", "pw");
|
||||
const res = await put({ camera: true }, auth);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("enabling a signal persists it AND signs an attributed config_change", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
|
||||
const res = await put({ camera: true }, auth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
|
||||
|
||||
const changes = configChanges();
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].source).toBe("manual");
|
||||
expect(changes[0].signature.length).toBeGreaterThan(0);
|
||||
expect(changes[0].payload).toMatchObject({
|
||||
setting: "entryPresenceBypass.camera",
|
||||
value: true,
|
||||
prev: false,
|
||||
operator: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("a no-op toggle (already in that state) signs nothing", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
await put({ camera: true }, auth); // 1st: on → 1 event
|
||||
await put({ camera: true }, auth); // 2nd: still on → no new event
|
||||
expect(configChanges()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disabling signs the off transition too (auditable both ways)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
await put({ radar: true }, auth);
|
||||
await put({ radar: false }, auth);
|
||||
const changes = configChanges();
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
|
||||
});
|
||||
|
||||
it("rejects a non-boolean and an empty body", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
|
||||
expect((await put({}, auth)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user