@7365admin1/core 3.80.0 → 3.82.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.82.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/lift-contract.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,271 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ LIFT_DOOR,
6
+ LIFT_MODE,
7
+ LIFT_MOTION,
8
+ LIFT_REQUEST,
9
+ createLiftSimulator,
10
+ holdsLift,
11
+ isLiftAvailable,
12
+ isSafeToCross,
13
+ servesFloor,
14
+ } from "./.build/utils/lift-contract.util.mjs";
15
+
16
+ /* --------------------------------------------------------------------------
17
+ * The lift is the one part of this system that can injure somebody, and the
18
+ * Inovance register map has not arrived, so every decision here is made against
19
+ * a simulator rather than a real car. These assert the decisions that would
20
+ * otherwise only be discovered on a live lift.
21
+ *
22
+ * Enum values match rmf_lift_msgs verbatim (open-rmf/rmf_internal_msgs).
23
+ * ----------------------------------------------------------------------- */
24
+
25
+ const FLOORS = ["L1", "L2", "L3", "L4"];
26
+ const sim = (o = {}) => createLiftSimulator({ name: "LIFT-A", floors: FLOORS, ...o });
27
+
28
+ const stateWith = (over = {}) => ({
29
+ liftName: "LIFT-A",
30
+ liftTime: 0,
31
+ availableFloors: [...FLOORS],
32
+ currentFloor: "L1",
33
+ destinationFloor: "L1",
34
+ doorState: LIFT_DOOR.OPEN,
35
+ motionState: LIFT_MOTION.STOPPED,
36
+ currentMode: LIFT_MODE.HUMAN,
37
+ sessionId: "",
38
+ ...over,
39
+ });
40
+
41
+ /* ------------------------------------------------- the enums match RMF */
42
+
43
+ test("the enum values are rmf_lift_msgs values, not our own numbering", () => {
44
+ assert.deepEqual(LIFT_DOOR, { CLOSED: 0, MOVING: 1, OPEN: 2 });
45
+ assert.deepEqual(LIFT_MOTION, { STOPPED: 0, UP: 1, DOWN: 2, UNKNOWN: 3 });
46
+ assert.deepEqual(LIFT_MODE, {
47
+ UNKNOWN: 0, HUMAN: 1, AGV: 2, FIRE: 3, OFFLINE: 4, EMERGENCY: 5,
48
+ });
49
+ assert.deepEqual(LIFT_REQUEST, { END_SESSION: 0, AGV_MODE: 1, HUMAN_MODE: 2 });
50
+ });
51
+
52
+ /* ------------------------------------------------------------ availability */
53
+
54
+ test("a lift in FIRE or EMERGENCY mode is never available", () => {
55
+ // During an evacuation the lift belongs to the fire service. A robot must not
56
+ // be queued for it, whatever else is true.
57
+ for (const mode of [LIFT_MODE.FIRE, LIFT_MODE.EMERGENCY, LIFT_MODE.OFFLINE, LIFT_MODE.UNKNOWN]) {
58
+ const s = stateWith({ currentMode: mode });
59
+ assert.equal(isLiftAvailable(s), false, `mode ${mode}`);
60
+ assert.equal(isSafeToCross(s, "L1"), false, `mode ${mode}`);
61
+ }
62
+ });
63
+
64
+ test("a lift that has never reported is not available", () => {
65
+ assert.equal(isLiftAvailable(null), false);
66
+ assert.equal(isSafeToCross(null, "L1"), false);
67
+ assert.equal(servesFloor(null, "L1"), false);
68
+ });
69
+
70
+ /* -------------------------------------------------------- crossing the door */
71
+
72
+ test("a MOVING door is not an open door", () => {
73
+ // The branch that matters most: telling a robot to cross a closing door plane
74
+ // is how a robot gets struck.
75
+ assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.MOVING }), "L1"), false);
76
+ assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.CLOSED }), "L1"), false);
77
+ assert.equal(isSafeToCross(stateWith({ doorState: LIFT_DOOR.OPEN }), "L1"), true);
78
+ });
79
+
80
+ test("a car still in motion is never safe to cross, doors notwithstanding", () => {
81
+ for (const motion of [LIFT_MOTION.UP, LIFT_MOTION.DOWN, LIFT_MOTION.UNKNOWN]) {
82
+ assert.equal(
83
+ isSafeToCross(stateWith({ motionState: motion, doorState: LIFT_DOOR.OPEN }), "L1"),
84
+ false,
85
+ `motion ${motion}`
86
+ );
87
+ }
88
+ });
89
+
90
+ test("a car at the WRONG floor is not safe to cross", () => {
91
+ // A passenger redirecting the car mid-journey is exactly this case.
92
+ assert.equal(isSafeToCross(stateWith({ currentFloor: "L3" }), "L1"), false);
93
+ assert.equal(isSafeToCross(stateWith({ currentFloor: "L3" }), "L3"), true);
94
+ });
95
+
96
+ /* ------------------------------------------------------------- the session */
97
+
98
+ test("an empty session id never holds the lift", () => {
99
+ // Otherwise a caller that forgot to set one is handed a lift somebody is riding.
100
+ assert.equal(holdsLift(stateWith({ sessionId: "" }), ""), false);
101
+ assert.equal(holdsLift(stateWith({ sessionId: "j-1" }), ""), false);
102
+ assert.equal(holdsLift(stateWith({ sessionId: "" }), "j-1"), false);
103
+ assert.equal(holdsLift(stateWith({ sessionId: "j-1" }), "j-1"), true);
104
+ });
105
+
106
+ test("a floor the lift does not serve is not a destination", () => {
107
+ assert.equal(servesFloor(stateWith(), "L9"), false);
108
+ assert.equal(servesFloor(stateWith(), ""), false);
109
+ assert.equal(servesFloor(stateWith(), "L3"), true);
110
+ });
111
+
112
+ /* ------------------------------------------------------------- simulator */
113
+
114
+ test("a requested lift travels, arrives, and opens its doors", async () => {
115
+ const lift = sim({ msPerFloor: 1_000 });
116
+
117
+ await lift.request({
118
+ liftName: "LIFT-A", sessionId: "j-1",
119
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L3",
120
+ doorState: LIFT_DOOR.OPEN,
121
+ });
122
+
123
+ let s = await lift.read();
124
+ assert.equal(s.motionState, LIFT_MOTION.UP);
125
+ assert.equal(s.doorState, LIFT_DOOR.CLOSED, "doors shut while travelling");
126
+ assert.equal(isSafeToCross(s, "L3"), false);
127
+
128
+ lift.tick(2_000); // two floors
129
+ s = await lift.read();
130
+ assert.equal(s.currentFloor, "L3");
131
+ assert.equal(s.motionState, LIFT_MOTION.STOPPED);
132
+ assert.equal(s.doorState, LIFT_DOOR.OPEN);
133
+ assert.equal(isSafeToCross(s, "L3"), true);
134
+ });
135
+
136
+ test("the doors close themselves — this is why the hold must be refreshed", async () => {
137
+ // The tightest timing in the whole design: a passenger lift times its doors
138
+ // out while the robot is still crossing.
139
+ const lift = sim({ msPerFloor: 1_000, doorDwellMs: 4_000 });
140
+ await lift.request({
141
+ liftName: "LIFT-A", sessionId: "j-1",
142
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
143
+ doorState: LIFT_DOOR.OPEN,
144
+ });
145
+
146
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.OPEN);
147
+ lift.tick(4_000);
148
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.CLOSED, "closed on its own");
149
+ });
150
+
151
+ test("refreshing the hold keeps the doors open", async () => {
152
+ const lift = sim({ doorDwellMs: 4_000 });
153
+ await lift.request({
154
+ liftName: "LIFT-A", sessionId: "j-1",
155
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
156
+ doorState: LIFT_DOOR.OPEN,
157
+ });
158
+
159
+ for (let i = 0; i < 5; i++) {
160
+ lift.tick(3_000);
161
+ await lift.refresh("j-1");
162
+ }
163
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.OPEN);
164
+ });
165
+
166
+ test("refreshing somebody else's session does NOT hold the doors", async () => {
167
+ const lift = sim({ doorDwellMs: 4_000 });
168
+ await lift.request({
169
+ liftName: "LIFT-A", sessionId: "j-1",
170
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L1",
171
+ doorState: LIFT_DOOR.OPEN,
172
+ });
173
+
174
+ lift.tick(3_000);
175
+ await lift.refresh("j-2"); // not the holder
176
+ lift.tick(2_000);
177
+ assert.equal((await lift.read()).doorState, LIFT_DOOR.CLOSED);
178
+ });
179
+
180
+ test("a lift held by one journey is refused to another, not stolen", async () => {
181
+ const lift = sim();
182
+ await lift.request({
183
+ liftName: "LIFT-A", sessionId: "j-1",
184
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
185
+ doorState: LIFT_DOOR.OPEN,
186
+ });
187
+
188
+ await assert.rejects(
189
+ () => lift.request({
190
+ liftName: "LIFT-A", sessionId: "j-2",
191
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L4",
192
+ doorState: LIFT_DOOR.OPEN,
193
+ }),
194
+ /held by j-1/
195
+ );
196
+ });
197
+
198
+ test("release is safe to call twice, and frees the lift for the next journey", async () => {
199
+ const lift = sim();
200
+ await lift.request({
201
+ liftName: "LIFT-A", sessionId: "j-1",
202
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
203
+ doorState: LIFT_DOOR.OPEN,
204
+ });
205
+
206
+ await lift.release("j-1");
207
+ await lift.release("j-1"); // the watchdog fires unconditionally
208
+ const s = await lift.read();
209
+ assert.equal(s.sessionId, "");
210
+ assert.equal(s.currentMode, LIFT_MODE.HUMAN, "handed back to passengers");
211
+
212
+ await lift.request({
213
+ liftName: "LIFT-A", sessionId: "j-2",
214
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L4",
215
+ doorState: LIFT_DOOR.OPEN,
216
+ });
217
+ assert.equal((await lift.read()).sessionId, "j-2");
218
+ });
219
+
220
+ test("releasing a lift somebody else holds does nothing", async () => {
221
+ const lift = sim();
222
+ await lift.request({
223
+ liftName: "LIFT-A", sessionId: "j-1",
224
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
225
+ doorState: LIFT_DOOR.OPEN,
226
+ });
227
+
228
+ await lift.release("j-2");
229
+ assert.equal((await lift.read()).sessionId, "j-1", "still held by j-1");
230
+ });
231
+
232
+ test("a floor the lift does not serve is refused", async () => {
233
+ const lift = sim();
234
+ await assert.rejects(
235
+ () => lift.request({
236
+ liftName: "LIFT-A", sessionId: "j-1",
237
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "B2",
238
+ doorState: LIFT_DOOR.OPEN,
239
+ }),
240
+ /does not serve B2/
241
+ );
242
+ });
243
+
244
+ test("END_SESSION hands the lift back to passengers", async () => {
245
+ const lift = sim();
246
+ await lift.request({
247
+ liftName: "LIFT-A", sessionId: "j-1",
248
+ requestType: LIFT_REQUEST.AGV_MODE, destinationFloor: "L2",
249
+ doorState: LIFT_DOOR.OPEN,
250
+ });
251
+ assert.equal((await lift.read()).currentMode, LIFT_MODE.AGV);
252
+
253
+ await lift.request({
254
+ liftName: "LIFT-A", sessionId: "j-1",
255
+ requestType: LIFT_REQUEST.END_SESSION, destinationFloor: "L2",
256
+ doorState: LIFT_DOOR.CLOSED,
257
+ });
258
+ const s = await lift.read();
259
+ assert.equal(s.sessionId, "");
260
+ assert.equal(s.currentMode, LIFT_MODE.HUMAN);
261
+ });
262
+
263
+ test("read() returns a copy — a caller cannot mutate the lift", async () => {
264
+ const lift = sim();
265
+ const s = await lift.read();
266
+ s.availableFloors.push("L99");
267
+ s.currentFloor = "L4";
268
+ const again = await lift.read();
269
+ assert.equal(again.availableFloors.includes("L99"), false);
270
+ assert.equal(again.currentFloor, "L1");
271
+ });
@@ -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
+ });