@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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +28 -11
- package/dist/index.js +317 -143
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +315 -143
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/member-direct-grantor.e2e.test.mjs +137 -0
- package/test/e2e/org-members-people.e2e.test.mjs +137 -0
- package/test/e2e/privilege-escalation.e2e.test.mjs +12 -6
- package/test/ounitech-api-2025-10.test.mjs +103 -4
- package/test/role-member-org-scope.test.mjs +4 -3
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|