@7365admin1/core 3.80.0 → 3.81.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 +40 -0
- package/dist/index.d.ts +697 -4
- package/dist/index.js +3050 -2327
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1643 -960
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/robot-scope.e2e.test.mjs +57 -33
- package/test/robot-apps.test.mjs +154 -0
- package/test/robot-cloud.test.mjs +222 -0
- package/test/robot-permission.test.mjs +133 -0
- package/test/robot-tracking.test.mjs +231 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.81.0",
|
|
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/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/utils/camera-playback.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/occurrence-book-create.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 src/utils/service-app-type.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/robot-cloud.util.ts src/utils/robot-tracking.util.ts src/utils/robot-apps.util.ts src/utils/robot-permission.util.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/utils/camera-playback.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/occurrence-book-create.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 src/utils/service-app-type.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
|
},
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// 1. THE LIST HANDS OUT CREDENTIALS. `robot.repo.ts` projects `appKey` and
|
|
13
13
|
// `appToken` into the response. Any signed-in account on the platform could
|
|
14
14
|
// name any organisation and site in the URL and read that estate's robot
|
|
15
|
-
// credentials.
|
|
16
|
-
//
|
|
15
|
+
// credentials. Cases 1, 2 and 5 show the estate is now out of reach, and case
|
|
16
|
+
// 0 shows the credentials are no longer in the payload AT ALL - not even for a
|
|
17
|
+
// caller standing at the robot's own estate.
|
|
17
18
|
//
|
|
18
19
|
// 2. THE WRITE COULD BE AIMED ANYWHERE. `metadata.siteId` came off the body, so a
|
|
19
20
|
// robot could be aimed at another client's estate (case 3), and it is
|
|
@@ -24,18 +25,17 @@
|
|
|
24
25
|
// the service looked the display name up from it. It now comes from the
|
|
25
26
|
// session.
|
|
26
27
|
//
|
|
27
|
-
// A STRAIGHT WORD ABOUT THE WRITE PATH
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// `
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
28
|
+
// A STRAIGHT WORD ABOUT THE WRITE PATH: `POST /api/robots` never worked. The
|
|
29
|
+
// service overwrote `value.createdBy` with `createdBy._id`, an `ObjectId`, and
|
|
30
|
+
// `MRobot` then re-validated against `robotSchema`, where `createdBy` is
|
|
31
|
+
// `Joi.string()`. Every create ended in `400 "createdBy" must be a string` and
|
|
32
|
+
// no robot was ever stored, by anybody, through either app. It is fixed here,
|
|
33
|
+
// because the cleaning robot integration cannot begin without the ability to
|
|
34
|
+
// register a robot.
|
|
34
35
|
//
|
|
35
|
-
// So
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// 3 and 6 are that pair. The spec does not pretend a robot is ever stored.
|
|
36
|
+
// So the write is now proved BOTH ways: an unauthorised caller is stopped by the
|
|
37
|
+
// guard (404, before the service), and an authorised one is stored (201). Cases
|
|
38
|
+
// 3 and 6 are that pair.
|
|
39
39
|
//
|
|
40
40
|
// The fix is `requireSiteReach`, the rule `/api/attendances`, `/api/site-cameras`
|
|
41
41
|
// and `/api/documents` already use, so the contracted agency still reaches its
|
|
@@ -66,6 +66,10 @@ const COLL = "robots";
|
|
|
66
66
|
|
|
67
67
|
// What an authorised create fails with today: the pre-existing model defect,
|
|
68
68
|
// reached only AFTER the guard has let the caller through.
|
|
69
|
+
// The refusal every create used to end in, kept so case 3 can assert the guard
|
|
70
|
+
// stopped the request BEFORE the model rather than at it. Creates now succeed:
|
|
71
|
+
// the service handed `MRobot` an ObjectId where the schema wanted a string, so
|
|
72
|
+
// no robot could ever be registered through either app.
|
|
69
73
|
const MODEL_REFUSAL = '"createdBy" must be a string';
|
|
70
74
|
|
|
71
75
|
describe("robots and their credentials are scoped to their estate", { concurrency: 1 }, () => {
|
|
@@ -127,20 +131,30 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
127
131
|
assert.equal(list.status, 200);
|
|
128
132
|
assert.equal(list.body?.items?.length, 1);
|
|
129
133
|
|
|
130
|
-
//
|
|
131
|
-
// robot's
|
|
134
|
+
// The projection no longer carries the robot's credentials AT ALL - not even
|
|
135
|
+
// to a caller standing at the robot's own estate. The tenancy guard decided
|
|
136
|
+
// WHO could read them; this decides that nobody reads them over HTTP. The
|
|
137
|
+
// connector reads them server-side.
|
|
132
138
|
const row = list.body.items[0];
|
|
133
|
-
assert.equal("appKey" in row,
|
|
134
|
-
assert.equal("appToken" in row,
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
assert.equal("appKey" in row, false);
|
|
140
|
+
assert.equal("appToken" in row, false);
|
|
141
|
+
// and the row is still a usable listing.
|
|
142
|
+
assert.equal(typeof row.name, "string");
|
|
143
|
+
|
|
144
|
+
// And the write route answers - and now SUCCEEDS. It never did before: the
|
|
145
|
+
// service handed `MRobot` an ObjectId where the schema wanted a string, so
|
|
146
|
+
// every create ended in `400 "createdBy" must be a string`.
|
|
137
147
|
const create = await h.api("/robots", {
|
|
138
148
|
method: "POST",
|
|
139
149
|
sid: outsiderSid,
|
|
140
150
|
body: newRobot(id.siteA, id.orgA, "rb-baseline"),
|
|
141
151
|
});
|
|
142
|
-
assert.equal(create.status,
|
|
143
|
-
|
|
152
|
+
assert.equal(create.status, 201);
|
|
153
|
+
|
|
154
|
+
// Stored against the SESSION's account, and defaulted to a cleaning robot.
|
|
155
|
+
const stored = await byName("rb-baseline");
|
|
156
|
+
assert.equal(String(stored.metadata.siteId), String(id.siteA));
|
|
157
|
+
assert.equal(stored.type, "cleaning");
|
|
144
158
|
});
|
|
145
159
|
|
|
146
160
|
// ---- refusals -----------------------------------------------------------
|
|
@@ -167,8 +181,9 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
167
181
|
body: newRobot(id.siteB, id.orgB, "rb-planted"),
|
|
168
182
|
});
|
|
169
183
|
|
|
170
|
-
// 404 from the guard
|
|
171
|
-
//
|
|
184
|
+
// 404 from the guard - the request never reached the service, so nothing was
|
|
185
|
+
// stored. Case 6 is the same request from someone who may make it, and it
|
|
186
|
+
// now succeeds, which is what makes this refusal meaningful.
|
|
172
187
|
assert.equal(res.status, 404);
|
|
173
188
|
assert.notEqual(res.body?.message, MODEL_REFUSAL);
|
|
174
189
|
assert.equal(await countAtSite(id.siteB), before);
|
|
@@ -195,21 +210,28 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
195
210
|
|
|
196
211
|
// ---- still works: the people who are supposed to reach it ---------------
|
|
197
212
|
|
|
198
|
-
record("6. an authorised create
|
|
213
|
+
record("6. an authorised create gets past the guard AND is stored", async () => {
|
|
199
214
|
const res = await h.api("/robots", {
|
|
200
215
|
method: "POST",
|
|
201
216
|
sid: managerSid,
|
|
202
217
|
body: newRobot(id.siteB, id.orgB, "rb-allowed"),
|
|
203
218
|
});
|
|
204
219
|
|
|
205
|
-
assert.equal(res.status,
|
|
206
|
-
|
|
220
|
+
assert.equal(res.status, 201);
|
|
221
|
+
const stored = await byName("rb-allowed");
|
|
222
|
+
assert.equal(String(stored.metadata.siteId), String(id.siteB));
|
|
207
223
|
});
|
|
208
224
|
|
|
209
225
|
record("7. the site B client still LISTS site B", async () => {
|
|
210
226
|
const res = await h.api(listUrl(id.orgB, id.siteB), { sid: managerSid });
|
|
211
227
|
assert.equal(res.status, 200);
|
|
212
|
-
|
|
228
|
+
|
|
229
|
+
// A count, not an exact one: case 6 now genuinely stores a robot at site B,
|
|
230
|
+
// so pinning this to 1 would only be asserting the order the cases run in.
|
|
231
|
+
// What matters is that site B's own robots are here and site A's are not.
|
|
232
|
+
const names = (res.body?.items ?? []).map((r) => r.name);
|
|
233
|
+
assert.ok(names.length >= 1);
|
|
234
|
+
assert.equal(names.includes("rb-baseline"), false, "site A's robot");
|
|
213
235
|
});
|
|
214
236
|
|
|
215
237
|
record("8. the contracted agency still LISTS its client's site (customer.sites branch)", async () => {
|
|
@@ -217,18 +239,21 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
217
239
|
// ACTIVE `customer.sites` engagement.
|
|
218
240
|
const res = await h.api(listUrl(id.orgB, id.siteB), { sid: cleanerSid });
|
|
219
241
|
assert.equal(res.status, 200);
|
|
220
|
-
|
|
242
|
+
|
|
243
|
+
const names = (res.body?.items ?? []).map((r) => r.name);
|
|
244
|
+
assert.ok(names.length >= 1);
|
|
245
|
+
assert.equal(names.includes("rb-baseline"), false, "site A's robot");
|
|
221
246
|
});
|
|
222
247
|
|
|
223
|
-
record("9. the contracted agency's create
|
|
248
|
+
record("9. the contracted agency's create gets past the guard too", async () => {
|
|
224
249
|
const res = await h.api("/robots", {
|
|
225
250
|
method: "POST",
|
|
226
251
|
sid: cleanerSid,
|
|
227
252
|
body: newRobot(id.siteB, id.orgB, "rb-agency"),
|
|
228
253
|
});
|
|
229
254
|
|
|
230
|
-
assert.equal(res.status,
|
|
231
|
-
assert.
|
|
255
|
+
assert.equal(res.status, 201);
|
|
256
|
+
assert.notEqual(await byName("rb-agency"), null);
|
|
232
257
|
});
|
|
233
258
|
|
|
234
259
|
record("10. Seven365 platform staff still reach any estate on both routes", async () => {
|
|
@@ -240,8 +265,7 @@ describe("robots and their credentials are scoped to their estate", { concurrenc
|
|
|
240
265
|
sid: staffSid,
|
|
241
266
|
body: newRobot(id.siteA, id.orgA, "rb-staff"),
|
|
242
267
|
});
|
|
243
|
-
assert.equal(create.status,
|
|
244
|
-
assert.equal(create.body?.message, MODEL_REFUSAL);
|
|
268
|
+
assert.equal(create.status, 201);
|
|
245
269
|
});
|
|
246
270
|
|
|
247
271
|
// ---- the refusal leaks nothing ------------------------------------------
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ROBOT_APP_SCOPES,
|
|
6
|
+
canMonitorRobotType,
|
|
7
|
+
canSetUpRobotType,
|
|
8
|
+
robotScopeForApp,
|
|
9
|
+
robotTypesVisibleTo,
|
|
10
|
+
} from "./.build/utils/robot-apps.util.mjs";
|
|
11
|
+
|
|
12
|
+
/* --------------------------------------------------------------------------
|
|
13
|
+
* The owner's rule, 2026-09-22: iService365 is the fleet manager for EVERY
|
|
14
|
+
* robot on a property. A cleaning robot is set up in the cleaning apps, a
|
|
15
|
+
* security robot in the security apps, Property Management does both because
|
|
16
|
+
* the property owns the fleet, the Seven365 console does everything everywhere,
|
|
17
|
+
* and the managing agent watches without configuring.
|
|
18
|
+
*
|
|
19
|
+
* These assert that table, and the two ways it can fail quietly: an app nobody
|
|
20
|
+
* declared getting everything instead of nothing, and a refusal being read as
|
|
21
|
+
* "no filter".
|
|
22
|
+
* ----------------------------------------------------------------------- */
|
|
23
|
+
|
|
24
|
+
const HYGIENE_WEB = "iservice365-web-app-hygiene";
|
|
25
|
+
const ICLEAN = "iservice365-hygiene-mobile-app";
|
|
26
|
+
const SECURITY_WEB = "iservice365-web-app-security";
|
|
27
|
+
const ISECURE = "iservice365-security-mobile-app";
|
|
28
|
+
const PM_WEB = "iservice365-web-app-property-management";
|
|
29
|
+
const IMA = "iservice365-ma-mobile-app";
|
|
30
|
+
const CONSOLE = "iservice365-web-app-admin";
|
|
31
|
+
|
|
32
|
+
/* ------------------------------------------------------- the owner's rule */
|
|
33
|
+
|
|
34
|
+
test("cleaning apps see cleaning robots and NOT security patrols", () => {
|
|
35
|
+
for (const app of [HYGIENE_WEB, ICLEAN]) {
|
|
36
|
+
assert.equal(canSetUpRobotType(app, "cleaning"), true, app);
|
|
37
|
+
assert.equal(canMonitorRobotType(app, "cleaning"), true, app);
|
|
38
|
+
|
|
39
|
+
// A cleaner must never be shown where a security patrol is.
|
|
40
|
+
assert.equal(canSetUpRobotType(app, "security"), false, app);
|
|
41
|
+
assert.equal(canMonitorRobotType(app, "security"), false, app);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("security apps see security robots and NOT cleaning robots", () => {
|
|
46
|
+
for (const app of [SECURITY_WEB, ISECURE]) {
|
|
47
|
+
assert.equal(canSetUpRobotType(app, "security"), true, app);
|
|
48
|
+
assert.equal(canMonitorRobotType(app, "security"), true, app);
|
|
49
|
+
assert.equal(canSetUpRobotType(app, "cleaning"), false, app);
|
|
50
|
+
assert.equal(canMonitorRobotType(app, "cleaning"), false, app);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Property Management sets up and monitors BOTH", () => {
|
|
55
|
+
for (const type of ["cleaning", "security"]) {
|
|
56
|
+
assert.equal(canSetUpRobotType(PM_WEB, type), true, type);
|
|
57
|
+
assert.equal(canMonitorRobotType(PM_WEB, type), true, type);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the Seven365 console does everything, for every robot type", () => {
|
|
62
|
+
for (const type of ["cleaning", "security"]) {
|
|
63
|
+
assert.equal(canSetUpRobotType(CONSOLE, type), true, type);
|
|
64
|
+
assert.equal(canMonitorRobotType(CONSOLE, type), true, type);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the managing agent watches the whole fleet but configures nothing", () => {
|
|
69
|
+
assert.equal(canMonitorRobotType(IMA, "cleaning"), true);
|
|
70
|
+
assert.equal(canMonitorRobotType(IMA, "security"), true);
|
|
71
|
+
|
|
72
|
+
// Setup is a different job from monitoring - iMA365 must not be able to
|
|
73
|
+
// re-point a robot at a different cloud.
|
|
74
|
+
assert.equal(canSetUpRobotType(IMA, "cleaning"), false);
|
|
75
|
+
assert.equal(canSetUpRobotType(IMA, "security"), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------ fails closed */
|
|
79
|
+
|
|
80
|
+
test("an app nobody declared gets NO robots, not all of them", () => {
|
|
81
|
+
// A new service app, a renamed deployment, a typo in an env var. The wrong
|
|
82
|
+
// default here is a landscaping app listing a building's security patrols.
|
|
83
|
+
for (const unknown of [
|
|
84
|
+
"iservice365-landscaping-mobile-app",
|
|
85
|
+
"iservice365-web-app-pest-control",
|
|
86
|
+
"not-an-app",
|
|
87
|
+
"",
|
|
88
|
+
null,
|
|
89
|
+
undefined,
|
|
90
|
+
42,
|
|
91
|
+
{},
|
|
92
|
+
]) {
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
robotScopeForApp(unknown),
|
|
95
|
+
{ setup: [], monitor: [] },
|
|
96
|
+
String(unknown)
|
|
97
|
+
);
|
|
98
|
+
assert.equal(canMonitorRobotType(unknown, "cleaning"), false);
|
|
99
|
+
assert.equal(canSetUpRobotType(unknown, "security"), false);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an unknown robot type is refused everywhere, including the console", () => {
|
|
104
|
+
for (const app of Object.keys(ROBOT_APP_SCOPES)) {
|
|
105
|
+
assert.equal(canSetUpRobotType(app, "drone"), false, app);
|
|
106
|
+
assert.equal(canMonitorRobotType(app, undefined), false, app);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/* --------------------------------------------------- the listing filter */
|
|
111
|
+
|
|
112
|
+
test("no type requested means every type the app may monitor", () => {
|
|
113
|
+
assert.deepEqual(robotTypesVisibleTo(PM_WEB), ["cleaning", "security"]);
|
|
114
|
+
assert.deepEqual(robotTypesVisibleTo(ICLEAN), ["cleaning"]);
|
|
115
|
+
assert.deepEqual(robotTypesVisibleTo(ISECURE, ""), ["security"]);
|
|
116
|
+
assert.deepEqual(robotTypesVisibleTo(PM_WEB, null), ["cleaning", "security"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a permitted type narrows the listing to that type", () => {
|
|
120
|
+
assert.deepEqual(robotTypesVisibleTo(PM_WEB, "security"), ["security"]);
|
|
121
|
+
assert.deepEqual(robotTypesVisibleTo(CONSOLE, "cleaning"), ["cleaning"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("asking for a type the app may not see returns [] - list NOTHING", () => {
|
|
125
|
+
// The case that must never be read as "no filter": if a caller treats the
|
|
126
|
+
// empty array as "no constraint", a refusal becomes a listing of every robot
|
|
127
|
+
// on the estate.
|
|
128
|
+
assert.deepEqual(robotTypesVisibleTo(ICLEAN, "security"), []);
|
|
129
|
+
assert.deepEqual(robotTypesVisibleTo(ISECURE, "cleaning"), []);
|
|
130
|
+
assert.deepEqual(robotTypesVisibleTo(IMA, "drone"), []);
|
|
131
|
+
assert.deepEqual(robotTypesVisibleTo("not-an-app", "cleaning"), []);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("an app that monitors nothing is never widened by an empty request", () => {
|
|
135
|
+
// iMA365 configures nothing, so its SETUP list is empty - but that must not
|
|
136
|
+
// make `robotTypesVisibleTo` return everything for an unlisted app.
|
|
137
|
+
assert.deepEqual(robotTypesVisibleTo("not-an-app"), []);
|
|
138
|
+
assert.deepEqual(robotTypesVisibleTo("not-an-app", undefined), []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------ the table */
|
|
142
|
+
|
|
143
|
+
test("every declared app names only known robot types", () => {
|
|
144
|
+
const known = new Set(["cleaning", "security"]);
|
|
145
|
+
for (const [app, scope] of Object.entries(ROBOT_APP_SCOPES)) {
|
|
146
|
+
for (const t of [...scope.setup, ...scope.monitor]) {
|
|
147
|
+
assert.equal(known.has(t), true, `${app} declares unknown type ${t}`);
|
|
148
|
+
}
|
|
149
|
+
// Anything an app can set up, it must also be able to see.
|
|
150
|
+
for (const t of scope.setup) {
|
|
151
|
+
assert.equal(scope.monitor.includes(t), true, `${app} sets up ${t} blind`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ROBOT_CLOUD_PATHS,
|
|
7
|
+
ROBOT_STATUS_NAMES,
|
|
8
|
+
areaColourToHex,
|
|
9
|
+
hexToAreaColour,
|
|
10
|
+
isDispatchable,
|
|
11
|
+
metresToPixel,
|
|
12
|
+
pixelToMetres,
|
|
13
|
+
planDayFromJsDay,
|
|
14
|
+
robotCloudBody,
|
|
15
|
+
robotCloudSign,
|
|
16
|
+
robotCloudSigningString,
|
|
17
|
+
robotCloudTimestamp,
|
|
18
|
+
robotMode,
|
|
19
|
+
robotModeFromList,
|
|
20
|
+
} from "./.build/utils/robot-cloud.util.mjs";
|
|
21
|
+
|
|
22
|
+
/* --------------------------------------------------------------------------
|
|
23
|
+
* What these guard.
|
|
24
|
+
*
|
|
25
|
+
* The cleaning-robot cloud authenticates every request with a SHA-1 over a
|
|
26
|
+
* string whose field order, separators and url path are all load-bearing: get
|
|
27
|
+
* any character wrong and every call is rejected with no clue why. And its
|
|
28
|
+
* status vocabulary is 27 codes deep, where mapping one of them to the wrong
|
|
29
|
+
* side of "is this robot free?" sends a second job to a robot already cleaning.
|
|
30
|
+
*
|
|
31
|
+
* Source: "Robot Cloud API Document (2025.05.27)".
|
|
32
|
+
* ----------------------------------------------------------------------- */
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ signing */
|
|
35
|
+
|
|
36
|
+
test("the signing string is exactly the documented field order", () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
robotCloudSigningString("APPID", "20250520200000", "/portal/x", "SECRET"),
|
|
39
|
+
"appid=APPID,timestamp=20250520200000,url=/portal/x,secret=SECRET"
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the signature is sha1 of that string, in lower-case hex", () => {
|
|
44
|
+
const expected = createHash("sha1")
|
|
45
|
+
.update("appid=a,timestamp=20250520200000,url=/portal/x,secret=s")
|
|
46
|
+
.digest("hex");
|
|
47
|
+
|
|
48
|
+
assert.equal(robotCloudSign("a", "20250520200000", "/portal/x", "s"), expected);
|
|
49
|
+
assert.match(
|
|
50
|
+
robotCloudSign("a", "20250520200000", "/portal/x", "s"),
|
|
51
|
+
/^[0-9a-f]{40}$/
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the url is part of the hash, so two endpoints never share a signature", () => {
|
|
56
|
+
// The document's own examples show get-map-list and get-file-url carrying the
|
|
57
|
+
// SAME sign at the same timestamp, which its own formula makes impossible.
|
|
58
|
+
// That is a copy-paste in the PDF; this asserts the formula, not the PDF.
|
|
59
|
+
const a = robotCloudSign("a", "20250520200000", ROBOT_CLOUD_PATHS.mapList, "s");
|
|
60
|
+
const b = robotCloudSign("a", "20250520200000", ROBOT_CLOUD_PATHS.fileUrl, "s");
|
|
61
|
+
assert.notEqual(a, b);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a path without a leading slash is refused rather than silently signed", () => {
|
|
65
|
+
assert.throws(
|
|
66
|
+
() => robotCloudSign("a", "20250520200000", "portal/x", "s"),
|
|
67
|
+
/must start with/
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("missing credentials throw instead of signing an empty secret", () => {
|
|
72
|
+
assert.throws(() => robotCloudSign("", "20250520200000", "/p", "s"), /appid/);
|
|
73
|
+
assert.throws(() => robotCloudSign("a", "20250520200000", "/p", ""), /secret/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the timestamp is yyyyMMddHHmmss, zero-padded", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
robotCloudTimestamp(new Date(Date.UTC(2025, 4, 20, 20, 0, 0))),
|
|
79
|
+
"20250520200000"
|
|
80
|
+
);
|
|
81
|
+
// Single-digit month, day, hour, minute and second all pad.
|
|
82
|
+
assert.equal(
|
|
83
|
+
robotCloudTimestamp(new Date(Date.UTC(2025, 0, 2, 3, 4, 5))),
|
|
84
|
+
"20250102030405"
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the secret is hashed and never becomes a field in the body", () => {
|
|
89
|
+
const body = robotCloudBody(
|
|
90
|
+
"APPID",
|
|
91
|
+
"SUPER-SECRET",
|
|
92
|
+
ROBOT_CLOUD_PATHS.robotList,
|
|
93
|
+
{ robotSn: "CASDF03456" },
|
|
94
|
+
new Date(Date.UTC(2025, 4, 20, 20, 0, 0))
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
assert.deepEqual(Object.keys(body).sort(), [
|
|
98
|
+
"appid",
|
|
99
|
+
"robotSn",
|
|
100
|
+
"sign",
|
|
101
|
+
"timestamp",
|
|
102
|
+
]);
|
|
103
|
+
assert.equal(
|
|
104
|
+
JSON.stringify(body).includes("SUPER-SECRET"),
|
|
105
|
+
false,
|
|
106
|
+
"the secret must never be transmitted"
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/* ------------------------------------------------------------------ status */
|
|
111
|
+
|
|
112
|
+
test("an unknown status is UNAVAILABLE, never dispatchable", () => {
|
|
113
|
+
// The branch that matters: a firmware code we have never seen must not read
|
|
114
|
+
// as "free, send it a job".
|
|
115
|
+
for (const unknown of [42, -1, 998, null, undefined, "1", NaN]) {
|
|
116
|
+
assert.equal(robotMode(unknown), "UNAVAILABLE", `status ${unknown}`);
|
|
117
|
+
assert.equal(isDispatchable(unknown), false, `status ${unknown}`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("only idle, ready and charged robots are dispatchable", () => {
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
Object.keys(ROBOT_STATUS_NAMES)
|
|
124
|
+
.map(Number)
|
|
125
|
+
.filter(isDispatchable)
|
|
126
|
+
.sort((a, b) => a - b),
|
|
127
|
+
[1, 9, 24]
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("every cleaning status is WORKING and refuses a second job", () => {
|
|
132
|
+
for (const cleaning of [2, 10, 12]) {
|
|
133
|
+
assert.equal(robotMode(cleaning), "WORKING");
|
|
134
|
+
assert.equal(isDispatchable(cleaning), false);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("switching floor is IN_TRANSIT - the lift case, not an idle robot", () => {
|
|
139
|
+
assert.equal(robotMode(21), "IN_TRANSIT");
|
|
140
|
+
assert.equal(isDispatchable(21), false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("every documented status has both a name and a mode", () => {
|
|
144
|
+
for (const code of Object.keys(ROBOT_STATUS_NAMES).map(Number)) {
|
|
145
|
+
assert.ok(ROBOT_STATUS_NAMES[code], `status ${code} has no name`);
|
|
146
|
+
assert.notEqual(
|
|
147
|
+
robotMode(code),
|
|
148
|
+
undefined,
|
|
149
|
+
`status ${code} has no mode`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("offline beats a stale cached status", () => {
|
|
155
|
+
// get-robot-list can report a status from before the robot dropped off.
|
|
156
|
+
assert.equal(robotModeFromList({ statusFlag: 1, onlineFlag: false }), "UNAVAILABLE");
|
|
157
|
+
assert.equal(robotModeFromList({ statusFlag: 1, onlineFlag: true }), "IDLE");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------- coordinates */
|
|
161
|
+
|
|
162
|
+
test("pixels and metres round-trip through the map transform", () => {
|
|
163
|
+
// The example map from the document.
|
|
164
|
+
const map = { originX: -49.713, originY: -35.846, resolution: 0.05 };
|
|
165
|
+
const height = 800;
|
|
166
|
+
|
|
167
|
+
const metres = pixelToMetres(504, 416, map, height);
|
|
168
|
+
const back = metresToPixel(metres.x, metres.y, map, height);
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(back, { x: 504, y: 416 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("the transform matches the documented formula", () => {
|
|
174
|
+
const map = { originX: -49.713, originY: -35.846, resolution: 0.05 };
|
|
175
|
+
const { x, y } = pixelToMetres(100, 200, map, 800);
|
|
176
|
+
|
|
177
|
+
assert.equal(Number(x.toFixed(4)), Number((-49.713 + 100 * 0.05).toFixed(4)));
|
|
178
|
+
assert.equal(
|
|
179
|
+
Number(y.toFixed(4)),
|
|
180
|
+
Number((-35.846 + (800 - 200) * 0.05).toFixed(4))
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a zero resolution throws rather than dividing by zero", () => {
|
|
185
|
+
assert.throws(
|
|
186
|
+
() => metresToPixel(0, 0, { originX: 0, originY: 0, resolution: 0 }, 800),
|
|
187
|
+
/resolution/
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------- areas */
|
|
192
|
+
|
|
193
|
+
test("area colours round-trip between the map's hex and the plan's object", () => {
|
|
194
|
+
// The two endpoints name the same area two different ways.
|
|
195
|
+
assert.deepEqual(hexToAreaColour("FF0B0B83"), {
|
|
196
|
+
r: 255,
|
|
197
|
+
g: 11,
|
|
198
|
+
b: 11,
|
|
199
|
+
a: 131,
|
|
200
|
+
});
|
|
201
|
+
assert.equal(areaColourToHex({ r: 255, g: 11, b: 11, a: 131 }), "FF0B0B83");
|
|
202
|
+
assert.equal(areaColourToHex(hexToAreaColour("B7CAEFFF")), "B7CAEFFF");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("the plan example's colour survives the round trip", () => {
|
|
206
|
+
const fromPlan = { r: 186, g: 20, b: 75, a: 255 };
|
|
207
|
+
assert.deepEqual(hexToAreaColour(areaColourToHex(fromPlan)), fromPlan);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a malformed colour is refused, not half-parsed", () => {
|
|
211
|
+
assert.throws(() => hexToAreaColour("FF0B0B"), /8 hex digits/);
|
|
212
|
+
assert.throws(() => hexToAreaColour("ZZ0B0B83"), /8 hex digits/);
|
|
213
|
+
assert.throws(() => areaColourToHex({ r: 256, g: 0, b: 0, a: 0 }), /out of range/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/* --------------------------------------------------------------- job plans */
|
|
217
|
+
|
|
218
|
+
test("plan days are 1-7 Monday-Sunday, not JavaScript's 0-6 Sunday-Saturday", () => {
|
|
219
|
+
assert.equal(planDayFromJsDay(0), 7, "Sunday");
|
|
220
|
+
assert.equal(planDayFromJsDay(1), 1, "Monday");
|
|
221
|
+
assert.equal(planDayFromJsDay(6), 6, "Saturday");
|
|
222
|
+
});
|