Stop camera routes echoing raw error messages
cameraErrorResponse now returns fixed text for each error class and logs the original server-side. Upstream ONVIF/SOAP responses, socket errors and credential-file paths no longer reach the browser (vrek iss-tz5s098). CameraAuthError gains a missingLogin flag so the UI can still tell "no login saved" apart from "login rejected". Tests throw errors carrying a fake password and file path, and check neither appears in any response. Closes the dashboard migration (vrek iss-qbh3541). Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
870b4cd2af
commit
33c12ea70c
@ -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"}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}` : ""}`)),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user