@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.80.0",
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. Case 0 shows the fields really are in the payload; cases 1, 2
16
- // and 5 show they are now out of reach.
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, because the cases below are shaped by it:
28
- // `POST /api/robots` does not work today and did not before this change. The
29
- // service overwrites `value.createdBy` with `createdBy._id`, an `ObjectId`, and
30
- // `MRobot` then re-validates against `robotSchema`, where `createdBy` is
31
- // `Joi.string()`. Every create therefore ends in
32
- // `400 "createdBy" must be a string` and nothing is ever stored. That is a
33
- // pre-existing defect, reported on the PR and deliberately NOT fixed here.
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 for the write the guard is proved by WHICH refusal a caller gets: an
36
- // unauthorised caller is stopped by the guard (404, before the service), an
37
- // authorised one gets through the guard and falls over at the model (400). Cases
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
- // This is what the leak was worth: the projection really does carry the
131
- // robot's credentials. The fields are checked for PRESENCE only.
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, true);
134
- assert.equal("appToken" in row, true);
135
-
136
- // And the write route answers: past the guard, into the model.
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, 400);
143
- assert.equal(create.body?.message, MODEL_REFUSAL);
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, NOT the model's 400 - the request never reached the
171
- // service. Case 6 is the same request from someone who may make it.
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 still gets PAST the guard (and dies at the model)", async () => {
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, 400);
206
- assert.equal(res.body?.message, MODEL_REFUSAL);
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
- assert.equal(res.body?.items?.length, 1);
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
- assert.equal(res.body?.items?.length, 1);
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 still gets past the guard too", async () => {
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, 400);
231
- assert.equal(res.body?.message, MODEL_REFUSAL);
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, 400);
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
+ });