diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 09c31c7..7a8f702 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -229,3 +229,9 @@ {"id":"evt-pqwgd02ntpka","type":"edge.added","subject":"ver-d925rqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-d925rqw","to":"iss-qbh3541"},"at":"2026-09-19T14:03:00.600Z","parents":["evt-s95vt9k8f63d"],"hash":"13c905b15e00750c6d5380978fad98e4e9ecc1adfee1ed1b3880b712cdbb4052"} {"id":"evt-41e116z167g3","type":"verification.recorded","subject":"ver-d925rqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Children iss-a0hz0py, iss-rjqy3hy, iss-dbwgww8, iss-2fm6x2y, iss-ksxmctm, iss-m032zwq, iss-8hfq2y2 and iss-tz5s098 are all done with passing evidence. The only fetch calls in client code are in src/app/camera-queries.ts, wrapped in React Query; the only useEffect in camera-card.tsx manages object URLs, not polling. page.tsx has no 'use client', and next build lists `ƒ /`. The user manually tested the login flows in a browser, 2026-09-19."},"at":"2026-09-19T14:03:00.601Z","parents":["evt-pqwgd02ntpka"],"hash":"f1cffc889f8bf493746e287f36ab195418460ccfc909c39975f11126ffe74888"} {"id":"evt-kd9pxqn4bcd8","type":"node.status_changed","subject":"iss-qbh3541","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:03:01.991Z","parents":["evt-41e116z167g3"],"hash":"b22b1be718202b8f41e5491d630fa52dd1f46083789a98c8a89a4aa273bb796d"} +{"id":"evt-kan6asvzm60c","type":"node.created","subject":"ver-rqk3ktd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"camera.ts is at 100% line coverage. Tests cover info mapping (Media1 and Media2 profiles), connection reuse and resetConnection, inactive/auth/missing-login/no-profile failures, testCredentials not caching, snapshot fetch from the target address, Digest and Basic challenges, a refused login, HTTP errors, an unreachable host, snapshot URI caching and failure recovery, and isAllowedHost edge cases.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:07:14.315Z","parents":["evt-kd9pxqn4bcd8"],"hash":"56ec4ef908aebc91798a7244c7e114ac806e30c8f4c7042c7563c2d3f0442f43"} +{"id":"evt-j9mdfr9msk1v","type":"edge.added","subject":"ver-rqk3ktd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-rqk3ktd","to":"iss-qak2mz8"},"at":"2026-09-19T14:07:14.317Z","parents":["evt-kan6asvzm60c"],"hash":"6b0824745dde7fa28c6b00f93afd25f1f7032929b2c02d7c0ab525c21be44e45"} +{"id":"evt-2amh1dbbb8v2","type":"verification.recorded","subject":"ver-rqk3ktd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/camera.test.ts, 47 tests; `npx vitest run src/lib/camera.test.ts --coverage --coverage.include=src/lib/camera.ts` gives 100% lines and 93.3% branches, 2026-09-19. Deviation from the issue text: the onvif Cam class is a scripted test double (vi.mock) rather than a fake SOAP server. Emulating onvif's SOAP parsing would test the library rather than our code. Snapshots use a real node:http server on 127.0.0.1 with real 401 challenges. Not covered: the 10 s socket-timeout callback, and the https branch (no self-signed cert without a new dependency). Full suite 143/143, tsc and eslint clean."},"at":"2026-09-19T14:07:14.318Z","parents":["evt-j9mdfr9msk1v"],"hash":"8e594820c4c2eab47b002b459d9c02cd6ab391e3e4f8710f50016807e4f62f37"} +{"id":"evt-vpr581bxp7sw","type":"node.status_changed","subject":"iss-qak2mz8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:07:15.570Z","parents":["evt-2amh1dbbb8v2"],"hash":"c83dad23d807ecca00d847f3a30a682df460eba2e5ec6c65d2f3779232aef003"} +{"id":"evt-2gyj0y16gz93","type":"node.created","subject":"mea-prapanf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Line coverage of src/** (excluding page.tsx, *.d.ts, tests)","body":"`npm run coverage`, 2026-09-19, after camera.ts tests (iss-qak2mz8): 317/438 lines, 143 tests. camera.ts is now at 100%. Remaining gaps: onvif.ts (76 lines), the info/snapshot/credentials routes (36), layout and providers (8), camera-registry (1).","status":"recorded","owner":null,"attrs":{"value":72.37,"applies_at":"2026-09-19T14:07:16.619Z"}},"at":"2026-09-19T14:07:16.619Z","parents":["evt-vpr581bxp7sw"],"hash":"80890f1f1c559bca0f6f33fc114338073510bc94fa2fd84153c123a19f1cf9a8"} +{"id":"evt-f17cnwzvhjqf","type":"edge.added","subject":"mea-prapanf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-prapanf","to":"gol-9zxah3p"},"at":"2026-09-19T14:07:16.621Z","parents":["evt-2gyj0y16gz93"],"hash":"e26fa3671b95cc3b9dae9ffcf894da0d704f61588d0b9111e25fce22e0b6ed7d"} diff --git a/src/lib/camera.test.ts b/src/lib/camera.test.ts new file mode 100644 index 0000000..36f815d --- /dev/null +++ b/src/lib/camera.test.ts @@ -0,0 +1,426 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Credentials } from "./credential-store"; + +/** + * camera.ts drives the `onvif` library's Cam class; a scripted stand-in replaces it so each + * test decides what the "camera" answers during connect. Snapshots go over real HTTP to a + * local server, so the Digest/Basic challenge handling runs for real. + */ +const fake = await vi.hoisted(async () => { + // Hoisted above the imports, so load what it needs itself. + const { EventEmitter } = await import("node:events"); + type Callback = (err: Error | null, value?: T) => void; + interface Script { + connect: (cam: FakeCam, done: (err: Error | null) => void) => void; + deviceInformation: (done: Callback>) => void; + snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void; + } + class FakeCam extends EventEmitter { + static instances: FakeCam[] = []; + static script: Script; + profiles?: unknown[]; + constructor(readonly options: Record) { + super(); + FakeCam.instances.push(this); + } + connect(cb: (err: Error | null) => void) { + FakeCam.script.connect(this, cb); + } + getDeviceInformation(cb: Callback>) { + FakeCam.script.deviceInformation(cb); + } + getSnapshotUri(options: { profileToken?: string }, cb: Callback<{ uri?: string }>) { + FakeCam.script.snapshotUri(options, cb); + } + digestAuth(challenges: string[], req: { method: string; path: string }) { + return `Digest from=${challenges.length} ${req.method} ${req.path}`; + } + } + return { FakeCam }; +}); + +vi.mock("onvif", () => ({ Cam: fake.FakeCam })); + +const getCredentials = vi.fn<(id: string) => Promise>(); +vi.mock("./credential-store", () => ({ getCredentials })); + +const { FakeCam } = fake; +type Camera = typeof import("./camera"); +let camera: Camera; + +const LOGIN = { username: "admin", password: "pw" }; +const media1Profile = { + token: "p1", + name: "Main", + videoEncoderConfiguration: { + encoding: "H264", + resolution: { width: 1920, height: 1080 }, + rateControl: { frameRateLimit: 25 }, + }, +}; +const media2Profile = { + $: { token: "p2" }, + name: "Sub", + configurations: { + videoEncoder: { + encoding: "H265", + resolution: { width: 640, height: 360 }, + rateControl: { $: { FrameRateLimit: 15 } }, + }, + }, +}; + +// --- local snapshot server ------------------------------------------------------------- + +type SnapshotHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void; +let snapshotHandler: SnapshotHandler; +const snapshotRequests: { url?: string; authorization?: string }[] = []; +const server = http.createServer((req, res) => { + snapshotRequests.push({ url: req.url, authorization: req.headers.authorization }); + snapshotHandler(req, res); +}); +let port: number; + +beforeAll(async () => { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + +const jpeg: SnapshotHandler = (_req, res) => { + res.writeHead(200, { "Content-Type": "image/jpeg" }); + res.end("JPEGDATA"); +}; + +// The camera advertises an internal hostname; getSnapshot must use the target's address. +const advertisedUri = () => `http://camera.internal:${port}/snap.jpg?channel=1`; + +// --- per-test setup -------------------------------------------------------------------- + +const target = () => ({ id: "cam-1", host: "127.0.0.1", port }); + +function healthyCamera() { + FakeCam.script = { + connect: (cam, done) => { + cam.profiles = [media1Profile, media2Profile]; + done(null); + }, + deviceInformation: (done) => + done(null, { + manufacturer: "Hikvision", + model: "DS-2CD", + firmwareVersion: "V5.7", + serialNumber: "SN1", + }), + snapshotUri: (_options, done) => done(null, { uri: advertisedUri() }), + }; +} + +beforeEach(async () => { + FakeCam.instances = []; + snapshotRequests.length = 0; + snapshotHandler = jpeg; + healthyCamera(); + getCredentials.mockReset().mockResolvedValue(LOGIN); + vi.resetModules(); + camera = await import("./camera"); +}); + +// --- tests ----------------------------------------------------------------------------- + +describe("isAllowedHost", () => { + it.each([ + ["10.0.0.1", true], + ["10.255.255.255", true], + ["172.16.0.1", true], + ["172.31.255.254", true], + ["192.168.1.10", true], + ["172.15.0.1", false], + ["172.32.0.1", false], + ["192.169.1.1", false], + ["8.8.8.8", false], + ["127.0.0.1", false], + ["169.254.1.1", false], + ["10.0.0.256", false], + ["999.1.1.1", false], + ["10.0.0", false], + ["::1", false], + ["fe80::1", false], + ["camera.local", false], + [" 10.0.0.1", false], + ])("%s → %s", (host, allowed) => { + expect(camera.isAllowedHost(host)).toBe(allowed); + }); +}); + +describe("getCameraInfo", () => { + it("connects with the stored login and maps device info and both profile formats", async () => { + const info = await camera.getCameraInfo(target()); + + expect(info).toEqual({ + manufacturer: "Hikvision", + model: "DS-2CD", + firmwareVersion: "V5.7", + serialNumber: "SN1", + profiles: [ + { token: "p1", name: "Main", encoding: "H264", width: 1920, height: 1080, fps: 25 }, + { token: "p2", name: "Sub", encoding: "H265", width: 640, height: 360, fps: 15 }, + ], + }); + expect(getCredentials).toHaveBeenCalledWith("cam-1"); + expect(FakeCam.instances[0].options).toMatchObject({ + hostname: "127.0.0.1", + port, + ...LOGIN, + preserveAddress: true, + autoconnect: false, + }); + }); + + it("still returns profiles when device information fails", async () => { + FakeCam.script.deviceInformation = (done) => done(new Error("not supported")); + const info = await camera.getCameraInfo(target()); + expect(info.manufacturer).toBeUndefined(); + expect(info.profiles).toHaveLength(2); + }); + + it("treats a missing device-information body as empty", async () => { + FakeCam.script.deviceInformation = (done) => done(null); + expect((await camera.getCameraInfo(target())).model).toBeUndefined(); + }); + + it("reuses one connection across requests", async () => { + await camera.getCameraInfo(target()); + await camera.getCameraInfo(target()); + expect(FakeCam.instances).toHaveLength(1); + }); + + it("reconnects after resetConnection", async () => { + await camera.getCameraInfo(target()); + camera.resetConnection(target()); + await camera.getCameraInfo(target()); + expect(FakeCam.instances).toHaveLength(2); + }); + + describe("when connecting fails", () => { + it("reports an unactivated camera, whatever else went wrong", async () => { + FakeCam.script.connect = (cam, done) => { + cam.emit("rawResponse", "The device is inactive"); + done(new Error("ONVIF SOAP Fault")); + }; + await expect(camera.getCameraInfo(target())).rejects.toBeInstanceOf( + camera.CameraInactiveError, + ); + }); + + it("ignores unrelated raw responses", async () => { + FakeCam.script.connect = (cam, done) => { + cam.emit("rawResponse", ""); + cam.profiles = [media1Profile]; + done(null); + }; + expect((await camera.getCameraInfo(target())).profiles).toHaveLength(1); + }); + + it.each([ + "Digest authentication failed 401", + "ONVIF SOAP Fault: Sender not Authorized", + "Unauthorized", + ])("turns '%s' into a CameraAuthError", async (message) => { + FakeCam.script.connect = (_cam, done) => done(new Error(message)); + const err = await camera.getCameraInfo(target()).catch((e) => e); + expect(err).toBeInstanceOf(camera.CameraAuthError); + expect(err.missingLogin).toBe(false); + }); + + it("passes other connection errors through unchanged", async () => { + const failure = new Error("connect ECONNREFUSED"); + FakeCam.script.connect = (_cam, done) => done(failure); + await expect(camera.getCameraInfo(target())).rejects.toBe(failure); + }); + + it("says no login is saved when there are no credentials and no profiles", async () => { + getCredentials.mockResolvedValue(null); + FakeCam.script.connect = (_cam, done) => done(null); + const err = await camera.getCameraInfo(target()).catch((e) => e); + expect(err).toBeInstanceOf(camera.CameraAuthError); + expect(err.missingLogin).toBe(true); + }); + + it("treats an auth warning with no profiles as a rejected login", async () => { + FakeCam.script.connect = (cam, done) => { + cam.emit("warning", "ONVIF SOAP Fault: NotAuthorized"); + cam.profiles = []; + done(null); + }; + const err = await camera.getCameraInfo(target()).catch((e) => e); + expect(err).toBeInstanceOf(camera.CameraAuthError); + expect(err.missingLogin).toBe(false); + }); + + it("reports no profiles, with any warning, as a plain error", async () => { + FakeCam.script.connect = (cam, done) => { + cam.emit("warning", "Optional action not implemented"); + done(null); + }; + const err = await camera.getCameraInfo(target()).catch((e) => e); + expect(err).not.toBeInstanceOf(camera.CameraAuthError); + expect(err.message).toBe("Camera returned no media profiles: Optional action not implemented"); + }); + + it("reports no profiles without a warning", async () => { + FakeCam.script.connect = (_cam, done) => done(null); + await expect(camera.getCameraInfo(target())).rejects.toThrow( + /^Camera returned no media profiles$/, + ); + }); + + it("doesn't cache a failed connection", async () => { + FakeCam.script.connect = (_cam, done) => done(new Error("boom")); + await expect(camera.getCameraInfo(target())).rejects.toThrow("boom"); + healthyCamera(); + await camera.getCameraInfo(target()); + expect(FakeCam.instances).toHaveLength(2); + }); + }); +}); + +describe("testCredentials", () => { + it("tries the given login without caching the connection", async () => { + await camera.testCredentials(target(), { username: "new", password: "secret" }); + expect(FakeCam.instances[0].options).toMatchObject({ username: "new", password: "secret" }); + expect(getCredentials).not.toHaveBeenCalled(); + + await camera.getCameraInfo(target()); + expect(FakeCam.instances).toHaveLength(2); + expect(FakeCam.instances[1].options).toMatchObject(LOGIN); + }); + + it("rejects a login the camera refuses", async () => { + FakeCam.script.connect = (_cam, done) => done(new Error("Digest authentication failed 401")); + await expect( + camera.testCredentials(target(), { username: "bad", password: "x" }), + ).rejects.toBeInstanceOf(camera.CameraAuthError); + }); +}); + +describe("getSnapshot", () => { + it("fetches the frame from the camera's address, not the advertised hostname", async () => { + const snap = await camera.getSnapshot(target()); + expect(snap).toEqual({ contentType: "image/jpeg", body: Buffer.from("JPEGDATA") }); + expect(snapshotRequests).toEqual([{ url: "/snap.jpg?channel=1", authorization: undefined }]); + }); + + it("defaults the content type to image/jpeg", async () => { + snapshotHandler = (_req, res) => { + res.removeHeader("Content-Type"); + res.end("RAW"); + }; + expect((await camera.getSnapshot(target())).contentType).toBe("image/jpeg"); + }); + + it("asks for the requested profile and caches its URI", async () => { + const snapshotUri = vi.fn((_o, done) => + done(null, { uri: advertisedUri() }), + ); + FakeCam.script.snapshotUri = snapshotUri; + + await camera.getSnapshot(target(), "p2"); + await camera.getSnapshot(target(), "p2"); + await camera.getSnapshot(target()); + + expect(snapshotUri.mock.calls.map(([options]) => options)).toEqual([ + { profileToken: "p2" }, + {}, + ]); + }); + + it("answers a Digest challenge using the camera's digest implementation", async () => { + snapshotHandler = (req, res) => { + if (!req.headers.authorization) { + res.writeHead(401, { "WWW-Authenticate": 'Digest realm="IP Camera", nonce="abc", qop="auth"' }); + return res.end(); + } + jpeg(req, res); + }; + await camera.getSnapshot(target()); + expect(snapshotRequests[1].authorization).toBe("Digest from=1 GET /snap.jpg?channel=1"); + }); + + it("answers a Basic challenge with the stored login", async () => { + snapshotHandler = (req, res) => { + if (!req.headers.authorization) { + res.writeHead(401, { "WWW-Authenticate": 'Basic realm="cam"' }); + return res.end(); + } + jpeg(req, res); + }; + await camera.getSnapshot(target()); + expect(snapshotRequests[1].authorization).toBe( + `Basic ${Buffer.from("admin:pw").toString("base64")}`, + ); + }); + + it("sends an empty Basic login when none is stored", async () => { + let calls = 0; + getCredentials.mockImplementation(async () => (calls++ === 0 ? LOGIN : null)); + snapshotHandler = (req, res) => { + if (!req.headers.authorization) { + res.writeHead(401, { "WWW-Authenticate": 'Basic realm="cam"' }); + return res.end(); + } + jpeg(req, res); + }; + await camera.getSnapshot(target()); + expect(snapshotRequests[1].authorization).toBe(`Basic ${Buffer.from(":").toString("base64")}`); + }); + + it("rejects a refused login and drops the cached connection", async () => { + snapshotHandler = (_req, res) => { + res.writeHead(401, { "WWW-Authenticate": 'Basic realm="cam"' }); + res.end(); + }; + await expect(camera.getSnapshot(target())).rejects.toBeInstanceOf(camera.CameraAuthError); + + snapshotHandler = jpeg; + await camera.getSnapshot(target()); + expect(FakeCam.instances).toHaveLength(2); + }); + + it("reports other HTTP failures", async () => { + snapshotHandler = (_req, res) => { + res.writeHead(503); + res.end(); + }; + await expect(camera.getSnapshot(target())).rejects.toThrow( + "Snapshot request failed with HTTP 503", + ); + }); + + it("rejects when the snapshot server can't be reached", async () => { + const closed = http.createServer(); + await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve)); + const deadPort = (closed.address() as AddressInfo).port; + await new Promise((resolve) => closed.close(() => resolve())); + FakeCam.script.snapshotUri = (_o, done) => + done(null, { uri: `http://camera.internal:${deadPort}/snap.jpg` }); + + await expect(camera.getSnapshot(target())).rejects.toThrow(/ECONNREFUSED/); + }); + + describe("when the camera gives no snapshot URI", () => { + it.each([ + ["an error", (done: (err: Error | null, v?: { uri?: string }) => void) => done(new Error("fault"))], + ["an empty answer", (done: (err: Error | null, v?: { uri?: string }) => void) => done(null, {})], + ])("fails on %s, drops the connection, and retries next time", async (_label, answer) => { + FakeCam.script.snapshotUri = (_o, done) => answer(done); + await expect(camera.getSnapshot(target())).rejects.toThrow(/fault|no snapshot URI/); + + healthyCamera(); + await camera.getSnapshot(target()); + expect(FakeCam.instances).toHaveLength(2); + }); + }); +});