@7365admin1/core 3.69.0 → 3.71.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/.github/workflows/publish.yml +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +29 -3
- package/dist/index.js +903 -6666
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1212 -7006
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/test/e2e/harness.mjs +6 -0
- package/test/e2e/member-list-site-scope.e2e.test.mjs +111 -0
- package/test/e2e/site-modules-client-admin.e2e.test.mjs +139 -0
- package/test/member-list-site-scope.test.mjs +30 -0
- package/test/modules-endpoint.test.mjs +24 -9
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.71.0",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"exceljs": "^4.4.0",
|
|
44
44
|
"express": "^4.21.2",
|
|
45
45
|
"fast-levenshtein": "^3.0.0",
|
|
46
|
+
"handlebars": "^4.7.8",
|
|
46
47
|
"iservice365-expo-notifications": "^1.0.0",
|
|
47
48
|
"joi": "^17.13.3",
|
|
48
49
|
"jsonwebtoken": "^9.0.2",
|
|
@@ -62,5 +63,6 @@
|
|
|
62
63
|
"xlsx": "^0.18.5",
|
|
63
64
|
"xml2js": "^0.6.2",
|
|
64
65
|
"zod": "^3.24.2"
|
|
65
|
-
}
|
|
66
|
+
},
|
|
67
|
+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
|
66
68
|
}
|
package/test/e2e/harness.mjs
CHANGED
|
@@ -319,6 +319,7 @@ function buildApp(nsu, core) {
|
|
|
319
319
|
updateRoleById,
|
|
320
320
|
updateSiteById,
|
|
321
321
|
completeOnboarding,
|
|
322
|
+
getModulesByUserIdType,
|
|
322
323
|
} = core.useMemberController();
|
|
323
324
|
|
|
324
325
|
members.post("/verification/:id", requireAuth, createMember);
|
|
@@ -332,6 +333,7 @@ function buildApp(nsu, core) {
|
|
|
332
333
|
members.put("/site", requireAuth, updateSiteById);
|
|
333
334
|
members.put("/:id/onboarding/complete", requireAuth, completeOnboarding);
|
|
334
335
|
members.get("/user/:id/app/:type/org", requireAuth, getByUserIdTypeOrg);
|
|
336
|
+
members.get("/user/:id/app/:type/modules", requireAuth, getModulesByUserIdType);
|
|
335
337
|
}
|
|
336
338
|
|
|
337
339
|
// mirrors iservice365-API-core/src/routes/organization-v2.route.ts
|
|
@@ -596,6 +598,8 @@ function buildApp(nsu, core) {
|
|
|
596
598
|
updateGuardPostsById,
|
|
597
599
|
siteInformation,
|
|
598
600
|
getAllSitesForResidentCreation,
|
|
601
|
+
updateSiteModules,
|
|
602
|
+
previewSiteModules,
|
|
599
603
|
} = core.useSiteController();
|
|
600
604
|
|
|
601
605
|
sites.post("/", requireAuth, createSite);
|
|
@@ -604,6 +608,8 @@ function buildApp(nsu, core) {
|
|
|
604
608
|
sites.put("/:id/block", requireAuth, updateSiteBlock);
|
|
605
609
|
sites.delete("/:id", requireAuth, deleteSite);
|
|
606
610
|
sites.patch("/id/:id", requireAuth, updateById);
|
|
611
|
+
sites.patch("/:id/modules", requireAuth, updateSiteModules);
|
|
612
|
+
sites.post("/:id/modules/preview", requireAuth, previewSiteModules);
|
|
607
613
|
sites.patch("/guard-post/id/:id", requireAuth, updateGuardPostsById);
|
|
608
614
|
sites.patch("/information/id/:id", requireAuth, siteInformation);
|
|
609
615
|
sites.get("/site/resident", getAllSitesForResidentCreation); // no requireAuth — as shipped
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// GET /api/members?org=: belonging to the organisation is not belonging to every
|
|
2
|
+
// site in it. A guard pinned to site A sees site A's members only; a request for
|
|
3
|
+
// site B is refused; the organisation's own admin and Seven365 staff see all;
|
|
4
|
+
// a resident sees none.
|
|
5
|
+
//
|
|
6
|
+
// In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
|
|
7
|
+
//
|
|
8
|
+
// Run with: yarn test:e2e
|
|
9
|
+
|
|
10
|
+
import { after, before, describe, it } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { ObjectId } from "mongodb";
|
|
13
|
+
|
|
14
|
+
import { startHarness } from "./harness.mjs";
|
|
15
|
+
|
|
16
|
+
const PASSWORD = "MemberListScope-Passw0rd!";
|
|
17
|
+
const ADMIN = "admin-mls@e2e.example.com";
|
|
18
|
+
const GUARD_A = "guard-a-mls@e2e.example.com";
|
|
19
|
+
const GUARD_B = "guard-b-mls@e2e.example.com";
|
|
20
|
+
const RESIDENT = "resident-mls@e2e.example.com";
|
|
21
|
+
const STAFF = "staff-mls@e2e.example.com";
|
|
22
|
+
|
|
23
|
+
describe("Member lists follow the caller's sites", { concurrency: 1 }, () => {
|
|
24
|
+
let h;
|
|
25
|
+
const id = {};
|
|
26
|
+
const sid = {};
|
|
27
|
+
|
|
28
|
+
before(async () => {
|
|
29
|
+
h = await startHarness();
|
|
30
|
+
Object.assign(id, await seed(h));
|
|
31
|
+
for (const email of [ADMIN, GUARD_A, GUARD_B, RESIDENT, STAFF]) sid[email] = await h.login(email, PASSWORD);
|
|
32
|
+
}, { timeout: 300000 });
|
|
33
|
+
|
|
34
|
+
after(async () => {
|
|
35
|
+
if (h) await h.stop();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const list = (who, extra = "") =>
|
|
39
|
+
h.api(`/members?org=${id.org}&type=security_agency&status=active&limit=50${extra}`, { sid: sid[who] });
|
|
40
|
+
const emails = (res) => (res.body?.items ?? []).map((row) => row.email).sort();
|
|
41
|
+
|
|
42
|
+
it("a guard pinned to site A lists only site A's members", async () => {
|
|
43
|
+
const res = await list(GUARD_A);
|
|
44
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
45
|
+
assert.deepEqual(emails(res), [GUARD_A]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("a guard asking for a site they are not pinned to is refused", async () => {
|
|
49
|
+
const res = await list(GUARD_A, `&siteId=${id.siteB}`);
|
|
50
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
51
|
+
const own = await list(GUARD_A, `&siteId=${id.siteA}`);
|
|
52
|
+
assert.equal(own.status, 200, JSON.stringify(own.body));
|
|
53
|
+
assert.deepEqual(emails(own), [GUARD_A]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("CONTROL: the organisation's admin and Seven365 staff list every site", async () => {
|
|
57
|
+
for (const who of [ADMIN, STAFF]) {
|
|
58
|
+
const res = await list(who);
|
|
59
|
+
assert.equal(res.status, 200, `${who}: ${JSON.stringify(res.body)}`);
|
|
60
|
+
assert.deepEqual(emails(res), [GUARD_A, GUARD_B].sort(), who);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("a resident of the organisation lists no staff", async () => {
|
|
65
|
+
const res = await list(RESIDENT);
|
|
66
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
67
|
+
assert.deepEqual(emails(res), []);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function seed(h) {
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
const org = new ObjectId();
|
|
74
|
+
const siteA = new ObjectId();
|
|
75
|
+
const siteB = new ObjectId();
|
|
76
|
+
const adminRole = new ObjectId();
|
|
77
|
+
const guardRoleA = new ObjectId();
|
|
78
|
+
const guardRoleB = new ObjectId();
|
|
79
|
+
const residentRole = new ObjectId();
|
|
80
|
+
const staffRole = new ObjectId();
|
|
81
|
+
|
|
82
|
+
await h.db.collection("organizations").insertOne({ _id: org, name: "MLS Security", email: "org-mls@e2e.example.com", type: "org", nature: "security_agency", status: "active", createdAt: now });
|
|
83
|
+
await h.db.collection("sites").insertMany([
|
|
84
|
+
{ _id: siteA, name: "MLS A", orgId: org, status: "active", createdAt: now },
|
|
85
|
+
{ _id: siteB, name: "MLS B", orgId: org, status: "active", createdAt: now },
|
|
86
|
+
]);
|
|
87
|
+
await h.db.collection("roles").insertMany([
|
|
88
|
+
{ _id: adminRole, name: "owner", type: "organization", org, permissions: ["*"], status: "active" },
|
|
89
|
+
{ _id: guardRoleA, name: "Guard A", type: "security_agency", org, site: siteA, permissions: ["*"], status: "active" },
|
|
90
|
+
{ _id: guardRoleB, name: "Guard B", type: "security_agency", org, site: siteB, permissions: ["*"], status: "active" },
|
|
91
|
+
{ _id: residentRole, name: "Resident", type: "resident", org, site: siteA, permissions: ["*"], status: "active" },
|
|
92
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const users = await h.db.collection("users").insertMany(
|
|
96
|
+
[ADMIN, GUARD_A, GUARD_B, RESIDENT, STAFF].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
97
|
+
);
|
|
98
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
99
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
100
|
+
const [admin, guardA, guardB, resident, staff] = Object.values(users.insertedIds);
|
|
101
|
+
const member = (user, role, type, email, siteId = "") => ({ user, org, role, type, siteId, siteName: "", name: email, email, status: "active", deletedAt: "", createdAt: now });
|
|
102
|
+
await h.db.collection("members").insertMany([
|
|
103
|
+
member(admin, adminRole, "organization", ADMIN),
|
|
104
|
+
member(guardA, guardRoleA, "security_agency", GUARD_A, siteA),
|
|
105
|
+
member(guardB, guardRoleB, "security_agency", GUARD_B, siteB),
|
|
106
|
+
member(resident, residentRole, "resident", RESIDENT, siteA),
|
|
107
|
+
{ user: staff, type: "admin", role: staffRole, status: "active", name: STAFF, email: STAFF, createdAt: now },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
return { org, siteA, siteB };
|
|
111
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Per-site modules, set by the client's own owner/admin (owner, 2026-09-14):
|
|
2
|
+
// Seven365 gives the organisation its modules; the organisation's owner/admin
|
|
3
|
+
// decides which of those each site gets; the site's staff get only that.
|
|
4
|
+
//
|
|
5
|
+
// - `PATCH /api/sites/:id/modules` and its preview need Seven365 staff or the
|
|
6
|
+
// caller's ORGANISATION membership with `*` or `sites:manage-site-modules`.
|
|
7
|
+
// Reaching the site (a guard pinned to it) is not enough.
|
|
8
|
+
// - A key the organisation was not given is refused.
|
|
9
|
+
// - An acknowledged save by the client admin is ENFORCED: the site's staff are
|
|
10
|
+
// denied what the site list leaves out.
|
|
11
|
+
//
|
|
12
|
+
// In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
|
|
13
|
+
//
|
|
14
|
+
// Run with: yarn test:e2e
|
|
15
|
+
|
|
16
|
+
import { after, before, describe, it } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "SiteModules-Passw0rd!";
|
|
24
|
+
const OWNER = "owner-smca@e2e.example.com";
|
|
25
|
+
const ADMIN_WITH = "admin-with-smca@e2e.example.com";
|
|
26
|
+
const ADMIN_WITHOUT = "admin-without-smca@e2e.example.com";
|
|
27
|
+
const GUARD = "guard-smca@e2e.example.com";
|
|
28
|
+
|
|
29
|
+
const ORG_MODULES = ["visitor-mgmt", "work_orders", "members", "roles", "invitations", "sites"];
|
|
30
|
+
const SITE_LIST = ["visitor-mgmt"];
|
|
31
|
+
|
|
32
|
+
const listHash = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex");
|
|
33
|
+
|
|
34
|
+
describe("A client's owner/admin sets which modules each site gets", { concurrency: 1 }, () => {
|
|
35
|
+
let h;
|
|
36
|
+
const id = {};
|
|
37
|
+
const sid = {};
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
h = await startHarness();
|
|
41
|
+
Object.assign(id, await seed(h));
|
|
42
|
+
for (const email of [OWNER, ADMIN_WITH, ADMIN_WITHOUT, GUARD]) sid[email] = await h.login(email, PASSWORD);
|
|
43
|
+
}, { timeout: 300000 });
|
|
44
|
+
|
|
45
|
+
after(async () => {
|
|
46
|
+
if (h) await h.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const save = (who, body) =>
|
|
50
|
+
h.api(`/sites/${id.site}/modules`, { method: "PATCH", sid: sid[who], body });
|
|
51
|
+
const preview = (who, body) =>
|
|
52
|
+
h.api(`/sites/${id.site}/modules/preview`, { method: "POST", sid: sid[who], body });
|
|
53
|
+
const stored = async () => (await h.db.collection("sites").findOne({ _id: id.site }))?.metadata?.modules;
|
|
54
|
+
|
|
55
|
+
it("a guard at the site, or an admin without the permission, is refused and nothing is written", async () => {
|
|
56
|
+
for (const who of [GUARD, ADMIN_WITHOUT]) {
|
|
57
|
+
const res = await save(who, { modules: SITE_LIST, acknowledgeImpact: true });
|
|
58
|
+
assert.equal(res.status, 401, `${who}: ${JSON.stringify(res.body)}`);
|
|
59
|
+
const peek = await preview(who, { modules: SITE_LIST });
|
|
60
|
+
assert.equal(peek.status, 401, `${who} preview: ${JSON.stringify(peek.body)}`);
|
|
61
|
+
}
|
|
62
|
+
assert.equal(await stored(), undefined);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a module the organisation was not given is refused", async () => {
|
|
66
|
+
const res = await save(OWNER, { modules: [...SITE_LIST, "cleaning-schedule"], acknowledgeImpact: true });
|
|
67
|
+
assert.equal(res.status, 400, JSON.stringify(res.body));
|
|
68
|
+
assert.equal(await stored(), undefined);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("the admin holding the permission can preview", async () => {
|
|
72
|
+
const res = await preview(ADMIN_WITH, { modules: SITE_LIST });
|
|
73
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("the owner's narrowing save needs acknowledging, then is enforced for the site's staff", async () => {
|
|
77
|
+
const before = await h.api(`/members/user/${id.guardUser}/app/security_agency/modules?site=${id.site}`, { sid: sid[GUARD] });
|
|
78
|
+
assert.equal(before.status, 200, JSON.stringify(before.body));
|
|
79
|
+
assert.ok(!JSON.stringify(before.body).includes("work_orders"), "CONTROL: nothing denied before the save");
|
|
80
|
+
|
|
81
|
+
const unacknowledged = await save(OWNER, { modules: SITE_LIST });
|
|
82
|
+
assert.equal(unacknowledged.status, 409, JSON.stringify(unacknowledged.body));
|
|
83
|
+
assert.equal(await stored(), undefined, "a refused save writes nothing");
|
|
84
|
+
|
|
85
|
+
const ok = await save(OWNER, { modules: SITE_LIST, acknowledgeImpact: true });
|
|
86
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
87
|
+
assert.deepEqual(await stored(), SITE_LIST);
|
|
88
|
+
|
|
89
|
+
const row = await h.db
|
|
90
|
+
.collection("console-audit")
|
|
91
|
+
.findOne({ action: "site.modules-changed", target: id.site.toString() }, { sort: { createdAt: -1 } });
|
|
92
|
+
assert.equal(row?.after?.gateListHash, listHash(SITE_LIST), "the client admin's save is the enforcement marker");
|
|
93
|
+
|
|
94
|
+
const after = await h.api(`/members/user/${id.guardUser}/app/security_agency/modules?site=${id.site}`, { sid: sid[GUARD] });
|
|
95
|
+
assert.equal(after.status, 200, JSON.stringify(after.body));
|
|
96
|
+
assert.ok(JSON.stringify(after.body).includes("work_orders"), `the guard loses what the site left out: ${JSON.stringify(after.body)}`);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function seed(h) {
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const org = new ObjectId();
|
|
103
|
+
const site = new ObjectId();
|
|
104
|
+
const ownerRole = new ObjectId();
|
|
105
|
+
const withRole = new ObjectId();
|
|
106
|
+
const withoutRole = new ObjectId();
|
|
107
|
+
const guardRole = new ObjectId();
|
|
108
|
+
|
|
109
|
+
await h.db.collection("organizations").insertOne({
|
|
110
|
+
_id: org, name: "SMCA Security", email: "org-smca@e2e.example.com", type: "org", nature: "security_agency",
|
|
111
|
+
status: "active", modules: ORG_MODULES, createdAt: now,
|
|
112
|
+
});
|
|
113
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "SMCA Tower", orgId: org, status: "active", createdAt: now });
|
|
114
|
+
await h.db.collection("roles").insertMany([
|
|
115
|
+
{ _id: ownerRole, name: "owner", type: "organization", org, permissions: ["*"], status: "active" },
|
|
116
|
+
{ _id: withRole, name: "Site modules admin", type: "organization", org, permissions: ["sites:manage-site-modules"], status: "active" },
|
|
117
|
+
{ _id: withoutRole, name: "Sites viewer", type: "organization", org, permissions: ["sites:see-all-sites"], status: "active" },
|
|
118
|
+
{ _id: guardRole, name: "Guard", type: "security_agency", org, site, permissions: ["*"], status: "active" },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const users = await h.db.collection("users").insertMany(
|
|
122
|
+
[OWNER, ADMIN_WITH, ADMIN_WITHOUT, GUARD].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
123
|
+
);
|
|
124
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
125
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
126
|
+
|
|
127
|
+
const [owner, withUser, withoutUser, guard] = Object.values(users.insertedIds);
|
|
128
|
+
const member = (user, role, type, siteId = "") => ({
|
|
129
|
+
user, org, role, type, siteId, siteName: "", name: "", email: "", status: "active", deletedAt: "", createdAt: now,
|
|
130
|
+
});
|
|
131
|
+
await h.db.collection("members").insertMany([
|
|
132
|
+
member(owner, ownerRole, "organization"),
|
|
133
|
+
member(withUser, withRole, "organization"),
|
|
134
|
+
member(withoutUser, withoutRole, "organization"),
|
|
135
|
+
member(guard, guardRole, "security_agency", site),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
return { org, site, guardUser: guard };
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// GET /api/members?org=: a caller pinned to sites lists only those sites' members
|
|
2
|
+
// (owner policy: agency staff see only their assigned site).
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
import { memberListSiteScope } from "./.build/utils/member-lookup.util.mjs";
|
|
7
|
+
|
|
8
|
+
const ORG = "aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
9
|
+
const OTHER = "bbbbbbbbbbbbbbbbbbbbbbbb";
|
|
10
|
+
const A = "111111111111111111111111";
|
|
11
|
+
const B = "222222222222222222222222";
|
|
12
|
+
const m = (org, siteId, type = "security_agency", orgLevelRole = false) => ({ org, siteId, type, orgLevelRole });
|
|
13
|
+
|
|
14
|
+
test("an organisation-wide membership, or an organisation-level role, lists every site", () => {
|
|
15
|
+
assert.equal(memberListSiteScope([m(ORG, "", "organization")], ORG), null);
|
|
16
|
+
assert.equal(memberListSiteScope([m(ORG, A, "security_agency", true)], ORG), null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a caller pinned to sites lists only those sites", () => {
|
|
20
|
+
assert.deepEqual(memberListSiteScope([m(ORG, A), m(ORG, B), m(ORG, A, "cleaning_services")], ORG), [A, B]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("memberships in another organisation do not widen this one", () => {
|
|
24
|
+
assert.deepEqual(memberListSiteScope([m(ORG, A), m(OTHER, "", "organization")], ORG), [A]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a resident-only caller lists nothing; an owner or invitee with no membership keeps today's answer", () => {
|
|
28
|
+
assert.deepEqual(memberListSiteScope([m(ORG, A, "resident")], ORG), []);
|
|
29
|
+
assert.equal(memberListSiteScope([], ORG), null);
|
|
30
|
+
});
|
|
@@ -362,10 +362,13 @@ const RESIDENT = {
|
|
|
362
362
|
memberships: [{ org: ORG, siteId: SITE, orgLevelRole: false, type: "resident" }],
|
|
363
363
|
};
|
|
364
364
|
const STAFF = { ...ESTATE, isSuperAdmin: true, staffPermissions: [], orgIds: [], memberships: [] };
|
|
365
|
+
// The client's owner/admin: the estate member whose organisation row holds the permission.
|
|
366
|
+
const CLIENT_ADMIN = { ...ESTATE, id: "client-admin" };
|
|
365
367
|
|
|
366
368
|
function siteHandlers(d, actor) {
|
|
367
369
|
return siteModulesHandlers({
|
|
368
370
|
...d.shared,
|
|
371
|
+
canManageSiteModules: async (who) => (d.log.push("manage"), who === STAFF || who === CLIENT_ADMIN),
|
|
369
372
|
requireSiteWrite: async () => {
|
|
370
373
|
d.log.push("guard");
|
|
371
374
|
if (!actor) throw refused();
|
|
@@ -438,18 +441,30 @@ test("updateSiteModules: Seven365 staff's narrowing save gets 409 before any wri
|
|
|
438
441
|
assert.equal(d.audit[0].after[HASH].length, 64);
|
|
439
442
|
});
|
|
440
443
|
|
|
441
|
-
test("updateSiteModules:
|
|
444
|
+
test("updateSiteModules: an estate member without the permission, a resident or an engaged provider is refused and nothing is written", async () => {
|
|
442
445
|
for (const [who, actor] of [["estate member", ESTATE], ["resident", RESIDENT], ["provider", VENDOR]]) {
|
|
443
446
|
const d = deps({ stored: undefined });
|
|
444
447
|
const { updateSiteModules } = siteHandlers(d, actor);
|
|
445
448
|
const out = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW, acknowledgeImpact: true } });
|
|
446
|
-
assert.equal(out.error,
|
|
447
|
-
assert.deepEqual(d.writes(), [
|
|
448
|
-
assert.equal(d.audit[0].after[HASH], undefined, `${who}: the list can never be enforced`);
|
|
449
|
+
assert.equal(out.error?.statusCode, 401, who);
|
|
450
|
+
assert.deepEqual(d.writes(), [], `${who}: nothing written`);
|
|
449
451
|
assert.equal(d.calls.staffMembers ?? 0, 0, `${who}: the estate's staff are never read for it`);
|
|
450
452
|
}
|
|
451
453
|
});
|
|
452
454
|
|
|
455
|
+
test("updateSiteModules: the client's owner/admin gets the same 409-then-marker as staff (owner rule 2026-09-14)", async () => {
|
|
456
|
+
const d = deps({ stored: undefined });
|
|
457
|
+
const { updateSiteModules } = siteHandlers(d, CLIENT_ADMIN);
|
|
458
|
+
const out = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
|
|
459
|
+
assert.equal(out.error?.statusCode, 409);
|
|
460
|
+
assert.deepEqual(d.writes(), []);
|
|
461
|
+
|
|
462
|
+
const ok = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW, acknowledgeImpact: true } });
|
|
463
|
+
assert.equal(ok.error, undefined);
|
|
464
|
+
assert.deepEqual(d.writes(), ["write", "audit"]);
|
|
465
|
+
assert.equal(d.audit[0].after[HASH].length, 64, "the client admin's save enforces the list");
|
|
466
|
+
});
|
|
467
|
+
|
|
453
468
|
test("updateSiteModules: re-saving the stored site list unchanged needs no acknowledgement and writes no marker", async () => {
|
|
454
469
|
const d = deps({ stored: NARROW });
|
|
455
470
|
const out = await call(siteHandlers(d, STAFF).updateSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
|
|
@@ -457,20 +472,20 @@ test("updateSiteModules: re-saving the stored site list unchanged needs no ackno
|
|
|
457
472
|
assert.equal(d.audit[0].after[HASH], undefined);
|
|
458
473
|
});
|
|
459
474
|
|
|
460
|
-
test("previewSiteModules:
|
|
461
|
-
for (const [who, actor] of [["provider", VENDOR], ["resident", RESIDENT]]) {
|
|
475
|
+
test("previewSiteModules: anyone who may not manage the site's modules is refused before any member is read", async () => {
|
|
476
|
+
for (const [who, actor] of [["provider", VENDOR], ["resident", RESIDENT], ["estate member without the permission", ESTATE]]) {
|
|
462
477
|
const d = deps({ stored: undefined });
|
|
463
478
|
const out = await call(siteHandlers(d, actor).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
|
|
464
479
|
assert.equal(out.error?.statusCode, 401, who);
|
|
465
480
|
assert.equal(out.json, undefined, `${who}: no staff names or roles leave`);
|
|
466
|
-
assert.deepEqual(d.log, ["guard", "read"], who);
|
|
481
|
+
assert.deepEqual(d.log, ["guard", "read", "manage"], who);
|
|
467
482
|
assert.deepEqual(d.calls, {}, who);
|
|
468
483
|
}
|
|
469
484
|
|
|
470
485
|
// A missing site answers as missing, like the rest of the site routes.
|
|
471
486
|
const gone = deps();
|
|
472
487
|
gone.missing = true;
|
|
473
|
-
const missing = await call(siteHandlers(gone,
|
|
488
|
+
const missing = await call(siteHandlers(gone, CLIENT_ADMIN).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
|
|
474
489
|
assert.equal(missing.error?.statusCode, 404);
|
|
475
490
|
|
|
476
491
|
// A caller the site guard refuses reads nothing at all.
|
|
@@ -479,7 +494,7 @@ test("previewSiteModules: an engagement-only provider or a resident is refused b
|
|
|
479
494
|
assert.deepEqual(none.log, ["guard"]);
|
|
480
495
|
|
|
481
496
|
// CONTROL: the estate's own member gets the preview, and nothing is written.
|
|
482
|
-
for (const actor of [
|
|
497
|
+
for (const actor of [CLIENT_ADMIN, STAFF]) {
|
|
483
498
|
const ok = deps({ stored: undefined });
|
|
484
499
|
const answer = await call(siteHandlers(ok, actor).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
|
|
485
500
|
assert.ok(answer.json.impact.memberCount > 0);
|