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 = {}) { 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 = ` 10.0.10.121 1 2026-06-22T10:15:30+02:00 fielddetection active vehicle `; function alarmEvents(): { detail: Record }[] { return db .select() .from(deviceEventsTable) .where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm"))) .all() as { detail: Record }[]; } /** Every recorded push for a device — accepted (kind:"alarm") AND rejected * (kind:"alarm-rejected"). */ function allRecorded(deviceId: string): { kind: string; detail: Record }[] { return db .select() .from(deviceEventsTable) .where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))) .all() as { kind: string; detail: Record }[]; } 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 = `ANPR AA123BB`; 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; 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("active", "inactive"); await post(leave); expect(onVehicleDetected).not.toHaveBeenCalled(); }); });