Files
parking_solution/apps/server/src/routes/hikvision-alarm.test.ts
T
julian 65328b8c11
CI / check (push) Failing after 15s
feat(anpr): subscriber-entry bridge + admin disable toggle
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
2026-06-22 19:49:18 +02:00

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();
});
});