Say why a rename was refused, instead of the library's generic error
Your camera answered SetScopes with a SOAP fault, ter:NotAuthorized: the Operator login may not rename it. The onvif library reports every SetScopes failure as "Wrong `SetScopes` response", so that cause was invisible. We now keep the raw reply and turn a NotAuthorized fault into our own error, and the page says the login isn't allowed to rename the camera and that the name kept here still works. Also give credential-store an explicit .ts import so plain Node can run it, as the admin script's modules already do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
10d08e3b87
commit
622d24b4be
@ -1213,3 +1213,6 @@
|
|||||||
{"id":"evt-s05mgh083ghz","type":"verification.recorded","subject":"ver-6qnn6cd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"getCameraScopes and setCameraScopes in src/lib/camera.ts: SetScopes replaces every configurable scope, so the others are read and sent back, fixed scopes are left out, and values are URL-encoded; the result is re-read from the camera. saveCameraNames in the camera page's actions writes it, reports applied or adjusted per field, and appends to the audit log. Tests against the fake camera (camera.test.ts \"camera name and location over ONVIF\") cover keeping other scopes, encoding, changing only what was given, and a refusal; actions.test.ts covers the action. Full suite 991 tests pass; tsc, eslint and a build clean. Not yet tried against a real camera: that write needs the user's go-ahead."},"at":"2026-09-20T12:42:11.869Z","parents":["evt-f16r6p9a97rb"],"hash":"8c2fbbfabf5962ce3eb4f291b4273166c6a39309688542a05faff3be2a9c5e52"}
|
{"id":"evt-s05mgh083ghz","type":"verification.recorded","subject":"ver-6qnn6cd","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"getCameraScopes and setCameraScopes in src/lib/camera.ts: SetScopes replaces every configurable scope, so the others are read and sent back, fixed scopes are left out, and values are URL-encoded; the result is re-read from the camera. saveCameraNames in the camera page's actions writes it, reports applied or adjusted per field, and appends to the audit log. Tests against the fake camera (camera.test.ts \"camera name and location over ONVIF\") cover keeping other scopes, encoding, changing only what was given, and a refusal; actions.test.ts covers the action. Full suite 991 tests pass; tsc, eslint and a build clean. Not yet tried against a real camera: that write needs the user's go-ahead."},"at":"2026-09-20T12:42:11.869Z","parents":["evt-f16r6p9a97rb"],"hash":"8c2fbbfabf5962ce3eb4f291b4273166c6a39309688542a05faff3be2a9c5e52"}
|
||||||
{"id":"evt-kaxnd2x25951","type":"node.status_changed","subject":"iss-t2bmvgj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:13.219Z","parents":["evt-s05mgh083ghz"],"hash":"62808924dff0c7b84d9ef3d4bb4a4df0d0ad062241ae980859cbecee798b8269"}
|
{"id":"evt-kaxnd2x25951","type":"node.status_changed","subject":"iss-t2bmvgj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:13.219Z","parents":["evt-s05mgh083ghz"],"hash":"62808924dff0c7b84d9ef3d4bb4a4df0d0ad062241ae980859cbecee798b8269"}
|
||||||
{"id":"evt-pvbrzehmt1bc","type":"node.status_changed","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:14.303Z","parents":["evt-kaxnd2x25951"],"hash":"a04519d7428434e42e4d99da3ee04d4c29bd1510f71ec6f8adfe8c9cb47e535c"}
|
{"id":"evt-pvbrzehmt1bc","type":"node.status_changed","subject":"iss-h1qwke5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T12:42:14.303Z","parents":["evt-kaxnd2x25951"],"hash":"a04519d7428434e42e4d99da3ee04d4c29bd1510f71ec6f8adfe8c9cb47e535c"}
|
||||||
|
{"id":"evt-h1trqzdyf5pc","type":"node.created","subject":"fnd-egj8kk9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Renaming a camera over ONVIF is refused for the Operator login: ter:NotAuthorized, and only the name scope is configurable","body":"Probed on camera …af2e on 2026-09-20 after the user's rename attempt failed. Sending the camera's current scopes back unchanged, so nothing would have changed either way, produced a SOAP fault: env:Sender / ter:NotAuthorized, \"The action requested requires authorization and the sender is not authorized\". So SetScopes needs more than the Operator level this app holds, the same wall ISAPI imaging writes hit (fnd-vfrgk0m). Reading scopes is allowed.\n\nTwo other things the probe showed. The camera has exactly one configurable scope, onvif://www.onvif.org/name/I91ET; Profile/G, Profile/T, type/audio_encoder, MAC and hardware are all Fixed, so location would have to be added rather than changed, and whether the camera accepts a new scope name is untested. And the onvif library reports every SetScopes failure as \"Wrong `SetScopes` response\": its check treats any reply that isn't the exact expected shape, including a fault, as that one error, which hides the real cause. The app now keeps the raw reply and turns a NotAuthorized fault into its own error, so the page says the login may not rename the camera rather than something generic.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-h1qwke5","note":"Camera …af2e, address redacted per dec-kgm44qw; GetScopes then SetScopes with the same scopes, raw SOAP captured"}],"as_of":"2026-09-20"}},"at":"2026-09-20T18:48:07.551Z","parents":["evt-pvbrzehmt1bc"],"hash":"9c313bede66a1eabaaa016885f7357085a5bd6365bed9bb054237627b0494bd9"}
|
||||||
|
{"id":"evt-ey4kq1gfj73t","type":"edge.added","subject":"fnd-egj8kk9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-egj8kk9","to":"iss-h1qwke5"},"at":"2026-09-20T18:48:07.553Z","parents":["evt-h1trqzdyf5pc"],"hash":"2b4c7418eca5aa42242843c7c63655625e58eab07b31c33f346c1f38c89c3ee1"}
|
||||||
|
{"id":"evt-0wxwjen8y0gg","type":"edge.added","subject":"fnd-egj8kk9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-egj8kk9","to":"gol-6x4ctm2"},"at":"2026-09-20T18:48:07.554Z","parents":["evt-ey4kq1gfj73t"],"hash":"cecfff0a53da570fad7223086789ca5c76ca4044897f24a77a4a06a470b26bcc"}
|
||||||
|
|||||||
@ -182,11 +182,21 @@ describe("saveCameraNames (iss-t2bmvgj, iss-h1qwke5)", () => {
|
|||||||
|
|
||||||
it("explains a camera that refuses the rename", async () => {
|
it("explains a camera that refuses the rename", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
m.setCameraScopes.mockRejectedValue(new Error("Sender not authorized"));
|
m.setCameraScopes.mockRejectedValue(new Error("socket hang up"));
|
||||||
const result = await saveCameraNames(ID, { name: "Porch" });
|
const result = await saveCameraNames(ID, { name: "Porch" });
|
||||||
expect(result).toEqual({ ok: false, reason: "camera", message: SAVE_MESSAGES.unreachable });
|
expect(result).toEqual({ ok: false, reason: "camera", message: SAVE_MESSAGES.unreachable });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says plainly when the camera won't let this login rename it", async () => {
|
||||||
|
const { CameraNotPermittedError } = await import("@/lib/camera");
|
||||||
|
m.setCameraScopes.mockRejectedValue(new CameraNotPermittedError("nope"));
|
||||||
|
expect(await saveCameraNames(ID, { name: "Porch" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "not-permitted",
|
||||||
|
message: SAVE_MESSAGES.notPermitted,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses when signed out", async () => {
|
it("refuses when signed out", async () => {
|
||||||
m.access.mockResolvedValue("signed-out");
|
m.access.mockResolvedValue("signed-out");
|
||||||
expect(await saveCameraNames(ID, { nickname: "Front door" })).toMatchObject({ reason: "signed-out" });
|
expect(await saveCameraNames(ID, { nickname: "Front door" })).toMatchObject({ reason: "signed-out" });
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { access } from "@/lib/access";
|
|||||||
import {
|
import {
|
||||||
CameraAuthError,
|
CameraAuthError,
|
||||||
CameraInactiveError,
|
CameraInactiveError,
|
||||||
|
CameraNotPermittedError,
|
||||||
isAllowedHost,
|
isAllowedHost,
|
||||||
setCameraScopes,
|
setCameraScopes,
|
||||||
type CameraTarget,
|
type CameraTarget,
|
||||||
@ -47,6 +48,9 @@ async function actor(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cameraProblem(err: unknown): SaveResult {
|
function cameraProblem(err: unknown): SaveResult {
|
||||||
|
if (err instanceof CameraNotPermittedError) {
|
||||||
|
return { ok: false, reason: "not-permitted", message: SAVE_MESSAGES.notPermitted };
|
||||||
|
}
|
||||||
if (err instanceof CameraAuthError) {
|
if (err instanceof CameraAuthError) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@ -12,5 +12,7 @@ export const SAVE_MESSAGES = {
|
|||||||
missingLogin: "No login is saved for this camera. Set it from the dashboard card first.",
|
missingLogin: "No login is saved for this camera. Set it from the dashboard card first.",
|
||||||
rejectedLogin: "The camera rejected its saved login. Update it from the dashboard card.",
|
rejectedLogin: "The camera rejected its saved login. Update it from the dashboard card.",
|
||||||
inactive: "This camera hasn't been activated yet.",
|
inactive: "This camera hasn't been activated yet.",
|
||||||
|
notPermitted:
|
||||||
|
"The camera won't let this login change that. Renaming the camera itself needs an administrator account on the camera; the name kept here still works.",
|
||||||
unreachable: "Couldn't reach the camera. See the server log for details.",
|
unreachable: "Couldn't reach the camera. See the server log for details.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const fake = await vi.hoisted(async () => {
|
|||||||
snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
||||||
streamUri?: (options: { protocol: string; profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
streamUri?: (options: { protocol: string; profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
||||||
scopes?: () => { ScopeDef: string; ScopeItem: string }[];
|
scopes?: () => { ScopeDef: string; ScopeItem: string }[];
|
||||||
setScopes?: (uris: string[]) => Error | null;
|
setScopes?: (uris: string[], cam: FakeCam) => Error | null;
|
||||||
}
|
}
|
||||||
class FakeCam extends EventEmitter {
|
class FakeCam extends EventEmitter {
|
||||||
static instances: FakeCam[] = [];
|
static instances: FakeCam[] = [];
|
||||||
@ -46,7 +46,7 @@ const fake = await vi.hoisted(async () => {
|
|||||||
cb(null, FakeCam.script.scopes!());
|
cb(null, FakeCam.script.scopes!());
|
||||||
}
|
}
|
||||||
setScopes(uris: string[], cb: (err: Error | null) => void) {
|
setScopes(uris: string[], cb: (err: Error | null) => void) {
|
||||||
cb(FakeCam.script.setScopes!(uris));
|
cb(FakeCam.script.setScopes!(uris, this));
|
||||||
}
|
}
|
||||||
digestAuth(challenges: string[], req: { method: string; path: string }) {
|
digestAuth(challenges: string[], req: { method: string; path: string }) {
|
||||||
return `Digest from=${challenges.length} ${req.method} ${req.path}`;
|
return `Digest from=${challenges.length} ${req.method} ${req.path}`;
|
||||||
@ -132,7 +132,7 @@ function healthyCamera() {
|
|||||||
streamUri: (options, done) =>
|
streamUri: (options, done) =>
|
||||||
done(null, { uri: `rtsp://camera.internal:554/Streaming/Channels/${options.profileToken}?transportmode=unicast` }),
|
done(null, { uri: `rtsp://camera.internal:554/Streaming/Channels/${options.profileToken}?transportmode=unicast` }),
|
||||||
scopes: () => [...scopes],
|
scopes: () => [...scopes],
|
||||||
setScopes: (uris) => {
|
setScopes: (uris: string[]) => {
|
||||||
scopes = [
|
scopes = [
|
||||||
...scopes.filter((s) => s.ScopeDef === "Fixed"),
|
...scopes.filter((s) => s.ScopeDef === "Fixed"),
|
||||||
...uris.map((ScopeItem) => ({ ScopeDef: "Configurable", ScopeItem })),
|
...uris.map((ScopeItem) => ({ ScopeDef: "Configurable", ScopeItem })),
|
||||||
@ -530,7 +530,23 @@ describe("camera name and location over ONVIF (iss-h1qwke5)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes a refusal from the camera to the caller", async () => {
|
it("passes a refusal from the camera to the caller", async () => {
|
||||||
FakeCam.script.setScopes = () => new Error("Sender not authorized");
|
FakeCam.script.setScopes = () => new Error("Wrong `SetScopes` response");
|
||||||
await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toThrow("Sender not authorized");
|
await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toThrow("Wrong `SetScopes` response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls a refusal what it is, since the library reports every failure the same way", async () => {
|
||||||
|
// What these cameras actually answer an Operator: a SOAP fault the library hides
|
||||||
|
// behind its own message (fnd on iss-h1qwke5).
|
||||||
|
FakeCam.script.setScopes = (_uris, cam) => {
|
||||||
|
cam.emit(
|
||||||
|
"rawResponse",
|
||||||
|
'<env:Fault><env:Code><env:Subcode><env:Value>ter:NotAuthorized</env:Value>' +
|
||||||
|
"</env:Subcode></env:Code></env:Fault>",
|
||||||
|
);
|
||||||
|
return new Error("Wrong `SetScopes` response");
|
||||||
|
};
|
||||||
|
await expect(camera.setCameraScopes(target(), { name: "Porch" })).rejects.toBeInstanceOf(
|
||||||
|
camera.CameraNotPermittedError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -52,6 +52,11 @@ export class CameraAuthError extends Error {
|
|||||||
* The camera hasn't been activated (first admin password never set). Vendors such as
|
* The camera hasn't been activated (first admin password never set). Vendors such as
|
||||||
* Hikvision refuse every ONVIF request, even unauthenticated ones, until then.
|
* Hikvision refuse every ONVIF request, even unauthenticated ones, until then.
|
||||||
*/
|
*/
|
||||||
|
/** The camera understood, and said this login may not do it (ONVIF ter:NotAuthorized). */
|
||||||
|
export class CameraNotPermittedError extends Error {
|
||||||
|
name = "CameraNotPermittedError";
|
||||||
|
}
|
||||||
|
|
||||||
export class CameraInactiveError extends Error {
|
export class CameraInactiveError extends Error {
|
||||||
name = "CameraInactiveError";
|
name = "CameraInactiveError";
|
||||||
}
|
}
|
||||||
@ -371,11 +376,25 @@ export async function setCameraScopes(target: CameraTarget, patch: CameraScopes)
|
|||||||
if (name) wanted.push(scopeUri("name", name));
|
if (name) wanted.push(scopeUri("name", name));
|
||||||
if (location) wanted.push(scopeUri("location", location));
|
if (location) wanted.push(scopeUri("location", location));
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) =>
|
// The library reports one generic error however the camera refused, so the raw reply is
|
||||||
(cam as unknown as { setScopes(uris: string[], cb: (err: Error | null) => void): void }).setScopes(
|
// kept to tell "you may not" apart from "that didn't work".
|
||||||
wanted,
|
let raw = "";
|
||||||
(err) => (err ? reject(err) : resolve()),
|
const capture = (xml: string) => (raw = xml);
|
||||||
),
|
cam.on("rawResponse", capture);
|
||||||
);
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
(cam as unknown as { setScopes(uris: string[], cb: (err: Error | null) => void): void }).setScopes(
|
||||||
|
wanted,
|
||||||
|
(err) => (err ? reject(err) : resolve()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (/NotAuthorized/i.test(raw)) {
|
||||||
|
throw new CameraNotPermittedError("The camera refused: this login may not rename it");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
cam.removeListener("rawResponse", capture);
|
||||||
|
}
|
||||||
return getCameraScopes(target);
|
return getCameraScopes(target);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:
|
|||||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { credentialsFile } from "./paths";
|
import { credentialsFile } from "./paths.ts";
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user