@7365admin1/core 3.70.0 → 3.72.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.70.0",
4
+ "version": "3.72.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -25,6 +25,10 @@ const STAFF = "sevenadmin-cim@e2e.example.com";
25
25
  const OUTSIDER = "outsider-cim@e2e.example.com";
26
26
  const CLIENT = "client-cim@e2e.example.com";
27
27
  const OTHER_NATURE = "othernature-cim@e2e.example.com";
28
+ const ROLE_CLIENT = "roleclient-cim@e2e.example.com";
29
+
30
+ // The owner role Seven365 chooses with the modules (owner, 2026-09-15).
31
+ const OWNER_PERMISSIONS = ["visitor-mgmt:view-visitors", "visitor-mgmt:add-visitor", "members:view-members"];
28
32
 
29
33
  const MODULES = ["visitor-mgmt", "members", "roles", "invitations", "sites"];
30
34
 
@@ -39,7 +43,7 @@ describe("Invite Client chooses modules and the owner gets the client's own role
39
43
  before(async () => {
40
44
  h = await startHarness();
41
45
  Object.assign(id, await seed(h));
42
- for (const email of [STAFF, OUTSIDER, CLIENT, OTHER_NATURE]) sid[email] = await h.login(email, PASSWORD);
46
+ for (const email of [STAFF, OUTSIDER, CLIENT, OTHER_NATURE, ROLE_CLIENT]) sid[email] = await h.login(email, PASSWORD);
43
47
  }, { timeout: 300000 });
44
48
 
45
49
  after(async () => {
@@ -136,6 +140,119 @@ describe("Invite Client chooses modules and the owner gets the client's own role
136
140
  0,
137
141
  );
138
142
  });
143
+ it("permissions are refused outside the modules, without modules, or empty", async () => {
144
+ const outside = await invite(STAFF, {
145
+ email: "outside-cim@e2e.example.com", app: ["security_agency"], modules: MODULES,
146
+ permissions: ["patrol-mgmt:view-patrols"],
147
+ });
148
+ assert.equal(outside.status, 400, JSON.stringify(outside.body));
149
+
150
+ const noModules = await invite(STAFF, {
151
+ email: "nomodules-cim@e2e.example.com", app: ["security_agency"], permissions: OWNER_PERMISSIONS,
152
+ });
153
+ assert.equal(noModules.status, 400, JSON.stringify(noModules.body));
154
+
155
+ const empty = await invite(STAFF, {
156
+ email: "empty-cim@e2e.example.com", app: ["security_agency"], modules: MODULES, permissions: [],
157
+ });
158
+ assert.equal(empty.status, 400, JSON.stringify(empty.body));
159
+
160
+ for (const email of ["outside-cim@e2e.example.com", "nomodules-cim@e2e.example.com", "empty-cim@e2e.example.com"]) {
161
+ assert.equal(await inviteOf(email), null, "nothing was written for " + email);
162
+ }
163
+ });
164
+
165
+ it("a re-invite replaces the chosen permissions on the pending invitation", async () => {
166
+ const first = await invite(STAFF, { email: ROLE_CLIENT, app: ["security_agency"], modules: MODULES, permissions: ["members:view-members"] });
167
+ assert.equal(first.status, 201, JSON.stringify(first.body));
168
+ const again = await invite(STAFF, { email: ROLE_CLIENT, app: ["security_agency"], modules: MODULES, permissions: [...OWNER_PERMISSIONS, "members:view-members"] });
169
+ assert.equal(again.status, 201, JSON.stringify(again.body));
170
+
171
+ const rows = await h.db.collection("verifications").find({ type: "user-invite", email: ROLE_CLIENT }).toArray();
172
+ assert.equal(rows.length, 1, "one pending invitation");
173
+ assert.deepEqual(rows[0].metadata.permissions, OWNER_PERMISSIONS, "the latest choice, once each");
174
+ });
175
+
176
+ it("onboarding gives the owner the application role Seven365 chose, and the owner can take it", async () => {
177
+ const row = await inviteOf(ROLE_CLIENT);
178
+ const res = await h.api("/organizations/onboarding", {
179
+ method: "POST",
180
+ sid: sid[ROLE_CLIENT],
181
+ body: { name: "CIM Role Client", type: "org", nature: "security_agency", email: ROLE_CLIENT, inviteId: row._id.toString() },
182
+ });
183
+ assert.equal(res.status, 201, JSON.stringify(res.body));
184
+
185
+ const org = await h.db.collection("organizations").findOne({ name: "CIM Role Client" });
186
+ assert.ok(org.invitedRole, "the chosen role is recorded for the finish");
187
+
188
+ const appRole = await h.db.collection("roles").findOne({ _id: org.invitedRole });
189
+ assert.equal(String(appRole.org), org._id.toString(), "the client's own role");
190
+ assert.equal(appRole.type, "security_agency");
191
+ assert.equal(appRole.name, "owner");
192
+ assert.deepEqual(appRole.permissions, OWNER_PERMISSIONS, "exactly what was ticked");
193
+
194
+ // Running the organisation is never narrowed.
195
+ const orgMember = await h.db.collection("members").findOne({ org: org._id, type: "organization" });
196
+ const orgRole = await h.db.collection("roles").findOne({ _id: orgMember.role });
197
+ assert.deepEqual(orgRole.permissions, ["*"]);
198
+
199
+ // The finish writes the application membership with that role (web-app-org onboarding-finish).
200
+ const site = new ObjectId();
201
+ const owner = await h.db.collection("users").findOne({ email: ROLE_CLIENT });
202
+ const member = await h.api("/members/direct", {
203
+ method: "POST",
204
+ sid: sid[ROLE_CLIENT],
205
+ body: { userId: owner._id.toString(), orgId: org._id.toString(), roleId: org.invitedRole.toString(), app: "security_agency", siteId: site.toString(), siteName: "HQ" },
206
+ });
207
+ assert.equal(member.status, 201, JSON.stringify(member.body));
208
+ const appMember = await h.db.collection("members").findOne({ org: org._id, type: "security_agency", user: owner._id });
209
+ assert.equal(String(appMember.role), String(org.invitedRole));
210
+ });
211
+
212
+ it("the client cannot widen its application roles past what Seven365 chose; Seven365 can", async () => {
213
+ const org = await h.db.collection("organizations").findOne({ name: "CIM Role Client" });
214
+ const roleId = org.invitedRole.toString();
215
+ const patch = (who, permissions) =>
216
+ h.api(`/roles/permissions/id/${roleId}`, { method: "PATCH", sid: sid[who], body: { permissions } });
217
+ const roleNow = async () => (await h.db.collection("roles").findOne({ _id: org.invitedRole })).permissions;
218
+
219
+ const wider = await patch(ROLE_CLIENT, [...OWNER_PERMISSIONS, "visitor-mgmt:delete-visitor"]);
220
+ assert.ok([401, 403].includes(wider.status), `owner widening refused, got ${wider.status} ${JSON.stringify(wider.body)}`);
221
+ assert.deepEqual(await roleNow(), OWNER_PERMISSIONS, "nothing was written");
222
+
223
+ const runOrg = await patch(ROLE_CLIENT, [...OWNER_PERMISSIONS, "members:delete-member"]);
224
+ assert.equal(runOrg.status, 200, "running the organisation is not capped: " + JSON.stringify(runOrg.body));
225
+
226
+ const staffRole = await h.api("/roles", {
227
+ method: "POST", sid: sid[ROLE_CLIENT],
228
+ body: { name: "Guard lead", type: "security_agency", org: org._id.toString(), permissions: ["*"] },
229
+ });
230
+ assert.equal(staffRole.status, 201, JSON.stringify(staffRole.body));
231
+ const created = await h.db.collection("roles").findOne({ org: org._id, name: "Guard lead" });
232
+ assert.ok(!created.permissions.includes("*"), "an everything role becomes the grant");
233
+ assert.ok(created.permissions.includes("visitor-mgmt:view-visitors"));
234
+ assert.ok(!created.permissions.includes("visitor-mgmt:delete-visitor"));
235
+
236
+ const widerStaffRole = await h.api("/roles", {
237
+ method: "POST", sid: sid[ROLE_CLIENT],
238
+ body: { name: "Too wide", type: "security_agency", org: org._id.toString(), permissions: ["visitor-mgmt:delete-visitor"] },
239
+ });
240
+ assert.ok([401, 403].includes(widerStaffRole.status), `got ${widerStaffRole.status}`);
241
+
242
+ const bySeven365 = await patch(STAFF, [...OWNER_PERMISSIONS, "members:delete-member", "visitor-mgmt:delete-visitor"]);
243
+ assert.equal(bySeven365.status, 200, JSON.stringify(bySeven365.body));
244
+ assert.ok((await roleNow()).includes("visitor-mgmt:delete-visitor"), "Seven365 widened the grant");
245
+ });
246
+
247
+ it("a client invited before this keeps its roles uncapped", async () => {
248
+ const org = await h.db.collection("organizations").findOne({ name: "CIM Security Client" });
249
+ const member = await h.db.collection("members").findOne({ org: org._id, type: "organization" });
250
+ const res = await h.api("/roles", {
251
+ method: "POST", sid: sid[CLIENT],
252
+ body: { name: "Anything", type: "security_agency", org: org._id.toString(), permissions: ["visitor-mgmt:delete-visitor"] },
253
+ });
254
+ assert.equal(res.status, 201, JSON.stringify(res.body) + " member " + Boolean(member));
255
+ });
139
256
  });
140
257
 
141
258
  async function seed(h) {
@@ -153,7 +270,7 @@ async function seed(h) {
153
270
  ]);
154
271
 
155
272
  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 })),
273
+ [STAFF, OUTSIDER, CLIENT, OTHER_NATURE, ROLE_CLIENT].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
157
274
  );
158
275
  const hashed = await h.hashPassword(PASSWORD);
159
276
  await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
@@ -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,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
+ });
@@ -259,3 +259,25 @@ test("positive control: the guard DOES refuse the shapes this seeding avoids", (
259
259
  "a platform grant on a client role is still refused",
260
260
  );
261
261
  });
262
+
263
+ // Owner, 2026-09-15: the permissions Seven365 chose on Invite Client land on the
264
+ // NATURE-typed owner role only; the organisation owner role keeps "*".
265
+ test("chosen owner permissions narrow only the application owner role", () => {
266
+ const chosen = ["visitor-mgmt:view-visitors", "members:view-members"];
267
+ const roles = defaultOwnerRoles(ORG, "security_agency", chosen);
268
+ const owner = (type) => roles.find((r) => r.type === type && r.name === "owner");
269
+
270
+ assert.deepEqual(owner("security_agency").permissions, chosen);
271
+ assert.deepEqual(owner("organization").permissions, ["*"]);
272
+ // An empty list would mean EVERYTHING, so it is ignored rather than stored.
273
+ assert.deepEqual(
274
+ defaultOwnerRoles(ORG, "security_agency", []).find((r) => r.type === "security_agency" && r.name === "owner").permissions,
275
+ ["*"],
276
+ );
277
+ });
278
+
279
+ test("the owner-permissions kill switch", async () => {
280
+ const { clientInviteOwnerPermissionsOn } = await import("./.build/utils/org-default-roles.util.mjs");
281
+ assert.equal(clientInviteOwnerPermissionsOn({}), true);
282
+ assert.equal(clientInviteOwnerPermissionsOn({ CLIENT_INVITE_OWNER_PERMISSIONS: "off" }), false);
283
+ });
@@ -257,3 +257,29 @@ test("a real list now offers every spelling and the always-included set", () =>
257
257
  ["members", "visitor-mgmt", "workOrder"],
258
258
  );
259
259
  });
260
+
261
+ // Owner, 2026-09-15: the owner role chosen on Invite Client must stay inside the
262
+ // chosen modules.
263
+ test("permissions outside the chosen modules are named", async () => {
264
+ const { normalisePermissions, permissionsOutsideModules } = await import("./.build/utils/org-modules.util.mjs");
265
+ const chosen = normalisePermissions([" visitor-mgmt:view-visitors", "visitor-mgmt:view-visitors", "*", "patrol-mgmt:view", "members:view-members", 7]);
266
+ assert.deepEqual(chosen, ["visitor-mgmt:view-visitors", "patrol-mgmt:view", "members:view-members"]);
267
+ // members is always allowed; visitorManagement is a spelling of visitor-mgmt.
268
+ assert.deepEqual(permissionsOutsideModules(chosen, ["visitorManagement"]), ["patrol-mgmt:view"]);
269
+ });
270
+
271
+ test("roles stay inside the owner grant Seven365 chose", async () => {
272
+ const { boundToOwnerGrant } = await import("./.build/utils/org-modules.util.mjs");
273
+ const ceiling = ["visitor-mgmt:view-visitors", "visitor-mgmt:add-visitor"];
274
+
275
+ assert.deepEqual(boundToOwnerGrant(null, ["x:y"]), { permissions: ["x:y"], refused: [] }, "no ceiling, no change");
276
+ assert.deepEqual(boundToOwnerGrant(ceiling, ["*"]).permissions, ceiling, "everything becomes the grant");
277
+ assert.deepEqual(boundToOwnerGrant(ceiling, []).permissions, ceiling, "an empty list means everything too");
278
+ assert.deepEqual(boundToOwnerGrant(ceiling, ["visitor-mgmt:delete-visitor"]).refused, ["visitor-mgmt:delete-visitor"]);
279
+ assert.deepEqual(boundToOwnerGrant(ceiling, ["members:delete-member", "visitor-mgmt:add-visitor"]).refused, [], "running the organisation is not capped");
280
+ assert.deepEqual(
281
+ boundToOwnerGrant(ceiling, ["visitor-mgmt:delete-visitor"], ["visitor-mgmt:delete-visitor"]).refused,
282
+ [],
283
+ "a string already held is never refused",
284
+ );
285
+ });