@7365admin1/core 3.130.0 → 3.131.1

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.130.0",
4
+ "version": "3.131.1",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -0,0 +1,137 @@
1
+ // POST /api/members/direct enrolling SOMEBODY ELSE (follow-up of Site access,
2
+ // 2026-09-29): it needed only org membership, so a member confined to ONE site
3
+ // could create another person's membership anywhere in the organisation with
4
+ // any of its roles. Now the Site access editor rule applies (core
5
+ // `requireMemberGrantor`): the owner, cross-client staff, or an org-WIDE member
6
+ // who may assign roles - never a site-confined member - and never beyond what
7
+ // the organisation can give at a site it serves, nor an owner role the caller
8
+ // does not hold, nor the owner's own access.
9
+ //
10
+ // Nothing outside the harness is touched. Run with: yarn test:e2e
11
+
12
+ import { after, before, describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { ObjectId } from "mongodb";
15
+
16
+ import { startHarness } from "./harness.mjs";
17
+
18
+ const PASSWORD = "DirectGrant-Passw0rd!";
19
+ const SEC = "security_agency";
20
+ const U = {
21
+ owner: "mdg-owner@e2e.example.com",
22
+ orgAdmin: "mdg-org-admin@e2e.example.com",
23
+ assigner: "mdg-assigner@e2e.example.com",
24
+ viewer: "mdg-viewer@e2e.example.com",
25
+ siteAdmin: "mdg-site-admin@e2e.example.com",
26
+ support: "mdg-support@e2e.example.com",
27
+ n1: "mdg-n1@e2e.example.com",
28
+ n2: "mdg-n2@e2e.example.com",
29
+ n3: "mdg-n3@e2e.example.com",
30
+ n4: "mdg-n4@e2e.example.com",
31
+ };
32
+
33
+ describe("members/direct: enrolling somebody else", { concurrency: 1 }, () => {
34
+ let h;
35
+ const sid = {};
36
+ const id = {};
37
+
38
+ before(async () => {
39
+ h = await startHarness();
40
+ Object.assign(id, await seed(h));
41
+ for (const k of ["owner", "orgAdmin", "assigner", "viewer", "siteAdmin"]) sid[k] = await h.login(U[k], PASSWORD);
42
+ }, { timeout: 300000 });
43
+
44
+ after(async () => {
45
+ if (h) await h.stop();
46
+ });
47
+
48
+ const refused = (res) => [401, 403].includes(res.status);
49
+ const refused404 = (res) => [400, 401, 403, 404].includes(res.status);
50
+ const direct = (who, target, roleId, siteId = "", app = SEC) =>
51
+ h.api("/members/direct", {
52
+ method: "POST",
53
+ sid: sid[who],
54
+ body: { userId: String(id.users[target]), orgId: String(id.org), roleId: String(roleId), app, siteId: siteId ? String(siteId) : "", siteName: "" },
55
+ });
56
+ const rows = (target) => h.db.collection("members").countDocuments({ user: id.users[target] });
57
+
58
+ it("1. ESCALATION: a site-confined member cannot enrol anybody - at their own site, another site, or org-wide", async () => {
59
+ assert.ok(refused(await direct("siteAdmin", "n1", id.X, id.A)));
60
+ assert.ok(refused(await direct("siteAdmin", "n1", id.Y, id.B)));
61
+ assert.ok(refused(await direct("siteAdmin", "n1", id.orgRole, "", "organization")));
62
+ assert.equal(await rows("n1"), 0);
63
+ });
64
+
65
+ it("2. an org-wide member without Assign Role permission cannot enrol anybody", async () => {
66
+ assert.ok(refused(await direct("viewer", "n1", id.X, id.A)));
67
+ assert.equal(await rows("n1"), 0);
68
+ });
69
+
70
+ it("3. the owner enrols a colleague at a site, and the Seven365 support account org-wide with the owner role", async () => {
71
+ const res = await direct("owner", "n1", id.X, id.A);
72
+ assert.equal(res.status, 201, JSON.stringify(res.body));
73
+ const sup = await direct("owner", "support", id.ownerRole, "", "organization");
74
+ assert.equal(sup.status, 201, JSON.stringify(sup.body));
75
+ });
76
+
77
+ it("4. an org-wide admin enrols a colleague with that site's role", async () => {
78
+ const res = await direct("orgAdmin", "n2", id.Y, id.B);
79
+ assert.equal(res.status, 201, JSON.stringify(res.body));
80
+ });
81
+
82
+ it("5. the admin cannot go beyond the organisation: another site's role, another client's role or site", async () => {
83
+ assert.equal((await direct("orgAdmin", "n3", id.X, id.B)).status, 400, "role of site A at site B");
84
+ assert.equal((await direct("orgAdmin", "n3", id.foreignRole, id.A)).status, 400, "another client's role");
85
+ { const r = await direct("orgAdmin", "n3", id.foreignRole, id.foreign); assert.ok([400, 401, 403, 404].includes(r.status), `another client site: ${r.status} ${JSON.stringify(r.body)}`); }
86
+ assert.ok(refused404(await direct("owner", "n3", id.X, id.foreign)), "not even the owner at another client's site");
87
+ assert.equal(await rows("n3"), 0);
88
+ });
89
+
90
+ it("6. an owner role only from somebody who holds one; the owner's access never from an admin", async () => {
91
+ assert.ok(refused(await direct("assigner", "n4", id.ownerRole, "", "organization")));
92
+ assert.equal(await rows("n4"), 0);
93
+ assert.equal((await direct("assigner", "n4", id.Y, id.B)).status, 201, "a site role is fine");
94
+ assert.ok(refused(await direct("orgAdmin", "owner", id.Z, id.C)));
95
+ });
96
+ });
97
+
98
+ async function seed(h) {
99
+ const now = new Date().toISOString();
100
+ const org = new ObjectId(), other = new ObjectId();
101
+ const A = new ObjectId(), B = new ObjectId(), C = new ObjectId(), foreign = new ObjectId();
102
+ const X = new ObjectId(), Y = new ObjectId(), Z = new ObjectId(), orgRole = new ObjectId(), ownerRole = new ObjectId();
103
+ const assignRole = new ObjectId(), viewRole = new ObjectId(), foreignRole = new ObjectId();
104
+
105
+ await h.db.collection("organizations").insertMany([
106
+ { _id: org, name: "Aigle MDG", email: "aigle-mdg@e2e.example.com", type: "org", nature: SEC, status: "active", createdAt: now },
107
+ { _id: other, name: "Other MDG", email: "other-mdg@e2e.example.com", type: "org", nature: SEC, status: "active", createdAt: now },
108
+ ]);
109
+ const site = (_id, name, o) => ({ _id, name, org: o, orgId: o, status: "active", createdAt: now });
110
+ await h.db.collection("sites").insertMany([site(A, "One Pearl MDG", org), site(B, "The Loft MDG", org), site(C, "Third MDG", org), site(foreign, "Foreign MDG", other)]);
111
+ await h.db.collection("roles").insertMany([
112
+ { _id: X, name: "X at A", type: SEC, org, site: A, permissions: ["*"], status: "active" },
113
+ { _id: Y, name: "Y at B", type: SEC, org, site: B, permissions: ["dashboard:view"], status: "active" },
114
+ { _id: Z, name: "Z at C", type: SEC, org, site: C, permissions: ["dashboard:view"], status: "active" },
115
+ { _id: orgRole, name: "Org Admin", type: "organization", org, site: "", permissions: ["*"], status: "active" },
116
+ { _id: ownerRole, name: "owner", type: "organization", org, site: "", permissions: ["*"], default: true, status: "active" },
117
+ { _id: assignRole, name: "Assigner", type: "organization", org, site: "", permissions: ["members:assign-member-role"], status: "active" },
118
+ { _id: viewRole, name: "Viewer", type: "organization", org, site: "", permissions: ["dashboard:view"], status: "active" },
119
+ { _id: foreignRole, name: "Foreign", type: SEC, org: other, site: foreign, permissions: ["*"], status: "active" },
120
+ ]);
121
+ const users = {};
122
+ for (const [k, email] of Object.entries(U)) {
123
+ const { insertedId } = await h.db.collection("users").insertOne({ email, name: k, status: "active", createdAt: now });
124
+ await h.db.collection("users").updateOne({ _id: insertedId }, { $set: { password: await h.hashPassword(PASSWORD) } });
125
+ users[k] = insertedId;
126
+ }
127
+ await h.db.collection("organizations").updateOne({ _id: org }, { $set: { createdBy: users.owner } });
128
+ const m = (k, role, type = "organization", siteId = "") => ({ user: users[k], email: U[k], org, type, role, siteId, status: "active", deletedAt: "", createdAt: now });
129
+ await h.db.collection("members").insertMany([
130
+ m("owner", ownerRole),
131
+ m("orgAdmin", orgRole),
132
+ m("assigner", assignRole),
133
+ m("viewer", viewRole),
134
+ m("siteAdmin", X, SEC, A),
135
+ ]);
136
+ return { org, other, A, B, C, foreign, X, Y, Z, orgRole, ownerRole, foreignRole, users };
137
+ }
@@ -0,0 +1,137 @@
1
+ // Organization app -> Members lists EVERY person of the organisation (owner
2
+ // report 2026-09-29, Aigle / Kanesan): after the owner removed Kanesan's
3
+ // organisation access and limited him to sites, he vanished from the list, so
4
+ // Site access could not be opened again. `GET /members?type=organization&
5
+ // people=1` now returns the organisation-wide rows as before PLUS one row per
6
+ // site-limited person carrying `limitedTo` (site + role). Residents, Seven365
7
+ // staff, other organisations and removed rows never appear. Without `people`
8
+ // the answer is unchanged.
9
+ //
10
+ // Nothing outside the harness is touched. Run with: yarn test:e2e
11
+
12
+ import { after, before, describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { ObjectId } from "mongodb";
15
+
16
+ import { startHarness } from "./harness.mjs";
17
+
18
+ const PASSWORD = "OrgPeople-Passw0rd!";
19
+ const SEC = "security_agency";
20
+ const U = {
21
+ owner: "mop-owner@e2e.example.com",
22
+ admin: "mop-admin@e2e.example.com",
23
+ kanesan: "mop-kanesan@e2e.example.com",
24
+ both: "mop-both@e2e.example.com",
25
+ siteGuard: "mop-guard@e2e.example.com",
26
+ pinnedOrg: "mop-pinned-org@e2e.example.com",
27
+ resident: "mop-resident@e2e.example.com",
28
+ gone: "mop-gone@e2e.example.com",
29
+ outsider: "mop-outsider@e2e.example.com",
30
+ };
31
+
32
+ describe("org Members: every person, site-limited ones included", { concurrency: 1 }, () => {
33
+ let h;
34
+ const sid = {};
35
+ const id = {};
36
+
37
+ before(async () => {
38
+ h = await startHarness();
39
+ Object.assign(id, await seed(h));
40
+ for (const k of ["owner", "siteGuard"]) sid[k] = await h.login(U[k], PASSWORD);
41
+ }, { timeout: 300000 });
42
+
43
+ after(async () => {
44
+ if (h) await h.stop();
45
+ });
46
+
47
+ const list = (who, extra = "&people=1", status = "active") =>
48
+ h.api(`/members?org=${id.org}&type=organization&status=${status}&page=1&limit=50${extra}`, { sid: sid[who] });
49
+ const byEmail = (res) => Object.fromEntries(res.body.items.map((i) => [i.email, i]));
50
+
51
+ it("1. the owner sees every person once: org-wide people with no limit, Kanesan limited to A and B", async () => {
52
+ const res = await list("owner");
53
+ assert.equal(res.status, 200, JSON.stringify(res.body));
54
+ const rows = byEmail(res);
55
+ assert.deepEqual(Object.keys(rows).sort(), [U.admin, U.both, U.kanesan, U.owner, U.siteGuard, U.pinnedOrg].sort());
56
+ assert.equal(res.body.items.length, 6, "one row per person");
57
+ assert.equal(res.body.total, 6);
58
+ assert.deepEqual(rows[U.pinnedOrg].limitedTo.map((l) => l.siteName), ["The Loft MOP"], "an organisation row pinned to a site is limited");
59
+ assert.deepEqual(rows[U.owner].limitedTo, []);
60
+ assert.deepEqual(rows[U.both].limitedTo, [], "an org-wide person with site rows too is 'All sites'");
61
+ assert.deepEqual(
62
+ rows[U.kanesan].limitedTo.map((l) => `${l.siteName}:${l.roleName}`),
63
+ ["One Pearl MOP:X at A", "The Loft MOP:Y at B"],
64
+ );
65
+ });
66
+
67
+ it("2. residents, removed rows, suspended-only and other organisations never appear", async () => {
68
+ const rows = byEmail(await list("owner"));
69
+ for (const e of [U.resident, U.gone, U.outsider]) assert.ok(!rows[e], e);
70
+ });
71
+
72
+ it("3. without people=1 the list is unchanged (organisation rows only)", async () => {
73
+ const rows = byEmail(await list("owner", ""));
74
+ assert.deepEqual(Object.keys(rows).sort(), [U.admin, U.both, U.owner, U.pinnedOrg].sort());
75
+ });
76
+
77
+ it("4. paging and totals count people, not rows", async () => {
78
+ const res = await h.api(`/members?org=${id.org}&type=organization&status=active&page=2&limit=2&people=1`, { sid: sid.owner });
79
+ assert.equal(res.status, 200);
80
+ assert.equal(res.body.items.length, 2);
81
+ assert.equal(res.body.total, 6);
82
+ });
83
+
84
+ it("5. a site-confined member sees only people limited to their own site", async () => {
85
+ const res = await list("siteGuard");
86
+ assert.equal(res.status, 200, JSON.stringify(res.body));
87
+ const rows = byEmail(res);
88
+ assert.deepEqual(Object.keys(rows).sort(), [U.kanesan, U.siteGuard].sort());
89
+ assert.deepEqual(rows[U.kanesan].limitedTo.map((l) => l.siteName), ["One Pearl MOP"], "never another site's name");
90
+ });
91
+ });
92
+
93
+ async function seed(h) {
94
+ const now = new Date().toISOString();
95
+ const org = new ObjectId(), other = new ObjectId();
96
+ const A = new ObjectId(), B = new ObjectId();
97
+ const X = new ObjectId(), Y = new ObjectId(), orgRole = new ObjectId(), ownerRole = new ObjectId();
98
+
99
+ await h.db.collection("organizations").insertMany([
100
+ { _id: org, name: "Aigle MOP", email: "aigle-mop@e2e.example.com", type: "org", nature: SEC, status: "active", createdAt: now },
101
+ { _id: other, name: "Other MOP", email: "other-mop@e2e.example.com", type: "org", nature: SEC, status: "active", createdAt: now },
102
+ ]);
103
+ await h.db.collection("sites").insertMany([
104
+ { _id: A, name: "One Pearl MOP", org, orgId: org, status: "active", createdAt: now },
105
+ { _id: B, name: "The Loft MOP", org, orgId: org, status: "active", createdAt: now },
106
+ ]);
107
+ await h.db.collection("roles").insertMany([
108
+ { _id: X, name: "X at A", type: SEC, org, site: A, permissions: ["dashboard:view"], status: "active" },
109
+ { _id: Y, name: "Y at B", type: SEC, org, site: B, permissions: ["dashboard:view"], status: "active" },
110
+ { _id: orgRole, name: "Org Admin", type: "organization", org, site: "", permissions: ["*"], status: "active" },
111
+ { _id: ownerRole, name: "owner", type: "organization", org, site: "", permissions: ["*"], status: "active" },
112
+ ]);
113
+ const users = {};
114
+ for (const [k, email] of Object.entries(U)) {
115
+ const { insertedId } = await h.db.collection("users").insertOne({ email, name: k, status: "active", createdAt: now });
116
+ await h.db.collection("users").updateOne({ _id: insertedId }, { $set: { password: await h.hashPassword(PASSWORD) } });
117
+ users[k] = insertedId;
118
+ }
119
+ await h.db.collection("organizations").updateOne({ _id: org }, { $set: { createdBy: users.owner } });
120
+ const m = (k, role, type = "organization", siteId = "", status = "active", o = org) =>
121
+ ({ _id: new ObjectId(), user: users[k], email: U[k], name: k, org: o, type, role, siteId, siteName: "", status, deletedAt: "", createdAt: now });
122
+ await h.db.collection("members").insertMany([
123
+ m("owner", ownerRole),
124
+ m("admin", orgRole),
125
+ m("kanesan", orgRole, "organization", "", "deleted"),
126
+ m("kanesan", X, SEC, A),
127
+ m("kanesan", Y, SEC, B),
128
+ m("both", orgRole),
129
+ m("both", X, SEC, A),
130
+ m("siteGuard", X, SEC, A),
131
+ m("pinnedOrg", orgRole, "organization", B),
132
+ m("resident", X, "resident", A),
133
+ m("gone", X, SEC, A, "deleted"),
134
+ m("outsider", X, SEC, A, "active", other),
135
+ ]);
136
+ return { org, A, B, users };
137
+ }
@@ -253,7 +253,7 @@ describe("privilege escalation to platform staff, end to end", { concurrency: 1
253
253
  assert.equal(res.status, 201, JSON.stringify(res.body));
254
254
  });
255
255
 
256
- record("10. the onboarding flows that pass an ordinary app still work", async () => {
256
+ record("10. CLOSED 2026-09-29: a resident cannot enrol somebody ELSE (the live sign-in flows enrol the signed-in user themselves)", async () => {
257
257
  // web-app-main pages/sign-in.vue:347 and web-app-org
258
258
  // onboarding/getting-started/index.vue:357 both post
259
259
  // `app: "organization"` for an org the caller is not yet a member of. That
@@ -276,10 +276,12 @@ describe("privilege escalation to platform staff, end to end", { concurrency: 1
276
276
  },
277
277
  });
278
278
 
279
- assert.equal(res.status, 201, JSON.stringify(res.body));
279
+ // The Site access editor rule (`requireMemberGrantor`): a resident is none of
280
+ // owner, cross-client staff or org-wide admin.
281
+ assert.equal(res.status, 401, JSON.stringify(res.body));
280
282
  });
281
283
 
282
- record("11. and so does a service-provider membership", async () => {
284
+ record("11. CLOSED 2026-09-29: nor with a service-provider membership", async () => {
283
285
  const fresh = await h.db.collection("users").insertOne({
284
286
  email: "guard@e2e.example.com",
285
287
  name: "guard",
@@ -298,10 +300,12 @@ describe("privilege escalation to platform staff, end to end", { concurrency: 1
298
300
  },
299
301
  });
300
302
 
301
- assert.equal(res.status, 201, JSON.stringify(res.body));
303
+ // The Site access editor rule (`requireMemberGrantor`): a resident is none of
304
+ // owner, cross-client staff or org-wide admin.
305
+ assert.equal(res.status, 401, JSON.stringify(res.body));
302
306
  });
303
307
 
304
- record("12. TRANSITIONAL: an org id sent as `app` still works", async () => {
308
+ record("12. CLOSED 2026-09-29: nor with an org id sent as `app`", async () => {
305
309
  // `web-app-org pages/org/[organization]/invitations/invite.vue:122` and
306
310
  // `web-app-hygiene pages/[org]/[site]/invitations/invite.vue:135` send the
307
311
  // ORGANISATION ID as `app`. It is a defect in those two screens, but it is
@@ -325,7 +329,9 @@ describe("privilege escalation to platform staff, end to end", { concurrency: 1
325
329
  },
326
330
  });
327
331
 
328
- assert.equal(res.status, 201, JSON.stringify(res.body));
332
+ // The Site access editor rule (`requireMemberGrantor`): a resident is none of
333
+ // owner, cross-client staff or org-wide admin.
334
+ assert.equal(res.status, 401, JSON.stringify(res.body));
329
335
  });
330
336
  });
331
337
 
@@ -6,7 +6,7 @@ import { after, before, test } from "node:test";
6
6
 
7
7
  import { ROBOT_CLOUD_PATHS, OUNITECH_TIMESTAMP_SKEW_S } from "./.build/utils/robot-cloud.util.mjs";
8
8
  import { RobotCloudError, useRobotCloudService } from "./.build/services/robot-cloud.service.mjs";
9
- import { createOunitechAdapter, ounitechRobotFromHomeRow, ounitechFault } from "./.build/services/ounitech.adapter.mjs";
9
+ import { createOunitechAdapter, ounitechRobotFromHomeRow, ounitechFault, ounitechJobWords, OUNITECH_FRESH_MS } from "./.build/services/ounitech.adapter.mjs";
10
10
 
11
11
  /* --------------------------------------------------------------------------
12
12
  * Ounitech "Robot Cloud API Document (2025.10.22)", received 2026-09-28.
@@ -53,8 +53,45 @@ const ROWS = [
53
53
  robotId: 427, robotSn: "ERROR1", modelCode: "Q3W", sceneId: 438, floorNo: 2,
54
54
  robotState: { x: 0, y: 0, theta: 0, statusFlag: 7, properties: [prop("battery-percent", 55)] },
55
55
  },
56
+ // Prod 2026-09-30: the list said offline while the live state (below) answered Idle.
57
+ {
58
+ robotId: 430, robotSn: "LISTOFFLINE", modelCode: "Q3", sceneId: 438, floorNo: 3,
59
+ robotState: { x: 0, y: 0, theta: 0, statusFlag: 99, properties: [prop("battery-percent", 0)] },
60
+ },
56
61
  ];
57
62
 
63
+ /**
64
+ * The shape of get-robot-state-v2.0's reply for robot 424 (Q3, 4G) on prod,
65
+ * 2026-09-30 ~02:04Z, while our UI showed it offline. Readings as the cloud
66
+ * sends them: every value a string, labels Chinese. No credential in it.
67
+ */
68
+ const RECORDED_UPDATE_ON = Date.parse("2026-09-30T02:04:08Z");
69
+ const s = (key, value, type = "string", unit = "") => ({ key, name: "", type, value: String(value), unit });
70
+ const recordedState = (robotId, updateOn = RECORDED_UPDATE_ON) => ({
71
+ robotId,
72
+ updateOn,
73
+ x: 0.4213, y: -1.0875, theta: 1.5708,
74
+ statusFlag: 1,
75
+ statusName: "待机",
76
+ subStatusFlag: 0,
77
+ subStatusName: "",
78
+ rtFaultList: [],
79
+ properties: [
80
+ s("battery-percent", 100, "int", "%"),
81
+ s("left-battery-percent", 100, "int", "%"),
82
+ s("right-battery-percent", 100, "int", "%"),
83
+ s("clean-water", 62, "int", "%"),
84
+ s("dirty-water", 8, "int", "%"),
85
+ s("job", "已结束"),
86
+ s("status", "待机"),
87
+ s("top-cover", 1, "int"),
88
+ s("clean-water-tank", 1, "int"),
89
+ s("waste-water-tank", 1, "int"),
90
+ s("collision-left", 0, "int"),
91
+ s("collision-right", 0, "int"),
92
+ ],
93
+ });
94
+
58
95
  let server;
59
96
  let base;
60
97
  const seen = [];
@@ -95,6 +132,8 @@ before(async () => {
95
132
  case ROBOT_CLOUD_PATHS.robotList:
96
133
  return res.end(JSON.stringify({ robotList: [] })); // chapter 1: owner-bound only
97
134
  case ROBOT_CLOUD_PATHS.robotState:
135
+ if (body.robotId === 430 || body.robotId === 424) return ok({ robotState: recordedState(body.robotId, Date.now() - 8000) });
136
+ if (body.robotId === 425) return ok({ robotState: { robotId: 425, updateOn: Date.now() - 3_600_000, statusFlag: 99, properties: [] } });
98
137
  return ok({ robotState: { robotId: body.robotId, statusFlag: 7, rtFaultList: [{ errCode: "621", errMsg: "Right wheel driver reset" }] } });
99
138
  case ROBOT_CLOUD_PATHS.floorMaps:
100
139
  return ok({
@@ -131,9 +170,11 @@ const adapter = (opts = {}) => createOunitechAdapter({ appid: APPID, secret: SEC
131
170
  test("the robot list is the chapter-6 list, signed for its own path, credentials in the body", async () => {
132
171
  seen.length = 0;
133
172
  const robots = await adapter().listRobots();
134
- assert.deepEqual(robots.map((r) => r.robotSn), ["F8FCA9A1B1C4C", "OFFLINE1", "WORKING1", "ERROR1"]);
135
- assert.equal(seen.length, 1);
173
+ assert.deepEqual(robots.map((r) => r.robotSn), ["F8FCA9A1B1C4C", "OFFLINE1", "WORKING1", "ERROR1", "LISTOFFLINE"]);
136
174
  assert.equal(seen[0].path, "/portal/home/get-robot-list");
175
+ // Live state only for the rows the list can't vouch for (99, 7) - never for a known-good status.
176
+ assert.deepEqual(seen.slice(1).map((x) => x.body.robotId).sort(), [425, 427, 430]);
177
+ assert.ok(seen.slice(1).every((x) => x.path === ROBOT_CLOUD_PATHS.robotState));
137
178
  assert.deepEqual(Object.keys(seen[0].body).sort(), ["appid", "sign", "timestamp"]);
138
179
  assert.equal(JSON.stringify(seen).includes(SECRET), false);
139
180
  });
@@ -219,7 +260,7 @@ test("refusals are the host's own plain words, with the credential hint", async
219
260
 
220
261
  test("within the 300 s window is accepted", async () => {
221
262
  const early = adapter({ now: () => new Date(Date.now() - (OUNITECH_TIMESTAMP_SKEW_S - 30) * 1000) });
222
- assert.equal((await early.listRobots()).length, 4);
263
+ assert.equal((await early.listRobots()).length, 5);
223
264
  });
224
265
 
225
266
  test("diagnose: every read answered, chapter 1 shows why the old list was empty, never a command", async () => {
@@ -240,3 +281,61 @@ test("row mapping holds on its own (pure)", () => {
240
281
  assert.equal(r.floorKnown, false);
241
282
  assert.equal(ounitechRobotFromHomeRow({ robotSn: "Y" }).mode, "UNAVAILABLE");
242
283
  });
284
+
285
+ test("prod 2026-09-30: list says offline, live state answers Idle -> Online / Idle / battery 100 (fleet, detail, Diagnose)", async () => {
286
+ const a = adapter();
287
+ const listed = (await a.listRobots()).find((r) => r.robotSn === "LISTOFFLINE");
288
+ const detail = await a.getRobot("LISTOFFLINE");
289
+ for (const r of [listed, detail]) {
290
+ assert.equal(r.online, true);
291
+ assert.equal(r.mode, "IDLE");
292
+ assert.equal(r.statusName, "Idle");
293
+ assert.equal(r.batteryPercent, 100);
294
+ assert.deepEqual(r.position, { x: 0.4213, y: -1.0875, theta: 1.5708 });
295
+ assert.equal(r.fault, undefined, "empty rtFaultList is no fault");
296
+ }
297
+ const steps = await a.diagnose("LISTOFFLINE");
298
+ const live = steps.find((x) => x.step.startsWith("live state")).data;
299
+ assert.equal(live.online, true);
300
+ assert.equal(live.status, "Idle");
301
+ assert.equal(live.job, "job finished");
302
+ assert.equal(live.battery, 100);
303
+ assert.equal(live.cleanWater, 62);
304
+ assert.equal(live.dirtyWater, 8);
305
+ assert.equal(live.fault, null);
306
+ });
307
+
308
+ test("live state mapping, recorded reply (pure)", () => {
309
+ const row = { robotId: 424, robotSn: "F8FCA9A1B1C4C", modelCode: "Q3", floorNo: 3, robotState: { statusFlag: 99, properties: [prop("battery-percent", 0)] } };
310
+ const now = RECORDED_UPDATE_ON + 20_000;
311
+ const r = ounitechRobotFromHomeRow(row, recordedState(424), now);
312
+ assert.equal(r.online, true);
313
+ assert.equal(r.statusName, "Idle");
314
+ assert.equal(r.batteryPercent, 100);
315
+ assert.equal(ounitechJobWords(recordedState(424)), "job finished");
316
+
317
+ // statusFlag as a numeric string
318
+ assert.equal(ounitechRobotFromHomeRow({ robotSn: "S", robotState: { statusFlag: "1" } }, null, now).mode, "IDLE");
319
+ // no statusFlag: the Chinese status reading supplies it
320
+ const noFlag = { ...recordedState(424), statusFlag: undefined };
321
+ assert.equal(ounitechRobotFromHomeRow({ robotSn: "C" }, noFlag, now).statusName, "Idle");
322
+ const charging = { properties: [s("status", "充电中")] };
323
+ assert.equal(ounitechRobotFromHomeRow({ robotSn: "C" }, charging, now).statusName, "Charging");
324
+ // no status at all: a fresh updateOn is online, a stale one is not
325
+ const bare = (age) => ({ updateOn: now - age, properties: [s("battery-percent", 70, "int", "%")] });
326
+ const fresh = ounitechRobotFromHomeRow({ robotSn: "F" }, bare(60_000), now);
327
+ assert.equal(fresh.online, true);
328
+ assert.equal(fresh.mode, "UNAVAILABLE", "never dispatchable without a known status");
329
+ assert.equal(fresh.batteryPercent, 70);
330
+ const stale = ounitechRobotFromHomeRow({ robotSn: "F" }, bare(OUNITECH_FRESH_MS + 1000), now);
331
+ assert.equal(stale.online, false);
332
+ assert.equal(stale.statusName, "Unknown");
333
+ assert.equal(stale.batteryPercent, null);
334
+ // the live state saying 99 beats a fresh-looking list row
335
+ const off = ounitechRobotFromHomeRow({ robotSn: "O", robotState: { statusFlag: 1 } }, { statusFlag: 99, updateOn: now }, now);
336
+ assert.equal(off.online, false);
337
+ assert.equal(off.statusName, "Offline");
338
+ // a fault listed live is named even without status 7
339
+ const faulty = ounitechRobotFromHomeRow({ robotSn: "E" }, { statusFlag: 1, rtFaultList: [{ errCode: "302", errMsg: "Clean water tank empty" }] }, now);
340
+ assert.deepEqual(faulty.fault, { code: "302", message: "Clean water tank empty" });
341
+ });
@@ -85,12 +85,13 @@ test("the org a NEW role is filed under is checked, on both mounts", () => {
85
85
  );
86
86
  });
87
87
 
88
- test("enrolling yourself needs reach; enrolling anybody else needs membership", () => {
88
+ test("enrolling yourself needs reach; enrolling anybody else needs the Site access editor rule", () => {
89
89
  const body = fn(memberController, "createMemberDirect");
90
90
  assert.match(
91
91
  body,
92
- // ...and yourself only with a role you may give yourself (requireSelfEnrolRole).
93
- /if \(userId === callerId\(req\)\)[\s\S]{0,120}await requireOrgReach\(req, orgId\)[\s\S]{0,120}await requireSelfEnrolRole\(req, orgId, roleId\)[\s\S]{0,40}else[\s\S]{0,80}await requireOrgAccess\(req, orgId\)/,
92
+ // ...and yourself only with a role you may give yourself (requireSelfEnrolRole);
93
+ // somebody else only as owner / staff / org-wide admin (2026-09-29).
94
+ /if \(userId === callerId\(req\)\)[\s\S]{0,120}await requireOrgReach\(req, orgId\)[\s\S]{0,120}await requireSelfEnrolRole\(req, orgId, roleId\)[\s\S]{0,40}else[\s\S]{0,400}await requireMemberGrantor\(req, \{ orgId, targetUser: userId, roleId, siteId \}\)/,
94
95
  );
95
96
  });
96
97