feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals
Debugging "is the camera event coming or not?" was painful: a rejected push only logged a warning and recorded nothing, so "no event" was ambiguous (never sent vs sent-and-refused), and the only durable record was an unreadable device_events row. - Record EVERY push, accepted or rejected: accepted -> kind:"alarm", rejected -> kind:"alarm-rejected" with the precise reason (unknown device / not-hikvision / push-disabled / source-IP mismatch / digest fail). The 404 body now also returns the reason. - New GET /api/devices/hikvision/alarms (device:read): the recent pushes newest-first as JSON (accepted+rejected, with ip/reason/eventType/ target/plate/rawHead) so you can SEE arrivals in the browser instead of grepping the dev log or querying SQLite. Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint list + gating). server 111/111; build+lint green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { and, eq, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.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 /
|
||||
@@ -61,6 +62,16 @@ function alarmEvents(): { detail: Record<string, unknown> }[] {
|
||||
.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();
|
||||
@@ -122,7 +133,14 @@ describe("Hikvision Alarm Server push", () => {
|
||||
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 () => {
|
||||
@@ -146,5 +164,29 @@ describe("Hikvision Alarm Server push", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user