306d136a08
Add a per-relay "Test" control on each saved controller in /setup so an admin can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a barrier relay — but because a physical open with no matching signed command is the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest, source manual, attributed to the acting admin) BEFORE it fires. Reconciliation then reads the open as explained, not an anomaly, and there's an audit trail. - Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller (real id → clean attribution; also stops a redirected/unsaved config from opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is reported, not a 500. radarAlert relays (lamps) are excluded from the UI. - New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys. - EventLog constructed before setupRoutes so the route can sign. - Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed barrier_open_command on success, 400 unknown relay w/ no ledger row, 404 unknown controller, 400 bad relay value. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
116 lines
4.5 KiB
TypeScript
116 lines
4.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { devices, 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";
|
|
|
|
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
|
|
// wiring — it physically opens the barrier. Because "a physical open with no matching
|
|
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
|
|
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
|
|
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
|
|
// no real hardware) so they exercise the validate → sign → pulse path safely.
|
|
|
|
let db: Db;
|
|
let close: () => void;
|
|
let app: FastifyInstance;
|
|
|
|
const CTL = "ctl-stub";
|
|
|
|
beforeEach(async () => {
|
|
const t = createTestDb();
|
|
db = t.db;
|
|
close = t.close;
|
|
db.insert(devices).values({
|
|
id: CTL,
|
|
category: "access",
|
|
driverId: "stub-access",
|
|
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
|
|
enabled: true,
|
|
}).run();
|
|
app = await buildServer({ db });
|
|
await app.ready();
|
|
});
|
|
afterEach(async () => {
|
|
await app.close();
|
|
close();
|
|
});
|
|
|
|
async function pulse(
|
|
body: unknown,
|
|
auth?: { cookie: string; csrf: string },
|
|
) {
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/api/setup/test-relay",
|
|
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
|
|
payload: body as Record<string, unknown>,
|
|
});
|
|
}
|
|
|
|
describe("POST /api/setup/test-relay", () => {
|
|
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 pulse({ id: CTL, relay: 1 }, auth);
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("requires CSRF on the mutation", async () => {
|
|
await seedUser(db, { username: "admin", password: "pw" });
|
|
const { cookie } = await login(app, "admin", "pw");
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/setup/test-relay",
|
|
headers: { cookie }, // no x-csrf-token
|
|
payload: { id: CTL, relay: 1 },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
|
|
await seedUser(db, { username: "admin", password: "pw" });
|
|
const auth = await login(app, "admin", "pw");
|
|
|
|
const res = await pulse({ id: CTL, relay: 2 }, auth);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toMatchObject({ ok: true });
|
|
|
|
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
|
|
const rows = db.select().from(ledgerEvents).all();
|
|
const testOpen = rows.find((r) => r.type === "barrier_open_command");
|
|
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
|
|
expect(testOpen!.source).toBe("manual"); // deliberate human action
|
|
expect(testOpen!.signature.length).toBeGreaterThan(0);
|
|
const payload = testOpen!.payload as Record<string, unknown>;
|
|
expect(payload.relayTest).toBe(true);
|
|
expect(payload.reasonCode).toBe("setup.relayTest");
|
|
expect(payload.relay).toBe(2);
|
|
expect(payload.controllerId).toBe(CTL);
|
|
expect(payload.operator).toBe("admin"); // attributed to the acting admin
|
|
});
|
|
|
|
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
|
|
await seedUser(db, { username: "admin", password: "pw" });
|
|
const auth = await login(app, "admin", "pw");
|
|
const res = await pulse({ id: CTL, relay: 9 }, auth);
|
|
expect(res.statusCode).toBe(400);
|
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
|
|
});
|
|
|
|
it("404s an unknown controller id", async () => {
|
|
await seedUser(db, { username: "admin", password: "pw" });
|
|
const auth = await login(app, "admin", "pw");
|
|
const res = await pulse({ id: "nope", relay: 1 }, auth);
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("rejects a bad relay value (non-positive-integer)", async () => {
|
|
await seedUser(db, { username: "admin", password: "pw" });
|
|
const auth = await login(app, "admin", "pw");
|
|
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
|
|
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
|
|
});
|
|
});
|