diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index c3261bf..09c31c7 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -221,3 +221,11 @@ {"id":"evt-f6skgy2z97a7","type":"node.created","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against real cameras, 'Change login' and 'Forget saved login' work end to end.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:01:24.882Z","parents":["evt-5q5femhczhwy"],"hash":"c6c5f6af9cf551e1cfeae733656cd853d9d712dccab3da0914b87198a99c90a8"} {"id":"evt-2xx680y4h043","type":"edge.added","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-h8wgh7r","to":"iss-8hfq2y2"},"at":"2026-09-19T14:01:24.884Z","parents":["evt-f6skgy2z97a7"],"hash":"e276e752fb4db6dff1c9a931f93047d819a47f367298e1417c814aa43d89804a"} {"id":"evt-e2r4bptapxrq","type":"verification.recorded","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual test by the user (Michael Mainguy) on 2026-09-19: both flows reported working."},"at":"2026-09-19T14:01:24.885Z","parents":["evt-2xx680y4h043"],"hash":"edcb6d01b6770914dc084b7542709bcbc8eba7129391cf6fc4a548191e7e05cd"} +{"id":"evt-x7z0pgaa5x2r","type":"node.created","subject":"ver-axagv0j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"cameraErrorResponse returns fixed text per error class (inactive 409, auth 401 in 'rejected' and 'missing login' forms, store 500, other 502) and never echoes the original message; unexpected failures and rejected logins are logged server-side; no API route passes err.message to the client.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:02:56.605Z","parents":["evt-e2r4bptapxrq"],"hash":"cbc4392c1e36b178d229ca87d653a003779b8e42c199e8bc9e90a492f06edff1"} +{"id":"evt-xr3hmxyydv24","type":"edge.added","subject":"ver-axagv0j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-axagv0j","to":"iss-tz5s098"},"at":"2026-09-19T14:02:56.606Z","parents":["evt-x7z0pgaa5x2r"],"hash":"b24fb843e703f39972383240ec84d316fb9f697cccda24b03f151e70d9283aea"} +{"id":"evt-zyatdc21q8ja","type":"verification.recorded","subject":"ver-axagv0j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/camera-route.test.ts 'cameraErrorResponse' (8 tests; each case throws a message containing a fake password and a /Users/ path and checks neither appears in the body). `grep -rn \"message|String(err)\" src/app/api --include=route.ts` finds nothing. Full suite 96/96, tsc and eslint clean, 2026-09-19. CameraAuthError gained a missingLogin flag so the UI can still tell 'no login saved' from 'rejected'."},"at":"2026-09-19T14:02:56.607Z","parents":["evt-xr3hmxyydv24"],"hash":"ac177142e64cb1619ae0c37a9eaebbcf071affa6cfad1a19ed2259bdada13d9b"} +{"id":"evt-nxc9zdmq28zq","type":"node.status_changed","subject":"iss-tz5s098","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T14:02:57.690Z","parents":["evt-zyatdc21q8ja"],"hash":"ada40e8bb503d18898af64208ad09f07feca6fc2d19c243805360576e1a20d3f"} +{"id":"evt-s95vt9k8f63d","type":"node.created","subject":"ver-d925rqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"All children are closed; no client component contains hand-rolled fetch or useEffect polling; page.tsx is a Server Component with no 'use client' ancestor above the per-camera tiles.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:03:00.599Z","parents":["evt-nxc9zdmq28zq"],"hash":"ad8b4d8baff2ae889a92115d2cfd77530855cdc3b70d28f935b2db085c0bceba"} +{"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"} diff --git a/src/lib/camera-route.test.ts b/src/lib/camera-route.test.ts index b245f07..797c3ff 100644 --- a/src/lib/camera-route.test.ts +++ b/src/lib/camera-route.test.ts @@ -70,24 +70,80 @@ describe("cameraTarget", () => { }); describe("cameraErrorResponse", () => { + // iss-tz5s098: upstream text can carry SOAP bodies, socket details or file paths. + const secret = "http://admin:hunter2@192.168.1.10/onvif at /Users/me/.data/credentials.json"; + it.each([ - ["inactive camera", new CameraInactiveError("not activated"), 409, "inactive"], - ["rejected login", new CameraAuthError("401"), 401, "auth"], - ["credential store problem", new CredentialStoreError("no key"), 500, "store"], - ])("maps a %s to status %i with code %s", async (_label, err, status, code) => { + [ + "inactive camera", + new CameraInactiveError(secret), + 409, + { error: "Camera has not been activated yet", code: "inactive" }, + ], + [ + "rejected login", + new CameraAuthError(secret), + 401, + { error: "The camera rejected the saved login", code: "auth" }, + ], + [ + "missing login", + new CameraAuthError(secret, true), + 401, + { error: "No login saved for this camera", code: "auth" }, + ], + [ + "credential store problem", + new CredentialStoreError(secret), + 500, + { + error: "Saved camera logins can't be read; check CAMERA_CREDENTIALS_KEY on the server", + code: "store", + }, + ], + [ + "any other failure", + new Error(secret), + 502, + { error: "Camera request failed; see the server log for details" }, + ], + [ + "a non-Error value", + secret, + 502, + { error: "Camera request failed; see the server log for details" }, + ], + ])("maps a %s to fixed text and never echoes the original", async (_label, err, status, body) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const res = cameraErrorResponse(err); + const text = await res.text(); + expect(res.status).toBe(status); - expect(await res.json()).toMatchObject({ code }); + expect(JSON.parse(text)).toEqual(body); + expect(text).not.toContain("hunter2"); + expect(text).not.toContain("/Users/"); }); - it("maps any other failure to 502 without a code", async () => { - const res = cameraErrorResponse(new Error("socket hang up")); - expect(res.status).toBe(502); - expect(await res.json()).not.toHaveProperty("code"); + it("logs unexpected failures and rejected logins server-side", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const failure = new Error("socket hang up"); + cameraErrorResponse(failure); + expect(error).toHaveBeenCalledWith("Camera request failed:", failure); + + cameraErrorResponse(new CameraAuthError("401 Unauthorized")); + expect(warn).toHaveBeenCalledWith("Camera login rejected:", "401 Unauthorized"); }); - it("accepts non-Error values", async () => { - const res = cameraErrorResponse("boom"); - expect(res.status).toBe(502); + it("doesn't log expected states: no saved login, or an inactive camera", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + cameraErrorResponse(new CameraAuthError("none", true)); + cameraErrorResponse(new CameraInactiveError("inactive")); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/camera-route.ts b/src/lib/camera-route.ts index 143bbe6..e491daf 100644 --- a/src/lib/camera-route.ts +++ b/src/lib/camera-route.ts @@ -33,17 +33,37 @@ export async function cameraTarget( /** * Error codes the UI acts on: "inactive" shows the setup panel, "auth" stops polling * and asks for a login, "store" is a problem with the encrypted credentials file. + * + * Messages are fixed text. The original error can carry camera responses, socket details + * or file paths, so it goes to the server log only. */ export function cameraErrorResponse(err: unknown): Response { - const message = err instanceof Error ? err.message : String(err); if (err instanceof CameraInactiveError) { - return Response.json({ error: message, code: "inactive" }, { status: 409 }); + return Response.json( + { error: "Camera has not been activated yet", code: "inactive" }, + { status: 409 }, + ); } if (err instanceof CameraAuthError) { - return Response.json({ error: message, code: "auth" }, { status: 401 }); + const error = err.missingLogin + ? "No login saved for this camera" + : "The camera rejected the saved login"; + if (!err.missingLogin) console.warn("Camera login rejected:", err.message); + return Response.json({ error, code: "auth" }, { status: 401 }); } if (err instanceof CredentialStoreError) { - return Response.json({ error: message, code: "store" }, { status: 500 }); + console.error("Credential store error:", err); + return Response.json( + { + error: "Saved camera logins can't be read; check CAMERA_CREDENTIALS_KEY on the server", + code: "store", + }, + { status: 500 }, + ); } - return Response.json({ error: message }, { status: 502 }); + console.error("Camera request failed:", err); + return Response.json( + { error: "Camera request failed; see the server log for details" }, + { status: 502 }, + ); } diff --git a/src/lib/camera.ts b/src/lib/camera.ts index 8f295ac..ad0f8c9 100644 --- a/src/lib/camera.ts +++ b/src/lib/camera.ts @@ -39,6 +39,13 @@ export interface CameraTarget { /** The camera rejected (or was never given) a username/password. */ export class CameraAuthError extends Error { name = "CameraAuthError"; + constructor( + message: string, + /** True when no login was saved at all, as opposed to the camera rejecting one. */ + readonly missingLogin = false, + ) { + super(message); + } } /** @@ -105,7 +112,7 @@ function openCam(host: string, port: number, creds: Credentials | null): Promise } if (err) return reject(asAuthError(err)); if (!cam.profiles?.length) { - if (!creds) return reject(new CameraAuthError("No login saved for this camera")); + if (!creds) return reject(new CameraAuthError("No login saved for this camera", true)); return reject( asAuthError(new Error(`Camera returned no media profiles${warning ? `: ${warning}` : ""}`)), );