@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,792 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AUTH_FAILURE_LIMIT,
|
|
8
|
+
AUTH_FAILURE_WINDOW_MS,
|
|
9
|
+
AUTH_LOCKOUT_MS,
|
|
10
|
+
EVENT_HEALTH_CODES,
|
|
11
|
+
MEDIA_FIND_CREATE,
|
|
12
|
+
PTZ_MOVE_CODES,
|
|
13
|
+
buildDigestAuthorization,
|
|
14
|
+
clampPtzSpeed,
|
|
15
|
+
createAuthFailureBudget,
|
|
16
|
+
createDeviceHttpAdapter,
|
|
17
|
+
eventAttachPath,
|
|
18
|
+
gotoPresetPath,
|
|
19
|
+
loadFileByTimePath,
|
|
20
|
+
mediaFindClosePath,
|
|
21
|
+
mediaFindFilePath,
|
|
22
|
+
mediaFindNextPath,
|
|
23
|
+
parseDigestChallenge,
|
|
24
|
+
parseEventBody,
|
|
25
|
+
parseKeyValues,
|
|
26
|
+
parseMediaFindResults,
|
|
27
|
+
parsePresets,
|
|
28
|
+
parsePtzCapability,
|
|
29
|
+
presetListPath,
|
|
30
|
+
probeDeviceCapabilities,
|
|
31
|
+
ptzAbsolutePath,
|
|
32
|
+
ptzCapabilityPath,
|
|
33
|
+
ptzMovePath,
|
|
34
|
+
query,
|
|
35
|
+
snapshotPath,
|
|
36
|
+
} from "./.build/services/camera-device-http.service.mjs";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* **No device or recorder is contacted by this file.** Everything below runs
|
|
40
|
+
* against a mock HTTP server on 127.0.0.1 that imitates the digest handshake and
|
|
41
|
+
* the response bodies documented in V3.37. The credential is an obvious fake.
|
|
42
|
+
*/
|
|
43
|
+
const USER = "not-a-real-user";
|
|
44
|
+
const PASS = "not-a-real-password";
|
|
45
|
+
const REALM = "Login to TEST";
|
|
46
|
+
const NONCE = "fixednonce0123456789";
|
|
47
|
+
|
|
48
|
+
function md5(value) {
|
|
49
|
+
return createHash("md5").update(value, "utf8").digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* -------------------------------------------------------------------------- */
|
|
53
|
+
/* An in-memory budget store and a controllable clock */
|
|
54
|
+
/* -------------------------------------------------------------------------- */
|
|
55
|
+
|
|
56
|
+
function memoryStore() {
|
|
57
|
+
const map = new Map();
|
|
58
|
+
return {
|
|
59
|
+
map,
|
|
60
|
+
get: async (key) => map.get(key) ?? null,
|
|
61
|
+
set: async (key, value) => void map.set(key, value),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fakeClock(start = 1_770_000_000_000) {
|
|
66
|
+
let at = start;
|
|
67
|
+
return {
|
|
68
|
+
now: () => at,
|
|
69
|
+
advance: (ms) => {
|
|
70
|
+
at += ms;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function budgetFor(store = memoryStore(), clock = fakeClock()) {
|
|
76
|
+
return { budget: createAuthFailureBudget(store, clock.now), store, clock };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* -------------------------------------------------------------------------- */
|
|
80
|
+
/* The mock device */
|
|
81
|
+
/* -------------------------------------------------------------------------- */
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A Dahua-shaped device.
|
|
85
|
+
*
|
|
86
|
+
* `routes` maps a path prefix to `{status, body, headers}`. `credentials`
|
|
87
|
+
* decides whether a correct digest response is accepted, so "wrong password" is
|
|
88
|
+
* testable without changing anything on our side.
|
|
89
|
+
*/
|
|
90
|
+
async function startDevice(options = {}) {
|
|
91
|
+
const hits = [];
|
|
92
|
+
const accept = options.accept ?? true;
|
|
93
|
+
|
|
94
|
+
const server = createServer((req, res) => {
|
|
95
|
+
hits.push(req.url);
|
|
96
|
+
|
|
97
|
+
const authorization = req.headers.authorization;
|
|
98
|
+
if (!authorization) {
|
|
99
|
+
res.writeHead(401, {
|
|
100
|
+
"WWW-Authenticate": `Digest realm="${REALM}", qop="auth", nonce="${NONCE}", opaque="op4qu3"`,
|
|
101
|
+
});
|
|
102
|
+
res.end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!accept || !isDigestValid(authorization, req.method)) {
|
|
107
|
+
res.writeHead(options.rejectWith ?? 403).end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const route = Object.entries(options.routes ?? {}).find(([prefix]) =>
|
|
112
|
+
req.url.startsWith(prefix),
|
|
113
|
+
);
|
|
114
|
+
if (!route) {
|
|
115
|
+
res.writeHead(404).end();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const reply = route[1];
|
|
120
|
+
if (typeof reply === "function") {
|
|
121
|
+
reply(req, res);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
res.writeHead(reply.status ?? 200, reply.headers ?? {});
|
|
125
|
+
res.end(reply.body ?? "OK");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
129
|
+
const { port } = server.address();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
hits,
|
|
133
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
134
|
+
async close() {
|
|
135
|
+
await new Promise((resolve) => server.close(resolve));
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Recomputes the response the way a real device would, per RFC 2617. */
|
|
141
|
+
function isDigestValid(header, method) {
|
|
142
|
+
const parts = parseDigestChallenge(header);
|
|
143
|
+
const ha1 = md5(`${USER}:${REALM}:${PASS}`);
|
|
144
|
+
const ha2 = md5(`${method}:${parts.uri}`);
|
|
145
|
+
const expected = parts.qop
|
|
146
|
+
? md5(`${ha1}:${NONCE}:${parts.nc}:${parts.cnonce}:${parts.qop}:${ha2}`)
|
|
147
|
+
: md5(`${ha1}:${NONCE}:${ha2}`);
|
|
148
|
+
return parts.username === USER && parts.response === expected;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function targetFor(device, overrides = {}) {
|
|
152
|
+
return {
|
|
153
|
+
authority: "relay.example.net",
|
|
154
|
+
baseUrl: device.baseUrl,
|
|
155
|
+
username: USER,
|
|
156
|
+
password: PASS,
|
|
157
|
+
enabled: true,
|
|
158
|
+
controlEnabled: false,
|
|
159
|
+
timeoutMs: 4000,
|
|
160
|
+
probeTtlSeconds: 900,
|
|
161
|
+
...overrides,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* -------------------------------------------------------------------------- */
|
|
166
|
+
/* Digest */
|
|
167
|
+
/* -------------------------------------------------------------------------- */
|
|
168
|
+
|
|
169
|
+
test("a challenge is parsed, including a quoted value containing a comma", () => {
|
|
170
|
+
const parsed = parseDigestChallenge(
|
|
171
|
+
'Digest realm="Login to X", qop="auth,auth-int", nonce=abc123, opaque="o", algorithm=MD5',
|
|
172
|
+
);
|
|
173
|
+
assert.equal(parsed.realm, "Login to X");
|
|
174
|
+
assert.equal(parsed.qop, "auth,auth-int");
|
|
175
|
+
assert.equal(parsed.nonce, "abc123");
|
|
176
|
+
assert.equal(parsed.algorithm, "MD5");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a Basic challenge yields nothing, so we never answer one with a password", () => {
|
|
180
|
+
assert.deepEqual(parseDigestChallenge('Basic realm="X"'), {});
|
|
181
|
+
assert.deepEqual(parseDigestChallenge(""), {});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the digest response matches the RFC 2617 §3.5 worked example", () => {
|
|
185
|
+
// The canonical vector, so an arithmetic slip in HA1/HA2/response is caught
|
|
186
|
+
// without a device: user "Mufasa", password "Circle Of Life".
|
|
187
|
+
const header = buildDigestAuthorization({
|
|
188
|
+
username: "Mufasa",
|
|
189
|
+
password: "Circle Of Life",
|
|
190
|
+
method: "GET",
|
|
191
|
+
uri: "/dir/index.html",
|
|
192
|
+
challenge: {
|
|
193
|
+
realm: "testrealm@host.com",
|
|
194
|
+
qop: "auth,auth-int",
|
|
195
|
+
nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
|
196
|
+
opaque: "5ccc069c403ebaf9f0171e9517f40e41",
|
|
197
|
+
},
|
|
198
|
+
nc: "00000001",
|
|
199
|
+
cnonce: "0a4f113b",
|
|
200
|
+
});
|
|
201
|
+
assert.match(header, /response="6629fae49393a05397450978507c4ef1"/);
|
|
202
|
+
assert.match(header, /qop=auth, nc=00000001, cnonce="0a4f113b"/);
|
|
203
|
+
assert.match(header, /opaque="5ccc069c403ebaf9f0171e9517f40e41"/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("MD5-sess and a challenge with no qop are both handled", () => {
|
|
207
|
+
const sess = buildDigestAuthorization({
|
|
208
|
+
username: "u",
|
|
209
|
+
password: "p",
|
|
210
|
+
method: "GET",
|
|
211
|
+
uri: "/x",
|
|
212
|
+
challenge: { realm: "r", nonce: "n", algorithm: "MD5-sess", qop: "auth" },
|
|
213
|
+
cnonce: "c",
|
|
214
|
+
});
|
|
215
|
+
const plain = buildDigestAuthorization({
|
|
216
|
+
username: "u",
|
|
217
|
+
password: "p",
|
|
218
|
+
method: "GET",
|
|
219
|
+
uri: "/x",
|
|
220
|
+
challenge: { realm: "r", nonce: "n" },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const ha1 = md5(`${md5("u:r:p")}:n:c`);
|
|
224
|
+
assert.match(sess, new RegExp(`response="${md5(`${ha1}:n:00000001:c:auth:${md5("GET:/x")}`)}"`));
|
|
225
|
+
assert.ok(!plain.includes("qop="));
|
|
226
|
+
assert.match(plain, new RegExp(`response="${md5(`${md5("u:r:p")}:n:${md5("GET:/x")}`)}"`));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/* -------------------------------------------------------------------------- */
|
|
230
|
+
/* The lockout budget — the mandatory guard */
|
|
231
|
+
/* -------------------------------------------------------------------------- */
|
|
232
|
+
|
|
233
|
+
test("the budget stops one attempt short of the device's own limit", () => {
|
|
234
|
+
// The device locks at 3 failures in 30 s (§4.7.x). Stopping at 2 leaves a
|
|
235
|
+
// slot: for a lost update, and for an installer standing at the recorder.
|
|
236
|
+
assert.equal(AUTH_FAILURE_LIMIT, 2);
|
|
237
|
+
assert.equal(AUTH_FAILURE_WINDOW_MS, 30_000);
|
|
238
|
+
assert.equal(AUTH_LOCKOUT_MS, 1_800_000);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("two failures spend the budget and further attempts are refused, not retried", async () => {
|
|
242
|
+
const { budget, clock } = budgetFor();
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(await budget.check("dev"), { allowed: true });
|
|
245
|
+
assert.equal((await budget.recordFailure("dev")).allowed, true);
|
|
246
|
+
assert.equal((await budget.recordFailure("dev")).allowed, false);
|
|
247
|
+
|
|
248
|
+
const verdict = await budget.check("dev");
|
|
249
|
+
assert.equal(verdict.allowed, false);
|
|
250
|
+
assert.equal(verdict.reason, "device-http-locked-out");
|
|
251
|
+
assert.equal(verdict.retryAfterSeconds, 1800);
|
|
252
|
+
|
|
253
|
+
// Still refused most of the way through the cooldown…
|
|
254
|
+
clock.advance(AUTH_LOCKOUT_MS - 1000);
|
|
255
|
+
assert.equal((await budget.check("dev")).allowed, false);
|
|
256
|
+
// …and open again after it, matching the device's own LoginFailLockTime.
|
|
257
|
+
clock.advance(2000);
|
|
258
|
+
assert.equal((await budget.check("dev")).allowed, true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("failures older than the device's 30 s window do not accumulate", async () => {
|
|
262
|
+
const { budget, clock } = budgetFor();
|
|
263
|
+
await budget.recordFailure("dev");
|
|
264
|
+
clock.advance(AUTH_FAILURE_WINDOW_MS + 1);
|
|
265
|
+
// A second failure half an hour later must not lock anything out.
|
|
266
|
+
assert.equal((await budget.recordFailure("dev")).allowed, true);
|
|
267
|
+
assert.equal((await budget.check("dev")).allowed, true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("the budget is per device, so one bad recorder cannot lock out another", async () => {
|
|
271
|
+
const { budget } = budgetFor();
|
|
272
|
+
await budget.recordFailure("a");
|
|
273
|
+
await budget.recordFailure("a");
|
|
274
|
+
assert.equal((await budget.check("a")).allowed, false);
|
|
275
|
+
assert.equal((await budget.check("b")).allowed, true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a success clears the window but never unlocks a spent budget", async () => {
|
|
279
|
+
const { budget } = budgetFor();
|
|
280
|
+
await budget.recordFailure("dev");
|
|
281
|
+
await budget.recordSuccess("dev");
|
|
282
|
+
assert.equal((await budget.recordFailure("dev")).allowed, true, "window cleared");
|
|
283
|
+
|
|
284
|
+
await budget.recordFailure("dev");
|
|
285
|
+
assert.equal((await budget.check("dev")).allowed, false);
|
|
286
|
+
await budget.recordSuccess("dev");
|
|
287
|
+
assert.equal(
|
|
288
|
+
(await budget.check("dev")).allowed,
|
|
289
|
+
false,
|
|
290
|
+
"a lockout must not be clearable by a later success",
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("an unreadable budget fails CLOSED", async () => {
|
|
295
|
+
const store = { get: async () => "{not json", set: async () => {} };
|
|
296
|
+
const budget = createAuthFailureBudget(store, fakeClock().now);
|
|
297
|
+
assert.equal((await budget.check("dev")).allowed, false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("the budget survives a restart: it is written to the store, not held in memory", async () => {
|
|
301
|
+
const store = memoryStore();
|
|
302
|
+
const clock = fakeClock();
|
|
303
|
+
await createAuthFailureBudget(store, clock.now).recordFailure("dev");
|
|
304
|
+
await createAuthFailureBudget(store, clock.now).recordFailure("dev");
|
|
305
|
+
// A brand-new instance, as a redeployed process would be.
|
|
306
|
+
const fresh = createAuthFailureBudget(store, clock.now);
|
|
307
|
+
assert.equal((await fresh.check("dev")).allowed, false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/* -------------------------------------------------------------------------- */
|
|
311
|
+
/* The adapter cannot exist, let alone fire, while it is switched off */
|
|
312
|
+
/* -------------------------------------------------------------------------- */
|
|
313
|
+
|
|
314
|
+
test("the adapter is null when device access is disabled", () => {
|
|
315
|
+
const { budget } = budgetFor();
|
|
316
|
+
const adapter = createDeviceHttpAdapter({
|
|
317
|
+
target: targetFor({ baseUrl: "http://127.0.0.1:1" }, { enabled: false }),
|
|
318
|
+
budget,
|
|
319
|
+
});
|
|
320
|
+
// Not a disabled object with guarded methods — nothing to call at all.
|
|
321
|
+
assert.equal(adapter, null);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("control is null unless control is explicitly enabled", () => {
|
|
325
|
+
const { budget } = budgetFor();
|
|
326
|
+
const reads = createDeviceHttpAdapter({
|
|
327
|
+
target: targetFor({ baseUrl: "http://127.0.0.1:1" }),
|
|
328
|
+
budget,
|
|
329
|
+
});
|
|
330
|
+
assert.equal(reads.control, null, "reads on must not arm motors");
|
|
331
|
+
assert.equal(typeof reads.getDeviceInfo, "function");
|
|
332
|
+
|
|
333
|
+
const armed = createDeviceHttpAdapter({
|
|
334
|
+
target: targetFor({ baseUrl: "http://127.0.0.1:1" }, { controlEnabled: true }),
|
|
335
|
+
budget,
|
|
336
|
+
});
|
|
337
|
+
for (const name of ["ptzContinuous", "ptzStop", "ptzAbsolute", "gotoPreset"]) {
|
|
338
|
+
assert.equal(typeof armed.control[name], "function", name);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("a spent budget means no request leaves the process at all", async () => {
|
|
343
|
+
const device = await startDevice({ routes: { "/cgi-bin": { body: "version=1" } } });
|
|
344
|
+
try {
|
|
345
|
+
const { budget } = budgetFor();
|
|
346
|
+
await budget.recordFailure(device.baseUrl);
|
|
347
|
+
await budget.recordFailure(device.baseUrl);
|
|
348
|
+
|
|
349
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
350
|
+
await assert.rejects(() => adapter.getDeviceInfo(), (error) => {
|
|
351
|
+
assert.equal(error.code, "device-http-locked-out");
|
|
352
|
+
assert.equal(error.retryAfterSeconds, 1800);
|
|
353
|
+
return true;
|
|
354
|
+
});
|
|
355
|
+
assert.deepEqual(device.hits, [], "the device must not have been touched");
|
|
356
|
+
} finally {
|
|
357
|
+
await device.close();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/* -------------------------------------------------------------------------- */
|
|
362
|
+
/* The handshake, end to end, against the mock */
|
|
363
|
+
/* -------------------------------------------------------------------------- */
|
|
364
|
+
|
|
365
|
+
test("a 401 challenge is the handshake working and costs nothing", async () => {
|
|
366
|
+
const device = await startDevice({
|
|
367
|
+
routes: {
|
|
368
|
+
"/cgi-bin/magicBox.cgi?action=getSoftwareVersion": {
|
|
369
|
+
body: "version=3.140.0000000.0\r\nBuildDate=2024-01-01\r\n",
|
|
370
|
+
},
|
|
371
|
+
"/cgi-bin/magicBox.cgi?action=getDeviceType": { body: "type=IPC-TEST\r\n" },
|
|
372
|
+
"/cgi-bin/IntervideoManager.cgi": { body: "version=3.37\r\n" },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
try {
|
|
376
|
+
const { budget, store } = budgetFor();
|
|
377
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
378
|
+
const info = await adapter.getDeviceInfo();
|
|
379
|
+
|
|
380
|
+
assert.equal(info.softwareVersion, "3.140.0000000.0");
|
|
381
|
+
assert.equal(info.deviceType, "IPC-TEST");
|
|
382
|
+
assert.equal(info.apiVersion, "3.37");
|
|
383
|
+
// Three calls, each a 401 then a 200 — and not one counted as a failure.
|
|
384
|
+
assert.equal(device.hits.length, 6);
|
|
385
|
+
assert.equal(store.map.size, 0, "an ordinary challenge must not spend the budget");
|
|
386
|
+
} finally {
|
|
387
|
+
await device.close();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("bad credentials are one failure, and the second one closes the budget", async () => {
|
|
392
|
+
const device = await startDevice({ accept: false, rejectWith: 403 });
|
|
393
|
+
try {
|
|
394
|
+
const { budget } = budgetFor();
|
|
395
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
396
|
+
|
|
397
|
+
await assert.rejects(
|
|
398
|
+
() => adapter.getDeviceTime(),
|
|
399
|
+
(error) => error.code === "device-auth-failed",
|
|
400
|
+
);
|
|
401
|
+
// The second attempt spends the budget, and the caller is told THAT rather
|
|
402
|
+
// than being handed a generic failure it might retry.
|
|
403
|
+
await assert.rejects(
|
|
404
|
+
() => adapter.getDeviceTime(),
|
|
405
|
+
(error) => error.code === "device-http-locked-out",
|
|
406
|
+
);
|
|
407
|
+
// The third does not reach the network.
|
|
408
|
+
const before = device.hits.length;
|
|
409
|
+
await assert.rejects(
|
|
410
|
+
() => adapter.getDeviceTime(),
|
|
411
|
+
(error) => error.code === "device-http-locked-out",
|
|
412
|
+
);
|
|
413
|
+
assert.equal(device.hits.length, before);
|
|
414
|
+
} finally {
|
|
415
|
+
await device.close();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("a repeated 401 after we answered is a failure too, not an endless handshake", async () => {
|
|
420
|
+
const device = await startDevice({ accept: false, rejectWith: 401 });
|
|
421
|
+
try {
|
|
422
|
+
const { budget } = budgetFor();
|
|
423
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
424
|
+
await assert.rejects(
|
|
425
|
+
() => adapter.getDeviceTime(),
|
|
426
|
+
(error) => error.code === "device-auth-failed",
|
|
427
|
+
);
|
|
428
|
+
assert.equal(device.hits.length, 2, "exactly one attempt, no retry loop");
|
|
429
|
+
} finally {
|
|
430
|
+
await device.close();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("an unreachable device is reported as unreachable, and never as bad credentials", async () => {
|
|
435
|
+
const { budget, store } = budgetFor();
|
|
436
|
+
const adapter = createDeviceHttpAdapter({
|
|
437
|
+
// Port 1 on loopback: nothing listens, so the connection is refused.
|
|
438
|
+
target: targetFor({ baseUrl: "http://127.0.0.1:1" }),
|
|
439
|
+
budget,
|
|
440
|
+
});
|
|
441
|
+
await assert.rejects(
|
|
442
|
+
() => adapter.getDeviceTime(),
|
|
443
|
+
(error) => error.code === "device-http-unreachable",
|
|
444
|
+
);
|
|
445
|
+
assert.equal(store.map.size, 0, "a network failure is not a login failure");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a snapshot comes back as bytes", async () => {
|
|
449
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
|
|
450
|
+
const device = await startDevice({
|
|
451
|
+
routes: {
|
|
452
|
+
"/cgi-bin/snapshot.cgi": (req, res) => {
|
|
453
|
+
res.writeHead(200, { "Content-Type": "image/jpeg" });
|
|
454
|
+
res.end(jpeg);
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
try {
|
|
459
|
+
const { budget } = budgetFor();
|
|
460
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
461
|
+
const bytes = await adapter.getSnapshot(4);
|
|
462
|
+
assert.deepEqual([...bytes], [...jpeg]);
|
|
463
|
+
assert.ok(device.hits.some((url) => url.includes("channel=4&type=0")));
|
|
464
|
+
} finally {
|
|
465
|
+
await device.close();
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
/* -------------------------------------------------------------------------- */
|
|
470
|
+
/* Recorded files — and the handle is always released */
|
|
471
|
+
/* -------------------------------------------------------------------------- */
|
|
472
|
+
|
|
473
|
+
test("a recording search runs the four-call session and closes the handle", async () => {
|
|
474
|
+
const device = await startDevice({
|
|
475
|
+
routes: {
|
|
476
|
+
"/cgi-bin/mediaFileFind.cgi?action=factory.create": { body: "result=1234\r\n" },
|
|
477
|
+
"/cgi-bin/mediaFileFind.cgi?action=findFile": { body: "OK\r\n" },
|
|
478
|
+
"/cgi-bin/mediaFileFind.cgi?action=findNextFile": {
|
|
479
|
+
body: [
|
|
480
|
+
"found=2",
|
|
481
|
+
"items[0].Channel=4",
|
|
482
|
+
"items[0].StartTime=2026-08-10 09:00:00",
|
|
483
|
+
"items[0].EndTime=2026-08-10 09:15:00",
|
|
484
|
+
"items[0].FilePath=/mnt/dvr/2026-08-10/4/dav/09.00.00-09.15.00.dav",
|
|
485
|
+
"items[0].Length=104857600",
|
|
486
|
+
"items[0].Type=dav",
|
|
487
|
+
"items[1].Channel=4",
|
|
488
|
+
"items[1].StartTime=2026-08-10 09:15:00",
|
|
489
|
+
"items[1].EndTime=2026-08-10 09:30:00",
|
|
490
|
+
"items[1].FilePath=/mnt/dvr/2026-08-10/4/dav/09.15.00-09.30.00.dav",
|
|
491
|
+
"items[1].Type=dav",
|
|
492
|
+
"",
|
|
493
|
+
].join("\r\n"),
|
|
494
|
+
},
|
|
495
|
+
"/cgi-bin/mediaFileFind.cgi?action=close": { body: "OK" },
|
|
496
|
+
"/cgi-bin/mediaFileFind.cgi?action=destroy": { body: "OK" },
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
try {
|
|
500
|
+
const { budget } = budgetFor();
|
|
501
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
502
|
+
const files = await adapter.findRecordings({
|
|
503
|
+
channel: 4,
|
|
504
|
+
startTime: "2026-08-10 09:00:00",
|
|
505
|
+
endTime: "2026-08-10 10:00:00",
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
assert.equal(files.length, 2);
|
|
509
|
+
assert.equal(files[0].channel, 4);
|
|
510
|
+
assert.equal(files[0].bytes, 104857600);
|
|
511
|
+
assert.equal(files[1].bytes, null, "a missing Length is null, not 0");
|
|
512
|
+
assert.ok(device.hits.some((url) => url.includes("action=close")));
|
|
513
|
+
assert.ok(device.hits.some((url) => url.includes("action=destroy")));
|
|
514
|
+
} finally {
|
|
515
|
+
await device.close();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("the handle is released even when the search itself fails", async () => {
|
|
520
|
+
const device = await startDevice({
|
|
521
|
+
routes: {
|
|
522
|
+
"/cgi-bin/mediaFileFind.cgi?action=factory.create": { body: "result=99\r\n" },
|
|
523
|
+
"/cgi-bin/mediaFileFind.cgi?action=findFile": { status: 500, body: "" },
|
|
524
|
+
"/cgi-bin/mediaFileFind.cgi?action=close": { body: "OK" },
|
|
525
|
+
"/cgi-bin/mediaFileFind.cgi?action=destroy": { body: "OK" },
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
try {
|
|
529
|
+
const { budget } = budgetFor();
|
|
530
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
531
|
+
await assert.rejects(() =>
|
|
532
|
+
adapter.findRecordings({
|
|
533
|
+
channel: 1,
|
|
534
|
+
startTime: "2026-08-10 09:00:00",
|
|
535
|
+
endTime: "2026-08-10 10:00:00",
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
assert.ok(device.hits.some((url) => url.includes("action=close&object=99")));
|
|
539
|
+
} finally {
|
|
540
|
+
await device.close();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
/* -------------------------------------------------------------------------- */
|
|
545
|
+
/* Presets and PTZ capability */
|
|
546
|
+
/* -------------------------------------------------------------------------- */
|
|
547
|
+
|
|
548
|
+
test("a preset list is parsed in order and rows with no index are dropped", () => {
|
|
549
|
+
const presets = parsePresets(
|
|
550
|
+
[
|
|
551
|
+
"presets[0].Index=1",
|
|
552
|
+
"presets[0].Name=Main Gate",
|
|
553
|
+
"presets[2].Index=5",
|
|
554
|
+
"presets[2].Name=Car Park",
|
|
555
|
+
"presets[1].Name=nameless",
|
|
556
|
+
"",
|
|
557
|
+
].join("\r\n"),
|
|
558
|
+
);
|
|
559
|
+
assert.deepEqual(presets, [
|
|
560
|
+
{ index: 1, name: "Main Gate" },
|
|
561
|
+
{ index: 5, name: "Car Park" },
|
|
562
|
+
]);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("PTZ capability: a missing field is unknown, never a silent no", () => {
|
|
566
|
+
const full = parsePtzCapability(
|
|
567
|
+
"caps.Pan=true\r\ncaps.Tilt=true\r\ncaps.Zoom=true\r\ncaps.Preset=true\r\ncaps.PresetNumber=80\r\n",
|
|
568
|
+
);
|
|
569
|
+
assert.deepEqual(full, { ptz: true, presets: true, presetCount: 80 });
|
|
570
|
+
|
|
571
|
+
const fixed = parsePtzCapability("caps.Pan=false\r\ncaps.Tilt=false\r\ncaps.Zoom=false\r\n");
|
|
572
|
+
assert.equal(fixed.ptz, false);
|
|
573
|
+
|
|
574
|
+
// The case that must not become `false`: firmware that answers nothing useful.
|
|
575
|
+
const silent = parsePtzCapability("result=OK\r\n");
|
|
576
|
+
assert.equal(silent.ptz, null);
|
|
577
|
+
assert.equal(silent.presets, null);
|
|
578
|
+
|
|
579
|
+
// Presets inferred from the count when the flag is absent.
|
|
580
|
+
assert.equal(parsePtzCapability("caps.PresetNumber=0\r\n").presets, false);
|
|
581
|
+
assert.equal(parsePtzCapability("caps.PresetNumber=80\r\n").presets, true);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
/* -------------------------------------------------------------------------- */
|
|
585
|
+
/* Events */
|
|
586
|
+
/* -------------------------------------------------------------------------- */
|
|
587
|
+
|
|
588
|
+
test("an event body is parsed, and a heartbeat is not an event", () => {
|
|
589
|
+
const event = parseEventBody(
|
|
590
|
+
'Code=VideoBlind;action=Start;index=0;data={"Channel":4,"Name":"GYM"}',
|
|
591
|
+
);
|
|
592
|
+
assert.deepEqual(event, {
|
|
593
|
+
code: "VideoBlind",
|
|
594
|
+
action: "Start",
|
|
595
|
+
index: 0,
|
|
596
|
+
data: { Channel: 4, Name: "GYM" },
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// A heartbeat treated as an event raises an alarm every 20 seconds.
|
|
600
|
+
assert.equal(parseEventBody("Heartbeat"), null);
|
|
601
|
+
assert.equal(parseEventBody(""), null);
|
|
602
|
+
assert.equal(parseEventBody("nonsense"), null);
|
|
603
|
+
assert.equal(parseEventBody("Code=NetAbort;action=Start;index=0;data={oops").data, null);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("a multipart event stream is read until the subscriber aborts", async () => {
|
|
607
|
+
const boundary = "myboundary";
|
|
608
|
+
const device = await startDevice({
|
|
609
|
+
routes: {
|
|
610
|
+
"/cgi-bin/eventManager.cgi": (req, res) => {
|
|
611
|
+
res.writeHead(200, {
|
|
612
|
+
"Content-Type": `multipart/x-mixed-replace; boundary=${boundary}`,
|
|
613
|
+
});
|
|
614
|
+
const part = (payload) =>
|
|
615
|
+
`--${boundary}\r\nContent-Type: text/plain\r\nContent-Length: ${payload.length}\r\n\r\n${payload}\r\n`;
|
|
616
|
+
res.write(part("Heartbeat"));
|
|
617
|
+
res.write(part('Code=VideoBlind;action=Start;index=0;data={"Channel":4}'));
|
|
618
|
+
res.write(part("Code=StorageLowSpace;action=Start;index=0"));
|
|
619
|
+
res.write(`--${boundary}\r\n`);
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
const { budget } = budgetFor();
|
|
626
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
627
|
+
const controller = new AbortController();
|
|
628
|
+
const seen = [];
|
|
629
|
+
|
|
630
|
+
const done = adapter
|
|
631
|
+
.subscribeEvents({
|
|
632
|
+
onEvent: (event) => {
|
|
633
|
+
seen.push(event.code);
|
|
634
|
+
if (seen.length === 2) controller.abort();
|
|
635
|
+
},
|
|
636
|
+
signal: controller.signal,
|
|
637
|
+
})
|
|
638
|
+
.catch(() => undefined);
|
|
639
|
+
|
|
640
|
+
await done;
|
|
641
|
+
assert.deepEqual(seen, ["VideoBlind", "StorageLowSpace"]);
|
|
642
|
+
} finally {
|
|
643
|
+
await device.close();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
/* -------------------------------------------------------------------------- */
|
|
648
|
+
/* Probing degrades to "unknown" instead of failing a caller */
|
|
649
|
+
/* -------------------------------------------------------------------------- */
|
|
650
|
+
|
|
651
|
+
test("a probe of a null adapter is unreachable, not a crash", async () => {
|
|
652
|
+
const probe = await probeDeviceCapabilities({ adapter: null });
|
|
653
|
+
assert.equal(probe.reachable, false);
|
|
654
|
+
assert.ok(probe.probedAt);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("a probe reports what the device said, in two read-only calls", async () => {
|
|
658
|
+
const device = await startDevice({
|
|
659
|
+
routes: {
|
|
660
|
+
"/cgi-bin/magicBox.cgi?action=getSoftwareVersion": { body: "version=3.140\r\n" },
|
|
661
|
+
"/cgi-bin/magicBox.cgi?action=getDeviceType": { body: "type=SD-TEST\r\n" },
|
|
662
|
+
"/cgi-bin/IntervideoManager.cgi": { status: 404, body: "" },
|
|
663
|
+
"/cgi-bin/ptz.cgi?action=getCurrentProtocolCaps": {
|
|
664
|
+
body: "caps.Pan=true\r\ncaps.Tilt=true\r\ncaps.PresetNumber=80\r\n",
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
try {
|
|
669
|
+
const { budget } = budgetFor();
|
|
670
|
+
const adapter = createDeviceHttpAdapter({ target: targetFor(device), budget });
|
|
671
|
+
const probe = await probeDeviceCapabilities({ adapter, channel: 4 });
|
|
672
|
+
|
|
673
|
+
assert.equal(probe.reachable, true);
|
|
674
|
+
assert.equal(probe.ptz, true);
|
|
675
|
+
assert.equal(probe.presets, true);
|
|
676
|
+
assert.equal(probe.softwareVersion, "3.140");
|
|
677
|
+
// Nothing but GETs, and no ptz.cgi?action=start anywhere near it.
|
|
678
|
+
assert.ok(!device.hits.some((url) => url.includes("action=start")));
|
|
679
|
+
} finally {
|
|
680
|
+
await device.close();
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("a probe never throws: an unreachable device and a spent budget both return a result", async () => {
|
|
685
|
+
const { budget } = budgetFor();
|
|
686
|
+
const unreachable = createDeviceHttpAdapter({
|
|
687
|
+
target: targetFor({ baseUrl: "http://127.0.0.1:1" }),
|
|
688
|
+
budget,
|
|
689
|
+
});
|
|
690
|
+
assert.deepEqual(
|
|
691
|
+
{ reachable: (await probeDeviceCapabilities({ adapter: unreachable })).reachable },
|
|
692
|
+
{ reachable: false },
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
await budget.recordFailure("http://127.0.0.1:1");
|
|
696
|
+
await budget.recordFailure("http://127.0.0.1:1");
|
|
697
|
+
const locked = await probeDeviceCapabilities({ adapter: unreachable });
|
|
698
|
+
assert.equal(locked.lockedOut, true);
|
|
699
|
+
assert.equal(locked.reachable, false);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
/* -------------------------------------------------------------------------- */
|
|
703
|
+
/* Paths: §3.2 encoding, and the codes we refuse to send */
|
|
704
|
+
/* -------------------------------------------------------------------------- */
|
|
705
|
+
|
|
706
|
+
test("every value is percent-encoded and keys are left literal", () => {
|
|
707
|
+
assert.equal(
|
|
708
|
+
query({ action: "findFile", "condition.Types[0]": "dav", t: "2026-08-10 09:00:00" }),
|
|
709
|
+
// Keys literal (Dahua expects `condition.Types[0]` as written), values
|
|
710
|
+
// encoded — §3.2.
|
|
711
|
+
"action=findFile&condition.Types[0]=dav&t=2026-08-10%2009%3A00%3A00",
|
|
712
|
+
);
|
|
713
|
+
assert.equal(query({ a: 1, b: undefined }), "a=1");
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("snapshot, capability and preset paths are the documented ones", () => {
|
|
717
|
+
assert.equal(snapshotPath(4), "/cgi-bin/snapshot.cgi?channel=4&type=0");
|
|
718
|
+
assert.equal(
|
|
719
|
+
ptzCapabilityPath(4),
|
|
720
|
+
"/cgi-bin/ptz.cgi?action=getCurrentProtocolCaps&channel=4",
|
|
721
|
+
);
|
|
722
|
+
assert.equal(presetListPath(4), "/cgi-bin/ptz.cgi?action=getPresets&channel=4");
|
|
723
|
+
assert.equal(MEDIA_FIND_CREATE, "/cgi-bin/mediaFileFind.cgi?action=factory.create");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("movement is an allow-list: tours, patterns and preset WRITES are not sendable", () => {
|
|
727
|
+
assert.match(
|
|
728
|
+
ptzMovePath({ action: "start", channel: 4, code: "Left", speed: 3 }),
|
|
729
|
+
/action=start&channel=4&code=Left&arg1=0&arg2=3&arg3=0/,
|
|
730
|
+
);
|
|
731
|
+
assert.match(ptzMovePath({ action: "stop", code: "Left" }), /action=stop/);
|
|
732
|
+
|
|
733
|
+
for (const forbidden of [
|
|
734
|
+
"SetPreset",
|
|
735
|
+
"ClearPreset",
|
|
736
|
+
"StartTour",
|
|
737
|
+
"AddTour",
|
|
738
|
+
"SetLimit",
|
|
739
|
+
"Reboot",
|
|
740
|
+
]) {
|
|
741
|
+
assert.ok(!PTZ_MOVE_CODES.includes(forbidden), forbidden);
|
|
742
|
+
assert.throws(() => ptzMovePath({ action: "start", code: forbidden }));
|
|
743
|
+
}
|
|
744
|
+
assert.throws(() => ptzMovePath({ action: "reboot", code: "Left" }));
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("speeds and absolute positions are clamped, never passed through", () => {
|
|
748
|
+
assert.equal(clampPtzSpeed(99), 8);
|
|
749
|
+
assert.equal(clampPtzSpeed(0), 1);
|
|
750
|
+
assert.equal(clampPtzSpeed("nonsense"), 4);
|
|
751
|
+
|
|
752
|
+
assert.match(
|
|
753
|
+
ptzAbsolutePath({ channel: 1, pan: 99999, tilt: -5, zoom: 999 }),
|
|
754
|
+
/code=PositionABS&arg1=3600&arg2=0&arg3=128/,
|
|
755
|
+
);
|
|
756
|
+
assert.match(gotoPresetPath({ preset: 500 }), /code=GotoPreset&arg1=0&arg2=255/);
|
|
757
|
+
assert.match(gotoPresetPath({ preset: 0 }), /arg2=1/);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("the event subscription names health codes rather than asking for everything", () => {
|
|
761
|
+
const path = eventAttachPath();
|
|
762
|
+
for (const code of EVENT_HEALTH_CODES) assert.ok(path.includes(code), code);
|
|
763
|
+
assert.match(path, /heartbeat=20/);
|
|
764
|
+
assert.match(eventAttachPath({ heartbeatSeconds: 9999 }), /heartbeat=60/);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("the media-find and load-file paths carry the object handle and the time range", () => {
|
|
768
|
+
assert.match(
|
|
769
|
+
mediaFindFilePath({
|
|
770
|
+
object: "7",
|
|
771
|
+
channel: 4,
|
|
772
|
+
startTime: "2026-08-10 09:00:00",
|
|
773
|
+
endTime: "2026-08-10 10:00:00",
|
|
774
|
+
}),
|
|
775
|
+
/object=7&condition.Channel=4/,
|
|
776
|
+
);
|
|
777
|
+
assert.match(mediaFindNextPath({ object: "7", count: 5000 }), /count=100/);
|
|
778
|
+
assert.equal(
|
|
779
|
+
mediaFindClosePath("7", true),
|
|
780
|
+
"/cgi-bin/mediaFileFind.cgi?action=destroy&object=7",
|
|
781
|
+
);
|
|
782
|
+
assert.match(
|
|
783
|
+
loadFileByTimePath({ channel: 4, startTime: "a b", endTime: "c d" }),
|
|
784
|
+
/action=startLoad&channel=4&startTime=a%20b&endTime=c%20d&subtype=0/,
|
|
785
|
+
);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("key=value parsing tolerates CRLF, blank lines and values containing '='", () => {
|
|
789
|
+
const parsed = parseKeyValues("a=1\r\n\r\nb=x=y\r\nnokey\r\n=novalue\r\n");
|
|
790
|
+
assert.deepEqual(parsed, { a: "1", b: "x=y" });
|
|
791
|
+
assert.deepEqual(parseMediaFindResults("found=0\r\n"), []);
|
|
792
|
+
});
|