@7365admin1/core 3.42.1 → 3.42.2
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 +69 -0
- package/dist/index.js +167 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +167 -38
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/test/camera-alert.util.test.mjs +109 -0
- package/test/dahua-protocol.util.test.mjs +217 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.42.
|
|
4
|
+
"version": "3.42.2",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
|
|
14
14
|
"release": "yarn run build && changeset publish",
|
|
15
15
|
"lint": "tsc",
|
|
16
|
-
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
16
|
+
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\"",
|
|
17
|
+
"test:anpr": "tsup src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts --format esm --out-dir test/.build/utils --no-dts --silent && node --test \"test/camera-alert.util.test.mjs\" \"test/dahua-protocol.util.test.mjs\""
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
20
|
"@changesets/cli": "^2.26.0",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CAMERA_RECONNECT_MS,
|
|
6
|
+
cameraCredentialsMessage,
|
|
7
|
+
cameraRecoveredMessage,
|
|
8
|
+
cameraUnreachableMessage,
|
|
9
|
+
describeCamera,
|
|
10
|
+
shouldAlertOnFailure,
|
|
11
|
+
} from "./.build/utils/camera-alert.util.mjs";
|
|
12
|
+
|
|
13
|
+
/* --------------------------------------------------------------------------
|
|
14
|
+
* 1. The label. The reported alert read "[Seventh Condominium-both]" — the
|
|
15
|
+
* site's name with the camera's direction enum glued on. No alert may ever
|
|
16
|
+
* show a raw enum again.
|
|
17
|
+
* ----------------------------------------------------------------------- */
|
|
18
|
+
|
|
19
|
+
test("a direction value is never shown to the operator as a raw enum", () => {
|
|
20
|
+
for (const direction of ["entry", "exit", "both", "none", "visitors", "residents", ""]) {
|
|
21
|
+
const text = cameraUnreachableMessage({ siteName: "Seventh Condominium", direction });
|
|
22
|
+
assert.doesNotMatch(text, /Seventh Condominium-/, `"${direction}" was glued to the site name`);
|
|
23
|
+
assert.doesNotMatch(text, /\bboth\b|\bvisitors\b|\bresidents\b/);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("the exact reported label is gone", () => {
|
|
28
|
+
assert.equal(
|
|
29
|
+
describeCamera({ siteName: "Seventh Condominium", direction: "both" }),
|
|
30
|
+
"The entry and exit ANPR camera at Seventh Condominium"
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a camera's own name is preferred over its direction", () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
describeCamera({ name: "Main Gate", siteName: "Seventh Condominium", direction: "both" }),
|
|
37
|
+
'Camera "Main Gate" at Seventh Condominium'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a camera with neither name nor direction still reads as a sentence", () => {
|
|
42
|
+
assert.equal(describeCamera({ siteName: "Seventh Condominium" }), "The ANPR camera at Seventh Condominium");
|
|
43
|
+
assert.equal(describeCamera(null), "The ANPR camera");
|
|
44
|
+
assert.equal(describeCamera({ name: null, siteName: null, direction: null }), "The ANPR camera");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/* --------------------------------------------------------------------------
|
|
48
|
+
* 2. The guidance. Telling an operator to deactivate a barrier camera to stop
|
|
49
|
+
* a notification is the defect, not a wording preference.
|
|
50
|
+
* ----------------------------------------------------------------------- */
|
|
51
|
+
|
|
52
|
+
test("no operator message offers deactivation as the remedy", () => {
|
|
53
|
+
const camera = { siteName: "Seventh Condominium", direction: "both" };
|
|
54
|
+
for (const text of [
|
|
55
|
+
cameraUnreachableMessage(camera),
|
|
56
|
+
cameraCredentialsMessage(camera),
|
|
57
|
+
cameraRecoveredMessage(camera),
|
|
58
|
+
]) {
|
|
59
|
+
assert.doesNotMatch(text, /inactive/i);
|
|
60
|
+
assert.doesNotMatch(text, /deactivat/i);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the fault message says what is broken and who fixes it", () => {
|
|
65
|
+
const text = cameraUnreachableMessage({ siteName: "Seventh Condominium", direction: "both" });
|
|
66
|
+
assert.match(text, /not responding/i);
|
|
67
|
+
assert.match(text, /barrier/i);
|
|
68
|
+
assert.match(text, /technician|installer/i);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a credentials failure sends the operator to the setting, not to the camera", () => {
|
|
72
|
+
const text = cameraCredentialsMessage({ siteName: "Seventh Condominium", direction: "entry" });
|
|
73
|
+
assert.match(text, /username or password/i);
|
|
74
|
+
assert.match(text, /Site Settings/i);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/* --------------------------------------------------------------------------
|
|
78
|
+
* 3. The pacing. The old code alerted on every reconnect attempt — once per
|
|
79
|
+
* 10 s, forever, into a 20 s snackbar, so the toast never cleared.
|
|
80
|
+
* ----------------------------------------------------------------------- */
|
|
81
|
+
|
|
82
|
+
test("the first failure is always reported", () => {
|
|
83
|
+
assert.equal(shouldAlertOnFailure(1), true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a camera that stays down does not alert on every reconnect", () => {
|
|
87
|
+
const alerts = [];
|
|
88
|
+
for (let n = 1; n <= 360; n++) if (shouldAlertOnFailure(n)) alerts.push(n);
|
|
89
|
+
// 360 attempts at 10 s is one hour of continuous outage.
|
|
90
|
+
assert.equal(alerts.length, 7, `one hour of downtime raised ${alerts.length} alerts`);
|
|
91
|
+
assert.deepEqual(alerts, [1, 6, 30, 90, 180, 270, 360]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("alerts never come faster than the snackbar can clear", () => {
|
|
95
|
+
// The web snackbar shows for 20 s. Two alerts closer together than that would
|
|
96
|
+
// keep a red toast on screen permanently, which is the reported symptom.
|
|
97
|
+
let previous = 0;
|
|
98
|
+
for (let n = 1; n <= 360; n++) {
|
|
99
|
+
if (!shouldAlertOnFailure(n)) continue;
|
|
100
|
+
if (previous) assert.ok((n - previous) * CAMERA_RECONNECT_MS > 20_000);
|
|
101
|
+
previous = n;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("nothing is reported while the camera is connected", () => {
|
|
106
|
+
assert.equal(shouldAlertOnFailure(0), false);
|
|
107
|
+
assert.equal(shouldAlertOnFailure(-1), false);
|
|
108
|
+
assert.equal(shouldAlertOnFailure(1.5), false);
|
|
109
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT,
|
|
6
|
+
DAHUA_LOCK_CHECK_DURATION_SEC,
|
|
7
|
+
DAHUA_LOCK_LOGIN_TIMES,
|
|
8
|
+
buildPlateInsertEndpoint,
|
|
9
|
+
buildPlateRemoveEndpoint,
|
|
10
|
+
buildPlateUpdateEndpoint,
|
|
11
|
+
classifyDahuaFailure,
|
|
12
|
+
classifyDahuaStatus,
|
|
13
|
+
dahuaAuthBackoffMs,
|
|
14
|
+
maxAuthAttemptsPerLockWindow,
|
|
15
|
+
} from "./.build/utils/dahua-protocol.util.mjs";
|
|
16
|
+
|
|
17
|
+
/* --------------------------------------------------------------------------
|
|
18
|
+
* 1. The reconnect loop must stay inside the device's login-failure budget.
|
|
19
|
+
*
|
|
20
|
+
* The old loop retried every 1000 ms up to 10 times, i.e. 10 failed logins
|
|
21
|
+
* inside 10 seconds, against a device that locks the account after 3 failures
|
|
22
|
+
* in 30 seconds - for 1800 seconds. These assertions fail on that schedule.
|
|
23
|
+
* ------------------------------------------------------------------------ */
|
|
24
|
+
|
|
25
|
+
test("no more than one authentication attempt lands in a lockout window", () => {
|
|
26
|
+
const worst = maxAuthAttemptsPerLockWindow(20);
|
|
27
|
+
|
|
28
|
+
assert.ok(
|
|
29
|
+
worst < DAHUA_LOCK_LOGIN_TIMES,
|
|
30
|
+
`${worst} attempts fit in a ${DAHUA_LOCK_CHECK_DURATION_SEC}s window; the device locks out at ${DAHUA_LOCK_LOGIN_TIMES}`,
|
|
31
|
+
);
|
|
32
|
+
assert.equal(worst, 1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("every authentication backoff outlasts the device's counting window", () => {
|
|
36
|
+
const windowMs = DAHUA_LOCK_CHECK_DURATION_SEC * 1000;
|
|
37
|
+
|
|
38
|
+
for (let failures = 1; failures <= 10; failures++) {
|
|
39
|
+
assert.ok(
|
|
40
|
+
dahuaAuthBackoffMs(failures) > windowMs,
|
|
41
|
+
`backoff after ${failures} failures was ${dahuaAuthBackoffMs(failures)}ms, not longer than ${windowMs}ms`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the backoff escalates and then holds at a cap", () => {
|
|
47
|
+
assert.equal(dahuaAuthBackoffMs(1), 60_000);
|
|
48
|
+
assert.equal(dahuaAuthBackoffMs(2), 300_000);
|
|
49
|
+
assert.equal(dahuaAuthBackoffMs(3), 900_000);
|
|
50
|
+
assert.equal(dahuaAuthBackoffMs(99), 900_000);
|
|
51
|
+
// Defensive: a caller that has not incremented yet must not get 0ms.
|
|
52
|
+
assert.equal(dahuaAuthBackoffMs(0), 60_000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("we alert the operator while still under the device's failure limit", () => {
|
|
56
|
+
assert.ok(DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT < DAHUA_LOCK_LOGIN_TIMES);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/* --------------------------------------------------------------------------
|
|
60
|
+
* 2. 401 and 403 mean what the specification says they mean.
|
|
61
|
+
*
|
|
62
|
+
* Spec §3.4: 401 = digest challenge / expired nonce; 403 = wrong credentials.
|
|
63
|
+
* The old code threw the credentials warning on 401 and killed the listener on
|
|
64
|
+
* 403 as "account locked" - the opposite of both.
|
|
65
|
+
* ------------------------------------------------------------------------ */
|
|
66
|
+
|
|
67
|
+
test("401 is a challenge to answer, not a credentials failure", () => {
|
|
68
|
+
assert.equal(classifyDahuaStatus(401), "challenge");
|
|
69
|
+
assert.equal(classifyDahuaFailure("401 Unauthorized"), "challenge");
|
|
70
|
+
assert.equal(classifyDahuaFailure("Dahua response: invalid authority"), "challenge");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("403 is a credentials rejection and must stop the listener", () => {
|
|
74
|
+
assert.equal(classifyDahuaStatus(403), "rejected");
|
|
75
|
+
assert.equal(classifyDahuaFailure("Request failed with 403 Forbidden"), "rejected");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a 403 is never mistaken for a retryable challenge", () => {
|
|
79
|
+
// "403 Forbidden - unauthorized" contains both markers; the refusal wins.
|
|
80
|
+
assert.equal(classifyDahuaFailure("403 Forbidden - unauthorized"), "rejected");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("network faults are transient and keep the ordinary reconnect", () => {
|
|
84
|
+
assert.equal(classifyDahuaFailure("socket hang up"), "transient");
|
|
85
|
+
assert.equal(classifyDahuaFailure("Body Timeout Error"), "transient");
|
|
86
|
+
assert.equal(classifyDahuaFailure("ECONNRESET"), "transient");
|
|
87
|
+
assert.equal(classifyDahuaFailure(undefined), "transient");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("other statuses behave as before", () => {
|
|
91
|
+
assert.equal(classifyDahuaStatus(200), "proceed");
|
|
92
|
+
assert.equal(classifyDahuaStatus(undefined), "proceed");
|
|
93
|
+
assert.equal(classifyDahuaStatus(400), "fatal");
|
|
94
|
+
assert.equal(classifyDahuaStatus(500), "fatal");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/* --------------------------------------------------------------------------
|
|
98
|
+
* 3. Percent-encoding (spec §3.2).
|
|
99
|
+
*
|
|
100
|
+
* The realistic failure is a plate or an owner name with a space or an "&":
|
|
101
|
+
* unencoded, the "&" ends the value and the remainder is read by the device as
|
|
102
|
+
* further CGI parameters on an already-authenticated request.
|
|
103
|
+
* ------------------------------------------------------------------------ */
|
|
104
|
+
|
|
105
|
+
const SPACED_PLATE = "SGX 1234 A";
|
|
106
|
+
const HOSTILE_OWNER = "Tan & Sons/Co";
|
|
107
|
+
|
|
108
|
+
test("a plate containing a space is encoded, not split", () => {
|
|
109
|
+
const url = buildPlateUpdateEndpoint({
|
|
110
|
+
mode: "TrafficRedList",
|
|
111
|
+
recno: "42",
|
|
112
|
+
plateNumber: SPACED_PLATE,
|
|
113
|
+
start: "2026-08-09 00:00:00",
|
|
114
|
+
end: "2036-08-09 00:00:00",
|
|
115
|
+
owner: "Resident",
|
|
116
|
+
openGate: "true",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.ok(url.includes("PlateNumber=SGX%201234%20A"));
|
|
120
|
+
assert.ok(!url.includes("PlateNumber=SGX 1234 A"));
|
|
121
|
+
assert.ok(url.includes("BeginTime=2026-08-09%2000%3A00%3A00"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an owner name cannot inject extra CGI parameters", () => {
|
|
125
|
+
const url = buildPlateUpdateEndpoint({
|
|
126
|
+
mode: "TrafficRedList",
|
|
127
|
+
recno: "42",
|
|
128
|
+
plateNumber: "SGX1234A",
|
|
129
|
+
owner: HOSTILE_OWNER,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
assert.ok(url.includes("MasterOfCar=Tan%20%26%20Sons%2FCo"));
|
|
133
|
+
|
|
134
|
+
// The whole query must still be the parameters we intended, no more.
|
|
135
|
+
const names = url
|
|
136
|
+
.split("?")[1]
|
|
137
|
+
.split("&")
|
|
138
|
+
.map((pair) => pair.split("=")[0]);
|
|
139
|
+
|
|
140
|
+
assert.deepEqual(names, [
|
|
141
|
+
"action",
|
|
142
|
+
"name",
|
|
143
|
+
"recno",
|
|
144
|
+
"PlateNumber",
|
|
145
|
+
"BeginTime",
|
|
146
|
+
"CancelTime",
|
|
147
|
+
"+OpenGate",
|
|
148
|
+
"MasterOfCar",
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("bulk insert encodes every interpolated value", () => {
|
|
153
|
+
const url = buildPlateInsertEndpoint({
|
|
154
|
+
mode: "TrafficRedList",
|
|
155
|
+
plateNumber: SPACED_PLATE,
|
|
156
|
+
vehicleType: "Light Truck",
|
|
157
|
+
vehicleColor: "Blue & White",
|
|
158
|
+
start: "2026-08-09 00:00:00",
|
|
159
|
+
end: "2036-08-09 00:00:00",
|
|
160
|
+
owner: HOSTILE_OWNER,
|
|
161
|
+
openGate: "true",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
assert.ok(url.includes("PlateNumber=SGX%201234%20A"));
|
|
165
|
+
assert.ok(url.includes("VehicleType=Light%20Truck"));
|
|
166
|
+
assert.ok(url.includes("VehicleColor=Blue%20%26%20White"));
|
|
167
|
+
assert.ok(url.includes("MasterOfCar=Tan%20%26%20Sons%2FCo"));
|
|
168
|
+
|
|
169
|
+
// One "&" per parameter separator and nothing else.
|
|
170
|
+
assert.equal(url.split("&").length - 1, 8);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("remove encodes its identifiers too", () => {
|
|
174
|
+
const url = buildPlateRemoveEndpoint({ recno: "4 2", mode: "TrafficBlackList" });
|
|
175
|
+
assert.equal(
|
|
176
|
+
url,
|
|
177
|
+
"/cgi-bin/recordUpdater.cgi?action=remove&recno=4%202&name=TrafficBlackList",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/* --------------------------------------------------------------------------
|
|
182
|
+
* 4. Regression guard: `+OpenGate` is deliberately untouched.
|
|
183
|
+
*
|
|
184
|
+
* The specification names this field AuthorityList.OpenGate but gives no
|
|
185
|
+
* default for when it is omitted, so correcting it blind could change what a
|
|
186
|
+
* live barrier does. It stays exactly as it has always gone on the wire until
|
|
187
|
+
* someone reads one camera's allow-list record. Do not "fix" this test.
|
|
188
|
+
* ------------------------------------------------------------------------ */
|
|
189
|
+
|
|
190
|
+
test("+OpenGate is sent byte-for-byte as before, including when unset", () => {
|
|
191
|
+
const set = buildPlateUpdateEndpoint({
|
|
192
|
+
mode: "TrafficRedList",
|
|
193
|
+
recno: "1",
|
|
194
|
+
plateNumber: "SGX1234A",
|
|
195
|
+
openGate: "false",
|
|
196
|
+
});
|
|
197
|
+
assert.ok(set.includes("&+OpenGate=false&"));
|
|
198
|
+
|
|
199
|
+
const unset = buildPlateUpdateEndpoint({
|
|
200
|
+
mode: "TrafficRedList",
|
|
201
|
+
recno: "1",
|
|
202
|
+
plateNumber: "SGX1234A",
|
|
203
|
+
});
|
|
204
|
+
assert.ok(unset.includes("&+OpenGate=undefined&"));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("an absent optional field still renders as it did before", () => {
|
|
208
|
+
// Previously these were template literals, so undefined became "undefined".
|
|
209
|
+
// Encoding must not quietly turn that into an empty value.
|
|
210
|
+
const url = buildPlateUpdateEndpoint({
|
|
211
|
+
mode: "TrafficRedList",
|
|
212
|
+
recno: "1",
|
|
213
|
+
plateNumber: "SGX1234A",
|
|
214
|
+
});
|
|
215
|
+
assert.ok(url.includes("BeginTime=undefined"));
|
|
216
|
+
assert.ok(url.includes("CancelTime=undefined"));
|
|
217
|
+
});
|