Test camera.ts: connection handling, errors and snapshot auth

Adds 47 tests for camera.ts (vrek iss-qak2mz8), bringing it to 100%
line coverage. A scripted stand-in for the onvif Cam class drives the
connect outcomes: profiles, warnings, inactive devices, and login
errors. Snapshots run over real HTTP against a local server with real
Digest and Basic 401 challenges. Also covers isAllowedHost edge cases.

Overall line coverage is 72.4% (143 tests). Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 09:07:53 -05:00
parent 33c12ea70c
commit 31026559b7
2 changed files with 432 additions and 0 deletions

View File

@ -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"}

426
src/lib/camera.test.ts Normal file
View File

@ -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<T> = (err: Error | null, value?: T) => void;
interface Script {
connect: (cam: FakeCam, done: (err: Error | null) => void) => void;
deviceInformation: (done: Callback<Record<string, string>>) => 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<string, unknown>) {
super();
FakeCam.instances.push(this);
}
connect(cb: (err: Error | null) => void) {
FakeCam.script.connect(this, cb);
}
getDeviceInformation(cb: Callback<Record<string, string>>) {
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<Credentials | null>>();
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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as AddressInfo).port;
});
afterAll(() => new Promise<void>((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", "<Fault>The device is inactive</Fault>");
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", "<ok/>");
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<typeof FakeCam.script.snapshotUri>((_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<void>((resolve) => closed.listen(0, "127.0.0.1", resolve));
const deadPort = (closed.address() as AddressInfo).port;
await new Promise<void>((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);
});
});
});