@7365admin1/core 3.42.2 → 3.43.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 +323 -0
- package/dist/index.d.ts +997 -1
- package/dist/index.js +8775 -6440
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8519 -6246
- 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,892 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CAMERA_NOT_PATROL_OR_CCTV,
|
|
6
|
+
CAMERA_VIEW_PERMISSIONS,
|
|
7
|
+
PATROL_CCTV_CAMERA_FILTER,
|
|
8
|
+
isPatrolCctvCamera,
|
|
9
|
+
cameraBaseUrl,
|
|
10
|
+
cameraHealthSummary,
|
|
11
|
+
clampPtzSpeed,
|
|
12
|
+
clockDriftSeconds,
|
|
13
|
+
hasAnyPermission,
|
|
14
|
+
isCameraEntitled,
|
|
15
|
+
cameraGrant,
|
|
16
|
+
parseDeviceTime,
|
|
17
|
+
parseSoftwareVersion,
|
|
18
|
+
ptzEndpoint,
|
|
19
|
+
publicCameraFields,
|
|
20
|
+
snapshotEndpoint,
|
|
21
|
+
mapWithLimit,
|
|
22
|
+
snapshotRefusalReason,
|
|
23
|
+
cameraDevices,
|
|
24
|
+
parseCameraHost,
|
|
25
|
+
resolveCamera,
|
|
26
|
+
resolutionRefusalReason,
|
|
27
|
+
rtspUrl,
|
|
28
|
+
ffmpegFrameArgs,
|
|
29
|
+
ffmpegPath,
|
|
30
|
+
wallConfig,
|
|
31
|
+
MAX_CAMERA_CHANNEL,
|
|
32
|
+
parseCameraChannel,
|
|
33
|
+
relayForRecorder,
|
|
34
|
+
deriveCameraHost,
|
|
35
|
+
CAMERA_SETUP_PERMISSIONS,
|
|
36
|
+
CAMERA_ANPR_PERMISSIONS,
|
|
37
|
+
cameraManagePermissions,
|
|
38
|
+
CAMERA_TEST_MIN_INTERVAL_SECONDS,
|
|
39
|
+
CAMERA_TEST_ROUND_LIMIT,
|
|
40
|
+
} from "./.build/utils/camera-view.util.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A configured recorder, the way a deployment would supply one. The values are
|
|
44
|
+
* obvious fakes on purpose — the real address and credential belong in the
|
|
45
|
+
* server's environment and nowhere else, least of all in a test file.
|
|
46
|
+
*/
|
|
47
|
+
const DEVICES = {
|
|
48
|
+
"relay.example.net": {
|
|
49
|
+
host: "recorder.example.net",
|
|
50
|
+
port: 554,
|
|
51
|
+
username: "user",
|
|
52
|
+
password: "secret",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The camera-view rules.
|
|
58
|
+
*
|
|
59
|
+
* These are the parts that decide who may see which camera and exactly what is
|
|
60
|
+
* sent to a device. They are pure on purpose: a camera on a desk is not needed to
|
|
61
|
+
* prove that an out-of-scope caller is refused or that a preset command can never
|
|
62
|
+
* be built.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
test("a caller with no membership at the camera's site is refused", () => {
|
|
66
|
+
assert.equal(
|
|
67
|
+
isCameraEntitled({
|
|
68
|
+
cameraSite: "6923d6664150ca6a69b9f2b2",
|
|
69
|
+
cameraOrg: "6923d6664150ca6a69b9f000",
|
|
70
|
+
memberships: [{ siteId: "aaaaaaaaaaaaaaaaaaaaaaaa", org: "bbbbbbbbbbbbbbbbbbbbbbbb" }],
|
|
71
|
+
}),
|
|
72
|
+
false,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a caller at the camera's site is allowed, ObjectId or hex string", () => {
|
|
77
|
+
const asObjectId = { toString: () => "6923d6664150ca6a69b9f2b2" };
|
|
78
|
+
assert.equal(
|
|
79
|
+
isCameraEntitled({
|
|
80
|
+
cameraSite: asObjectId,
|
|
81
|
+
memberships: [{ siteId: "6923d6664150ca6a69b9f2b2" }],
|
|
82
|
+
}),
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("an org-level membership reaches the org's cameras, but only its own org", () => {
|
|
88
|
+
const membership = [{ org: "6923d6664150ca6a69b9f000" }];
|
|
89
|
+
assert.equal(
|
|
90
|
+
isCameraEntitled({
|
|
91
|
+
cameraSite: "6923d6664150ca6a69b9f2b2",
|
|
92
|
+
cameraOrg: "6923d6664150ca6a69b9f000",
|
|
93
|
+
memberships: membership,
|
|
94
|
+
}),
|
|
95
|
+
true,
|
|
96
|
+
);
|
|
97
|
+
assert.equal(
|
|
98
|
+
isCameraEntitled({
|
|
99
|
+
cameraSite: "6923d6664150ca6a69b9f2b2",
|
|
100
|
+
cameraOrg: "6923d6664150ca6a69b9f111",
|
|
101
|
+
memberships: membership,
|
|
102
|
+
}),
|
|
103
|
+
false,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a camera whose org could not be resolved is not reachable org-wide", () => {
|
|
108
|
+
// Guards the fallback: a missing org must not compare undefined to undefined
|
|
109
|
+
// and quietly become "everyone".
|
|
110
|
+
assert.equal(
|
|
111
|
+
isCameraEntitled({
|
|
112
|
+
cameraSite: "6923d6664150ca6a69b9f2b2",
|
|
113
|
+
cameraOrg: undefined,
|
|
114
|
+
memberships: [{ org: undefined }],
|
|
115
|
+
}),
|
|
116
|
+
false,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Engagement — the third entitlement source.
|
|
122
|
+
*
|
|
123
|
+
* The product routes a security agency to a property manager's site through an
|
|
124
|
+
* ACTIVE `customer.sites` row, and that is the same list the web apps' site
|
|
125
|
+
* switcher is drawn from. Nothing in the camera path read it, so a guard whose
|
|
126
|
+
* agency is contracted at a site was refused the camera the switcher had just
|
|
127
|
+
* offered them. Measured on staging: 11 people reach a site holding a camera by
|
|
128
|
+
* this route and no other, 7 of them on wildcard owner/admin roles.
|
|
129
|
+
*/
|
|
130
|
+
const SITE = "6923d6664150ca6a69b9f2b2";
|
|
131
|
+
const OWNER_ORG = "6923d6664150ca6a69b9f000";
|
|
132
|
+
const AGENCY_ORG = "6923d6664150ca6a69b9f111";
|
|
133
|
+
const OTHER_ORG = "6923d6664150ca6a69b9f222";
|
|
134
|
+
|
|
135
|
+
test("an org-level member of an engaged organisation reaches the engaged site", () => {
|
|
136
|
+
assert.equal(
|
|
137
|
+
isCameraEntitled({
|
|
138
|
+
cameraSite: SITE,
|
|
139
|
+
cameraOrg: OWNER_ORG,
|
|
140
|
+
memberships: [{ org: AGENCY_ORG }],
|
|
141
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
142
|
+
}),
|
|
143
|
+
true,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The owner's decision, 2026-08-10: an agency engaged at several sites gives its
|
|
149
|
+
* staff access to the site each person is ASSIGNED to, and no other.
|
|
150
|
+
*
|
|
151
|
+
* This is the whole of defect 1. The branch used to match on organisation alone,
|
|
152
|
+
* so one pinned membership opened every site the agency serves — 68 (user, site)
|
|
153
|
+
* grants at camera sites on staging, 169 estate-wide.
|
|
154
|
+
*/
|
|
155
|
+
test("an agency member pinned to site A passes for A and is refused for site B", () => {
|
|
156
|
+
const SITE_B = "6923d6664150ca6a69b9f333";
|
|
157
|
+
const pinnedToA = [{ org: AGENCY_ORG, siteId: SITE }];
|
|
158
|
+
// Both sites are served by the same agency under the same engagement.
|
|
159
|
+
const engagedOrgs = new Set([AGENCY_ORG]);
|
|
160
|
+
|
|
161
|
+
assert.equal(
|
|
162
|
+
isCameraEntitled({
|
|
163
|
+
cameraSite: SITE,
|
|
164
|
+
cameraOrg: OWNER_ORG,
|
|
165
|
+
memberships: pinnedToA,
|
|
166
|
+
engagedOrgs,
|
|
167
|
+
}),
|
|
168
|
+
true,
|
|
169
|
+
"the site they are assigned to must still work",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
assert.equal(
|
|
173
|
+
isCameraEntitled({
|
|
174
|
+
cameraSite: SITE_B,
|
|
175
|
+
cameraOrg: OWNER_ORG,
|
|
176
|
+
memberships: pinnedToA,
|
|
177
|
+
engagedOrgs,
|
|
178
|
+
}),
|
|
179
|
+
false,
|
|
180
|
+
"a sibling site of the same engagement must not be reachable",
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The path the owner's decision was NOT about, pinned so a later tightening
|
|
186
|
+
* cannot quietly take it with it: somebody with an ORG-LEVEL role in the site's
|
|
187
|
+
* own organisation keeps exactly what they had.
|
|
188
|
+
*/
|
|
189
|
+
test("an estate-side org-level membership is unaffected by the engagement rule", () => {
|
|
190
|
+
assert.equal(
|
|
191
|
+
isCameraEntitled({
|
|
192
|
+
cameraSite: SITE,
|
|
193
|
+
cameraOrg: OWNER_ORG,
|
|
194
|
+
memberships: [{ org: OWNER_ORG }],
|
|
195
|
+
}),
|
|
196
|
+
true,
|
|
197
|
+
);
|
|
198
|
+
// …and still unaffected when engagements are in play.
|
|
199
|
+
assert.equal(
|
|
200
|
+
isCameraEntitled({
|
|
201
|
+
cameraSite: SITE,
|
|
202
|
+
cameraOrg: OWNER_ORG,
|
|
203
|
+
memberships: [{ org: OWNER_ORG }],
|
|
204
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
205
|
+
}),
|
|
206
|
+
true,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("an engagement held by somebody ELSE'S organisation grants nothing", () => {
|
|
211
|
+
assert.equal(
|
|
212
|
+
isCameraEntitled({
|
|
213
|
+
cameraSite: SITE,
|
|
214
|
+
cameraOrg: OWNER_ORG,
|
|
215
|
+
memberships: [{ org: OTHER_ORG }],
|
|
216
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
217
|
+
}),
|
|
218
|
+
false,
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("no engagement is exactly the same answer as no engagement set at all", () => {
|
|
223
|
+
const memberships = [{ org: AGENCY_ORG }];
|
|
224
|
+
assert.equal(
|
|
225
|
+
isCameraEntitled({ cameraSite: SITE, cameraOrg: OWNER_ORG, memberships }),
|
|
226
|
+
false,
|
|
227
|
+
);
|
|
228
|
+
assert.equal(
|
|
229
|
+
isCameraEntitled({
|
|
230
|
+
cameraSite: SITE,
|
|
231
|
+
cameraOrg: OWNER_ORG,
|
|
232
|
+
memberships,
|
|
233
|
+
engagedOrgs: new Set(),
|
|
234
|
+
}),
|
|
235
|
+
false,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the granting membership is returned, so permissions resolve against the right org", () => {
|
|
240
|
+
// 22 people on staging belong to more than one organisation. Reading their
|
|
241
|
+
// permissions off whichever membership Mongo returned first is a coin toss;
|
|
242
|
+
// the one that granted access is the one that decides.
|
|
243
|
+
const memberships = [{ org: OTHER_ORG }, { org: AGENCY_ORG }];
|
|
244
|
+
|
|
245
|
+
assert.equal(
|
|
246
|
+
cameraGrant({
|
|
247
|
+
cameraSite: SITE,
|
|
248
|
+
cameraOrg: OWNER_ORG,
|
|
249
|
+
memberships,
|
|
250
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
251
|
+
})?.org,
|
|
252
|
+
AGENCY_ORG,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// And the ROLE comes back with it, because that is what decides permissions.
|
|
256
|
+
assert.equal(
|
|
257
|
+
cameraGrant({
|
|
258
|
+
cameraSite: SITE,
|
|
259
|
+
cameraOrg: OWNER_ORG,
|
|
260
|
+
memberships: [
|
|
261
|
+
{ org: OTHER_ORG, role: "wrong-role" },
|
|
262
|
+
{ org: AGENCY_ORG, role: "granting-role" },
|
|
263
|
+
],
|
|
264
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
265
|
+
})?.role,
|
|
266
|
+
"granting-role",
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Two memberships in the SAME organisation, different roles: the one that
|
|
270
|
+
// granted access decides, deterministically, whichever order they arrive in.
|
|
271
|
+
const twoRoles = [
|
|
272
|
+
{ org: AGENCY_ORG, siteId: SITE, role: "role-at-the-site" },
|
|
273
|
+
{ org: AGENCY_ORG, role: "org-wide-role" },
|
|
274
|
+
];
|
|
275
|
+
assert.equal(
|
|
276
|
+
cameraGrant({
|
|
277
|
+
cameraSite: SITE,
|
|
278
|
+
cameraOrg: OWNER_ORG,
|
|
279
|
+
memberships: twoRoles,
|
|
280
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
281
|
+
})?.role,
|
|
282
|
+
"role-at-the-site",
|
|
283
|
+
);
|
|
284
|
+
assert.equal(
|
|
285
|
+
cameraGrant({
|
|
286
|
+
cameraSite: SITE,
|
|
287
|
+
cameraOrg: OWNER_ORG,
|
|
288
|
+
memberships: [...twoRoles].reverse(),
|
|
289
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
290
|
+
})?.role,
|
|
291
|
+
"role-at-the-site",
|
|
292
|
+
"the most specific membership must win regardless of document order",
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// A membership AT the site wins over both of the fallbacks.
|
|
296
|
+
assert.equal(
|
|
297
|
+
cameraGrant({
|
|
298
|
+
cameraSite: SITE,
|
|
299
|
+
cameraOrg: OWNER_ORG,
|
|
300
|
+
memberships: [{ org: OTHER_ORG, siteId: SITE }, { org: AGENCY_ORG }],
|
|
301
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
302
|
+
})?.org,
|
|
303
|
+
OTHER_ORG,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
assert.equal(
|
|
307
|
+
cameraGrant({ cameraSite: SITE, memberships: [{ org: OTHER_ORG }] }),
|
|
308
|
+
null,
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a site with no id entitles nobody, whatever else is passed", () => {
|
|
313
|
+
assert.equal(
|
|
314
|
+
isCameraEntitled({
|
|
315
|
+
cameraSite: undefined,
|
|
316
|
+
cameraOrg: OWNER_ORG,
|
|
317
|
+
memberships: [{ org: AGENCY_ORG }],
|
|
318
|
+
engagedOrgs: new Set([AGENCY_ORG]),
|
|
319
|
+
}),
|
|
320
|
+
false,
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("no permissions means no camera; the wildcard means any", () => {
|
|
325
|
+
assert.equal(hasAnyPermission([], CAMERA_VIEW_PERMISSIONS), false);
|
|
326
|
+
assert.equal(hasAnyPermission(undefined, CAMERA_VIEW_PERMISSIONS), false);
|
|
327
|
+
assert.equal(hasAnyPermission(["feedback:see-all"], CAMERA_VIEW_PERMISSIONS), false);
|
|
328
|
+
assert.equal(hasAnyPermission(["*"], CAMERA_VIEW_PERMISSIONS), true);
|
|
329
|
+
assert.equal(
|
|
330
|
+
hasAnyPermission(
|
|
331
|
+
["virtual-patrol:see-all-virtual-patrol-route"],
|
|
332
|
+
CAMERA_VIEW_PERMISSIONS,
|
|
333
|
+
),
|
|
334
|
+
true,
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("an ANPR unit is not a Virtual Patrol or CCTV camera at all", () => {
|
|
339
|
+
// The owner's domain rule, 2026-08-07: ANPR belongs to visitor and vehicle
|
|
340
|
+
// management. Patrol and CCTV are `ip` only, and this is the gate every
|
|
341
|
+
// per-camera endpoint goes through before entitlement is even considered.
|
|
342
|
+
assert.equal(isPatrolCctvCamera({ type: "anpr", status: "active", host: "cam" }), false);
|
|
343
|
+
assert.equal(isPatrolCctvCamera({ type: "ip", status: "active", host: "cam" }), true);
|
|
344
|
+
assert.equal(isPatrolCctvCamera(null), false);
|
|
345
|
+
assert.equal(isPatrolCctvCamera({}), false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("the listing filter admits ip cameras only, so no ANPR tile is ever built", () => {
|
|
349
|
+
assert.deepEqual(PATROL_CCTV_CAMERA_FILTER, { type: "ip" });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("an ANPR unit is refused a snapshot as a second line of defence", () => {
|
|
353
|
+
const reason = snapshotRefusalReason(
|
|
354
|
+
{ type: "anpr", status: "active", host: "cam" },
|
|
355
|
+
DEVICES,
|
|
356
|
+
);
|
|
357
|
+
assert.equal(reason, CAMERA_NOT_PATROL_OR_CCTV);
|
|
358
|
+
assert.match(reason, /visitor and vehicle management/i);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("inactive, address-less and missing cameras are all refused", () => {
|
|
362
|
+
assert.match(
|
|
363
|
+
snapshotRefusalReason({ type: "ip", status: "inactive", host: "cam" }, DEVICES),
|
|
364
|
+
/not active/,
|
|
365
|
+
);
|
|
366
|
+
assert.match(
|
|
367
|
+
snapshotRefusalReason({ type: "ip", status: "active", host: " " }, DEVICES),
|
|
368
|
+
/no address/,
|
|
369
|
+
);
|
|
370
|
+
assert.match(snapshotRefusalReason(null, DEVICES), /not found/i);
|
|
371
|
+
assert.equal(
|
|
372
|
+
snapshotRefusalReason(
|
|
373
|
+
{ type: "ip", status: "active", host: "https://relay.example.net/7" },
|
|
374
|
+
DEVICES,
|
|
375
|
+
),
|
|
376
|
+
null,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/* -------------------------------------------------------------------------- */
|
|
381
|
+
/* Resolving a camera record to a real device */
|
|
382
|
+
/* -------------------------------------------------------------------------- */
|
|
383
|
+
|
|
384
|
+
test("a stored host resolves to the relay authority and the channel on it", () => {
|
|
385
|
+
assert.deepEqual(parseCameraHost("https://relay.example.net/7"), {
|
|
386
|
+
authority: "relay.example.net",
|
|
387
|
+
channel: 7,
|
|
388
|
+
});
|
|
389
|
+
// Double-digit channels are real in the estate; a single-digit assumption
|
|
390
|
+
// would quietly point tile 10 at tile 1.
|
|
391
|
+
assert.equal(parseCameraHost("https://relay.example.net/10").channel, 10);
|
|
392
|
+
// Scheme-less hosts are stored too, and must resolve identically.
|
|
393
|
+
assert.deepEqual(parseCameraHost("relay.example.net/3"), {
|
|
394
|
+
authority: "relay.example.net",
|
|
395
|
+
channel: 3,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("a camera resolves to the configured recorder plus its channel", () => {
|
|
400
|
+
const resolved = resolveCamera("https://relay.example.net/6", DEVICES);
|
|
401
|
+
assert.equal(resolved.channel, 6);
|
|
402
|
+
assert.equal(resolved.device.host, "recorder.example.net");
|
|
403
|
+
assert.equal(resolved.device.port, 554);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("the three unresolvable cases are distinguishable, not one silent failure", () => {
|
|
407
|
+
// No channel on the address — one record in the estate is like this. It must
|
|
408
|
+
// NOT default to channel 1, which would show a different camera entirely.
|
|
409
|
+
assert.equal(parseCameraHost("https://relay.example.net").channel, null);
|
|
410
|
+
assert.match(
|
|
411
|
+
resolutionRefusalReason("https://relay.example.net", DEVICES),
|
|
412
|
+
/no channel/i,
|
|
413
|
+
);
|
|
414
|
+
// A placeholder record: parses fine, but no recorder is configured for it.
|
|
415
|
+
assert.match(
|
|
416
|
+
resolutionRefusalReason("http://example.com/1", DEVICES),
|
|
417
|
+
/no recorder is configured/i,
|
|
418
|
+
);
|
|
419
|
+
// Not an address at all.
|
|
420
|
+
assert.match(resolutionRefusalReason("", DEVICES), /not a valid address/i);
|
|
421
|
+
assert.equal(resolveCamera("https://relay.example.net", DEVICES), null);
|
|
422
|
+
assert.equal(resolveCamera("http://example.com/1", DEVICES), null);
|
|
423
|
+
assert.equal(resolutionRefusalReason("https://relay.example.net/4", DEVICES), null);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("device configuration is read from one variable, and bad input yields none", () => {
|
|
427
|
+
const parsed = cameraDevices({
|
|
428
|
+
CAMERA_RTSP_DEVICES: JSON.stringify({
|
|
429
|
+
"Relay.Example.NET": { host: "rec", username: "u", password: "p" },
|
|
430
|
+
}),
|
|
431
|
+
});
|
|
432
|
+
// Authorities are matched case-insensitively — a host stored with capitals
|
|
433
|
+
// must not silently miss its recorder.
|
|
434
|
+
assert.equal(parsed["relay.example.net"].host, "rec");
|
|
435
|
+
// 554 unless told otherwise, so a config that omits the port still works.
|
|
436
|
+
assert.equal(parsed["relay.example.net"].port, 554);
|
|
437
|
+
|
|
438
|
+
assert.deepEqual(cameraDevices({}), {});
|
|
439
|
+
assert.deepEqual(cameraDevices({ CAMERA_RTSP_DEVICES: "not json" }), {});
|
|
440
|
+
assert.deepEqual(cameraDevices({ CAMERA_RTSP_DEVICES: "[]" }), {});
|
|
441
|
+
// An entry with no credential is dropped rather than half-built: a device we
|
|
442
|
+
// cannot authenticate to must refuse with a reason, not fail at request time.
|
|
443
|
+
assert.deepEqual(
|
|
444
|
+
cameraDevices({ CAMERA_RTSP_DEVICES: JSON.stringify({ a: { host: "h" } }) }),
|
|
445
|
+
{},
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("the RTSP url carries the channel, the subtype and an encoded credential", () => {
|
|
450
|
+
const url = rtspUrl(DEVICES["relay.example.net"], 6, 1);
|
|
451
|
+
assert.match(url, /^rtsp:\/\/user:secret@recorder\.example\.net:554\//);
|
|
452
|
+
assert.match(url, /channel=6&subtype=1$/);
|
|
453
|
+
// Main stream for an evidence capture, substream for a tile.
|
|
454
|
+
assert.match(rtspUrl(DEVICES["relay.example.net"], 6, 0), /subtype=0$/);
|
|
455
|
+
// A credential with URL-significant characters must not rewrite the address.
|
|
456
|
+
const awkward = rtspUrl(
|
|
457
|
+
{ host: "h", port: 554, username: "a@b", password: "p/w:x" },
|
|
458
|
+
1,
|
|
459
|
+
1,
|
|
460
|
+
);
|
|
461
|
+
assert.match(awkward, /^rtsp:\/\/a%40b:p%2Fw%3Ax@h:554\//);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("the ffmpeg call takes one frame, over TCP, and writes no file", () => {
|
|
465
|
+
const args = ffmpegFrameArgs("rtsp://example");
|
|
466
|
+
assert.deepEqual(args.slice(-6), ["-frames:v", "1", "-an", "-f", "mjpeg", "-"]);
|
|
467
|
+
assert.ok(args.includes("-rtsp_transport") && args.includes("tcp"));
|
|
468
|
+
// Nothing that could write to the device or to disk.
|
|
469
|
+
assert.equal(args.filter((a) => a === "-y" || a === "-f").length, 1);
|
|
470
|
+
assert.equal(ffmpegPath({}), "ffmpeg");
|
|
471
|
+
assert.equal(ffmpegPath({ CAMERA_FFMPEG_PATH: "/usr/bin/ffmpeg" }), "/usr/bin/ffmpeg");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("a camera record never carries its credential off the server", () => {
|
|
475
|
+
const shaped = publicCameraFields({
|
|
476
|
+
_id: "1",
|
|
477
|
+
name: "Lobby",
|
|
478
|
+
type: "ip",
|
|
479
|
+
status: "active",
|
|
480
|
+
host: "https://relay.example.net/4",
|
|
481
|
+
username: "operator",
|
|
482
|
+
password: "s3cret",
|
|
483
|
+
});
|
|
484
|
+
const serialised = JSON.stringify(shaped);
|
|
485
|
+
assert.equal(serialised.includes("s3cret"), false);
|
|
486
|
+
assert.equal(serialised.includes("operator"), false);
|
|
487
|
+
assert.equal(shaped.name, "Lobby");
|
|
488
|
+
|
|
489
|
+
// `host` IS returned now, for `ip` records only, and it is not a credential:
|
|
490
|
+
// it is the video relay's player-page URL, which is how the live app shows
|
|
491
|
+
// motion video, and `GET /site-cameras` already returns it to every
|
|
492
|
+
// authenticated caller. The credential half of the record still never leaves.
|
|
493
|
+
assert.equal(shaped.host, "https://relay.example.net/4");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("a host without a scheme defaults to http, not https", () => {
|
|
497
|
+
// 9 of 17 hosts in the estate are cleartext; upgrading them silently would
|
|
498
|
+
// turn a working camera into a TLS failure.
|
|
499
|
+
assert.equal(cameraBaseUrl("cam.example.net"), "http://cam.example.net");
|
|
500
|
+
assert.equal(cameraBaseUrl("https://cam.example.net/"), "https://cam.example.net");
|
|
501
|
+
assert.equal(cameraBaseUrl(" http://cam.example.net// "), "http://cam.example.net");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("the snapshot endpoint is the documented one", () => {
|
|
505
|
+
assert.equal(snapshotEndpoint(), "/cgi-bin/snapshot.cgi?channel=1&type=0");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("PTZ will not build a preset, a tour or an unknown action", () => {
|
|
509
|
+
assert.throws(() => ptzEndpoint({ action: "start", code: "GotoPreset" }), /Unsupported PTZ code/);
|
|
510
|
+
assert.throws(() => ptzEndpoint({ action: "start", code: "StartTour" }), /Unsupported PTZ code/);
|
|
511
|
+
assert.throws(() => ptzEndpoint({ action: "reboot", code: "Up" }), /Unsupported PTZ action/);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("PTZ percent-encodes and clamps its speed", () => {
|
|
515
|
+
assert.equal(
|
|
516
|
+
ptzEndpoint({ action: "start", code: "ZoomTele", speed: 99 }),
|
|
517
|
+
"/cgi-bin/ptz.cgi?action=start&channel=1&code=ZoomTele&arg1=0&arg2=8&arg3=0",
|
|
518
|
+
);
|
|
519
|
+
assert.equal(clampPtzSpeed("not a number"), 4);
|
|
520
|
+
assert.equal(clampPtzSpeed(0), 1);
|
|
521
|
+
assert.equal(clampPtzSpeed(3.4), 3);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("firmware and device time are read out of the device's key=value body", () => {
|
|
525
|
+
assert.equal(
|
|
526
|
+
parseSoftwareVersion("version=3.140.0000000.0\r\nBuildDate=2023-09-04\r\n"),
|
|
527
|
+
"3.140.0000000.0",
|
|
528
|
+
);
|
|
529
|
+
assert.equal(parseSoftwareVersion("nothing useful"), null);
|
|
530
|
+
assert.equal(parseDeviceTime("result=2026-08-09 21:30:00\r\n"), "2026-08-09 21:30:00");
|
|
531
|
+
assert.equal(parseDeviceTime(""), null);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("clock drift is measured against Singapore time, not the host's timezone", () => {
|
|
535
|
+
// 21:30:00 SGT on 9 Aug 2026 is 13:30:00Z. A camera reading exactly that has
|
|
536
|
+
// zero drift wherever the API happens to be running.
|
|
537
|
+
const now = new Date("2026-08-09T13:30:00.000Z");
|
|
538
|
+
assert.equal(clockDriftSeconds("2026-08-09 21:30:00", now), 0);
|
|
539
|
+
assert.equal(clockDriftSeconds("2026-08-09 21:32:00", now), 120);
|
|
540
|
+
assert.equal(clockDriftSeconds("2026-08-09 21:28:00", now), -120);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("an unreadable clock is null, not zero drift", () => {
|
|
544
|
+
assert.equal(clockDriftSeconds(null, new Date()), null);
|
|
545
|
+
assert.equal(clockDriftSeconds("no idea", new Date()), null);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("health degrades cleanly: unreachable beats drift, drift beats ok", () => {
|
|
549
|
+
assert.equal(cameraHealthSummary({ reachable: false, driftSeconds: null }), "unreachable");
|
|
550
|
+
assert.equal(cameraHealthSummary({ reachable: false, driftSeconds: 0 }), "unreachable");
|
|
551
|
+
assert.equal(cameraHealthSummary({ reachable: true, driftSeconds: 3600 }), "drifted");
|
|
552
|
+
assert.equal(cameraHealthSummary({ reachable: true, driftSeconds: -3600 }), "drifted");
|
|
553
|
+
assert.equal(cameraHealthSummary({ reachable: true, driftSeconds: 5 }), "ok");
|
|
554
|
+
assert.equal(cameraHealthSummary({ reachable: true, driftSeconds: null }), "ok");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
/* -------------------------------------------------------------------------- */
|
|
558
|
+
/* The monitoring wall */
|
|
559
|
+
/* -------------------------------------------------------------------------- */
|
|
560
|
+
|
|
561
|
+
test("the wall query admits ip cameras only, and cannot be widened by a site id", () => {
|
|
562
|
+
// The shape the wall and the batched health probe both spread into their
|
|
563
|
+
// `find`. An ANPR unit is not a monitoring camera, so it must be excluded by
|
|
564
|
+
// the QUERY — a supervisor should not see a tile for it in any state.
|
|
565
|
+
const wallQuery = {
|
|
566
|
+
...PATROL_CCTV_CAMERA_FILTER,
|
|
567
|
+
site: "6923d6664150ca6a69b9f2b2",
|
|
568
|
+
status: { $ne: "deleted" },
|
|
569
|
+
};
|
|
570
|
+
assert.equal(wallQuery.type, "ip");
|
|
571
|
+
assert.equal(Object.keys(wallQuery).length, 3);
|
|
572
|
+
// A record that is not `ip` fails the same predicate the query encodes, so the
|
|
573
|
+
// filter and the per-camera gate can never disagree.
|
|
574
|
+
assert.equal(isPatrolCctvCamera({ type: "anpr" }), false);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("wall defaults are conservative, and every one is overridable", () => {
|
|
578
|
+
// The defaults matter: V3.37 documents no concurrent-connection limit for any
|
|
579
|
+
// unit, so these are a guess and the guess has to be slow.
|
|
580
|
+
const fallback = wallConfig({});
|
|
581
|
+
assert.equal(fallback.snapshotPollMs, 5000);
|
|
582
|
+
assert.equal(fallback.singlePollMs, 2000);
|
|
583
|
+
assert.equal(fallback.maxTiles, 9);
|
|
584
|
+
assert.equal(fallback.maxConcurrentProbes, 4);
|
|
585
|
+
assert.equal(fallback.healthCacheSeconds, 20);
|
|
586
|
+
|
|
587
|
+
const tuned = wallConfig({
|
|
588
|
+
CAMERA_WALL_POLL_MS: "12000",
|
|
589
|
+
CAMERA_SINGLE_POLL_MS: "3000",
|
|
590
|
+
CAMERA_WALL_MAX_TILES: "4",
|
|
591
|
+
CAMERA_WALL_MAX_CONCURRENT_PROBES: "1",
|
|
592
|
+
CAMERA_HEALTH_CACHE_SECONDS: "60",
|
|
593
|
+
});
|
|
594
|
+
assert.equal(tuned.snapshotPollMs, 12000);
|
|
595
|
+
assert.equal(tuned.singlePollMs, 3000);
|
|
596
|
+
assert.equal(tuned.maxTiles, 4);
|
|
597
|
+
assert.equal(tuned.maxConcurrentProbes, 1);
|
|
598
|
+
assert.equal(tuned.healthCacheSeconds, 60);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("nonsense tuning falls back rather than disabling the limit", () => {
|
|
602
|
+
// A typo must never become "poll every 0 ms" or "no concurrency cap".
|
|
603
|
+
const broken = wallConfig({
|
|
604
|
+
CAMERA_WALL_POLL_MS: "0",
|
|
605
|
+
CAMERA_SINGLE_POLL_MS: "-1",
|
|
606
|
+
CAMERA_WALL_MAX_TILES: "not a number",
|
|
607
|
+
CAMERA_WALL_MAX_CONCURRENT_PROBES: "",
|
|
608
|
+
});
|
|
609
|
+
assert.equal(broken.snapshotPollMs, 5000);
|
|
610
|
+
assert.equal(broken.singlePollMs, 2000);
|
|
611
|
+
assert.equal(broken.maxTiles, 9);
|
|
612
|
+
assert.equal(broken.maxConcurrentProbes, 4);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a health sweep never has more probes in flight than the cap", async () => {
|
|
616
|
+
// The whole reason the wall needed a backend change: nine tiles asking for
|
|
617
|
+
// health separately is eighteen device requests fired at once.
|
|
618
|
+
let inFlight = 0;
|
|
619
|
+
let peak = 0;
|
|
620
|
+
|
|
621
|
+
const items = Array.from({ length: 9 }, (_, index) => index);
|
|
622
|
+
const results = await mapWithLimit(items, 4, async (item) => {
|
|
623
|
+
inFlight += 1;
|
|
624
|
+
peak = Math.max(peak, inFlight);
|
|
625
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
626
|
+
inFlight -= 1;
|
|
627
|
+
return item * 2;
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
assert.equal(peak, 4);
|
|
631
|
+
assert.deepEqual(results, [0, 2, 4, 6, 8, 10, 12, 14, 16]);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("a slow camera does not reorder the wall", async () => {
|
|
635
|
+
// Results are written by index, so tile 1 stays tile 1 even when it answers last.
|
|
636
|
+
const results = await mapWithLimit([30, 1, 1], 3, async (delay) => {
|
|
637
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
638
|
+
return delay;
|
|
639
|
+
});
|
|
640
|
+
assert.deepEqual(results, [30, 1, 1]);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("an empty wall does no work at all", async () => {
|
|
644
|
+
let calls = 0;
|
|
645
|
+
const results = await mapWithLimit([], 4, async () => {
|
|
646
|
+
calls += 1;
|
|
647
|
+
return null;
|
|
648
|
+
});
|
|
649
|
+
assert.deepEqual(results, []);
|
|
650
|
+
assert.equal(calls, 0);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("a tile refuses a camera that cannot serve a picture, before any request", () => {
|
|
654
|
+
// An ANPR unit is not on the wall at all — the query excludes it. What is left
|
|
655
|
+
// for this to catch is an `ip` camera that is inactive, has no address, or
|
|
656
|
+
// whose relay has no configured recorder. Each must show as a reason on the
|
|
657
|
+
// tile rather than as a broken image.
|
|
658
|
+
assert.equal(
|
|
659
|
+
snapshotRefusalReason({ type: "ip", status: "active", host: " " }, DEVICES),
|
|
660
|
+
"This camera has no address configured.",
|
|
661
|
+
);
|
|
662
|
+
assert.match(
|
|
663
|
+
snapshotRefusalReason({ type: "ip", status: "active", host: "10.0.0.1" }, DEVICES),
|
|
664
|
+
/no recorder is configured/i,
|
|
665
|
+
);
|
|
666
|
+
assert.equal(
|
|
667
|
+
snapshotRefusalReason(
|
|
668
|
+
{ type: "ip", status: "active", host: "https://relay.example.net/9" },
|
|
669
|
+
DEVICES,
|
|
670
|
+
),
|
|
671
|
+
null,
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("a wall tile carries no credential, whatever the record holds", () => {
|
|
676
|
+
// The allow-list is the guarantee. A field added to the model later is opted IN,
|
|
677
|
+
// never remembered about.
|
|
678
|
+
const tile = publicCameraFields({
|
|
679
|
+
_id: "abc",
|
|
680
|
+
name: "Lobby",
|
|
681
|
+
type: "ip",
|
|
682
|
+
status: "active",
|
|
683
|
+
host: "https://relay.example.net/4",
|
|
684
|
+
username: "admin",
|
|
685
|
+
password: "hunter2",
|
|
686
|
+
rtspUrl: "rtsp://admin:hunter2@10.0.0.9/live",
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const serialised = JSON.stringify(tile);
|
|
690
|
+
assert.equal(serialised.includes("hunter2"), false);
|
|
691
|
+
assert.equal(serialised.includes("admin"), false);
|
|
692
|
+
// A field the model gains later is still opted IN, not remembered about.
|
|
693
|
+
assert.equal(serialised.includes("rtsp://"), false);
|
|
694
|
+
assert.equal(tile.name, "Lobby");
|
|
695
|
+
|
|
696
|
+
// An ANPR record's host IS a device endpoint with a real credential beside
|
|
697
|
+
// it, so the allow-list holds on its own rather than trusting a filter
|
|
698
|
+
// somewhere else to have excluded it.
|
|
699
|
+
const anprTile = publicCameraFields({
|
|
700
|
+
_id: "def",
|
|
701
|
+
type: "anpr",
|
|
702
|
+
status: "active",
|
|
703
|
+
host: "http://device.example.com:8080",
|
|
704
|
+
username: "admin",
|
|
705
|
+
password: "hunter2",
|
|
706
|
+
});
|
|
707
|
+
assert.equal("host" in anprTile, false);
|
|
708
|
+
assert.equal(JSON.stringify(anprTile).includes("device.example.com"), false);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
/* -------------------------------------------------------------------------- */
|
|
712
|
+
/* Deriving an address from a recorder + channel */
|
|
713
|
+
/* -------------------------------------------------------------------------- */
|
|
714
|
+
|
|
715
|
+
const SETUP_DEVICES = {
|
|
716
|
+
"relay.example.com": {
|
|
717
|
+
host: "recorder.example.net",
|
|
718
|
+
port: 554,
|
|
719
|
+
username: "reader",
|
|
720
|
+
password: "hunter2",
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
test("parseCameraChannel takes a channel a person could really type", () => {
|
|
725
|
+
assert.equal(parseCameraChannel(6), 6);
|
|
726
|
+
assert.equal(parseCameraChannel("6"), 6);
|
|
727
|
+
assert.equal(parseCameraChannel(" 6 "), 6);
|
|
728
|
+
assert.equal(parseCameraChannel(MAX_CAMERA_CHANNEL), MAX_CAMERA_CHANNEL);
|
|
729
|
+
|
|
730
|
+
for (const bad of [0, -1, 1.5, "", null, undefined, "six", MAX_CAMERA_CHANNEL + 1]) {
|
|
731
|
+
assert.equal(parseCameraChannel(bad), null, String(bad));
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("relayForRecorder reads the configured map backwards", () => {
|
|
736
|
+
assert.equal(relayForRecorder("recorder.example.net", 554, SETUP_DEVICES), "relay.example.com");
|
|
737
|
+
// Case and stray scheme/path are what a person pastes, not a different device.
|
|
738
|
+
assert.equal(relayForRecorder("RECORDER.EXAMPLE.NET", 554, SETUP_DEVICES), "relay.example.com");
|
|
739
|
+
assert.equal(relayForRecorder("http://recorder.example.net/", 554, SETUP_DEVICES), "relay.example.com");
|
|
740
|
+
// A port is a refinement, never the thing that loses a recognised recorder.
|
|
741
|
+
assert.equal(relayForRecorder("recorder.example.net", undefined, SETUP_DEVICES), "relay.example.com");
|
|
742
|
+
assert.equal(relayForRecorder("recorder.example.net", 8000, SETUP_DEVICES), "relay.example.com");
|
|
743
|
+
// A recorder nobody configured stays unknown.
|
|
744
|
+
assert.equal(relayForRecorder("someone-elses.example.net", 554, SETUP_DEVICES), null);
|
|
745
|
+
assert.equal(relayForRecorder("", 554, SETUP_DEVICES), null);
|
|
746
|
+
assert.equal(relayForRecorder("recorder.example.net", 554, {}), null);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("deriveCameraHost builds the address the product stores", () => {
|
|
750
|
+
assert.deepEqual(
|
|
751
|
+
deriveCameraHost(
|
|
752
|
+
{ recorderHost: "recorder.example.net", recorderPort: 554, channel: 6 },
|
|
753
|
+
SETUP_DEVICES,
|
|
754
|
+
),
|
|
755
|
+
{ host: "https://relay.example.com/6" },
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("a derived address is one the existing resolution accepts", () => {
|
|
760
|
+
const derived = deriveCameraHost(
|
|
761
|
+
{ recorderHost: "recorder.example.net", channel: 4 },
|
|
762
|
+
SETUP_DEVICES,
|
|
763
|
+
);
|
|
764
|
+
// The round trip is the whole contract: what setup writes, viewing resolves.
|
|
765
|
+
assert.equal(resolutionRefusalReason(derived.host, SETUP_DEVICES), null);
|
|
766
|
+
assert.deepEqual(resolveCamera(derived.host, SETUP_DEVICES), {
|
|
767
|
+
device: SETUP_DEVICES["relay.example.com"],
|
|
768
|
+
channel: 4,
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("each way of getting it wrong gets its own sentence", () => {
|
|
773
|
+
const missingRecorder = deriveCameraHost({ channel: 6 }, SETUP_DEVICES);
|
|
774
|
+
assert.match(missingRecorder.refusal, /recorder's address/);
|
|
775
|
+
|
|
776
|
+
const missingChannel = deriveCameraHost(
|
|
777
|
+
{ recorderHost: "recorder.example.net" },
|
|
778
|
+
SETUP_DEVICES,
|
|
779
|
+
);
|
|
780
|
+
assert.match(missingChannel.refusal, /channel number/);
|
|
781
|
+
|
|
782
|
+
const unknownRecorder = deriveCameraHost(
|
|
783
|
+
{ recorderHost: "someone-elses.example.net", channel: 6 },
|
|
784
|
+
SETUP_DEVICES,
|
|
785
|
+
);
|
|
786
|
+
assert.match(unknownRecorder.refusal, /not connected to the video service/);
|
|
787
|
+
|
|
788
|
+
// Three different problems, three different sentences.
|
|
789
|
+
const said = new Set([
|
|
790
|
+
missingRecorder.refusal,
|
|
791
|
+
missingChannel.refusal,
|
|
792
|
+
unknownRecorder.refusal,
|
|
793
|
+
]);
|
|
794
|
+
assert.equal(said.size, 3);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("no refusal carries an address, a port or a credential", () => {
|
|
798
|
+
const refusals = [
|
|
799
|
+
deriveCameraHost({ channel: 6 }, SETUP_DEVICES).refusal,
|
|
800
|
+
deriveCameraHost({ recorderHost: "recorder.example.net" }, SETUP_DEVICES).refusal,
|
|
801
|
+
deriveCameraHost(
|
|
802
|
+
{ recorderHost: "recorder.example.net", channel: 6 },
|
|
803
|
+
{},
|
|
804
|
+
).refusal,
|
|
805
|
+
];
|
|
806
|
+
|
|
807
|
+
for (const refusal of refusals) {
|
|
808
|
+
for (const secret of ["recorder.example.net", "relay.example.com", "hunter2", "reader", "554"]) {
|
|
809
|
+
assert.equal(refusal.includes(secret), false, `${secret} leaked into: ${refusal}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test("setting a camera up is a narrower right than viewing one", () => {
|
|
815
|
+
assert.deepEqual(CAMERA_SETUP_PERMISSIONS, ["site-settings:manage-cctv-camera"]);
|
|
816
|
+
for (const permission of CAMERA_SETUP_PERMISSIONS) {
|
|
817
|
+
assert.equal(CAMERA_VIEW_PERMISSIONS.includes(permission), false);
|
|
818
|
+
}
|
|
819
|
+
// A wildcard still passes, as it does everywhere else in the estate.
|
|
820
|
+
assert.equal(hasAnyPermission(["*"], CAMERA_SETUP_PERMISSIONS), true);
|
|
821
|
+
assert.equal(
|
|
822
|
+
hasAnyPermission(CAMERA_VIEW_PERMISSIONS, CAMERA_SETUP_PERMISSIONS),
|
|
823
|
+
false,
|
|
824
|
+
);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("the Test button's ration sits well below the device's own trigger", () => {
|
|
828
|
+
// V3.37 4.7.x locks at 3 failed logins inside 30 seconds. Both rations must
|
|
829
|
+
// make that unreachable by pressing a button.
|
|
830
|
+
assert.ok(CAMERA_TEST_MIN_INTERVAL_SECONDS >= 15);
|
|
831
|
+
assert.ok(CAMERA_TEST_ROUND_LIMIT < 3 * (300 / CAMERA_TEST_MIN_INTERVAL_SECONDS));
|
|
832
|
+
// Slowest legal press rate against one recorder: fewer than 3 in any 30 s.
|
|
833
|
+
assert.ok(30 / CAMERA_TEST_MIN_INTERVAL_SECONDS < 3);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
/* ---- writing a camera: the gate on POST / PATCH / DELETE ------------------ */
|
|
837
|
+
|
|
838
|
+
test("a write asks for the permission the panel that draws it is drawn on", () => {
|
|
839
|
+
// One endpoint, two panels. Settings draws the ANPR panel on one string and
|
|
840
|
+
// the CCTV panel on another, so the server must ask the same question the
|
|
841
|
+
// client asked - not one string for both.
|
|
842
|
+
assert.deepEqual(cameraManagePermissions("ip"), CAMERA_SETUP_PERMISSIONS);
|
|
843
|
+
assert.deepEqual(cameraManagePermissions("anpr"), CAMERA_ANPR_PERMISSIONS);
|
|
844
|
+
assert.deepEqual(CAMERA_ANPR_PERMISSIONS, ["site-settings:manage-anpr-camera"]);
|
|
845
|
+
assert.notDeepEqual(CAMERA_SETUP_PERMISSIONS, CAMERA_ANPR_PERMISSIONS);
|
|
846
|
+
|
|
847
|
+
// Anything unrecognised asks for a permission rather than none.
|
|
848
|
+
for (const type of [undefined, null, "", "IP", "unknown", 7, {}]) {
|
|
849
|
+
assert.deepEqual(cameraManagePermissions(type), CAMERA_SETUP_PERMISSIONS);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test("managing a camera is not implied by being allowed to look at one", () => {
|
|
854
|
+
// Measured on staging 2026-08-10: of the 107 users entitled to a site that has
|
|
855
|
+
// a CCTV camera, 12 pass this gate and every one of them passes on "*". The
|
|
856
|
+
// two roles that hold the string itself carry no members, and no role that
|
|
857
|
+
// would be refused holds ANY site-settings string - so no account that can see
|
|
858
|
+
// the panel today is refused by it.
|
|
859
|
+
assert.equal(hasAnyPermission(CAMERA_VIEW_PERMISSIONS, CAMERA_SETUP_PERMISSIONS), false);
|
|
860
|
+
assert.equal(hasAnyPermission(CAMERA_VIEW_PERMISSIONS, CAMERA_ANPR_PERMISSIONS), false);
|
|
861
|
+
assert.equal(hasAnyPermission(["*"], CAMERA_ANPR_PERMISSIONS), true);
|
|
862
|
+
|
|
863
|
+
// Holding one panel's string does not hand over the other panel.
|
|
864
|
+
assert.equal(hasAnyPermission(CAMERA_SETUP_PERMISSIONS, CAMERA_ANPR_PERMISSIONS), false);
|
|
865
|
+
assert.equal(hasAnyPermission(CAMERA_ANPR_PERMISSIONS, CAMERA_SETUP_PERMISSIONS), false);
|
|
866
|
+
|
|
867
|
+
// A permitted caller passes; an unpermitted one does not, and neither does a
|
|
868
|
+
// caller whose role resolved to nothing (a soft-deleted role reads as []).
|
|
869
|
+
assert.equal(hasAnyPermission(["site-settings:manage-cctv-camera"], CAMERA_SETUP_PERMISSIONS), true);
|
|
870
|
+
assert.equal(hasAnyPermission(["members:view-members"], CAMERA_SETUP_PERMISSIONS), false);
|
|
871
|
+
assert.equal(hasAnyPermission([], CAMERA_SETUP_PERMISSIONS), false);
|
|
872
|
+
assert.equal(hasAnyPermission(undefined, CAMERA_SETUP_PERMISSIONS), false);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("a write is scoped to the caller's own sites, not just to any signed-in user", () => {
|
|
876
|
+
const site = "6923d6664150ca6a69b9f2b2";
|
|
877
|
+
const otherSite = "68f05fcc0c11062938dadada";
|
|
878
|
+
|
|
879
|
+
// A member of the camera's site may reach it.
|
|
880
|
+
assert.equal(
|
|
881
|
+
isCameraEntitled({ cameraSite: site, memberships: [{ siteId: site }] }),
|
|
882
|
+
true,
|
|
883
|
+
);
|
|
884
|
+
// A member of a DIFFERENT site may not - this is the cross-organisation case
|
|
885
|
+
// the write endpoints had no answer for at all.
|
|
886
|
+
assert.equal(
|
|
887
|
+
isCameraEntitled({ cameraSite: site, memberships: [{ siteId: otherSite }] }),
|
|
888
|
+
false,
|
|
889
|
+
);
|
|
890
|
+
// Signed in, but a member of nothing.
|
|
891
|
+
assert.equal(isCameraEntitled({ cameraSite: site, memberships: [] }), false);
|
|
892
|
+
});
|