@7365admin1/core 3.42.3 → 3.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +302 -0
- package/dist/index.d.ts +1001 -2
- package/dist/index.js +8748 -6427
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8492 -6233
- package/dist/index.mjs.map +1 -1
- package/docs/camera-integration-config.md +191 -0
- package/package.json +3 -2
- package/test/camera-capability.util.test.mjs +545 -0
- package/test/camera-device-http.test.mjs +792 -0
- package/test/camera-entitlement-wiring.test.mjs +134 -0
- package/test/camera-view.util.test.mjs +892 -0
- package/test/camera-write-gate.test.mjs +121 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The write gate, asserted on the controller itself.
|
|
8
|
+
*
|
|
9
|
+
* The decision the gate makes is already covered as pure logic in
|
|
10
|
+
* `camera-view.util.test.mjs` (entitlement, permission strings, wildcard). What
|
|
11
|
+
* cannot be covered that way is the WIRING: constructing the controller needs a
|
|
12
|
+
* live Atlas connection, so there is no way to drive `add`/`updateById`/
|
|
13
|
+
* `deleteById` here without a database.
|
|
14
|
+
*
|
|
15
|
+
* The realistic regression is not that `isCameraEntitled` starts returning the
|
|
16
|
+
* wrong answer — it is that a later edit drops the call, or moves it after the
|
|
17
|
+
* write it was supposed to guard. That is a property of the source, so it is
|
|
18
|
+
* asserted against the source. End-to-end coverage of the HTTP status codes
|
|
19
|
+
* lives in API-core (`test/camera-write-auth.spec.ts`).
|
|
20
|
+
*/
|
|
21
|
+
const source = readFileSync(
|
|
22
|
+
fileURLToPath(new URL("../src/controllers/site-camera.controller.ts", import.meta.url)),
|
|
23
|
+
"utf8",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** The body of one handler, so ordering can be asserted inside it. */
|
|
27
|
+
function handler(name) {
|
|
28
|
+
const start = source.indexOf(`async function ${name}(`);
|
|
29
|
+
assert.notEqual(start, -1, `handler ${name} is gone`);
|
|
30
|
+
const rest = source.slice(start + 10);
|
|
31
|
+
const next = rest.indexOf("\n async function ");
|
|
32
|
+
return next === -1 ? rest : rest.slice(0, next);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The list endpoint, which until now was neither scoped nor session-aware.
|
|
37
|
+
*
|
|
38
|
+
* `GET /site-cameras` returned every camera in the estate to any signed-in
|
|
39
|
+
* caller - 15 active IP cameras across 3 owner organisations on staging, with
|
|
40
|
+
* `host` and `username` on each. The site is now required and authorised.
|
|
41
|
+
*/
|
|
42
|
+
test("listing cameras requires a site and authorises it before reading", () => {
|
|
43
|
+
const body = handler("getAll");
|
|
44
|
+
|
|
45
|
+
assert.match(body, /site:\s*Joi\.string\(\)[\s\S]{0,60}\.required\(\)/);
|
|
46
|
+
const gate = body.indexOf("entitleSite(");
|
|
47
|
+
const read = body.indexOf("_getAll(");
|
|
48
|
+
assert.notEqual(gate, -1, "the list endpoint no longer authorises the site");
|
|
49
|
+
assert.notEqual(read, -1, "the list endpoint no longer reads cameras");
|
|
50
|
+
assert.ok(gate < read, "cameras are read before the site is authorised");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the list endpoint takes its caller from the session, not the query", () => {
|
|
54
|
+
const body = handler("getAll");
|
|
55
|
+
assert.match(body, /entitleSite\(\{\s*siteId:\s*site,\s*userId:\s*callerId\(req\)/);
|
|
56
|
+
// A site the caller may not see must read exactly like a site that is absent.
|
|
57
|
+
assert.match(body, /NotFoundError\("Site not found\."\)/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("every mutating handler authorises before it mutates", () => {
|
|
61
|
+
for (const [name, write] of [
|
|
62
|
+
["add", "_add("],
|
|
63
|
+
["updateById", "_updateById("],
|
|
64
|
+
["deleteById", "_deleteById("],
|
|
65
|
+
]) {
|
|
66
|
+
const body = handler(name);
|
|
67
|
+
const gate = body.indexOf("authorizeWrite(");
|
|
68
|
+
const mutation = body.indexOf(write);
|
|
69
|
+
assert.notEqual(gate, -1, `${name} does not authorise at all`);
|
|
70
|
+
assert.notEqual(mutation, -1, `${name} no longer calls ${write}`);
|
|
71
|
+
assert.ok(
|
|
72
|
+
gate < mutation,
|
|
73
|
+
`${name} calls ${write} before authorising - the gate guards nothing`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("authorisation is derived from the session, never from the request body", () => {
|
|
79
|
+
// `callerId` reads `req.user`, which only `requireAuth` sets. A body field
|
|
80
|
+
// would let a caller nominate themselves.
|
|
81
|
+
assert.match(source, /function callerId\(req: Request\)[\s\S]{0,160}\(req as any\)\.user/);
|
|
82
|
+
assert.equal(/callerId\((?!req\b|params\.req\b)/.test(source), false);
|
|
83
|
+
// No handler may pass a user, site or type taken from the body into the gate
|
|
84
|
+
// for the two id-scoped handlers: those come off the stored camera.
|
|
85
|
+
for (const name of ["updateById", "deleteById"]) {
|
|
86
|
+
const body = handler(name);
|
|
87
|
+
assert.match(body, /site: camera\.site/);
|
|
88
|
+
assert.match(body, /type: camera\.type/);
|
|
89
|
+
assert.equal(/site:\s*(value|req\.body)/.test(body), false);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a camera that is not the caller's is reported exactly as one that is absent", () => {
|
|
94
|
+
// Otherwise the difference between 404 and 403 enumerates every camera id in
|
|
95
|
+
// the estate to any signed-in account.
|
|
96
|
+
for (const name of ["updateById", "deleteById"]) {
|
|
97
|
+
const body = handler(name);
|
|
98
|
+
assert.match(body, /NotFoundError\("Camera not found\."\)/);
|
|
99
|
+
}
|
|
100
|
+
assert.match(source, /if \(error instanceof NotFoundError\)[\s\S]{0,120}params\.notFound/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the refusal names no site, camera, host or credential", () => {
|
|
104
|
+
const messages = [
|
|
105
|
+
...source.matchAll(/(?:NotFoundError|BadRequestError)\("([^"]+)"\)/g),
|
|
106
|
+
...source.matchAll(/notFound: "([^"]+)"/g),
|
|
107
|
+
].map((m) => m[1]);
|
|
108
|
+
assert.ok(messages.length >= 2);
|
|
109
|
+
for (const message of messages) {
|
|
110
|
+
assert.equal(/\d{1,3}\.\d{1,3}\.|rtsp:|http:|password|username|\$\{/.test(message), false);
|
|
111
|
+
}
|
|
112
|
+
assert.ok(messages.includes("Camera not found."));
|
|
113
|
+
assert.ok(messages.includes("Site not found."));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the permission asked for follows the camera's type", () => {
|
|
117
|
+
// One endpoint serves both panels; asking for the CCTV string on an ANPR write
|
|
118
|
+
// would refuse a role provisioned for ANPR alone.
|
|
119
|
+
assert.match(source, /cameraManagePermissions\(params\.type\)/);
|
|
120
|
+
assert.equal(source.includes("CAMERA_SETUP_PERMISSIONS"), false);
|
|
121
|
+
});
|