@7365admin1/core 3.64.5 → 3.65.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.
@@ -0,0 +1,525 @@
1
+ /*
2
+ * THE MODULE GATE, SERVER STEP (master plan W1-A).
3
+ *
4
+ * GET /api/members/user/:id/app/:type/modules -> { denied }
5
+ * POST /api/organizations/:id/modules/preview -> { newlyDenied, impact }
6
+ * POST /api/sites/:id/modules/preview -> the same, for a site
7
+ * PATCH ... /modules -> 409 until a narrowing is acknowledged
8
+ *
9
+ * Owner rules pinned here: an absent list means everything; ALWAYS_ALLOWED is
10
+ * never denied; the switch turns it all off; any error denies nothing; a list
11
+ * is enforced only once saved through the acknowledged preview (2026-09-11).
12
+ * The rules run on the SHIPPED service with an in-memory data layer; the
13
+ * wiring is read from the shipped controllers.
14
+ */
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { readFileSync } from "node:fs";
18
+
19
+ import {
20
+ deniedModulesForUser,
21
+ orgModulePreview,
22
+ siteModulePreview,
23
+ } from "./.build/services/module-gate.service.mjs";
24
+ import {
25
+ ALWAYS_ALLOWED,
26
+ GATE_LIST_HASH_FIELD,
27
+ impactUnacknowledgedMessage,
28
+ mustAcknowledge,
29
+ } from "./.build/utils/module-gate.util.mjs";
30
+ import { pickLookupMembership } from "./.build/utils/member-lookup.util.mjs";
31
+ import { orgModulesHandlers } from "./.build/controllers/organization.controller.mjs";
32
+ import { siteModulesHandlers } from "./.build/controllers/site.controller.mjs";
33
+ import { memberModulesHandlers } from "./.build/controllers/member.controller.mjs";
34
+ import {
35
+ ConsoleAuditAction,
36
+ pickAuditFields,
37
+ } from "./.build/utils/console-audit.util.mjs";
38
+ import {
39
+ ORG,
40
+ OTHER_ORG,
41
+ SITE,
42
+ OTHER_SITE,
43
+ USER,
44
+ USER_2,
45
+ CLIENT_SAVE,
46
+ SITE_SAVE,
47
+ ackRow,
48
+ oldRow,
49
+ member,
50
+ fakeGate,
51
+ withEnv,
52
+ } from "./module-gate.fixtures.mjs";
53
+
54
+ const LIST = ["visitor-mgmt", "workOrder"];
55
+ const org = (modules) => ({ _id: ORG, modules });
56
+ const ask = (data, over = {}) =>
57
+ deniedModulesForUser({ user: USER, type: "security_agency", org: ORG, ...over }, data);
58
+
59
+ /* ── /modules: absent means everything ───────────────────────────────── */
60
+
61
+ test("no list, or an empty one, denies nothing and reads no audit row", async () => {
62
+ for (const modules of [undefined, null, []]) {
63
+ const { data, calls } = fakeGate({ rows: [member()], orgs: { [ORG]: org(modules) } });
64
+ assert.deepEqual(await ask(data), [], String(modules));
65
+ assert.equal(calls.latestSave ?? 0, 0, "inert: no extra read while no list is stored");
66
+ }
67
+ });
68
+
69
+ test("CONTROL: an acknowledged list denies what it does not give", async () => {
70
+ const { data } = fakeGate({
71
+ rows: [member()],
72
+ orgs: { [ORG]: org(LIST) },
73
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
74
+ });
75
+ const denied = await ask(data);
76
+ assert.ok(denied.includes("nfc-patrol"));
77
+ assert.ok(!denied.includes("visitor-mgmt"));
78
+ // A spelling group is allowed as a whole.
79
+ assert.ok(!denied.includes("work_orders"));
80
+ for (const key of ALWAYS_ALLOWED) assert.ok(!denied.includes(key), key);
81
+ });
82
+
83
+ /* ── owner decision: only an acknowledged save counts ─────────────────── */
84
+
85
+ test("a list saved before the acknowledged path is NOT enforced", async () => {
86
+ for (const save of [undefined, oldRow(LIST)]) {
87
+ const { data } = fakeGate({
88
+ rows: [member()],
89
+ orgs: { [ORG]: org(LIST) },
90
+ saves: save ? { [`${CLIENT_SAVE}|${ORG}`]: save } : {},
91
+ });
92
+ assert.deepEqual(await ask(data), [], save ? "old row" : "no row");
93
+ }
94
+ });
95
+
96
+ test("a list changed after its acknowledged save falls back to absent", async () => {
97
+ const { data } = fakeGate({
98
+ rows: [member()],
99
+ orgs: { [ORG]: org(["visitor-mgmt"]) }, // edited by some other route
100
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
101
+ });
102
+ assert.deepEqual(await ask(data), []);
103
+ });
104
+
105
+ /* ── the site tier ────────────────────────────────────────────────────── */
106
+
107
+ test("a site list applies only when the site belongs to the member's own organisation", async () => {
108
+ const siteList = ["visitor-mgmt"];
109
+ const gate = (siteId, siteOrg) =>
110
+ fakeGate({
111
+ rows: [member()],
112
+ orgs: { [ORG]: org(LIST) },
113
+ sites: { [siteId]: { _id: siteId, orgId: siteOrg, metadata: { modules: siteList } } },
114
+ saves: {
115
+ [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST),
116
+ [`${SITE_SAVE}|${siteId}`]: ackRow(siteList),
117
+ },
118
+ }).data;
119
+
120
+ const orgOnly = await ask(gate(OTHER_SITE, OTHER_ORG), { site: OTHER_SITE });
121
+ assert.ok(!orgOnly.includes("work_orders"), "another org's site list is ignored (Q1)");
122
+
123
+ // CONTROL: the same list on the member's own organisation's site narrows.
124
+ const own = await ask(gate(SITE, ORG), { site: SITE });
125
+ assert.ok(own.includes("work_orders"));
126
+ assert.ok(!own.includes("visitor-mgmt"));
127
+ });
128
+
129
+ /* ── fails open ───────────────────────────────────────────────────────── */
130
+
131
+ test("MODULE_LIST_GATE=off denies nothing and reads nothing", async () => {
132
+ const setup = {
133
+ rows: [member()],
134
+ orgs: { [ORG]: org(LIST) },
135
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
136
+ };
137
+ for (const off of ["off", "OFF", "false", "0"]) {
138
+ const { data, calls } = fakeGate(setup);
139
+ assert.deepEqual(await withEnv({ MODULE_LIST_GATE: off }, () => ask(data)), [], off);
140
+ assert.deepEqual(calls, {}, `${off}: nothing read`);
141
+ }
142
+ // CONTROL: on (and the code default) enforces.
143
+ const { data } = fakeGate(setup);
144
+ assert.ok((await withEnv({ MODULE_LIST_GATE: undefined }, () => ask(data))).length > 0);
145
+ });
146
+
147
+ test("any lookup error denies nothing", async () => {
148
+ const setup = {
149
+ rows: [member()],
150
+ orgs: { [ORG]: org(LIST) },
151
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
152
+ };
153
+ for (const broken of ["membershipsOf", "isPlatformStaff", "org", "latestSave"]) {
154
+ const { data } = fakeGate({ ...setup, throwOn: [broken] });
155
+ assert.deepEqual(await ask(data), [], broken);
156
+ }
157
+ });
158
+
159
+ test("Seven365 staff, residents and people with no membership get nothing denied", async () => {
160
+ const setup = { orgs: { [ORG]: org(LIST) }, saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) } };
161
+ assert.deepEqual(await ask(fakeGate({ ...setup, rows: [member()], staff: true }).data), []);
162
+ assert.deepEqual(
163
+ await ask(fakeGate({ ...setup, rows: [member({ type: "resident" })] }).data, { type: "resident" }),
164
+ [],
165
+ );
166
+ assert.deepEqual(await ask(fakeGate({ ...setup, rows: [] }).data), []);
167
+ });
168
+
169
+ /* ── the same membership getByUserIdType answers with ─────────────────── */
170
+
171
+ test("pickLookupMembership mirrors getByUserIdType: site pick, live org row, first of type", () => {
172
+ const first = member({ _id: "first", org: OTHER_ORG });
173
+ const inOrg = member({ _id: "inOrg" });
174
+ const pinned = member({ _id: "pinned", siteId: SITE });
175
+ assert.equal(pickLookupMembership([first, inOrg, pinned], "security_agency", SITE, ORG)._id, "pinned");
176
+ assert.equal(pickLookupMembership([first, inOrg], "security_agency", "", ORG)._id, "inOrg");
177
+ // A removed row in the org does not win; the old first-of-type answer does.
178
+ const gone = member({ _id: "gone", status: "deleted" });
179
+ assert.equal(pickLookupMembership([first, gone], "security_agency", "", ORG)._id, "first");
180
+ assert.equal(pickLookupMembership([first], "cleaning_services", "", ORG), null);
181
+ });
182
+
183
+ /* ── the impact preview ───────────────────────────────────────────────── */
184
+
185
+ const ROLES = {
186
+ star: { name: "Owner", permissions: ["*"] },
187
+ guard: { name: "Guard", permissions: ["visitor-mgmt:add-visitor"] },
188
+ };
189
+ const PEOPLE = [
190
+ member({ _id: "p1", name: "Ann", role: "star" }),
191
+ member({ _id: "p2", name: "Ben", role: "guard" }),
192
+ ];
193
+
194
+ test("the preview names who loses what, against full access for a never-acknowledged list", async () => {
195
+ // Stored, but saved the OLD way: today it is absent, so this is a first save.
196
+ const { data } = fakeGate({ members: PEOPLE, roles: ROLES, saves: {} });
197
+ const preview = await orgModulePreview(org(["nfc-patrol", "feedback"]), ["nfc-patrol"], data);
198
+ assert.equal(preview.enforces, true);
199
+ assert.ok(preview.newlyDenied.includes("visitor-mgmt"));
200
+ const visitors = preview.impact.modules.find((m) => m.module === "visitor-mgmt");
201
+ assert.deepEqual(visitors.members.map((m) => m.name).sort(), ["Ann", "Ben"]);
202
+ assert.equal(preview.impact.memberCount, 2);
203
+ });
204
+
205
+ test("re-saving the enforced list, or saving an empty one, reads no member and needs no acknowledgement", async () => {
206
+ const { data, calls } = fakeGate({
207
+ members: PEOPLE,
208
+ roles: ROLES,
209
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
210
+ throwOn: ["staffMembers", "roles"],
211
+ });
212
+ for (const proposed of [LIST, [], [...LIST, "nfc-patrol"]]) {
213
+ const preview = await orgModulePreview(org(LIST), proposed, data);
214
+ assert.deepEqual(preview.newlyDenied, [], String(proposed));
215
+ assert.equal(mustAcknowledge(preview, undefined), false);
216
+ }
217
+ assert.equal(calls.staffMembers ?? 0, 0);
218
+ });
219
+
220
+ test("re-saving the STORED list unchanged, never acknowledged: no acknowledgement, no marker, no member read", async () => {
221
+ const { data, calls } = fakeGate({ members: PEOPLE, roles: ROLES, throwOn: ["staffMembers", "roles"] });
222
+ const reordered = [...LIST].reverse(); // set-equal, order-insensitive
223
+ const orgSide = await orgModulePreview(org(LIST), reordered, data);
224
+ assert.deepEqual(orgSide, { newlyDenied: [], impact: { memberCount: 0, modules: [] }, enforces: false });
225
+ assert.equal(mustAcknowledge(orgSide, undefined), false);
226
+ const siteSide = await siteModulePreview({ _id: SITE, orgId: ORG, metadata: { modules: LIST } }, org(undefined), reordered, data);
227
+ assert.equal(siteSide.enforces, false);
228
+ assert.deepEqual(siteSide.newlyDenied, []);
229
+ assert.equal(calls.staffMembers ?? 0, 0);
230
+
231
+ // CONTROL: a CHANGE to the same stored list is previewed against full access, and enforces.
232
+ const changed = await orgModulePreview(org(LIST), ["visitor-mgmt"], fakeGate({ members: PEOPLE, roles: ROLES }).data);
233
+ assert.equal(changed.enforces, true);
234
+ assert.ok(changed.newlyDenied.length > 0);
235
+ // And an already-enforced list re-saved unchanged keeps its marker.
236
+ const kept = fakeGate({ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) } }).data;
237
+ assert.equal((await orgModulePreview(org(LIST), reordered, kept)).enforces, true);
238
+ });
239
+
240
+ test("a preview that cannot be computed is treated as a loss", async () => {
241
+ const { data } = fakeGate({ throwOn: ["staffMembers"] });
242
+ const preview = await orgModulePreview(org(undefined), ["nfc-patrol"], data);
243
+ assert.equal(preview.impact, null);
244
+ assert.equal(mustAcknowledge(preview, undefined), true);
245
+ assert.equal(mustAcknowledge(preview, true), false);
246
+ });
247
+
248
+ test("the site preview reads the site's members inside its organisation's enforced list", async () => {
249
+ const site = { _id: SITE, orgId: ORG, metadata: {} };
250
+ const { data, calls } = fakeGate({
251
+ members: PEOPLE,
252
+ roles: ROLES,
253
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
254
+ });
255
+ const preview = await siteModulePreview(site, org(LIST), ["visitor-mgmt"], data);
256
+ assert.deepEqual(calls.staffMembersQuery, { org: ORG, site: SITE });
257
+ // Only what the org allowed can be newly denied at the site.
258
+ assert.ok(preview.newlyDenied.includes("work_orders"));
259
+ assert.ok(!preview.newlyDenied.includes("nfc-patrol"), "already denied by the org");
260
+ assert.equal(preview.impact.memberCount, 1); // only `*` held work orders
261
+ });
262
+
263
+ test("mustAcknowledge: only a real loss, unacknowledged, refuses", () => {
264
+ const loss = { newlyDenied: ["nfc-patrol"], impact: { memberCount: 2, modules: [] } };
265
+ const nobody = { newlyDenied: ["nfc-patrol"], impact: { memberCount: 0, modules: [] } };
266
+ const nothing = { newlyDenied: [], impact: null };
267
+ assert.equal(mustAcknowledge(loss, undefined), true);
268
+ assert.equal(mustAcknowledge(loss, "true"), true, "only the boolean true acknowledges");
269
+ assert.equal(mustAcknowledge(loss, true), false);
270
+ assert.equal(mustAcknowledge(nobody, undefined), false);
271
+ assert.equal(mustAcknowledge(nothing, undefined), false);
272
+ const message = impactUnacknowledgedMessage({
273
+ newlyDenied: ["nfc-patrol"],
274
+ impact: { memberCount: 2, modules: [{ module: "nfc-patrol", memberCount: 2 }] },
275
+ });
276
+ assert.match(message, /2 people would lose nfc-patrol \(2\)/);
277
+ assert.match(message, /acknowledgeImpact: true/);
278
+ });
279
+
280
+ /* ── the audit row is the marker ──────────────────────────────────────── */
281
+
282
+ test("both module-list audit actions keep the marker, the acknowledgement and the count", () => {
283
+ assert.equal(GATE_LIST_HASH_FIELD, "gateListHash");
284
+ for (const action of [ConsoleAuditAction.CLIENT_MODULES_CHANGED, ConsoleAuditAction.SITE_MODULES_CHANGED]) {
285
+ const row = pickAuditFields(action, {
286
+ modules: "a, b",
287
+ moduleCount: 2,
288
+ gateListHash: "f".repeat(64),
289
+ acknowledged: false,
290
+ impactMemberCount: 3,
291
+ email: "someone@example.com",
292
+ });
293
+ assert.deepEqual(Object.keys(row).sort(), [
294
+ "acknowledged",
295
+ "gateListHash",
296
+ "impactMemberCount",
297
+ "moduleCount",
298
+ "modules",
299
+ ]);
300
+ assert.equal(row.gateListHash.length, 64, "the fingerprint survives the 120-character cut");
301
+ }
302
+ });
303
+
304
+ /* ── the shipped handlers, called with stubbed req/res and in-memory deps ── */
305
+
306
+ /** Run one handler: what it answered (`json`) or passed to `next` (`error`). */
307
+ async function call(handler, { params = {}, body = {}, query = {} } = {}) {
308
+ const out = { json: undefined, error: undefined };
309
+ const res = { json: (v) => ((out.json = v), res), status: () => res };
310
+ await handler({ params, body, query, user: { _id: USER } }, res, (e) => (out.error = e));
311
+ return out;
312
+ }
313
+
314
+ const refused = () => Object.assign(new Error("Not authorized."), { statusCode: 401 });
315
+ const NARROW = ["visitor-mgmt"]; // takes work orders away from Ann (`*`)
316
+
317
+ /** Everything the handlers touch, in memory; `log` records the order of reads and writes. */
318
+ function deps({ stored = LIST, saves = {}, guard = async () => USER } = {}) {
319
+ const log = [];
320
+ const gate = fakeGate({ members: PEOPLE, roles: ROLES, saves });
321
+ const note = (name, value) => (log.push(name), value);
322
+ return {
323
+ log,
324
+ calls: gate.calls,
325
+ writes: () => log.filter((n) => n === "write" || n === "audit"),
326
+ audit: [],
327
+ shared: {
328
+ _updateById: async () => note("write"),
329
+ _update: async () => note("write"),
330
+ gateData: gate.data,
331
+ },
332
+ guard,
333
+ stored,
334
+ };
335
+ }
336
+
337
+ function orgHandlers(d) {
338
+ return orgModulesHandlers({
339
+ ...d.shared,
340
+ requireConsolePermission: async (...a) => (d.log.push("guard"), d.guard(...a)),
341
+ _getById: async (id) => (d.log.push("read"), { _id: id, modules: d.stored }),
342
+ recordConsoleAction: async (row) => (d.log.push("audit"), d.audit.push(row)),
343
+ });
344
+ }
345
+
346
+ const ESTATE = {
347
+ id: USER,
348
+ isSuperAdmin: false,
349
+ orgIds: [ORG],
350
+ propertyManagementOrgIds: [],
351
+ memberships: [{ org: ORG, siteId: "", orgLevelRole: true, type: "property_management" }],
352
+ };
353
+ // Reaches the site only through an active engagement: no membership in the site's org.
354
+ const VENDOR = {
355
+ ...ESTATE,
356
+ orgIds: [OTHER_ORG],
357
+ memberships: [{ org: OTHER_ORG, siteId: "", orgLevelRole: true, type: "security_agency" }],
358
+ };
359
+ // Pinned to the site, so the site guard admits them, and `orgIds` counts them.
360
+ const RESIDENT = {
361
+ ...ESTATE,
362
+ memberships: [{ org: ORG, siteId: SITE, orgLevelRole: false, type: "resident" }],
363
+ };
364
+ const STAFF = { ...ESTATE, isSuperAdmin: true, staffPermissions: [], orgIds: [], memberships: [] };
365
+
366
+ function siteHandlers(d, actor) {
367
+ return siteModulesHandlers({
368
+ ...d.shared,
369
+ requireSiteWrite: async () => {
370
+ d.log.push("guard");
371
+ if (!actor) throw refused();
372
+ return actor;
373
+ },
374
+ _getSiteById: async (id) =>
375
+ (d.log.push("read"), d.missing ? null : { _id: id, orgId: ORG, name: "Tower", metadata: { modules: d.stored } }),
376
+ _getOrgById: async (id) => (d.log.push("read-org"), { _id: id }),
377
+ recordConsoleAction: async (row) => (d.log.push("audit"), d.audit.push(row)),
378
+ });
379
+ }
380
+
381
+ const HASH = GATE_LIST_HASH_FIELD;
382
+
383
+ test("updateModules: a narrowing save is refused with 409 before anything is written; acknowledged, it saves with the marker", async () => {
384
+ const d = deps({ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) } });
385
+ const { updateModules } = orgHandlers(d);
386
+ const out = await call(updateModules, { params: { id: ORG }, body: { modules: NARROW } });
387
+ assert.equal(out.error?.statusCode, 409);
388
+ assert.match(out.error.message, /acknowledgeImpact: true/);
389
+ assert.deepEqual(d.writes(), [], "nothing written");
390
+
391
+ // CONTROL: acknowledged, the same save goes through, and its row is the marker.
392
+ const ok = await call(updateModules, { params: { id: ORG }, body: { modules: NARROW, acknowledgeImpact: true } });
393
+ assert.equal(ok.error, undefined);
394
+ assert.deepEqual(d.writes(), ["write", "audit"], "written, then recorded");
395
+ assert.equal(d.audit[0].after[HASH].length, 64);
396
+ assert.equal(d.audit[0].after.acknowledged, true);
397
+ });
398
+
399
+ test("updateModules: the Super Admin console re-saving an UNCHANGED, never-acknowledged list needs no acknowledgement and writes no marker", async () => {
400
+ const d = deps(); // LIST stored the old way: no save row
401
+ const { updateModules } = orgHandlers(d);
402
+ const out = await call(updateModules, { params: { id: ORG }, body: { modules: [...LIST].reverse() } });
403
+ assert.equal(out.error, undefined);
404
+ assert.deepEqual(d.writes(), ["write", "audit"]);
405
+ assert.equal(d.audit[0].after[HASH], undefined, "the list stays unenforced");
406
+ assert.equal(d.calls.staffMembers ?? 0, 0, "nobody's roles read");
407
+ });
408
+
409
+ test("the console guard runs first: a refused caller reads and writes nothing", async () => {
410
+ const d = deps({ guard: async () => { throw refused(); } });
411
+ const { updateModules, previewOrgModules } = orgHandlers(d);
412
+ for (const handler of [updateModules, previewOrgModules]) {
413
+ const out = await call(handler, { params: { id: ORG }, body: { modules: NARROW } });
414
+ assert.equal(out.error?.statusCode, 401);
415
+ assert.equal(out.json, undefined);
416
+ }
417
+ assert.deepEqual(d.log, ["guard", "guard"]);
418
+ assert.deepEqual(d.calls, {});
419
+ });
420
+
421
+ test("previewOrgModules answers who loses what and writes nothing", async () => {
422
+ const d = deps({ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) } });
423
+ const out = await call(orgHandlers(d).previewOrgModules, { params: { id: ORG }, body: { modules: NARROW } });
424
+ assert.ok(out.json.impact.memberCount > 0);
425
+ assert.deepEqual(d.writes(), []);
426
+ });
427
+
428
+ test("updateSiteModules: Seven365 staff's narrowing save gets 409 before any write; acknowledged, it carries the marker", async () => {
429
+ const d = deps({ stored: undefined });
430
+ const { updateSiteModules } = siteHandlers(d, STAFF);
431
+ const out = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
432
+ assert.equal(out.error?.statusCode, 409);
433
+ assert.deepEqual(d.writes(), []);
434
+
435
+ const ok = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW, acknowledgeImpact: true } });
436
+ assert.equal(ok.error, undefined);
437
+ assert.deepEqual(d.writes(), ["write", "audit"]);
438
+ assert.equal(d.audit[0].after[HASH].length, 64);
439
+ });
440
+
441
+ test("updateSiteModules: anyone but staff (estate member, resident, engaged provider) still saves, but writes no marker", async () => {
442
+ for (const [who, actor] of [["estate member", ESTATE], ["resident", RESIDENT], ["provider", VENDOR]]) {
443
+ const d = deps({ stored: undefined });
444
+ const { updateSiteModules } = siteHandlers(d, actor);
445
+ const out = await call(updateSiteModules, { params: { id: SITE }, body: { modules: NARROW, acknowledgeImpact: true } });
446
+ assert.equal(out.error, undefined, `${who}: the existing save is not narrowed`);
447
+ assert.deepEqual(d.writes(), ["write", "audit"], who);
448
+ assert.equal(d.audit[0].after[HASH], undefined, `${who}: the list can never be enforced`);
449
+ assert.equal(d.calls.staffMembers ?? 0, 0, `${who}: the estate's staff are never read for it`);
450
+ }
451
+ });
452
+
453
+ test("updateSiteModules: re-saving the stored site list unchanged needs no acknowledgement and writes no marker", async () => {
454
+ const d = deps({ stored: NARROW });
455
+ const out = await call(siteHandlers(d, STAFF).updateSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
456
+ assert.equal(out.error, undefined);
457
+ assert.equal(d.audit[0].after[HASH], undefined);
458
+ });
459
+
460
+ test("previewSiteModules: an engagement-only provider or a resident is refused before any member is read", async () => {
461
+ for (const [who, actor] of [["provider", VENDOR], ["resident", RESIDENT]]) {
462
+ const d = deps({ stored: undefined });
463
+ const out = await call(siteHandlers(d, actor).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
464
+ assert.equal(out.error?.statusCode, 401, who);
465
+ assert.equal(out.json, undefined, `${who}: no staff names or roles leave`);
466
+ assert.deepEqual(d.log, ["guard", "read"], who);
467
+ assert.deepEqual(d.calls, {}, who);
468
+ }
469
+
470
+ // A missing site answers as missing, like the rest of the site routes.
471
+ const gone = deps();
472
+ gone.missing = true;
473
+ const missing = await call(siteHandlers(gone, ESTATE).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
474
+ assert.equal(missing.error?.statusCode, 404);
475
+
476
+ // A caller the site guard refuses reads nothing at all.
477
+ const none = deps();
478
+ assert.equal((await call(siteHandlers(none, null).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } })).error?.statusCode, 401);
479
+ assert.deepEqual(none.log, ["guard"]);
480
+
481
+ // CONTROL: the estate's own member gets the preview, and nothing is written.
482
+ for (const actor of [ESTATE, STAFF]) {
483
+ const ok = deps({ stored: undefined });
484
+ const answer = await call(siteHandlers(ok, actor).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
485
+ assert.ok(answer.json.impact.memberCount > 0);
486
+ assert.deepEqual(ok.writes(), []);
487
+ }
488
+ });
489
+
490
+ test("/modules: the self-or-staff guard runs before any lookup", async () => {
491
+ const { data, calls } = fakeGate({ rows: [member()] });
492
+ const { getModulesByUserIdType } = memberModulesHandlers({
493
+ requireSelfOrPlatformStaff: async () => { throw refused(); },
494
+ gateData: data,
495
+ });
496
+ const out = await call(getModulesByUserIdType, { params: { id: USER_2, type: "security_agency" } });
497
+ assert.equal(out.error?.statusCode, 401);
498
+ assert.deepEqual(calls, {}, "nothing looked up");
499
+
500
+ // CONTROL: past the guard it answers `{ denied }`, and a non-hex org/site is not passed on.
501
+ const ok = memberModulesHandlers({ requireSelfOrPlatformStaff: async () => USER, gateData: data });
502
+ const answer = await call(ok.getModulesByUserIdType, {
503
+ params: { id: USER, type: "security_agency" },
504
+ query: { org: { $ne: "" }, site: "not-an-id" },
505
+ });
506
+ assert.deepEqual(answer.json, { denied: [] });
507
+ });
508
+
509
+ test("owner decision 3: nothing in the gate reads the resident-app switches", () => {
510
+ for (const file of [
511
+ "../src/services/module-gate.service.ts",
512
+ "../src/utils/notification-access.util.ts",
513
+ "../src/utils/module-access.util.ts",
514
+ ]) {
515
+ const text = readFileSync(new URL(file, import.meta.url), "utf8");
516
+ assert.ok(!/residentAppModules/.test(text), `${file} reads residentAppModules`);
517
+ }
518
+ });
519
+
520
+ test("the gate never reads roles through the self-healing GET /api/roles path", () => {
521
+ const text = readFileSync(new URL("../src/services/module-gate.service.ts", import.meta.url), "utf8");
522
+ assert.ok(!/useRoleController|getRoles\(|healDefaultRoles/.test(text));
523
+ // And it never writes.
524
+ assert.ok(!/insert|updateOne|updateMany|deleteOne|deleteMany|\$set/.test(text));
525
+ });