65328b8c11
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { buildServer } from "../server.js";
|
|
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
|
|
import type { AnprBridge } from "../anpr-entry.js";
|
|
import { seedUser, login } from "../test-helpers.js";
|
|
|
|
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
|
|
// detection POST from the camera's configured IP is accepted, summarized (eventType /
|
|
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
|
|
// device_event — while a wrong source IP or a push-disabled device is refused.
|
|
|
|
const CAM_IP = "10.0.10.121";
|
|
const CAM_ID = "cam-1";
|
|
|
|
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();
|
|
});
|
|
|
|
function seedHikCamera(cfg: Record<string, unknown> = {}) {
|
|
db.insert(devices).values({
|
|
id: CAM_ID,
|
|
category: "camera",
|
|
driverId: "hikvision",
|
|
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
|
|
enabled: true,
|
|
}).run();
|
|
}
|
|
|
|
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
|
|
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
|
|
* best-effort summary extraction over a plausible shape. */
|
|
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
|
|
<ipAddress>10.0.10.121</ipAddress>
|
|
<channelID>1</channelID>
|
|
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
|
|
<eventType>fielddetection</eventType>
|
|
<eventState>active</eventState>
|
|
<DetectionRegionList>
|
|
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
|
|
</DetectionRegionList>
|
|
</EventNotificationAlert>`;
|
|
|
|
function alarmEvents(): { detail: Record<string, unknown> }[] {
|
|
return db
|
|
.select()
|
|
.from(deviceEventsTable)
|
|
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
|
|
.all() as { detail: Record<string, unknown> }[];
|
|
}
|
|
|
|
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
|
|
* (kind:"alarm-rejected"). */
|
|
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
|
|
return db
|
|
.select()
|
|
.from(deviceEventsTable)
|
|
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
|
|
.all() as { kind: string; detail: Record<string, unknown> }[];
|
|
}
|
|
|
|
describe("Hikvision Alarm Server push", () => {
|
|
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
|
|
seedHikCamera();
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: VEHICLE_XML,
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const events = alarmEvents();
|
|
expect(events).toHaveLength(1);
|
|
const d = events[0]!.detail;
|
|
expect(d.source).toBe("hikvision-alarm-server");
|
|
expect(d.eventType).toBe("fielddetection");
|
|
expect(d.target).toBe("vehicle");
|
|
expect(d.ip).toBe(CAM_IP);
|
|
// The raw body is kept verbatim for inspection.
|
|
expect(String(d.rawHead)).toContain("EventNotificationAlert");
|
|
});
|
|
|
|
it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => {
|
|
// The setup checkbox historically saved a STRING "true" instead of a boolean; the
|
|
// guard must coerce it, not silently reject a feature the admin enabled.
|
|
seedHikCamera({ alarmPushEnabled: "true" });
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: VEHICLE_XML,
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(alarmEvents()).toHaveLength(1);
|
|
});
|
|
|
|
it("pulls a plate out of an ANPR-style payload when present", async () => {
|
|
seedHikCamera();
|
|
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
|
|
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: anpr,
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
|
|
});
|
|
|
|
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
|
|
seedHikCamera();
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/octet-stream" },
|
|
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
|
|
});
|
|
|
|
it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => {
|
|
// WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's
|
|
// real IP never survives and a strict check rejects every push. With the opt-out, a
|
|
// push from the 'wrong' IP is accepted.
|
|
seedHikCamera({ skipSourceIpCheck: true });
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: VEHICLE_XML,
|
|
remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(alarmEvents()).toHaveLength(1);
|
|
expect(alarmEvents()[0]!.detail.target).toBe("vehicle");
|
|
});
|
|
|
|
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
|
|
seedHikCamera();
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: VEHICLE_XML,
|
|
remoteAddress: "10.0.10.200", // not the camera
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
// No ACCEPTED alarm...
|
|
expect(alarmEvents()).toHaveLength(0);
|
|
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
|
|
// ambiguous — you can see it came in and why it was refused.
|
|
const recorded = allRecorded(CAM_ID);
|
|
expect(recorded).toHaveLength(1);
|
|
expect(recorded[0]!.kind).toBe("alarm-rejected");
|
|
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
|
|
});
|
|
|
|
it("rejects when alarm push is disabled on the device", async () => {
|
|
seedHikCamera({ alarmPushEnabled: false });
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: VEHICLE_XML,
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("rejects an unknown device id", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/nope/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload: VEHICLE_XML,
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.json().reason).toMatch(/unknown device/i);
|
|
});
|
|
|
|
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
|
|
seedHikCamera();
|
|
// One accepted (right IP) + one rejected (wrong IP).
|
|
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
|
|
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
|
|
|
|
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
|
|
const { cookie } = await login(app, username, password);
|
|
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.count).toBe(2);
|
|
// Both accepted and rejected appear, with the accepted/reason flags.
|
|
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
|
|
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
|
|
expect(rejected.reason).toMatch(/source IP/i);
|
|
});
|
|
|
|
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
|
|
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
});
|
|
|
|
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
|
|
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
|
|
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
|
|
// anpr-entry.test.ts.)
|
|
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
|
|
let rawApp: RawFastify;
|
|
let rawDb: Db;
|
|
let rawClose: () => void;
|
|
let onVehicleDetected: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
const t = createTestDb();
|
|
rawDb = t.db;
|
|
rawClose = t.close;
|
|
onVehicleDetected = vi.fn(async () => {});
|
|
const bridge = { onVehicleDetected } as unknown as AnprBridge;
|
|
rawApp = Fastify();
|
|
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
|
|
await rawApp.ready();
|
|
rawDb.insert(devices).values({
|
|
id: CAM_ID,
|
|
category: "camera",
|
|
driverId: "hikvision",
|
|
config: { host: CAM_IP, alarmPushEnabled: true },
|
|
enabled: true,
|
|
}).run();
|
|
});
|
|
afterEach(async () => {
|
|
await rawApp.close();
|
|
rawClose();
|
|
});
|
|
|
|
async function post(payload: string) {
|
|
return rawApp.inject({
|
|
method: "POST",
|
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
|
headers: { "content-type": "application/xml" },
|
|
payload,
|
|
remoteAddress: CAM_IP,
|
|
});
|
|
}
|
|
|
|
it("hands a vehicle (active) detection to the bridge", async () => {
|
|
const res = await post(VEHICLE_XML);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
|
|
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
|
|
});
|
|
|
|
it("does NOT call the bridge for a human target", async () => {
|
|
const human = VEHICLE_XML.replace("vehicle", "human");
|
|
await post(human);
|
|
expect(onVehicleDetected).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
|
|
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
|
|
await post(leave);
|
|
expect(onVehicleDetected).not.toHaveBeenCalled();
|
|
});
|
|
});
|