fix(setup): render boolean config fields as a checkbox (not a text box)

The generic config-field loop had no boolean branch, so a type:"boolean"
field (e.g. the camera's alarmPushEnabled) fell through to a TEXT input and
saved the STRING "true" instead of a real boolean. Downstream checks use
=== true, so the feature read as disabled even when the admin ticked it.

- Web: render type:"boolean" config fields as a real checkbox; store/merge
  a true/false boolean (and persist false on edit so toggling off sticks);
  normalize a legacy string "true"/"false" on load.
- Server: isOn() coerces the flag when reading config (accepts true/"true"/
  1/"yes"/"on") so an existing row saved as the string "true" still works
  without a re-save, and no other boolean field hits the same trap.

Tests: hik-alarm accepts string "true" for alarmPushEnabled. server
112/112; web typecheck + build green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 10:39:10 +02:00
parent 3db8f517d3
commit 461275521d
3 changed files with 67 additions and 11 deletions
@@ -95,6 +95,21 @@ describe("Hikvision Alarm Server push", () => {
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>
+14 -3
View File
@@ -25,11 +25,22 @@ import { verifyDigest } from "../digest-auth.js";
interface HikDeviceConfig {
host?: string;
alarmPushEnabled?: boolean;
alarmPushEnabled?: boolean | string | number;
pushUser?: string;
pushPassword?: string;
}
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
function isOn(v: unknown): boolean {
if (v === true) return true;
if (typeof v === "number") return v === 1;
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
return false;
}
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
* event detail + the log line. Absent fields just mean "not found in this firmware's
* payload" — the raw body is always stored so nothing is lost. */
@@ -133,9 +144,9 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
// On rejection we STILL record it (with the precise reason) so a push that reached us
// never silently disappears — that's what makes "is it coming?" answerable.
let reason: string | null = null;
if (!row) reason = "unknown device id";
if (!row || !cfg) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
else if (!cfg?.alarmPushEnabled) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!cfg.host) reason = "device has no host IP configured";
else if (ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host}`;