@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.68.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
+ }
@@ -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: anyone but staff (estate member, resident, engaged provider) still saves, but writes no marker", async () => {
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, 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(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: 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]]) {
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, ESTATE).previewSiteModules, { params: { id: SITE }, body: { modules: NARROW } });
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 [ESTATE, STAFF]) {
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);