@7365admin1/core 3.68.0 → 3.68.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 +38 -0
- package/dist/index.d.ts +122 -6
- package/dist/index.js +2041 -858
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2219 -1035
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/anpr-plate.test.mjs +272 -0
- package/test/dahua-protocol.util.test.mjs +137 -0
- package/test/member-duplicate-error.test.mjs +30 -0
- package/test/people-create-gate.test.mjs +71 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.68.
|
|
4
|
+
"version": "3.68.2",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
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": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/anpr-plate.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts src/utils/socket-session.util.ts src/utils/people-create-gate.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
17
17
|
"test:camera": "yarn test",
|
|
18
18
|
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
19
19
|
},
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ALLOW_CAMERA_DIRECTIONS,
|
|
6
|
+
BLOCK_CAMERA_DIRECTIONS,
|
|
7
|
+
listsClearedByDirectionChange,
|
|
8
|
+
listsFilledByDirectionChange,
|
|
9
|
+
isAllowCameraDirection,
|
|
10
|
+
listsHeldBy,
|
|
11
|
+
listsToRevoke,
|
|
12
|
+
matchingPlateRecords,
|
|
13
|
+
parseDahuaFindRecords,
|
|
14
|
+
samePlate,
|
|
15
|
+
dahuaWallClock,
|
|
16
|
+
plateWindowMatches,
|
|
17
|
+
mustRevokeFrom,
|
|
18
|
+
} from "./.build/utils/anpr-plate.util.mjs";
|
|
19
|
+
|
|
20
|
+
/* --------------------------------------------------------------------------
|
|
21
|
+
* Which cameras a plate is written to.
|
|
22
|
+
* ----------------------------------------------------------------------- */
|
|
23
|
+
|
|
24
|
+
test("an allowed plate goes to Entry, Both and Residents cameras only", () => {
|
|
25
|
+
assert.deepEqual([...ALLOW_CAMERA_DIRECTIONS].sort(), ["both", "entry", "residents"]);
|
|
26
|
+
assert.ok(!ALLOW_CAMERA_DIRECTIONS.includes("exit"));
|
|
27
|
+
assert.ok(!ALLOW_CAMERA_DIRECTIONS.includes("visitors"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a blocked plate goes to every camera a car can come in through, not Exit", () => {
|
|
31
|
+
for (const direction of ALLOW_CAMERA_DIRECTIONS) {
|
|
32
|
+
assert.ok(
|
|
33
|
+
BLOCK_CAMERA_DIRECTIONS.includes(direction),
|
|
34
|
+
`${direction} lets an allowed plate in, so it must refuse a blocked one`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
assert.ok(BLOCK_CAMERA_DIRECTIONS.includes("visitors"));
|
|
38
|
+
assert.ok(!BLOCK_CAMERA_DIRECTIONS.includes("exit"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/* --------------------------------------------------------------------------
|
|
42
|
+
* Reading a camera's find answer.
|
|
43
|
+
* ----------------------------------------------------------------------- */
|
|
44
|
+
|
|
45
|
+
const TWO_COPIES =
|
|
46
|
+
"found=2\r\n" +
|
|
47
|
+
"records[0].RecNo=14\r\n" +
|
|
48
|
+
"records[0].PlateNumber=SGA1234X\r\n" +
|
|
49
|
+
"records[0].MasterOfCar=Old Name\r\n" +
|
|
50
|
+
"records[1].RecNo=91\r\n" +
|
|
51
|
+
"records[1].PlateNumber=SGA1234X\r\n";
|
|
52
|
+
|
|
53
|
+
test("every record is read, not just the first", () => {
|
|
54
|
+
const { found, records } = parseDahuaFindRecords(TWO_COPIES);
|
|
55
|
+
assert.equal(found, 2);
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
records.map((record) => record.recNo),
|
|
58
|
+
["14", "91"],
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("nothing found reads as no records", () => {
|
|
63
|
+
assert.deepEqual(parseDahuaFindRecords("found=0\r\n").records, []);
|
|
64
|
+
assert.deepEqual(parseDahuaFindRecords("").records, []);
|
|
65
|
+
assert.deepEqual(parseDahuaFindRecords(undefined).records, []);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("only records that really are this plate are matched", () => {
|
|
69
|
+
const loose =
|
|
70
|
+
"found=2\n" +
|
|
71
|
+
"records[0].RecNo=5\nrecords[0].PlateNumber=SGA123\n" +
|
|
72
|
+
"records[1].RecNo=6\nrecords[1].PlateNumber=SGA1234\n";
|
|
73
|
+
|
|
74
|
+
// A firmware that matches loosely must not get a stranger removed.
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
matchingPlateRecords(loose, "SGA123").map((record) => record.recNo),
|
|
77
|
+
["5"],
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("plates compare without case or spaces", () => {
|
|
82
|
+
assert.ok(samePlate("sga 1234x", "SGA1234X"));
|
|
83
|
+
assert.ok(!samePlate("SGA1234X", "SGA1234"));
|
|
84
|
+
assert.ok(!samePlate("", ""));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/* --------------------------------------------------------------------------
|
|
88
|
+
* Which lists a record holds, and which may be revoked.
|
|
89
|
+
* ----------------------------------------------------------------------- */
|
|
90
|
+
|
|
91
|
+
test("a blocked whitelist vehicle holds both lists", () => {
|
|
92
|
+
assert.deepEqual(listsHeldBy({ type: "whitelist" }), { allow: true, block: false });
|
|
93
|
+
assert.deepEqual(listsHeldBy({ type: "blocklist" }), { allow: false, block: true });
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
listsHeldBy({ type: "blocklist", blockedFrom: "whitelist" }),
|
|
96
|
+
{ allow: true, block: true },
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a plate nobody else uses comes off both lists", () => {
|
|
101
|
+
assert.deepEqual(listsToRevoke([]), { allow: true, block: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("deleting one record never lifts another record's block", () => {
|
|
105
|
+
// Staging has held the same plate as a whitelist AND a blocklist vehicle.
|
|
106
|
+
assert.deepEqual(listsToRevoke([{ type: "blocklist" }]), {
|
|
107
|
+
allow: true,
|
|
108
|
+
block: false,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("deleting one record never shuts out another record's allowed plate", () => {
|
|
113
|
+
assert.deepEqual(listsToRevoke([{ type: "whitelist" }]), {
|
|
114
|
+
allow: false,
|
|
115
|
+
block: true,
|
|
116
|
+
});
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
listsToRevoke([{ type: "blocklist", blockedFrom: "whitelist" }]),
|
|
119
|
+
{ allow: false, block: false },
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/* --------------------------------------------------------------------------
|
|
124
|
+
* Changing a camera's type.
|
|
125
|
+
* ----------------------------------------------------------------------- */
|
|
126
|
+
|
|
127
|
+
test("a camera changed to Exit has both lists cleared", () => {
|
|
128
|
+
for (const from of ["entry", "both", "residents", "visitors"]) {
|
|
129
|
+
assert.deepEqual(listsClearedByDirectionChange(from, "exit"), {
|
|
130
|
+
allow: true,
|
|
131
|
+
block: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an allowed camera changed to Visitors has only its allowed list cleared", () => {
|
|
137
|
+
for (const from of ["entry", "both", "residents"]) {
|
|
138
|
+
assert.deepEqual(listsClearedByDirectionChange(from, "visitors"), {
|
|
139
|
+
allow: true,
|
|
140
|
+
block: false,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("any other change clears nothing", () => {
|
|
146
|
+
const none = { allow: false, block: false };
|
|
147
|
+
assert.deepEqual(listsClearedByDirectionChange("entry", "residents"), none);
|
|
148
|
+
assert.deepEqual(listsClearedByDirectionChange("both", "both"), none);
|
|
149
|
+
assert.deepEqual(listsClearedByDirectionChange("exit", "exit"), none);
|
|
150
|
+
assert.deepEqual(listsClearedByDirectionChange("visitors", "entry"), none);
|
|
151
|
+
assert.deepEqual(listsClearedByDirectionChange("exit", "visitors"), none);
|
|
152
|
+
// An edit that does not send a type changes nothing.
|
|
153
|
+
assert.deepEqual(listsClearedByDirectionChange("entry", undefined), none);
|
|
154
|
+
assert.deepEqual(listsClearedByDirectionChange("entry", ""), none);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a new Entry, Both or Residents camera receives every allowed and blocked plate", () => {
|
|
158
|
+
for (const to of ["entry", "both", "residents"]) {
|
|
159
|
+
assert.deepEqual(listsFilledByDirectionChange("", to), { allow: true, block: true });
|
|
160
|
+
assert.deepEqual(listsFilledByDirectionChange(undefined, to), { allow: true, block: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a new Visitors camera receives the blocked plates only; a new Exit camera nothing", () => {
|
|
165
|
+
assert.deepEqual(listsFilledByDirectionChange("", "visitors"), { allow: false, block: true });
|
|
166
|
+
assert.deepEqual(listsFilledByDirectionChange("", "exit"), { allow: false, block: false });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a camera changed to Entry, Both or Residents receives what it did not hold", () => {
|
|
170
|
+
// Visitors already holds the blocked plates.
|
|
171
|
+
assert.deepEqual(listsFilledByDirectionChange("visitors", "entry"), { allow: true, block: false });
|
|
172
|
+
// Exit holds nothing.
|
|
173
|
+
assert.deepEqual(listsFilledByDirectionChange("exit", "residents"), { allow: true, block: true });
|
|
174
|
+
assert.deepEqual(listsFilledByDirectionChange("exit", "visitors"), { allow: false, block: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("changes between allowed types, or away from them, fill nothing", () => {
|
|
178
|
+
const none = { allow: false, block: false };
|
|
179
|
+
assert.deepEqual(listsFilledByDirectionChange("entry", "residents"), none);
|
|
180
|
+
assert.deepEqual(listsFilledByDirectionChange("both", "both"), none);
|
|
181
|
+
assert.deepEqual(listsFilledByDirectionChange("entry", "visitors"), none);
|
|
182
|
+
assert.deepEqual(listsFilledByDirectionChange("entry", "exit"), none);
|
|
183
|
+
assert.deepEqual(listsFilledByDirectionChange("entry", ""), none);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("no type change both clears and fills", () => {
|
|
187
|
+
const all = ["", "entry", "both", "residents", "visitors", "exit"];
|
|
188
|
+
for (const from of all) {
|
|
189
|
+
for (const to of all) {
|
|
190
|
+
const cleared = listsClearedByDirectionChange(from, to);
|
|
191
|
+
const filled = listsFilledByDirectionChange(from, to);
|
|
192
|
+
assert.ok(
|
|
193
|
+
!((cleared.allow || cleared.block) && (filled.allow || filled.block)),
|
|
194
|
+
`${from} -> ${to}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("direction spelling is forgiving", () => {
|
|
201
|
+
assert.ok(isAllowCameraDirection(" Residents "));
|
|
202
|
+
assert.ok(!isAllowCameraDirection("visitors"));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/* --------------------------------------------------------------------------
|
|
206
|
+
* The dates a season pass is allowed between, on the camera's own clock.
|
|
207
|
+
* ----------------------------------------------------------------------- */
|
|
208
|
+
|
|
209
|
+
test("a UTC instant is written in the site's zone, not the server's", () => {
|
|
210
|
+
// 08/05/2026 01:13 PM in Singapore, as the screen sends it.
|
|
211
|
+
assert.equal(dahuaWallClock("2026-08-05T05:13:00.000Z", "Asia/Singapore"), "2026-08-05 13:13:00");
|
|
212
|
+
assert.equal(dahuaWallClock(new Date("2036-08-05T05:13:00.000Z"), "Asia/Singapore"), "2036-08-05 13:13:00");
|
|
213
|
+
assert.equal(dahuaWallClock("2026-08-05T13:13:00+08:00", "Asia/Singapore"), "2026-08-05 13:13:00");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the default zone is Singapore, and an unknown zone falls back to it", () => {
|
|
217
|
+
assert.equal(dahuaWallClock("2026-08-05T05:13:00Z"), "2026-08-05 13:13:00");
|
|
218
|
+
assert.equal(dahuaWallClock("2026-08-05T05:13:00Z", "Not/AZone"), "2026-08-05 13:13:00");
|
|
219
|
+
assert.equal(dahuaWallClock("2026-08-05T05:13:00Z", "Asia/Kuala_Lumpur"), "2026-08-05 13:13:00");
|
|
220
|
+
assert.equal(dahuaWallClock("2026-08-05T05:13:00Z", "UTC"), "2026-08-05 05:13:00");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a time already written for the camera is kept as it is", () => {
|
|
224
|
+
assert.equal(dahuaWallClock("2026-08-05 13:13:00", "UTC"), "2026-08-05 13:13:00");
|
|
225
|
+
assert.equal(dahuaWallClock("2026-08-05 13:13", "UTC"), "2026-08-05 13:13:00");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("midnight is 00, never 24", () => {
|
|
229
|
+
assert.equal(dahuaWallClock("2026-08-04T16:00:00Z", "Asia/Singapore"), "2026-08-05 00:00:00");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("nothing, or nonsense, is no time at all", () => {
|
|
233
|
+
assert.equal(dahuaWallClock(""), null);
|
|
234
|
+
assert.equal(dahuaWallClock(null), null);
|
|
235
|
+
assert.equal(dahuaWallClock("not a date"), null);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("a camera record's window is read, and compared on the camera's clock", () => {
|
|
239
|
+
const text =
|
|
240
|
+
"found=1\r\nrecords[0].RecNo=7\r\nrecords[0].PlateNumber=TEST123\r\n" +
|
|
241
|
+
"records[0].BeginTime=2026-08-05 13:13:00\r\nrecords[0].CancelTime=2036-08-05 13:13:00\r\n" +
|
|
242
|
+
"records[0].MasterOfCar=Test Res 3\r\n";
|
|
243
|
+
const [record] = parseDahuaFindRecords(text).records;
|
|
244
|
+
assert.equal(record.beginTime, "2026-08-05 13:13:00");
|
|
245
|
+
assert.equal(record.cancelTime, "2036-08-05 13:13:00");
|
|
246
|
+
assert.equal(record.owner, "Test Res 3");
|
|
247
|
+
|
|
248
|
+
assert.ok(plateWindowMatches(record, { start: "2026-08-05 13:13:00", end: "2036-08-05 13:13:00" }));
|
|
249
|
+
// A plate left on for ten years does not match a season pass window.
|
|
250
|
+
assert.ok(!plateWindowMatches(record, { start: "2026-08-05 13:13:00", end: "2026-09-05 13:13:00" }));
|
|
251
|
+
// A camera that does not echo its times is never trusted.
|
|
252
|
+
assert.ok(!plateWindowMatches({ recNo: "7", plateNumber: "TEST123" }, { start: "2026-08-05 13:13:00", end: "2036-08-05 13:13:00" }));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("an allowed plate is revoked from Entry, Both and Residents only", () => {
|
|
256
|
+
for (const d of ["entry", "both", "residents", " Entry "]) {
|
|
257
|
+
assert.ok(mustRevokeFrom(d, "allowed"), d);
|
|
258
|
+
assert.ok(mustRevokeFrom(d, "allowed", true), d);
|
|
259
|
+
}
|
|
260
|
+
for (const d of ["visitors", "exit", " Exit "]) {
|
|
261
|
+
assert.ok(!mustRevokeFrom(d, "allowed"), d);
|
|
262
|
+
assert.ok(!mustRevokeFrom(d, "allowed", true), d);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("a blocked plate is revoked from every camera but Exit; Exit only when unblocking", () => {
|
|
267
|
+
for (const d of ["entry", "both", "residents", "visitors"]) {
|
|
268
|
+
assert.ok(mustRevokeFrom(d, "blocked"), d);
|
|
269
|
+
}
|
|
270
|
+
assert.ok(!mustRevokeFrom("exit", "blocked"), "deleting an ordinary vehicle");
|
|
271
|
+
assert.ok(mustRevokeFrom("exit", "blocked", true), "unblocking");
|
|
272
|
+
});
|
|
@@ -5,13 +5,19 @@ import {
|
|
|
5
5
|
DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT,
|
|
6
6
|
DAHUA_LOCK_CHECK_DURATION_SEC,
|
|
7
7
|
DAHUA_LOCK_LOGIN_TIMES,
|
|
8
|
+
DAHUA_COMMAND_ATTEMPTS,
|
|
9
|
+
DAHUA_COMMAND_DEADLINE_MS,
|
|
10
|
+
buildPlateClearEndpoint,
|
|
8
11
|
buildPlateInsertEndpoint,
|
|
9
12
|
buildPlateRemoveEndpoint,
|
|
10
13
|
buildPlateUpdateEndpoint,
|
|
14
|
+
classifyDahuaAttempt,
|
|
11
15
|
classifyDahuaFailure,
|
|
12
16
|
classifyDahuaStatus,
|
|
13
17
|
dahuaAuthBackoffMs,
|
|
18
|
+
dahuaCommandRetryDelayMs,
|
|
14
19
|
maxAuthAttemptsPerLockWindow,
|
|
20
|
+
shouldRetryDahuaCommand,
|
|
15
21
|
} from "./.build/utils/dahua-protocol.util.mjs";
|
|
16
22
|
|
|
17
23
|
/* --------------------------------------------------------------------------
|
|
@@ -178,6 +184,24 @@ test("remove encodes its identifiers too", () => {
|
|
|
178
184
|
);
|
|
179
185
|
});
|
|
180
186
|
|
|
187
|
+
test("clearing a plate list is one command, for that list only", () => {
|
|
188
|
+
assert.equal(
|
|
189
|
+
buildPlateClearEndpoint({ mode: "TrafficRedList" }),
|
|
190
|
+
"/cgi-bin/recordUpdater.cgi?action=clear&name=TrafficRedList",
|
|
191
|
+
);
|
|
192
|
+
assert.equal(
|
|
193
|
+
buildPlateClearEndpoint({ mode: "TrafficBlackList" }),
|
|
194
|
+
"/cgi-bin/recordUpdater.cgi?action=clear&name=TrafficBlackList",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("nothing but the two plate lists can ever be cleared", () => {
|
|
199
|
+
// The same CGI empties ANY record set on the device - access cards included.
|
|
200
|
+
for (const mode of ["AccessControlCard", "", "TrafficRedList&name=AccessControlCard"]) {
|
|
201
|
+
assert.throws(() => buildPlateClearEndpoint({ mode }), /Refusing to clear/);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
181
205
|
/* --------------------------------------------------------------------------
|
|
182
206
|
* 4. Regression guard: `+OpenGate` is deliberately untouched.
|
|
183
207
|
*
|
|
@@ -215,3 +239,116 @@ test("an absent optional field still renders as it did before", () => {
|
|
|
215
239
|
assert.ok(url.includes("BeginTime=undefined"));
|
|
216
240
|
assert.ok(url.includes("CancelTime=undefined"));
|
|
217
241
|
});
|
|
242
|
+
|
|
243
|
+
/* --------------------------------------------------------------------------
|
|
244
|
+
* 1b. Retrying one plate command (find, add, update, remove, clear).
|
|
245
|
+
* ------------------------------------------------------------------------ */
|
|
246
|
+
|
|
247
|
+
/** Replays a command whose every try comes to `kind`; returns tries made. */
|
|
248
|
+
function triesUntilGivingUp(kind, { loginProven = false, repeatable = true } = {}) {
|
|
249
|
+
let elapsedMs = 0;
|
|
250
|
+
let loginFailures = 0;
|
|
251
|
+
for (let attempt = 1; ; attempt++) {
|
|
252
|
+
if (kind === "login-refused") loginFailures++;
|
|
253
|
+
const nextDelayMs = dahuaCommandRetryDelayMs(attempt);
|
|
254
|
+
const retry = shouldRetryDahuaCommand({
|
|
255
|
+
kind,
|
|
256
|
+
attempt,
|
|
257
|
+
loginFailures,
|
|
258
|
+
loginProven,
|
|
259
|
+
repeatable,
|
|
260
|
+
elapsedMs,
|
|
261
|
+
nextDelayMs,
|
|
262
|
+
});
|
|
263
|
+
if (!retry) return attempt;
|
|
264
|
+
elapsedMs += nextDelayMs + 100; // the try itself
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
test("a refused login is tried ten times when these credentials just worked", () => {
|
|
269
|
+
assert.equal(DAHUA_COMMAND_ATTEMPTS, 10);
|
|
270
|
+
assert.equal(triesUntilGivingUp("login-refused", { loginProven: true }), 10);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a login that never worked stays below the device's lockout", () => {
|
|
274
|
+
const tries = triesUntilGivingUp("login-refused", { loginProven: false });
|
|
275
|
+
assert.equal(tries, DAHUA_AUTH_ATTEMPTS_BEFORE_ALERT);
|
|
276
|
+
assert.ok(tries < DAHUA_LOCK_LOGIN_TIMES);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("a 403 is never retried", () => {
|
|
280
|
+
assert.equal(triesUntilGivingUp("forbidden", { loginProven: true }), 1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("a camera that could not be reached or was busy gets ten tries", () => {
|
|
284
|
+
assert.equal(triesUntilGivingUp("not-sent"), 10);
|
|
285
|
+
assert.equal(triesUntilGivingUp("busy"), 10);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("an insert that may have landed is not sent again blindly", () => {
|
|
289
|
+
assert.equal(triesUntilGivingUp("maybe-sent", { repeatable: false }), 1);
|
|
290
|
+
assert.equal(triesUntilGivingUp("maybe-sent", { repeatable: true }), 10);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("all ten tries' pauses fit inside the deadline", () => {
|
|
294
|
+
let pauses = 0;
|
|
295
|
+
for (let attempt = 1; attempt < DAHUA_COMMAND_ATTEMPTS; attempt++) {
|
|
296
|
+
pauses += dahuaCommandRetryDelayMs(attempt);
|
|
297
|
+
}
|
|
298
|
+
assert.ok(pauses < DAHUA_COMMAND_DEADLINE_MS, `${pauses} ms of pauses`);
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
[1, 2, 3, 4, 5, 6, 9].map(dahuaCommandRetryDelayMs),
|
|
301
|
+
[1000, 2000, 3000, 4000, 5000, 5000, 5000],
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a command that has run past its deadline is not retried", () => {
|
|
306
|
+
assert.equal(
|
|
307
|
+
shouldRetryDahuaCommand({
|
|
308
|
+
kind: "not-sent",
|
|
309
|
+
attempt: 2,
|
|
310
|
+
loginFailures: 0,
|
|
311
|
+
loginProven: true,
|
|
312
|
+
repeatable: true,
|
|
313
|
+
elapsedMs: DAHUA_COMMAND_DEADLINE_MS - 500,
|
|
314
|
+
nextDelayMs: 1000,
|
|
315
|
+
}),
|
|
316
|
+
false,
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("each try is read for what it came to", () => {
|
|
321
|
+
assert.equal(classifyDahuaAttempt({ statusCode: 200, text: "OK" }), "answered");
|
|
322
|
+
// A duplicate plate is an answer, not a failure to retry.
|
|
323
|
+
assert.equal(classifyDahuaAttempt({ statusCode: 400, text: "Error" }), "answered");
|
|
324
|
+
assert.equal(classifyDahuaAttempt({ statusCode: 401, text: "" }), "login-refused");
|
|
325
|
+
assert.equal(
|
|
326
|
+
classifyDahuaAttempt({ statusCode: 200, text: "Error\r\nInvalid Authority!" }),
|
|
327
|
+
"login-refused",
|
|
328
|
+
);
|
|
329
|
+
assert.equal(classifyDahuaAttempt({ statusCode: 403 }), "forbidden");
|
|
330
|
+
assert.equal(classifyDahuaAttempt({ statusCode: 503 }), "busy");
|
|
331
|
+
assert.equal(classifyDahuaAttempt({}), "maybe-sent");
|
|
332
|
+
|
|
333
|
+
const refused = Object.assign(new Error("connect ECONNREFUSED 10.0.0.9:80"), {
|
|
334
|
+
code: "ECONNREFUSED",
|
|
335
|
+
});
|
|
336
|
+
assert.equal(classifyDahuaAttempt({ error: refused }), "not-sent");
|
|
337
|
+
assert.equal(
|
|
338
|
+
classifyDahuaAttempt({ error: { name: "HttpClientConnectTimeoutError", message: "Connect timeout for 10000ms" } }),
|
|
339
|
+
"not-sent",
|
|
340
|
+
);
|
|
341
|
+
assert.equal(
|
|
342
|
+
classifyDahuaAttempt({ error: Object.assign(new Error("other side closed"), { code: "ECONNRESET" }) }),
|
|
343
|
+
"maybe-sent",
|
|
344
|
+
);
|
|
345
|
+
assert.equal(
|
|
346
|
+
classifyDahuaAttempt({ error: new Error("Request timeout for 10000 ms") }),
|
|
347
|
+
"maybe-sent",
|
|
348
|
+
);
|
|
349
|
+
// A port number that contains 401 is not a refused login.
|
|
350
|
+
assert.equal(
|
|
351
|
+
classifyDahuaAttempt({ error: new Error("connect EHOSTUNREACH 10.0.0.9:8401") }),
|
|
352
|
+
"not-sent",
|
|
353
|
+
);
|
|
354
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A duplicate membership must read as "already a member" (400), not a 500.
|
|
3
|
+
*
|
|
4
|
+
* The live unique index on `members` is `org_1_siteId_1_user_1_type_1`; the old
|
|
5
|
+
* check only recognised a message naming `org_1_user_1`, so every real duplicate
|
|
6
|
+
* fell through as a raw MongoServerError.
|
|
7
|
+
*/
|
|
8
|
+
import { strict as assert } from "node:assert";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
import { isDuplicateMemberError } from "./.build/models/member.model.mjs";
|
|
12
|
+
|
|
13
|
+
test("the live unique index's duplicate is recognised", () => {
|
|
14
|
+
const err = Object.assign(
|
|
15
|
+
new Error(
|
|
16
|
+
'E11000 duplicate key error collection: iservice365.members index: org_1_siteId_1_user_1_type_1 dup key: { org: ObjectId("6a951ce603be44033d434f68") }',
|
|
17
|
+
),
|
|
18
|
+
{ code: 11000 },
|
|
19
|
+
);
|
|
20
|
+
assert.equal(isDuplicateMemberError(err), true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a duplicate is recognised by code alone", () => {
|
|
24
|
+
assert.equal(isDuplicateMemberError({ code: 11000, message: "" }), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("other errors are left alone", () => {
|
|
28
|
+
assert.equal(isDuplicateMemberError(new Error("connection reset")), false);
|
|
29
|
+
assert.equal(isDuplicateMemberError(undefined), false);
|
|
30
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* POST /api/people creates a `site.people` row — and, where the body carries an
|
|
3
|
+
* e-mail, a password and a resident/tenant type, a USER ACCOUNT and an active
|
|
4
|
+
* membership too. The site and organisation come from the request body, and the
|
|
5
|
+
* signed-in branch checked only that somebody was signed in.
|
|
6
|
+
*
|
|
7
|
+
* `site.people` is the collection every site-reach decision reads its verdict
|
|
8
|
+
* from, so a row written there manufactures reach into another client's estate.
|
|
9
|
+
*
|
|
10
|
+
* This gate ships SWITCHED OFF (`log`): it records what it would refuse and
|
|
11
|
+
* refuses nothing, because a person row carries no `createdBy` and the real
|
|
12
|
+
* refusal count is only knowable from live traffic. These tests pin that
|
|
13
|
+
* default, so it cannot become enforcing by accident.
|
|
14
|
+
*/
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
PEOPLE_CREATE_REACH_MODE_DEFAULT,
|
|
20
|
+
peopleCreateReachMode,
|
|
21
|
+
peopleCreateVerdict,
|
|
22
|
+
} from "./.build/utils/people-create-gate.util.mjs";
|
|
23
|
+
|
|
24
|
+
test("the code default is LOG - it must not ship refusing anybody", () => {
|
|
25
|
+
assert.equal(PEOPLE_CREATE_REACH_MODE_DEFAULT, "log");
|
|
26
|
+
assert.equal(peopleCreateReachMode({}), "log");
|
|
27
|
+
// An unrecognised value falls back to the safe default rather than enforcing.
|
|
28
|
+
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "nonsense" }), "log");
|
|
29
|
+
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "ENFORCE" }), "enforce");
|
|
30
|
+
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "off" }), "off");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a caller who reaches the estate is never refused, in any mode", () => {
|
|
34
|
+
for (const mode of ["log", "enforce", "off"]) {
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
peopleCreateVerdict({ mode, reachable: true }),
|
|
37
|
+
{ refuse: false, record: false },
|
|
38
|
+
mode,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("LOG records the cross-client creation and still allows it", () => {
|
|
44
|
+
// This is the whole shape of the ship: measured, not refused.
|
|
45
|
+
assert.deepEqual(peopleCreateVerdict({ mode: "log", reachable: false }), {
|
|
46
|
+
refuse: false,
|
|
47
|
+
record: true,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("ENFORCE refuses a caller creating a person in an estate that is not theirs", () => {
|
|
52
|
+
assert.deepEqual(peopleCreateVerdict({ mode: "enforce", reachable: false }), {
|
|
53
|
+
refuse: true,
|
|
54
|
+
record: true,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("OFF does not even look", () => {
|
|
59
|
+
assert.deepEqual(peopleCreateVerdict({ mode: "off", reachable: false }), {
|
|
60
|
+
refuse: false,
|
|
61
|
+
record: false,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the flag is the only thing between counting and refusing", () => {
|
|
66
|
+
// If this ever fails, the default has been changed and people who can create
|
|
67
|
+
// a person today may stop being able to - which is the RP-5 line.
|
|
68
|
+
const unreachable = { reachable: false };
|
|
69
|
+
assert.equal(peopleCreateVerdict({ mode: peopleCreateReachMode({}), ...unreachable }).refuse, false);
|
|
70
|
+
assert.equal(peopleCreateVerdict({ mode: "enforce", ...unreachable }).refuse, true);
|
|
71
|
+
});
|