@7365admin1/core 3.68.3 → 3.70.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 +28 -2
- package/dist/index.js +922 -6671
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1167 -6947
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/test/e2e/client-invite-modules.e2e.test.mjs +164 -0
- package/test/e2e/harness.mjs +6 -0
- package/test/e2e/site-modules-client-admin.e2e.test.mjs +139 -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.70.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
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Invite Client: Seven365 chooses the new client's modules, and the owner gets
|
|
2
|
+
// the client's OWN owner role (owner, 2026-09-14: "Client Owner Super Admin can
|
|
3
|
+
// only have the full access on what Seven365 Super Admin given to them, same
|
|
4
|
+
// goes to the modules").
|
|
5
|
+
//
|
|
6
|
+
// - `POST /api/auth/invite/user` with no organisation drops the role (it was a
|
|
7
|
+
// shared, org-less template) and, for staff only, stores `modules`.
|
|
8
|
+
// - `POST /api/organizations/onboarding` from that invitation writes the list on
|
|
9
|
+
// the organisation plus the save row that makes the module gate enforce it,
|
|
10
|
+
// and the owner's membership holds the organisation's own seeded owner role.
|
|
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 = "ClientModules-Passw0rd!";
|
|
24
|
+
const STAFF = "sevenadmin-cim@e2e.example.com";
|
|
25
|
+
const OUTSIDER = "outsider-cim@e2e.example.com";
|
|
26
|
+
const CLIENT = "client-cim@e2e.example.com";
|
|
27
|
+
const OTHER_NATURE = "othernature-cim@e2e.example.com";
|
|
28
|
+
|
|
29
|
+
const MODULES = ["visitor-mgmt", "members", "roles", "invitations", "sites"];
|
|
30
|
+
|
|
31
|
+
// `module-gate.util moduleListHash`: sorted keys, one per line, sha256.
|
|
32
|
+
const listHash = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex");
|
|
33
|
+
|
|
34
|
+
describe("Invite Client chooses modules and the owner gets the client's own role", { 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 [STAFF, OUTSIDER, CLIENT, OTHER_NATURE]) sid[email] = await h.login(email, PASSWORD);
|
|
43
|
+
}, { timeout: 300000 });
|
|
44
|
+
|
|
45
|
+
after(async () => {
|
|
46
|
+
if (h) await h.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const invite = (who, body) => h.api("/auth/invite/user", { method: "POST", sid: sid[who], body });
|
|
50
|
+
const inviteOf = (email) =>
|
|
51
|
+
h.db.collection("verifications").findOne({ type: "user-invite", email }, { sort: { createdAt: -1 } });
|
|
52
|
+
|
|
53
|
+
it("a staff invite for a new client stores the modules and no role", async () => {
|
|
54
|
+
const res = await invite(STAFF, {
|
|
55
|
+
email: CLIENT,
|
|
56
|
+
app: ["security_agency"],
|
|
57
|
+
role: id.templateRole.toString(),
|
|
58
|
+
modules: [...MODULES, "visitor-mgmt"],
|
|
59
|
+
});
|
|
60
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
61
|
+
|
|
62
|
+
const row = await inviteOf(CLIENT);
|
|
63
|
+
assert.ok(!row.metadata.role, "the shared template role is not kept");
|
|
64
|
+
assert.deepEqual(row.metadata.modules, MODULES, "stored once each, in order");
|
|
65
|
+
assert.equal(String(row.metadata.invitedBy), id.staffUser.toString());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("only Seven365 staff may choose modules", async () => {
|
|
69
|
+
const res = await invite(OUTSIDER, { email: "x-cim@e2e.example.com", app: ["security_agency"], modules: MODULES });
|
|
70
|
+
assert.ok([401, 403].includes(res.status), `expected a refusal, got ${res.status}`);
|
|
71
|
+
assert.equal(await inviteOf("x-cim@e2e.example.com"), null, "nothing was written");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("modules are refused for more than one application or an existing organisation", async () => {
|
|
75
|
+
const two = await invite(STAFF, { email: "two-cim@e2e.example.com", app: ["security_agency", "cleaning_services"], modules: MODULES });
|
|
76
|
+
assert.equal(two.status, 400, JSON.stringify(two.body));
|
|
77
|
+
|
|
78
|
+
const existing = await invite(STAFF, {
|
|
79
|
+
email: "existing-cim@e2e.example.com",
|
|
80
|
+
app: ["organization"],
|
|
81
|
+
org: id.org.toString(),
|
|
82
|
+
modules: MODULES,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(existing.status, 400, JSON.stringify(existing.body));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a Seven365 staff invitation keeps its role", async () => {
|
|
88
|
+
const res = await invite(STAFF, { email: "newstaff-cim@e2e.example.com", app: ["admin"], role: id.staffRole.toString() });
|
|
89
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
90
|
+
assert.equal(String((await inviteOf("newstaff-cim@e2e.example.com")).metadata.role), id.staffRole.toString());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("onboarding from the invitation enforces the list and gives the owner the client's own owner role", async () => {
|
|
94
|
+
const row = await inviteOf(CLIENT);
|
|
95
|
+
const res = await h.api("/organizations/onboarding", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
sid: sid[CLIENT],
|
|
98
|
+
body: { name: "CIM Security Client", type: "org", nature: "security_agency", email: CLIENT, inviteId: row._id.toString() },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
101
|
+
|
|
102
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIM Security Client" });
|
|
103
|
+
assert.deepEqual(org.modules, MODULES);
|
|
104
|
+
assert.ok(!org.invitedRole, "no template recorded on the organisation");
|
|
105
|
+
|
|
106
|
+
const save = await h.db
|
|
107
|
+
.collection("console-audit")
|
|
108
|
+
.find({ action: "client.modules-changed", target: org._id.toString().toLowerCase() })
|
|
109
|
+
.toArray();
|
|
110
|
+
assert.equal(save.length, 1, "one save row");
|
|
111
|
+
assert.equal(save[0].after.gateListHash, listHash(MODULES), "the gate's enforcement marker");
|
|
112
|
+
assert.equal(String(save[0].actor), id.staffUser.toString(), "the staff member who chose the list");
|
|
113
|
+
|
|
114
|
+
const member = await h.db.collection("members").findOne({ org: org._id, type: "organization" });
|
|
115
|
+
const role = await h.db.collection("roles").findOne({ _id: member.role });
|
|
116
|
+
assert.equal(String(role.org), org._id.toString(), "the organisation's own role, not a shared template");
|
|
117
|
+
assert.equal(role.name, "owner");
|
|
118
|
+
assert.deepEqual(role.permissions, ["*"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("an organisation of a different nature gets no list", async () => {
|
|
122
|
+
await invite(STAFF, { email: OTHER_NATURE, app: ["security_agency"], modules: MODULES });
|
|
123
|
+
const row = await inviteOf(OTHER_NATURE);
|
|
124
|
+
|
|
125
|
+
const res = await h.api("/organizations/onboarding", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
sid: sid[OTHER_NATURE],
|
|
128
|
+
body: { name: "CIM Cleaning Client", type: "org", nature: "cleaning_services", email: OTHER_NATURE, inviteId: row._id.toString() },
|
|
129
|
+
});
|
|
130
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
131
|
+
|
|
132
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIM Cleaning Client" });
|
|
133
|
+
assert.ok(!org.modules?.length, "a Security list would hide every Cleaning module");
|
|
134
|
+
assert.equal(
|
|
135
|
+
await h.db.collection("console-audit").countDocuments({ target: org._id.toString().toLowerCase() }),
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function seed(h) {
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
const org = new ObjectId();
|
|
144
|
+
const staffRole = new ObjectId();
|
|
145
|
+
const templateRole = new ObjectId();
|
|
146
|
+
|
|
147
|
+
await h.db.collection("organizations").insertOne({
|
|
148
|
+
_id: org, name: "CIM Existing Org", email: "existing-org-cim@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now,
|
|
149
|
+
});
|
|
150
|
+
await h.db.collection("roles").insertMany([
|
|
151
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
152
|
+
{ _id: templateRole, name: "Org Owner", type: "organization", permissions: ["*"], status: "active" },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const users = await h.db.collection("users").insertMany(
|
|
156
|
+
[STAFF, OUTSIDER, CLIENT, OTHER_NATURE].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
157
|
+
);
|
|
158
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
159
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
160
|
+
|
|
161
|
+
await h.db.collection("members").insertOne({ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" });
|
|
162
|
+
|
|
163
|
+
return { org, staffRole, templateRole, staffUser: users.insertedIds[0] };
|
|
164
|
+
}
|
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,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
|
+
}
|
|
@@ -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);
|