@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.
@@ -0,0 +1,545 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ CAMERA_CAPABILITIES,
6
+ CAMERA_CAPABILITY_REASONS,
7
+ TRANSPORT_DEVICE_HTTP,
8
+ TRANSPORT_RELAY_PLAYER,
9
+ TRANSPORT_RTSP_FRAME,
10
+ cameraCapabilitiesFor,
11
+ cameraTransports,
12
+ describeCameraCapabilities,
13
+ deviceControlEnabled,
14
+ deviceHttpEnabled,
15
+ deviceHttpTargets,
16
+ deviceProbeTtlSeconds,
17
+ describeCameraCapabilities as describe_,
18
+ formatCapabilityTrace,
19
+ hasAnyCapability,
20
+ isRelayPlayerUrl,
21
+ registerCameraTransport,
22
+ resetCameraTransports,
23
+ resolveDeviceHttp,
24
+ } from "./.build/utils/camera-capability.util.mjs";
25
+
26
+ import {
27
+ publicCameraFields,
28
+ resolutionRefusalReason,
29
+ snapshotRefusalReason,
30
+ } from "./.build/utils/camera-view.util.mjs";
31
+
32
+ /**
33
+ * Shapes copied from the real estate, with fake addresses.
34
+ *
35
+ * The three unresolvable records are real: two `example.com` placeholders and
36
+ * one bare authority with no channel. They are in here because they are the
37
+ * cases a descriptor has to explain rather than fail on.
38
+ */
39
+ const RELAY = "relay.example.net";
40
+
41
+ const DEVICES = {
42
+ [RELAY]: {
43
+ host: "recorder.example.net",
44
+ port: 554,
45
+ username: "not-a-real-user",
46
+ password: "not-a-real-password",
47
+ },
48
+ };
49
+
50
+ const HTTP_TARGETS = {
51
+ [RELAY]: {
52
+ authority: RELAY,
53
+ baseUrl: "https://recorder.example.net:443",
54
+ username: "not-a-real-user",
55
+ password: "not-a-real-password",
56
+ enabled: true,
57
+ controlEnabled: false,
58
+ timeoutMs: 8000,
59
+ probeTtlSeconds: 900,
60
+ },
61
+ };
62
+
63
+ const CAMERA = {
64
+ type: "ip",
65
+ status: "active",
66
+ host: `https://${RELAY}/4`,
67
+ };
68
+
69
+ function target(overrides = {}) {
70
+ return { ...HTTP_TARGETS[RELAY], ...overrides };
71
+ }
72
+
73
+ function describeWith(overrides = {}) {
74
+ return describeCameraCapabilities({
75
+ camera: CAMERA,
76
+ rtspDevices: DEVICES,
77
+ deviceHttp: null,
78
+ probe: null,
79
+ ...overrides,
80
+ });
81
+ }
82
+
83
+ test.afterEach(() => resetCameraTransports());
84
+
85
+ /* -------------------------------------------------------------------------- */
86
+ /* The descriptor is total */
87
+ /* -------------------------------------------------------------------------- */
88
+
89
+ test("every capability gets an entry, always, for every camera shape", () => {
90
+ const shapes = [
91
+ CAMERA,
92
+ { ...CAMERA, status: "inactive" },
93
+ { type: "anpr", status: "active", host: "http://device.example.com:8080" },
94
+ { ...CAMERA, host: "http://example.com" },
95
+ { ...CAMERA, host: "https://other.example.org" },
96
+ { ...CAMERA, host: "" },
97
+ null,
98
+ ];
99
+
100
+ for (const camera of shapes) {
101
+ const { capabilities, trace } = describeWith({ camera });
102
+ assert.equal(trace.length, CAMERA_CAPABILITIES.length);
103
+ for (const capability of CAMERA_CAPABILITIES) {
104
+ const entry = capabilities[capability];
105
+ assert.ok(entry, `${capability} missing`);
106
+ assert.ok(["supported", "unsupported", "unknown"].includes(entry.state));
107
+ // The contract a UI renders from: a "no" always carries a code AND the
108
+ // sentence for it, and a "yes" always names the transport that will do it.
109
+ if (entry.state === "supported") {
110
+ assert.ok(entry.transport, `${capability} supported with no transport`);
111
+ assert.equal(entry.reason, null);
112
+ } else {
113
+ assert.ok(entry.reason, `${capability} refused with no reason`);
114
+ assert.equal(entry.detail, CAMERA_CAPABILITY_REASONS[entry.reason]);
115
+ assert.equal(entry.transport, null);
116
+ }
117
+ }
118
+ }
119
+ });
120
+
121
+ /* -------------------------------------------------------------------------- */
122
+ /* Today's estate, exactly */
123
+ /* -------------------------------------------------------------------------- */
124
+
125
+ test("a real relay camera: live video AND stills at once, over different transports", () => {
126
+ const { capabilities } = describeWith();
127
+
128
+ assert.equal(capabilities.liveVideo.state, "supported");
129
+ assert.equal(capabilities.liveVideo.transport, TRANSPORT_RELAY_PLAYER);
130
+
131
+ assert.equal(capabilities.stillFrame.state, "supported");
132
+ assert.equal(capabilities.stillFrame.transport, TRANSPORT_RTSP_FRAME);
133
+
134
+ assert.equal(capabilities.digitalZoom.state, "supported");
135
+ assert.ok(hasAnyCapability(capabilities));
136
+ });
137
+
138
+ test("with no device HTTP configured, control names the configuration that is missing", () => {
139
+ const { capabilities } = describeWith();
140
+
141
+ // The actionable sentence, not an architectural one: a lead reading this is
142
+ // told what to set, which is the whole point of the reason code.
143
+ for (const capability of ["ptz", "presets", "playback", "events", "deviceInfo"]) {
144
+ assert.equal(capabilities[capability].state, "unsupported");
145
+ assert.equal(capabilities[capability].reason, "device-http-not-configured");
146
+ }
147
+ });
148
+
149
+ test("audio is claimed by no transport at all, and says exactly that", () => {
150
+ const { capabilities } = describeWith();
151
+ assert.equal(capabilities.audio.state, "unsupported");
152
+ assert.equal(capabilities.audio.reason, "no-transport");
153
+ assert.equal(capabilities.audio.transport, null);
154
+ });
155
+
156
+ test("a placeholder record: no live video, no still, distinct reasons", () => {
157
+ const { capabilities } = describeWith({
158
+ camera: { ...CAMERA, host: "http://example.com" },
159
+ });
160
+
161
+ assert.equal(capabilities.liveVideo.state, "unsupported");
162
+ assert.equal(capabilities.liveVideo.reason, "not-a-relay-player-url");
163
+ assert.equal(capabilities.stillFrame.state, "unsupported");
164
+ assert.equal(capabilities.stillFrame.reason, "no-recorder-configured");
165
+ });
166
+
167
+ test("a host with no channel is a data problem, not a deployment one", () => {
168
+ const { capabilities } = describeWith({
169
+ camera: { ...CAMERA, host: `https://${RELAY}/` },
170
+ });
171
+ assert.equal(capabilities.stillFrame.reason, "no-channel-in-address");
172
+ });
173
+
174
+ test("an inactive camera and an ANPR unit are refused by the record, not the transport", () => {
175
+ assert.equal(
176
+ describeWith({ camera: { ...CAMERA, status: "inactive" } }).capabilities
177
+ .liveVideo.reason,
178
+ "camera-inactive",
179
+ );
180
+ assert.equal(
181
+ describeWith({
182
+ camera: { type: "anpr", status: "active", host: `https://${RELAY}/4` },
183
+ }).capabilities.liveVideo.reason,
184
+ "not-patrol-cctv-camera",
185
+ );
186
+ });
187
+
188
+ /* -------------------------------------------------------------------------- */
189
+ /* DEVICE_HTTP: the ladder from "not configured" to "supported" */
190
+ /* -------------------------------------------------------------------------- */
191
+
192
+ test("configured but disabled: unsupported, and it names the switch", () => {
193
+ const { capabilities } = describeWith({
194
+ deviceHttp: target({ enabled: false }),
195
+ });
196
+ assert.equal(capabilities.deviceInfo.reason, "device-http-disabled");
197
+ });
198
+
199
+ test("enabled but never probed: UNKNOWN, not unsupported", () => {
200
+ const { capabilities } = describeWith({ deviceHttp: target() });
201
+
202
+ // The distinction that matters. "We have not asked" must not read as "the
203
+ // hardware cannot" — one prompts someone to switch probing on, the other
204
+ // makes them stop looking.
205
+ assert.equal(capabilities.deviceInfo.state, "unknown");
206
+ assert.equal(capabilities.deviceInfo.reason, "device-not-probed");
207
+ assert.equal(capabilities.ptz.state, "unknown");
208
+ });
209
+
210
+ test("the failure budget being spent is UNKNOWN, and it is preferred over a flat no", () => {
211
+ const { capabilities } = describeWith({
212
+ deviceHttp: target(),
213
+ probe: { reachable: false, lockedOut: true },
214
+ });
215
+ assert.equal(capabilities.deviceInfo.state, "unknown");
216
+ assert.equal(capabilities.deviceInfo.reason, "device-http-locked-out");
217
+ });
218
+
219
+ test("probed and unreachable: unsupported with the device's own reason", () => {
220
+ const { capabilities } = describeWith({
221
+ deviceHttp: target(),
222
+ probe: { reachable: false },
223
+ });
224
+ assert.equal(capabilities.deviceInfo.state, "unsupported");
225
+ assert.equal(capabilities.deviceInfo.reason, "device-http-unreachable");
226
+ });
227
+
228
+ test("probed and reachable: reads light up, PTZ still does not", () => {
229
+ const { capabilities } = describeWith({
230
+ deviceHttp: target(),
231
+ probe: { reachable: true, ptz: true, presets: true },
232
+ });
233
+
234
+ assert.equal(capabilities.deviceInfo.state, "supported");
235
+ assert.equal(capabilities.deviceInfo.transport, TRANSPORT_DEVICE_HTTP);
236
+ assert.equal(capabilities.playback.state, "supported");
237
+ assert.equal(capabilities.events.state, "supported");
238
+
239
+ // Reads on does NOT arm motors. This is the second flag, and it is the whole
240
+ // reason there are two.
241
+ assert.equal(capabilities.ptz.state, "unsupported");
242
+ assert.equal(capabilities.ptz.reason, "control-not-enabled");
243
+ assert.equal(capabilities.presets.reason, "control-not-enabled");
244
+ });
245
+
246
+ test("control enabled on a PTZ unit is the only way ptz becomes supported", () => {
247
+ const { capabilities } = describeWith({
248
+ deviceHttp: target({ controlEnabled: true }),
249
+ probe: { reachable: true, ptz: true, presets: true },
250
+ });
251
+ assert.equal(capabilities.ptz.state, "supported");
252
+ assert.equal(capabilities.ptz.transport, TRANSPORT_DEVICE_HTTP);
253
+ });
254
+
255
+ test("a fixed dome that answered says so, and control being on does not change it", () => {
256
+ const { capabilities } = describeWith({
257
+ deviceHttp: target({ controlEnabled: true }),
258
+ probe: { reachable: true, ptz: false, presets: false },
259
+ });
260
+ assert.equal(capabilities.ptz.state, "unsupported");
261
+ assert.equal(capabilities.ptz.reason, "device-no-ptz");
262
+ });
263
+
264
+ test("a unit that answered but would not say whether it pans is unknown", () => {
265
+ const { capabilities } = describeWith({
266
+ deviceHttp: target({ controlEnabled: true }),
267
+ probe: { reachable: true, ptz: null },
268
+ });
269
+ assert.equal(capabilities.ptz.state, "unknown");
270
+ });
271
+
272
+ test("stillFrame prefers the proven RTSP path even when device HTTP is up", () => {
273
+ const { capabilities } = describeWith({
274
+ deviceHttp: target(),
275
+ probe: { reachable: true, ptz: true },
276
+ });
277
+ assert.equal(capabilities.stillFrame.transport, TRANSPORT_RTSP_FRAME);
278
+ });
279
+
280
+ test("with no recorder configured, a reachable device HTTP still serves the still", () => {
281
+ const { capabilities } = describeWith({
282
+ rtspDevices: {},
283
+ deviceHttp: target(),
284
+ probe: { reachable: true },
285
+ });
286
+ assert.equal(capabilities.stillFrame.state, "supported");
287
+ assert.equal(capabilities.stillFrame.transport, TRANSPORT_DEVICE_HTTP);
288
+ });
289
+
290
+ /* -------------------------------------------------------------------------- */
291
+ /* The registry is the extension point */
292
+ /* -------------------------------------------------------------------------- */
293
+
294
+ test("a new transport lights a capability up with no change to the descriptor code", () => {
295
+ assert.equal(describeWith().capabilities.audio.state, "unsupported");
296
+
297
+ registerCameraTransport({
298
+ id: "HLS_RELAY",
299
+ provides: ["audio", "liveVideo"],
300
+ evaluate: () => ({ state: "supported", reason: null }),
301
+ });
302
+
303
+ const { capabilities } = describeWith();
304
+ assert.equal(capabilities.audio.state, "supported");
305
+ assert.equal(capabilities.audio.transport, "HLS_RELAY");
306
+ // Registered last, so it does not steal live video from the production path.
307
+ assert.equal(capabilities.liveVideo.transport, TRANSPORT_RELAY_PLAYER);
308
+ });
309
+
310
+ test("registering an existing id replaces it rather than shadowing it", () => {
311
+ const before = cameraTransports().length;
312
+ registerCameraTransport({
313
+ id: TRANSPORT_RELAY_PLAYER,
314
+ provides: ["liveVideo"],
315
+ evaluate: () => ({ state: "unsupported", reason: "no-transport" }),
316
+ });
317
+ assert.equal(cameraTransports().length, before);
318
+ assert.equal(describeWith().capabilities.liveVideo.state, "unsupported");
319
+ });
320
+
321
+ test("selection is deterministic: the same input gives the same trace", () => {
322
+ const a = formatCapabilityTrace(describeWith().trace);
323
+ const b = formatCapabilityTrace(describeWith().trace);
324
+ assert.equal(a, b);
325
+ assert.match(a, /liveVideo=RELAY_PLAYER/);
326
+ assert.match(a, /stillFrame=RTSP_FRAME/);
327
+ assert.match(a, /ptz=unsupported:device-http-not-configured/);
328
+ });
329
+
330
+ test("the trace carries no address and no credential", () => {
331
+ const line = formatCapabilityTrace(
332
+ describeWith({ deviceHttp: target() }).trace,
333
+ );
334
+ assert.ok(!line.includes(RELAY));
335
+ assert.ok(!line.includes("not-a-real-password"));
336
+ assert.ok(!line.includes("recorder.example.net"));
337
+ });
338
+
339
+ /* -------------------------------------------------------------------------- */
340
+ /* Relay player URL shape */
341
+ /* -------------------------------------------------------------------------- */
342
+
343
+ test("a relay player URL is https with exactly one path segment", () => {
344
+ assert.ok(isRelayPlayerUrl(`https://${RELAY}/4`));
345
+ assert.ok(isRelayPlayerUrl(`https://${RELAY}/10`));
346
+ assert.ok(isRelayPlayerUrl(`https://${RELAY}/gym`));
347
+ assert.ok(!isRelayPlayerUrl(`http://${RELAY}/4`), "cleartext is not enforced-shape");
348
+ assert.ok(!isRelayPlayerUrl(`https://${RELAY}`));
349
+ assert.ok(!isRelayPlayerUrl(`https://${RELAY}/a/b`));
350
+ assert.ok(!isRelayPlayerUrl(`https://${RELAY}/4?x=1`));
351
+ assert.ok(!isRelayPlayerUrl(""));
352
+ assert.ok(!isRelayPlayerUrl(undefined));
353
+ });
354
+
355
+ /* -------------------------------------------------------------------------- */
356
+ /* Configuration: names, validation, and no secret in the config value */
357
+ /* -------------------------------------------------------------------------- */
358
+
359
+ test("both switches are opt-in and control cannot be on by itself", () => {
360
+ assert.equal(deviceHttpEnabled({}), false);
361
+ assert.equal(deviceHttpEnabled({ CAMERA_DEVICE_HTTP_ENABLED: "1" }), false);
362
+ assert.equal(deviceHttpEnabled({ CAMERA_DEVICE_HTTP_ENABLED: "true" }), true);
363
+
364
+ assert.equal(
365
+ deviceControlEnabled({ CAMERA_DEVICE_CONTROL_ENABLED: "true" }),
366
+ false,
367
+ "control without device access must stay off",
368
+ );
369
+ assert.equal(
370
+ deviceControlEnabled({
371
+ CAMERA_DEVICE_HTTP_ENABLED: "true",
372
+ CAMERA_DEVICE_CONTROL_ENABLED: "true",
373
+ }),
374
+ true,
375
+ );
376
+ });
377
+
378
+ test("a valid target resolves by relay authority, with the credential referenced", () => {
379
+ const { targets, errors } = deviceHttpTargets({
380
+ CAMERA_DEVICE_HTTP: JSON.stringify({
381
+ [RELAY]: {
382
+ baseUrl: "https://recorder.example.net:443",
383
+ credentialRef: "CAMERA_DEVICE_CRED_MAIN",
384
+ },
385
+ }),
386
+ CAMERA_DEVICE_CRED_MAIN: "someone:pa:ss:word",
387
+ CAMERA_DEVICE_HTTP_ENABLED: "true",
388
+ });
389
+
390
+ assert.deepEqual(errors, []);
391
+ const resolved = resolveDeviceHttp(`https://${RELAY}/4`, targets);
392
+ // Normalised to an origin, so the default port drops off and a base URL
393
+ // cannot smuggle in a path, query or fragment.
394
+ assert.equal(resolved.baseUrl, "https://recorder.example.net");
395
+ assert.equal(resolved.username, "someone");
396
+ // Split on the FIRST colon only: a password may contain colons.
397
+ assert.equal(resolved.password, "pa:ss:word");
398
+ assert.equal(resolved.enabled, true);
399
+ assert.equal(resolved.controlEnabled, false);
400
+ });
401
+
402
+ test("a credential pasted into credentialRef is rejected, not used", () => {
403
+ const { targets, errors } = deviceHttpTargets({
404
+ CAMERA_DEVICE_HTTP: JSON.stringify({
405
+ [RELAY]: {
406
+ baseUrl: "https://recorder.example.net",
407
+ credentialRef: "someone:hunter2",
408
+ },
409
+ }),
410
+ });
411
+ assert.deepEqual(targets, {});
412
+ assert.equal(errors.length, 1);
413
+ assert.match(errors[0], /environment variable NAME/);
414
+ });
415
+
416
+ test("every rejection is reported rather than silently dropped", () => {
417
+ const cases = [
418
+ [{ CAMERA_DEVICE_HTTP: "{oops" }, /not valid JSON/],
419
+ [{ CAMERA_DEVICE_HTTP: "[]" }, /JSON object keyed by relay authority/],
420
+ [
421
+ { CAMERA_DEVICE_HTTP: JSON.stringify({ [RELAY]: { credentialRef: "A" } }) },
422
+ /baseUrl must be an http\(s\) origin/,
423
+ ],
424
+ [
425
+ {
426
+ CAMERA_DEVICE_HTTP: JSON.stringify({
427
+ [RELAY]: { baseUrl: "https://x/some/path", credentialRef: "A" },
428
+ }),
429
+ },
430
+ /baseUrl must be an http\(s\) origin/,
431
+ ],
432
+ [
433
+ {
434
+ CAMERA_DEVICE_HTTP: JSON.stringify({
435
+ [RELAY]: { baseUrl: "rtsp://x", credentialRef: "A" },
436
+ }),
437
+ },
438
+ /baseUrl must be an http\(s\) origin/,
439
+ ],
440
+ [
441
+ {
442
+ CAMERA_DEVICE_HTTP: JSON.stringify({
443
+ [RELAY]: { baseUrl: "https://x", credentialRef: "MISSING_ONE" },
444
+ }),
445
+ },
446
+ /not set on this server/,
447
+ ],
448
+ [
449
+ {
450
+ CAMERA_DEVICE_HTTP: JSON.stringify({
451
+ [RELAY]: { baseUrl: "https://x", credentialRef: "CRED" },
452
+ }),
453
+ CRED: "no-colon-here",
454
+ },
455
+ /username:password/,
456
+ ],
457
+ ];
458
+
459
+ for (const [envVars, expected] of cases) {
460
+ const { targets, errors } = deviceHttpTargets(envVars);
461
+ assert.deepEqual(targets, {}, `${JSON.stringify(envVars)} produced a target`);
462
+ assert.match(errors.join(" | "), expected);
463
+ }
464
+ });
465
+
466
+ test("an unset CAMERA_DEVICE_HTTP is not an error, it is just no device access", () => {
467
+ const { targets, errors } = deviceHttpTargets({});
468
+ assert.deepEqual(targets, {});
469
+ assert.deepEqual(errors, []);
470
+ });
471
+
472
+ test("the probe TTL defaults long, because what it answers changes rarely", () => {
473
+ assert.equal(deviceProbeTtlSeconds({}), 900);
474
+ assert.equal(
475
+ deviceProbeTtlSeconds({ CAMERA_DEVICE_HTTP_PROBE_TTL_SECONDS: "60" }),
476
+ 60,
477
+ );
478
+ assert.equal(
479
+ deviceProbeTtlSeconds({ CAMERA_DEVICE_HTTP_PROBE_TTL_SECONDS: "-1" }),
480
+ 900,
481
+ );
482
+ });
483
+
484
+ /* -------------------------------------------------------------------------- */
485
+ /* No drift between the descriptor's sentences and the legacy refusal strings */
486
+ /* -------------------------------------------------------------------------- */
487
+
488
+ test("the descriptor explains a camera in the same words the legacy field does", () => {
489
+ // Two code paths, one wording. A camera that says "not active" on one screen
490
+ // and something else on another is the drift this pins shut.
491
+ assert.equal(
492
+ CAMERA_CAPABILITY_REASONS["camera-inactive"],
493
+ snapshotRefusalReason({ type: "ip", status: "inactive", host: "x" }, DEVICES),
494
+ );
495
+ assert.equal(
496
+ CAMERA_CAPABILITY_REASONS["no-address"],
497
+ snapshotRefusalReason({ type: "ip", status: "active", host: " " }, DEVICES),
498
+ );
499
+ assert.equal(
500
+ CAMERA_CAPABILITY_REASONS["no-recorder-configured"],
501
+ resolutionRefusalReason("https://elsewhere.example.org/4", DEVICES),
502
+ );
503
+ assert.equal(
504
+ CAMERA_CAPABILITY_REASONS["no-channel-in-address"],
505
+ resolutionRefusalReason(`https://${RELAY}/`, DEVICES),
506
+ );
507
+ assert.equal(
508
+ CAMERA_CAPABILITY_REASONS["not-patrol-cctv-camera"],
509
+ snapshotRefusalReason({ type: "anpr", status: "active", host: "x" }, DEVICES),
510
+ );
511
+ });
512
+
513
+ /* -------------------------------------------------------------------------- */
514
+ /* The wall's one new field */
515
+ /* -------------------------------------------------------------------------- */
516
+
517
+ test("host is returned for an IP camera and for nothing else", () => {
518
+ const fields = publicCameraFields({ ...CAMERA, _id: "1", host: `https://${RELAY}/4` });
519
+ assert.equal(fields.host, `https://${RELAY}/4`);
520
+
521
+ // ANPR records hold a real device endpoint plus a real credential, and no
522
+ // patrol or CCTV path may ever hand one out.
523
+ const anpr = publicCameraFields({
524
+ _id: "2",
525
+ type: "anpr",
526
+ status: "active",
527
+ host: "http://device.example.com:8080",
528
+ username: "u",
529
+ password: "p",
530
+ });
531
+ assert.equal("host" in anpr, false);
532
+ assert.equal("username" in anpr, false);
533
+ assert.equal("password" in anpr, false);
534
+ });
535
+
536
+ test("cameraCapabilitiesFor reads configuration but never probes", () => {
537
+ const { capabilities } = cameraCapabilitiesFor({
538
+ camera: CAMERA,
539
+ rtspDevices: DEVICES,
540
+ deviceHttpTargets: {},
541
+ });
542
+ assert.equal(capabilities.liveVideo.state, "supported");
543
+ assert.equal(capabilities.stillFrame.state, "supported");
544
+ assert.equal(typeof describe_, "function");
545
+ });