@7365admin1/core 3.87.0 → 3.89.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 +12 -0
- package/dist/index.d.ts +352 -3
- package/dist/index.js +6417 -5440
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6504 -5537
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/harness.mjs +26 -0
- package/test/e2e/robot-map-lift.e2e.test.mjs +297 -0
- package/test/e2e/robot-network.e2e.test.mjs +269 -0
- package/test/robot-map-lift.test.mjs +141 -0
- package/test/robot-network.test.mjs +93 -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.89.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/building-map.util.ts src/utils/robot-cloud.util.ts src/utils/robot-adapter.util.ts src/utils/png-encoder.util.ts src/services/demo.adapter.ts src/utils/robot-fault.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",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/building-map.util.ts src/utils/robot-cloud.util.ts src/utils/robot-adapter.util.ts src/utils/png-encoder.util.ts src/services/demo.adapter.ts src/utils/robot-fault.util.ts src/utils/robot-tracking.util.ts src/utils/robot-apps.util.ts src/utils/robot-permission.util.ts src/utils/robot-network.util.ts src/utils/lift-contract.util.ts src/models/robot-building-map.model.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
|
},
|
package/test/e2e/harness.mjs
CHANGED
|
@@ -876,10 +876,36 @@ function buildApp(nsu, core) {
|
|
|
876
876
|
{
|
|
877
877
|
const { createRobot, getRobots, updateRobot, deleteRobot } = core.useRobotController();
|
|
878
878
|
|
|
879
|
+
// Seven365 console: every robot at every client's site. Mounted before the
|
|
880
|
+
// id routes, as in API-core.
|
|
881
|
+
const n = core.useRobotNetworkController();
|
|
882
|
+
robots.get("/network", requireAuth, n.listNetworkRobots);
|
|
883
|
+
robots.get("/network/summary", requireAuth, n.getNetworkSummary);
|
|
884
|
+
|
|
879
885
|
robots.post("/", requireAuth, createRobot);
|
|
880
886
|
robots.get("/organization/:organization/site/:site", requireAuth, getRobots);
|
|
881
887
|
robots.patch("/:id([0-9a-fA-F]{24})", requireAuth, updateRobot);
|
|
882
888
|
robots.delete("/:id([0-9a-fA-F]{24})", requireAuth, deleteRobot);
|
|
889
|
+
|
|
890
|
+
const m = core.useRobotMapController();
|
|
891
|
+
const MAP = "/building-maps/:mapId([0-9a-fA-F]{24})";
|
|
892
|
+
const SESSION = "/lift-sessions/:sessionId([0-9a-fA-F]{24})";
|
|
893
|
+
robots.get("/building-maps", requireAuth, m.listMaps);
|
|
894
|
+
robots.post("/building-maps", requireAuth, m.createMap);
|
|
895
|
+
robots.get(MAP, requireAuth, m.getMap);
|
|
896
|
+
robots.patch(MAP, requireAuth, m.updateMap);
|
|
897
|
+
robots.delete(MAP, requireAuth, m.deleteMap);
|
|
898
|
+
robots.get(`${MAP}/lifts`, requireAuth, m.getLiftQueue);
|
|
899
|
+
robots.post(`${MAP}/lifts/:liftName/sessions`, requireAuth, m.requestLift);
|
|
900
|
+
robots.post(`${SESSION}/refresh`, requireAuth, m.refreshLiftSession);
|
|
901
|
+
robots.post(`${SESSION}/release`, requireAuth, m.releaseLiftSession);
|
|
902
|
+
robots.get("/:id([0-9a-fA-F]{24})/lift-session", requireAuth, m.getRobotLiftSession);
|
|
903
|
+
|
|
904
|
+
// State and command, for DEMO-brand robots only in these tests: the demo
|
|
905
|
+
// adapter reaches no network, so no vendor cloud is ever contacted.
|
|
906
|
+
const f = core.useRobotFleetController();
|
|
907
|
+
robots.get("/:id([0-9a-fA-F]{24})/state", requireAuth, f.getState);
|
|
908
|
+
robots.post("/:id([0-9a-fA-F]{24})/command", requireAuth, f.sendCommand);
|
|
883
909
|
}
|
|
884
910
|
|
|
885
911
|
// mirrors iservice365-API-core/src/routes/attendance-settings.route.ts
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// End-to-end proof for building maps and the lift queue.
|
|
2
|
+
//
|
|
3
|
+
// 1. A map belongs to its robot's estate. The estate is copied from the STORED
|
|
4
|
+
// robot and every later call checks the STORED map - a caller elsewhere gets
|
|
5
|
+
// 404 by id, and cannot send a siteId to aim a map somewhere else.
|
|
6
|
+
// 2. A stale editor cannot overwrite a newer save (version check, 409).
|
|
7
|
+
// 3. The lift queue is the collection: one active holder per lift (enforced by a
|
|
8
|
+
// unique partial index, so racing promotions cannot both win), one open
|
|
9
|
+
// request per robot, timeouts that hand the lift on, release that hands it on.
|
|
10
|
+
// 4. Every write leaves a console-audit row.
|
|
11
|
+
//
|
|
12
|
+
// In-process MongoDB replica set, loopback Redis and mail sink only. No staging
|
|
13
|
+
// or production system, robot or lift is touched.
|
|
14
|
+
//
|
|
15
|
+
// Run with: yarn test:e2e
|
|
16
|
+
|
|
17
|
+
import { after, before, describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "Robot-Passw0rd!";
|
|
24
|
+
const OUTSIDER = "ml-outsider@e2e.example.com"; // org A, site A only
|
|
25
|
+
const MANAGER = "ml-manager@e2e.example.com"; // org B
|
|
26
|
+
|
|
27
|
+
describe("building maps and the lift queue", { concurrency: 1 }, () => {
|
|
28
|
+
let h;
|
|
29
|
+
const id = {};
|
|
30
|
+
let outsiderSid;
|
|
31
|
+
let managerSid;
|
|
32
|
+
let mapId;
|
|
33
|
+
|
|
34
|
+
before(async () => {
|
|
35
|
+
h = await startHarness();
|
|
36
|
+
Object.assign(id, await seed(h));
|
|
37
|
+
await h.core.useRobotBuildingMapRepo().createIndex();
|
|
38
|
+
await h.core.useRobotLiftSessionRepo().createIndex();
|
|
39
|
+
outsiderSid = await h.login(OUTSIDER, PASSWORD);
|
|
40
|
+
managerSid = await h.login(MANAGER, PASSWORD);
|
|
41
|
+
}, { timeout: 300000 });
|
|
42
|
+
|
|
43
|
+
after(async () => {
|
|
44
|
+
if (h) await h.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const body = {
|
|
48
|
+
name: "Tower B",
|
|
49
|
+
levels: [{ floorNo: 1, elevationM: 0 }, { floorNo: 2, elevationM: 4.2 }, { floorNo: 3 }],
|
|
50
|
+
doors: [{ name: "Lobby", floorNo: 1, start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }],
|
|
51
|
+
lifts: [{ name: "Lift A", x: 3, y: 3, floors: [1, 2, 3] }],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it("unauthenticated is 401", async () => {
|
|
55
|
+
const res = await h.api(`/robots/building-maps?robot=${id.demo1}`);
|
|
56
|
+
assert.equal(res.status, 401);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("the estate cannot be sent in the body", async () => {
|
|
60
|
+
const res = await h.api("/robots/building-maps", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
sid: managerSid,
|
|
63
|
+
body: { ...body, robotId: String(id.demo1), siteId: String(id.siteA) },
|
|
64
|
+
});
|
|
65
|
+
assert.equal(res.status, 400);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a caller at another estate cannot create a map on this robot", async () => {
|
|
69
|
+
const res = await h.api("/robots/building-maps", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
sid: outsiderSid,
|
|
72
|
+
body: { ...body, robotId: String(id.demo1) },
|
|
73
|
+
});
|
|
74
|
+
assert.equal(res.status, 404);
|
|
75
|
+
assert.equal(await h.db.collection("robot-building-maps").countDocuments(), 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("the estate's own manager saves a map; the estate is the robot's; audited", async () => {
|
|
79
|
+
const res = await h.api("/robots/building-maps", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
sid: managerSid,
|
|
82
|
+
body: { ...body, robotId: String(id.demo1) },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
85
|
+
mapId = res.body.id;
|
|
86
|
+
const stored = await h.db.collection("robot-building-maps").findOne({ _id: new ObjectId(mapId) });
|
|
87
|
+
assert.equal(String(stored.siteId), String(id.siteB));
|
|
88
|
+
assert.equal(String(stored.orgId), String(id.orgB));
|
|
89
|
+
assert.equal(stored.version, 1);
|
|
90
|
+
assert.ok(await findAudit(h, "robot.map-saved"), "audit row written");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("list is paginated and scoped; another estate gets 404 by id", async () => {
|
|
94
|
+
const list = await h.api(`/robots/building-maps?robot=${id.demo1}&page=1&limit=5`, { sid: managerSid });
|
|
95
|
+
assert.equal(list.status, 200);
|
|
96
|
+
assert.equal(list.body.items.length, 1);
|
|
97
|
+
assert.equal(list.body.items[0].liftCount, 1);
|
|
98
|
+
|
|
99
|
+
assert.equal((await h.api(`/robots/building-maps?robot=${id.demo1}`, { sid: outsiderSid })).status, 404);
|
|
100
|
+
assert.equal((await h.api(`/robots/building-maps/${mapId}`, { sid: outsiderSid })).status, 404);
|
|
101
|
+
const patch = await h.api(`/robots/building-maps/${mapId}`, {
|
|
102
|
+
method: "PATCH",
|
|
103
|
+
sid: outsiderSid,
|
|
104
|
+
body: { ...body, version: 1 },
|
|
105
|
+
});
|
|
106
|
+
assert.equal(patch.status, 404);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a save against an old version is refused with 409; the current one succeeds", async () => {
|
|
110
|
+
const ok = await h.api(`/robots/building-maps/${mapId}`, {
|
|
111
|
+
method: "PATCH",
|
|
112
|
+
sid: managerSid,
|
|
113
|
+
body: { ...body, name: "Tower B (edited)", version: 1 },
|
|
114
|
+
});
|
|
115
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
116
|
+
assert.equal(ok.body.version, 2);
|
|
117
|
+
|
|
118
|
+
const stale = await h.api(`/robots/building-maps/${mapId}`, {
|
|
119
|
+
method: "PATCH",
|
|
120
|
+
sid: managerSid,
|
|
121
|
+
body: { ...body, version: 1 },
|
|
122
|
+
});
|
|
123
|
+
assert.equal(stale.status, 409);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ask = (robot, over = {}) =>
|
|
127
|
+
h.api(`/robots/building-maps/${mapId}/lifts/${encodeURIComponent("Lift A")}/sessions`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
sid: managerSid,
|
|
130
|
+
body: { robotId: String(robot), fromFloor: 1, toFloor: 3, ...over },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a non-demo robot cannot ride the simulated lift", async () => {
|
|
134
|
+
const res = await ask(id.ounitech);
|
|
135
|
+
assert.equal(res.status, 400);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a floor the lift does not serve is refused", async () => {
|
|
139
|
+
assert.equal((await ask(id.demo1, { toFloor: 7 })).status, 400);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("first robot holds the lift, second queues, a robot cannot ask twice", async () => {
|
|
143
|
+
const first = await ask(id.demo1);
|
|
144
|
+
assert.equal(first.status, 201, JSON.stringify(first.body));
|
|
145
|
+
assert.equal(first.body.session.status, "active");
|
|
146
|
+
|
|
147
|
+
const second = await ask(id.demo2);
|
|
148
|
+
assert.equal(second.status, 201);
|
|
149
|
+
assert.equal(second.body.session.status, "queued");
|
|
150
|
+
|
|
151
|
+
assert.equal((await ask(id.demo1)).status, 409);
|
|
152
|
+
|
|
153
|
+
const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: managerSid });
|
|
154
|
+
assert.equal(queue.status, 200);
|
|
155
|
+
const lift = queue.body.items[0];
|
|
156
|
+
assert.equal(lift.simulated, true);
|
|
157
|
+
assert.equal(lift.active.robotName, "Demo 1");
|
|
158
|
+
assert.equal(lift.queue.length, 1);
|
|
159
|
+
assert.equal(lift.state.sessionId, String(lift.active._id));
|
|
160
|
+
|
|
161
|
+
const mine = await h.api(`/robots/${id.demo2}/lift-session`, { sid: managerSid });
|
|
162
|
+
assert.equal(mine.body.session.position, 1);
|
|
163
|
+
|
|
164
|
+
assert.equal((await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: outsiderSid })).status, 404);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("releasing hands the lift to the next robot; release is audited", async () => {
|
|
168
|
+
const holder = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
|
|
169
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: outsiderSid })).status, 404);
|
|
170
|
+
|
|
171
|
+
const rel = await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: managerSid });
|
|
172
|
+
assert.equal(rel.status, 200);
|
|
173
|
+
const now = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
|
|
174
|
+
assert.equal(String(now.robotId), String(id.demo2));
|
|
175
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/release`, { method: "POST", sid: managerSid })).status, 409);
|
|
176
|
+
assert.ok(await findAudit(h, "robot.lift-released"));
|
|
177
|
+
assert.ok(await findAudit(h, "robot.lift-requested"));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("a holder that stops refreshing loses the lift when its lease runs out", async () => {
|
|
181
|
+
const holder = await h.db.collection("robot-lift-sessions").findOne({ status: "active" });
|
|
182
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/refresh`, { method: "POST", sid: managerSid })).status, 200);
|
|
183
|
+
|
|
184
|
+
await ask(id.demo1); // queues behind demo2
|
|
185
|
+
await h.db.collection("robot-lift-sessions").updateOne(
|
|
186
|
+
{ _id: holder._id },
|
|
187
|
+
{ $set: { expiresAt: new Date(Date.now() - 1000) } },
|
|
188
|
+
);
|
|
189
|
+
const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: managerSid });
|
|
190
|
+
assert.equal(queue.body.items[0].active.robotName, "Demo 1");
|
|
191
|
+
const expired = await h.db.collection("robot-lift-sessions").findOne({ _id: holder._id });
|
|
192
|
+
assert.equal(expired.status, "expired");
|
|
193
|
+
assert.equal((await h.api(`/robots/lift-sessions/${holder._id}/refresh`, { method: "POST", sid: managerSid })).status, 409);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("racing promotions on many instances never produce two holders", async () => {
|
|
197
|
+
const repo = h.core.useRobotLiftSessionRepo();
|
|
198
|
+
const coll = h.db.collection("robot-lift-sessions");
|
|
199
|
+
await coll.updateMany({ open: true }, { $set: { status: "cancelled", open: false } });
|
|
200
|
+
const key = `race:${new ObjectId()}`;
|
|
201
|
+
const t = Date.now();
|
|
202
|
+
await coll.insertMany(
|
|
203
|
+
[0, 1, 2, 3, 4].map((i) => ({
|
|
204
|
+
liftKey: key,
|
|
205
|
+
liftName: "race",
|
|
206
|
+
mapId: new ObjectId(),
|
|
207
|
+
robotId: new ObjectId(),
|
|
208
|
+
status: "queued",
|
|
209
|
+
open: true,
|
|
210
|
+
requestedAt: new Date(t + i),
|
|
211
|
+
expiresAt: new Date(t + 600000),
|
|
212
|
+
})),
|
|
213
|
+
);
|
|
214
|
+
await Promise.all(Array.from({ length: 20 }, () => repo.advance(key)));
|
|
215
|
+
assert.equal(await coll.countDocuments({ liftKey: key, status: "active" }), 1);
|
|
216
|
+
const active = await coll.findOne({ liftKey: key, status: "active" });
|
|
217
|
+
assert.equal(active.requestedAt.getTime(), t, "the oldest request wins");
|
|
218
|
+
|
|
219
|
+
// And the database itself refuses a second holder, whatever the code does.
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
coll.updateOne({ liftKey: key, status: "queued" }, { $set: { status: "active" } }),
|
|
222
|
+
(e) => e.code === 11000,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("removing the map closes its lift queue and the map 404s", async () => {
|
|
227
|
+
await ask(id.demo2);
|
|
228
|
+
const del = await h.api(`/robots/building-maps/${mapId}`, { method: "DELETE", sid: managerSid });
|
|
229
|
+
assert.equal(del.status, 200);
|
|
230
|
+
assert.equal(await h.db.collection("robot-lift-sessions").countDocuments({ mapId: new ObjectId(mapId), open: true }), 0);
|
|
231
|
+
assert.equal((await h.api(`/robots/building-maps/${mapId}`, { sid: managerSid })).status, 404);
|
|
232
|
+
assert.ok(await findAudit(h, "robot.map-removed"));
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
async function findAudit(h, action) {
|
|
237
|
+
const names = (await h.db.listCollections().toArray()).map((c) => c.name);
|
|
238
|
+
for (const name of names.filter((n) => /audit/i.test(n))) {
|
|
239
|
+
const row = await h.db.collection(name).findOne({ action });
|
|
240
|
+
if (row) return row;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function seed(h) {
|
|
246
|
+
const now = new Date().toISOString();
|
|
247
|
+
const orgA = new ObjectId();
|
|
248
|
+
const orgB = new ObjectId();
|
|
249
|
+
const siteA = new ObjectId();
|
|
250
|
+
const siteB = new ObjectId();
|
|
251
|
+
const roleA = new ObjectId();
|
|
252
|
+
const roleB = new ObjectId();
|
|
253
|
+
|
|
254
|
+
await h.db.collection("organizations").insertMany([
|
|
255
|
+
{ _id: orgA, name: "ML Org A", email: "mla@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
256
|
+
{ _id: orgB, name: "ML Org B", email: "mlb@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
257
|
+
]);
|
|
258
|
+
await h.db.collection("sites").insertMany([
|
|
259
|
+
{ _id: siteA, name: "ML Site A", org: orgA, orgId: orgA, status: "active" },
|
|
260
|
+
{ _id: siteB, name: "ML Site B", org: orgB, orgId: orgB, status: "active" },
|
|
261
|
+
]);
|
|
262
|
+
await h.db.collection("roles").insertMany([
|
|
263
|
+
{ _id: roleA, name: "Manager A", org: orgA, permissions: ["*"], status: "active" },
|
|
264
|
+
{ _id: roleB, name: "Manager B", org: orgB, permissions: ["*"], status: "active" },
|
|
265
|
+
]);
|
|
266
|
+
const emails = [OUTSIDER, MANAGER];
|
|
267
|
+
const users = await h.db.collection("users").insertMany(
|
|
268
|
+
emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
269
|
+
);
|
|
270
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
271
|
+
await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
|
|
272
|
+
const [outsider, manager] = [0, 1].map((i) => users.insertedIds[i]);
|
|
273
|
+
await h.db.collection("members").insertMany([
|
|
274
|
+
{ user: outsider, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
|
|
275
|
+
{ user: manager, org: orgB, type: "organization", role: roleB, status: "active" },
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
const robot = (name, brand) => ({
|
|
279
|
+
name,
|
|
280
|
+
brand,
|
|
281
|
+
type: "cleaning",
|
|
282
|
+
appKey: "e2e-fake-key",
|
|
283
|
+
appToken: "e2e-fake-token",
|
|
284
|
+
createdBy: manager,
|
|
285
|
+
createdByName: "ml-manager",
|
|
286
|
+
metadata: { orgId: orgB, siteId: siteB, administratorId: "" },
|
|
287
|
+
status: "",
|
|
288
|
+
deletedAt: "",
|
|
289
|
+
createdAt: new Date(),
|
|
290
|
+
});
|
|
291
|
+
const r = await h.db.collection("robots").insertMany([
|
|
292
|
+
robot("Demo 1", "demo"),
|
|
293
|
+
robot("Demo 2", "demo"),
|
|
294
|
+
robot("Real one", "ounitech"),
|
|
295
|
+
]);
|
|
296
|
+
return { orgA, orgB, siteA, siteB, demo1: r.insertedIds[0], demo2: r.insertedIds[1], ounitech: r.insertedIds[2] };
|
|
297
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// End-to-end proof of the Seven365 console's all-robots view (owner, 2026-09-23:
|
|
2
|
+
// "Seven365 Super Admin can see ALL the robots deployed to ALL its clients'
|
|
3
|
+
// sites"), and that Super Admin holds every robot right at ANY client's site.
|
|
4
|
+
//
|
|
5
|
+
// Who may list: the super-admin MEMBERSHIP (a `members` row of type "admin"
|
|
6
|
+
// whose role is a live role of type "admin") with the cross-client grant -
|
|
7
|
+
// never an email, never a role NAME. Case 3 is an ordinary organisation role
|
|
8
|
+
// merely NAMED "Super Admin", and it is refused.
|
|
9
|
+
//
|
|
10
|
+
// Everything runs against the throwaway harness (in-process MongoDB, loopback
|
|
11
|
+
// Redis and mail sink). Robots are the DEMO brand, which reaches no network, so
|
|
12
|
+
// no vendor cloud is contacted. Credential values are obvious fakes and are only
|
|
13
|
+
// asserted ABSENT.
|
|
14
|
+
//
|
|
15
|
+
// Run with: yarn test:e2e
|
|
16
|
+
|
|
17
|
+
import { after, before, describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "Robot-Passw0rd!";
|
|
24
|
+
const STAFF = "net-staff@e2e.example.com"; // Seven365 super admin (seeded shape: permissions [])
|
|
25
|
+
const STAR = "net-star@e2e.example.com"; // Seven365 staff, role holds "*"
|
|
26
|
+
const PARTIAL = "net-partial@e2e.example.com"; // Seven365 staff ticked for promo codes only
|
|
27
|
+
const MANAGER = "net-manager@e2e.example.com"; // client A's manager, role holds "*"
|
|
28
|
+
const NAMED = "net-named@e2e.example.com"; // an org role merely NAMED "Super Admin"
|
|
29
|
+
|
|
30
|
+
describe("robot network view - Seven365 super admin only", { concurrency: 1 }, () => {
|
|
31
|
+
let h;
|
|
32
|
+
const id = {};
|
|
33
|
+
const sid = {};
|
|
34
|
+
|
|
35
|
+
before(async () => {
|
|
36
|
+
h = await startHarness();
|
|
37
|
+
Object.assign(id, await seed(h));
|
|
38
|
+
for (const [k, email] of Object.entries({ STAFF, STAR, PARTIAL, MANAGER, NAMED })) {
|
|
39
|
+
sid[k] = await h.login(email, PASSWORD);
|
|
40
|
+
}
|
|
41
|
+
}, { timeout: 300000 });
|
|
42
|
+
|
|
43
|
+
after(async () => {
|
|
44
|
+
if (h) await h.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const list = (qs = "", who = "STAFF") => h.api(`/robots/network${qs}`, { sid: sid[who] });
|
|
48
|
+
|
|
49
|
+
it("1. no session is a 401 on both routes", async () => {
|
|
50
|
+
assert.equal((await h.api("/robots/network")).status, 401);
|
|
51
|
+
assert.equal((await h.api("/robots/network/summary")).status, 401);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("2. a client's own manager (role '*') is refused - it is not a platform membership", async () => {
|
|
55
|
+
assert.equal((await list("", "MANAGER")).status, 401);
|
|
56
|
+
assert.equal((await h.api("/robots/network/summary", { sid: sid.MANAGER })).status, 401);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("3. an organisation role merely NAMED 'Super Admin' is refused", async () => {
|
|
60
|
+
assert.equal((await list("", "NAMED")).status, 401);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("4. staff without the cross-client grant are refused", async () => {
|
|
64
|
+
assert.equal((await list("", "PARTIAL")).status, 401);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("5. super admin sees every client's robots, named, without credentials or removed ones", async () => {
|
|
68
|
+
const res = await list("?limit=50");
|
|
69
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
70
|
+
const names = res.body.items.map((r) => r.name).sort();
|
|
71
|
+
assert.deepEqual(names, ["A demo 1", "A legacy", "B guard", "B scrubber"]);
|
|
72
|
+
assert.equal(res.body.total, 4);
|
|
73
|
+
assert.equal(names.includes("A removed"), false);
|
|
74
|
+
|
|
75
|
+
const legacy = res.body.items.find((r) => r.name === "A legacy");
|
|
76
|
+
assert.equal(legacy.client.name, "Net Client A");
|
|
77
|
+
assert.equal(legacy.site.name, "Net Site A");
|
|
78
|
+
// written before `type` / `brand` existed: shown as the model's defaults
|
|
79
|
+
assert.equal(legacy.type, "cleaning");
|
|
80
|
+
assert.equal(legacy.brand, "ounitech");
|
|
81
|
+
assert.equal(res.body.items.find((r) => r.name === "A demo 1").demo, true);
|
|
82
|
+
assert.equal(legacy.demo, false);
|
|
83
|
+
|
|
84
|
+
const raw = JSON.stringify(res.body);
|
|
85
|
+
assert.equal(raw.includes("appKey") || raw.includes("appToken") || raw.includes("e2e-fake"), false);
|
|
86
|
+
// default order: client, then site
|
|
87
|
+
assert.deepEqual(res.body.items.map((r) => r.client.name), [
|
|
88
|
+
"Net Client A", "Net Client A", "Net Client B", "Net Client B",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("6. a staff role holding '*' sees the same", async () => {
|
|
93
|
+
const res = await list("?limit=50", "STAR");
|
|
94
|
+
assert.equal(res.status, 200);
|
|
95
|
+
assert.equal(res.body.total, 4);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("7. filters: client, site, class (incl. legacy rows), brand, search by client name", async () => {
|
|
99
|
+
const byOrg = await list(`?org=${id.orgB}`);
|
|
100
|
+
assert.deepEqual(byOrg.body.items.map((r) => r.name).sort(), ["B guard", "B scrubber"]);
|
|
101
|
+
|
|
102
|
+
const bySite = await list(`?site=${id.siteA}`);
|
|
103
|
+
assert.equal(bySite.body.total, 2);
|
|
104
|
+
|
|
105
|
+
const cleaning = await list("?type=cleaning&limit=50");
|
|
106
|
+
assert.deepEqual(cleaning.body.items.map((r) => r.name).sort(), ["A demo 1", "A legacy", "B scrubber"]);
|
|
107
|
+
const security = await list("?type=security");
|
|
108
|
+
assert.deepEqual(security.body.items.map((r) => r.name), ["B guard"]);
|
|
109
|
+
|
|
110
|
+
const ounitech = await list("?brand=ounitech&limit=50");
|
|
111
|
+
assert.deepEqual(ounitech.body.items.map((r) => r.name).sort(), ["A legacy", "B guard", "B scrubber"]);
|
|
112
|
+
|
|
113
|
+
const search = await list("?search=client%20b");
|
|
114
|
+
assert.equal(search.body.total, 2);
|
|
115
|
+
const pattern = await list("?search=.*");
|
|
116
|
+
assert.equal(pattern.body.total, 0, "a search is text, not a regex");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("8. sort and pagination", async () => {
|
|
120
|
+
const page1 = await list("?sort=name&order=desc&limit=3&page=1");
|
|
121
|
+
assert.deepEqual(page1.body.items.map((r) => r.name), ["B scrubber", "B guard", "A legacy"]);
|
|
122
|
+
assert.equal(page1.body.pages, 2);
|
|
123
|
+
const page2 = await list("?sort=name&order=desc&limit=3&page=2");
|
|
124
|
+
assert.deepEqual(page2.body.items.map((r) => r.name), ["A demo 1"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("9. bad input is a 400 for staff", async () => {
|
|
128
|
+
assert.equal((await list("?type=drone")).status, 400);
|
|
129
|
+
assert.equal((await list("?org=nope")).status, 400);
|
|
130
|
+
assert.equal((await list("?limit=1000")).status, 400);
|
|
131
|
+
assert.equal((await list("?sort=appKey")).status, 400);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("10. the summary counts stored data and claims no fault count", async () => {
|
|
135
|
+
const res = await h.api("/robots/network/summary", { sid: sid.STAFF });
|
|
136
|
+
assert.equal(res.status, 200);
|
|
137
|
+
assert.equal(res.body.total, 4);
|
|
138
|
+
assert.equal(res.body.sites, 2);
|
|
139
|
+
assert.deepEqual(res.body.byType, [{ key: "cleaning", count: 3 }, { key: "security", count: 1 }]);
|
|
140
|
+
assert.equal(res.body.demo, 1);
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
res.body.byClient.map((c) => [c.name, c.count, c.sites]),
|
|
143
|
+
[["Net Client A", 2, 1], ["Net Client B", 2, 1]],
|
|
144
|
+
);
|
|
145
|
+
assert.deepEqual(res.body.faults, { live: false, count: null });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- Super Admin holds every robot right at ANY client's site ----------
|
|
149
|
+
|
|
150
|
+
it("11. super admin reads live state and commands a robot at a client it is not a member of", async () => {
|
|
151
|
+
const state = await h.api(`/robots/${id.demoA}/state`, { sid: sid.STAFF });
|
|
152
|
+
assert.equal(state.status, 200, JSON.stringify(state.body));
|
|
153
|
+
const cmd = await h.api(`/robots/${id.demoA}/command`, {
|
|
154
|
+
method: "POST", sid: sid.STAFF, body: { kind: "return" },
|
|
155
|
+
});
|
|
156
|
+
assert.equal(cmd.status, 202, JSON.stringify(cmd.body));
|
|
157
|
+
// and a client's manager still cannot reach another client's robot
|
|
158
|
+
assert.equal((await h.api(`/robots/${id.scrubberB}/state`, { sid: sid.MANAGER })).status, 404);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("12. super admin saves a building map and asks for the lift at another client's site", async () => {
|
|
162
|
+
const map = await h.api("/robots/building-maps", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
sid: sid.STAFF,
|
|
165
|
+
body: {
|
|
166
|
+
robotId: String(id.demoA),
|
|
167
|
+
name: "Net tower",
|
|
168
|
+
levels: [{ floorNo: 1, elevationM: 0 }, { floorNo: 2 }],
|
|
169
|
+
lifts: [{ name: "Lift A", x: 1, y: 1, floors: [1, 2] }],
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
assert.equal(map.status, 201, JSON.stringify(map.body));
|
|
173
|
+
const mapId = map.body.id ?? map.body._id ?? map.body.map?._id;
|
|
174
|
+
assert.ok(mapId, JSON.stringify(map.body));
|
|
175
|
+
|
|
176
|
+
const lift = await h.api(`/robots/building-maps/${mapId}/lifts/Lift%20A/sessions`, {
|
|
177
|
+
method: "POST", sid: sid.STAFF, body: { robotId: String(id.demoA), fromFloor: 1, toFloor: 2 },
|
|
178
|
+
});
|
|
179
|
+
assert.equal(lift.status, 201, JSON.stringify(lift.body));
|
|
180
|
+
const queue = await h.api(`/robots/building-maps/${mapId}/lifts`, { sid: sid.STAFF });
|
|
181
|
+
assert.equal(queue.status, 200);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("13. super admin edits and removes a robot at another client's site; it leaves the network list", async () => {
|
|
185
|
+
const edit = await h.api(`/robots/${id.scrubberB}`, {
|
|
186
|
+
method: "PATCH", sid: sid.STAFF, body: { name: "B scrubber (renamed)" },
|
|
187
|
+
});
|
|
188
|
+
assert.equal(edit.status, 200);
|
|
189
|
+
const remove = await h.api(`/robots/${id.scrubberB}`, { method: "DELETE", sid: sid.STAFF });
|
|
190
|
+
assert.equal(remove.status, 200);
|
|
191
|
+
const res = await list("?limit=50");
|
|
192
|
+
assert.equal(res.body.items.some((r) => r.name.startsWith("B scrubber")), false);
|
|
193
|
+
assert.equal(res.body.total, 3);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
async function seed(h) {
|
|
198
|
+
const now = new Date().toISOString();
|
|
199
|
+
const orgA = new ObjectId();
|
|
200
|
+
const orgB = new ObjectId();
|
|
201
|
+
const siteA = new ObjectId();
|
|
202
|
+
const siteB = new ObjectId();
|
|
203
|
+
const staffRole = new ObjectId();
|
|
204
|
+
const starRole = new ObjectId();
|
|
205
|
+
const partialRole = new ObjectId();
|
|
206
|
+
const managerRole = new ObjectId();
|
|
207
|
+
const namedRole = new ObjectId();
|
|
208
|
+
|
|
209
|
+
await h.db.collection("organizations").insertMany([
|
|
210
|
+
{ _id: orgA, name: "Net Client A", email: "neta@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
211
|
+
{ _id: orgB, name: "Net Client B", email: "netb@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
212
|
+
]);
|
|
213
|
+
await h.db.collection("sites").insertMany([
|
|
214
|
+
{ _id: siteA, name: "Net Site A", org: orgA, orgId: orgA, status: "active" },
|
|
215
|
+
{ _id: siteB, name: "Net Site B", org: orgB, orgId: orgB, status: "active" },
|
|
216
|
+
]);
|
|
217
|
+
await h.db.collection("roles").insertMany([
|
|
218
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
219
|
+
{ _id: starRole, name: "Ops", type: "admin", permissions: ["*"], status: "active" },
|
|
220
|
+
{ _id: partialRole, name: "Promo desk", type: "admin", permissions: ["promo-codes:see-all-promo-codes"], status: "active" },
|
|
221
|
+
{ _id: managerRole, name: "Manager A", org: orgA, type: "organization", permissions: ["*"], status: "active" },
|
|
222
|
+
{ _id: namedRole, name: "Super Admin", org: orgA, type: "organization", permissions: ["*"], status: "active" },
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const emails = [STAFF, STAR, PARTIAL, MANAGER, NAMED];
|
|
226
|
+
const users = await h.db.collection("users").insertMany(
|
|
227
|
+
emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
228
|
+
);
|
|
229
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
230
|
+
await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
|
|
231
|
+
const [staff, star, partial, manager, named] = [0, 1, 2, 3, 4].map((i) => users.insertedIds[i]);
|
|
232
|
+
|
|
233
|
+
await h.db.collection("members").insertMany([
|
|
234
|
+
{ user: staff, type: "admin", role: staffRole, status: "active" },
|
|
235
|
+
{ user: star, type: "admin", role: starRole, status: "active" },
|
|
236
|
+
{ user: partial, type: "admin", role: partialRole, status: "active" },
|
|
237
|
+
{ user: manager, org: orgA, type: "organization", role: managerRole, status: "active" },
|
|
238
|
+
{ user: named, org: orgA, type: "organization", role: namedRole, status: "active" },
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
const robot = (name, org, site, extra = {}) => ({
|
|
242
|
+
name,
|
|
243
|
+
appKey: "e2e-fake-key",
|
|
244
|
+
appToken: "e2e-fake-token",
|
|
245
|
+
createdBy: manager,
|
|
246
|
+
createdByName: "net-manager",
|
|
247
|
+
robotSn: "",
|
|
248
|
+
metadata: { orgId: org, siteId: site, administratorId: "" },
|
|
249
|
+
status: "",
|
|
250
|
+
createdAt: new Date(),
|
|
251
|
+
deletedAt: "",
|
|
252
|
+
...extra,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const res = await h.db.collection("robots").insertMany([
|
|
256
|
+
// written before `type` / `brand` existed, and before `deletedAt` was always set
|
|
257
|
+
(() => { const r = robot("A legacy", orgA, siteA); delete r.deletedAt; return r; })(),
|
|
258
|
+
robot("A demo 1", orgA, siteA, { type: "cleaning", brand: "demo", robotSn: "DEMO-0001" }),
|
|
259
|
+
robot("A removed", orgA, siteA, { type: "cleaning", brand: "ounitech", deletedAt: new Date() }),
|
|
260
|
+
robot("B scrubber", orgB, siteB, { type: "cleaning", brand: "ounitech" }),
|
|
261
|
+
robot("B guard", orgB, siteB, { type: "security", brand: "ounitech" }),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
orgA, orgB, siteA, siteB,
|
|
266
|
+
demoA: res.insertedIds[1],
|
|
267
|
+
scrubberB: res.insertedIds[3],
|
|
268
|
+
};
|
|
269
|
+
}
|