@7365admin1/core 3.64.5 → 3.65.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.64.5",
4
+ "version": "3.65.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,7 +13,7 @@
13
13
  "build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
14
14
  "release": "yarn run build && changeset publish",
15
15
  "lint": "tsc",
16
- "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
16
+ "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
17
17
  "test:camera": "yarn test",
18
18
  "test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
19
19
  },
@@ -211,10 +211,11 @@ function gateCalls() {
211
211
  return calls;
212
212
  }
213
213
 
214
- test("every console gate call site names a module - all 46 of them", () => {
214
+ test("every console gate call site names a module - all 48 of them", () => {
215
215
  const calls = gateCalls();
216
- // 46 since `organization.controller.ts updateModules` was added.
217
- assert.equal(calls.length, 46, `gate call sites moved: ${calls.length}`);
216
+ // 48 since the module gate added `organization.controller.ts previewOrgModules`
217
+ // and `member.controller.ts getModulesByUserIdType` (self-or-staff, `members`).
218
+ assert.equal(calls.length, 48, `gate call sites moved: ${calls.length}`);
218
219
 
219
220
  const ungoverned = calls.filter(
220
221
  ([, call]) =>
@@ -0,0 +1,255 @@
1
+ // End-to-end proof that `POST /api/members/direct` only enrols somebody with a
2
+ // role that belongs to the organisation being joined.
3
+ //
4
+ // `roleId` arrived as `Joi.string().hex()` and nothing tied it to `orgId`. The
5
+ // org gate (`requireOrgReach` for yourself, `requireOrgAccess` for somebody
6
+ // else) decides WHERE a membership may be written, but not WHAT it carries, so
7
+ // anybody with reach into one client - an invitation is enough - could enrol
8
+ // there wearing another client's role, and a member could hand a colleague one.
9
+ //
10
+ // Every legitimate caller keeps working, and each has a case below:
11
+ // - web-app-main sign-in / verify-email: the invitation's role, or "Admin
12
+ // Service Provider" found by name in the invited org (it may be pinned to a
13
+ // different site of that org than the `siteId` sent with it);
14
+ // - web-app-org onboarding: the invitation role, else the owner's existing
15
+ // organisation-row role (an org-less template for 27 staging owners, and for
16
+ // 1 a role of another client), reused for the app row and the support account.
17
+ //
18
+ // Everything runs against the harness's in-process MongoDB replica set. No
19
+ // staging or production database, Redis, device or endpoint is touched.
20
+ //
21
+ // Run with: yarn test:e2e
22
+
23
+ import { after, before, describe, it } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { ObjectId } from "mongodb";
26
+
27
+ import { startHarness } from "./harness.mjs";
28
+
29
+ const PASSWORD = "RoleOrg-Passw0rd!";
30
+ const NEWCOMER = "ro-newcomer@e2e.example.com"; // invited into org B, not yet a member
31
+ const MEMBER_B = "ro-member-b@e2e.example.com"; // an ordinary member of org B
32
+ const HOLDER = "ro-holder@e2e.example.com"; // org B owner whose org row carries org A's role
33
+ const SUPPORT = "ro-support@e2e.example.com"; // the account onboarding adds
34
+ const STAFF = "ro-sevenadmin@e2e.example.com";
35
+
36
+ describe("members/direct only hands out a role of the organisation being joined", { concurrency: 1 }, () => {
37
+ let h;
38
+ const id = {};
39
+ const result = [];
40
+ const sid = {};
41
+
42
+ const record = (name, fn) =>
43
+ it(name, async () => {
44
+ try {
45
+ await fn();
46
+ result.push(["PASS", name]);
47
+ } catch (error) {
48
+ result.push(["FAIL", name]);
49
+ throw error;
50
+ }
51
+ });
52
+
53
+ before(async () => {
54
+ h = await startHarness();
55
+ Object.assign(id, await seed(h));
56
+ }, { timeout: 300000 });
57
+
58
+ after(async () => {
59
+ if (h) await h.stop();
60
+ console.log("\n--- members/direct role organisation: per-case result ---");
61
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
62
+ });
63
+
64
+ const enrol = (who, body) =>
65
+ h.api("/members/direct", {
66
+ method: "POST",
67
+ sid: sid[who],
68
+ body: { orgId: id.orgB.toString(), siteId: "", siteName: "", ...body },
69
+ });
70
+
71
+ const rows = (user, type) =>
72
+ h.db.collection("members").countDocuments({ user, org: id.orgB, type });
73
+
74
+ record("0. baseline - everybody signs in", async () => {
75
+ for (const [k, e] of Object.entries({ NEWCOMER, MEMBER_B, HOLDER, SUPPORT, STAFF })) {
76
+ sid[k] = await h.login(e, PASSWORD);
77
+ }
78
+ });
79
+
80
+ // ---- refused: another client's role -------------------------------------
81
+
82
+ record("1. an invitee cannot enrol with ANOTHER client's role", async () => {
83
+ const res = await enrol("NEWCOMER", {
84
+ userId: id.newcomer.toString(),
85
+ roleId: id.roleA.toString(),
86
+ app: "organization",
87
+ });
88
+ // 400 from this check; 401 once the self-enrolment role gate (which runs
89
+ // first, in the controller) is also present — the invitation does not
90
+ // offer roleA. Either way nothing is written.
91
+ assert.ok([400, 401].includes(res.status), JSON.stringify(res.body));
92
+ assert.equal(await rows(id.newcomer, "organization"), 0, "nothing may be written");
93
+ });
94
+
95
+ record("2. a member cannot hand a colleague another client's role", async () => {
96
+ const res = await enrol("MEMBER_B", {
97
+ userId: id.support.toString(),
98
+ roleId: id.roleA.toString(),
99
+ app: "organization",
100
+ });
101
+ assert.equal(res.status, 400, JSON.stringify(res.body));
102
+ assert.equal(await rows(id.support, "organization"), 0);
103
+ });
104
+
105
+ record("3. nor can Seven365 staff attach another client's role", async () => {
106
+ const res = await enrol("STAFF", {
107
+ userId: id.support.toString(),
108
+ roleId: id.roleA.toString(),
109
+ app: "security_agency",
110
+ });
111
+ assert.equal(res.status, 400, JSON.stringify(res.body));
112
+ assert.equal(await rows(id.support, "security_agency"), 0);
113
+ });
114
+
115
+ // ---- still works: every live shape ----------------------------------------
116
+
117
+ record("4. STILL WORKS: the invitee enrols with the invited org's role", async () => {
118
+ const res = await enrol("NEWCOMER", {
119
+ userId: id.newcomer.toString(),
120
+ roleId: id.roleB.toString(),
121
+ app: "organization",
122
+ });
123
+ assert.equal(res.status, 201, JSON.stringify(res.body));
124
+ });
125
+
126
+ record("5. STILL WORKS: a site role of the org with a DIFFERENT siteId (SP sign-in shape)", async () => {
127
+ // "Admin Service Provider" is found by name with limit 1, so it may be pinned
128
+ // to another site of the same org than the invitation's site. Enrolled by a
129
+ // member of the org, so only this check is exercised.
130
+ const res = await enrol("MEMBER_B", {
131
+ userId: id.newcomer.toString(),
132
+ roleId: id.roleBSite2.toString(),
133
+ app: "security_agency",
134
+ siteId: id.siteB1.toString(),
135
+ siteName: "Site B1",
136
+ });
137
+ assert.equal(res.status, 201, JSON.stringify(res.body));
138
+ });
139
+
140
+ record("6. STILL WORKS: an org-less template role (the console's Invite Client picker)", async () => {
141
+ const res = await enrol("MEMBER_B", {
142
+ userId: id.newcomer.toString(),
143
+ roleId: id.template.toString(),
144
+ app: "property_management_agency",
145
+ });
146
+ assert.equal(res.status, 201, JSON.stringify(res.body));
147
+ });
148
+
149
+ record("7. STILL WORKS: a member adds a colleague with the org's own role", async () => {
150
+ const res = await enrol("MEMBER_B", {
151
+ userId: id.support.toString(),
152
+ roleId: id.roleB.toString(),
153
+ app: "organization",
154
+ });
155
+ assert.equal(res.status, 201, JSON.stringify(res.body));
156
+ });
157
+
158
+ record("8. STILL WORKS: onboarding finish reuses a role the owner already holds HERE", async () => {
159
+ // The owner's organisation row carries org A's role (1 staging owner has this
160
+ // shape). The finish writes the app row with it, then the support account.
161
+ const self = await enrol("HOLDER", {
162
+ userId: id.holder.toString(),
163
+ roleId: id.roleA.toString(),
164
+ app: "property_management_agency",
165
+ onboardingRequired: true,
166
+ });
167
+ assert.equal(self.status, 201, JSON.stringify(self.body));
168
+
169
+ const support = await enrol("HOLDER", {
170
+ userId: id.support.toString(),
171
+ roleId: id.roleA.toString(),
172
+ app: "property_management_agency",
173
+ });
174
+ assert.equal(support.status, 201, JSON.stringify(support.body));
175
+ });
176
+
177
+ record("9. STILL WORKS: a role id that resolves to nothing is not a new refusal", async () => {
178
+ const res = await enrol("MEMBER_B", {
179
+ userId: id.support.toString(),
180
+ roleId: new ObjectId().toString(),
181
+ app: "cleaning_services",
182
+ });
183
+ assert.equal(res.status, 201, JSON.stringify(res.body));
184
+ });
185
+
186
+ record("10. CONTROL: holding org A's role in ANOTHER org does not unlock it here", async () => {
187
+ // The newcomer holds nothing in org B with roleA; a membership in org A with
188
+ // it (seeded) must not count.
189
+ const res = await enrol("NEWCOMER", {
190
+ userId: id.newcomer.toString(),
191
+ roleId: id.roleA.toString(),
192
+ app: "cleaning_services",
193
+ });
194
+ assert.ok([400, 401].includes(res.status), JSON.stringify(res.body));
195
+ assert.equal(await rows(id.newcomer, "cleaning_services"), 0);
196
+ });
197
+ });
198
+
199
+ async function seed(h) {
200
+ const now = new Date().toISOString();
201
+ const orgA = new ObjectId();
202
+ const orgB = new ObjectId();
203
+ const siteB1 = new ObjectId();
204
+ const siteB2 = new ObjectId();
205
+ const staffRole = new ObjectId();
206
+ const roleA = new ObjectId();
207
+ const roleB = new ObjectId();
208
+ const roleBSite2 = new ObjectId();
209
+ const template = new ObjectId();
210
+
211
+ await h.db.collection("organizations").insertMany([
212
+ { _id: orgA, name: "RO Org A", email: "ro-a@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
213
+ { _id: orgB, name: "RO Org B", email: "ro-b@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
214
+ ]);
215
+
216
+ await h.db.collection("sites").insertMany([
217
+ { _id: siteB1, name: "Site B1", orgId: orgB, status: "active" },
218
+ { _id: siteB2, name: "Site B2", orgId: orgB, status: "active" },
219
+ ]);
220
+
221
+ await h.db.collection("roles").insertMany([
222
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
223
+ { _id: roleA, name: "Owner", type: "organization", org: orgA, permissions: ["*"], status: "active" },
224
+ { _id: roleB, name: "Supervisor", type: "organization", org: orgB, permissions: ["*"], status: "active" },
225
+ { _id: roleBSite2, name: "Admin Service Provider", type: "security_agency", org: orgB, site: siteB2, permissions: ["*"], status: "active" },
226
+ { _id: template, name: "Org Owner", type: "organization", permissions: ["*"], status: "active" },
227
+ ]);
228
+
229
+ const emails = [NEWCOMER, MEMBER_B, HOLDER, SUPPORT, STAFF];
230
+ const users = await h.db.collection("users").insertMany(
231
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
232
+ );
233
+ const hashed = await h.hashPassword(PASSWORD);
234
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
235
+ const [newcomer, memberB, holder, support, staff] = [0, 1, 2, 3, 4].map((i) => users.insertedIds[i]);
236
+
237
+ await h.db.collection("members").insertMany([
238
+ { user: memberB, org: orgB, type: "organization", role: roleB, status: "active" },
239
+ // the staging shape: an org-B organisation row carrying org A's role
240
+ { user: holder, org: orgB, type: "organization", role: roleA, status: "active" },
241
+ // the newcomer holds org A's role, but in org A - it must not count in org B
242
+ { user: newcomer, org: orgA, type: "organization", role: roleA, status: "active" },
243
+ { user: staff, type: "admin", role: staffRole, status: "active" },
244
+ ]);
245
+
246
+ await h.db.collection("verifications").insertOne({
247
+ type: "member-invite",
248
+ email: NEWCOMER,
249
+ status: "pending",
250
+ metadata: { org: orgB, role: roleB, app: "organization" },
251
+ createdAt: now,
252
+ });
253
+
254
+ return { orgA, orgB, siteB1, siteB2, roleA, roleB, roleBSite2, template, newcomer, memberB, holder, support, staff };
255
+ }
@@ -0,0 +1,204 @@
1
+ // End-to-end proof that a person can add THEMSELVES through
2
+ // `POST /api/members/direct` only with a role they are entitled to.
3
+ //
4
+ // `requireOrgReach` decided where a self-enrolment may land and never looked at
5
+ // the role, so a member with a narrow role - or anybody holding any invitation
6
+ // into the organisation - could add themselves a second membership wearing the
7
+ // owner's `["*"]` role.
8
+ //
9
+ // Allowed: the org owner (any role), a role already held in that org, Seven365
10
+ // staff, the role the caller's own invitation offers (a role-less service-
11
+ // provider invite offers a role of the org whose type is the invite's app).
12
+ // Enrolling SOMEBODY ELSE is unchanged.
13
+ //
14
+ // Everything runs against the harness's in-process MongoDB replica set. No
15
+ // staging or production database, Redis, device or endpoint is touched.
16
+ //
17
+ // Run with: yarn test:e2e
18
+
19
+ import { after, before, describe, it } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { ObjectId } from "mongodb";
22
+
23
+ import { startHarness } from "./harness.mjs";
24
+
25
+ const PASSWORD = "SelfEnrol-Passw0rd!";
26
+ const U = {
27
+ NARROW: "se-narrow@e2e.example.com", // member of org B with a narrow role
28
+ NEWCOMER: "se-newcomer@e2e.example.com", // member-invite into org B offering the narrow role
29
+ CANCELLED: "se-cancelled@e2e.example.com", // a CANCELLED invite offering the owner role
30
+ SP: "se-sp@e2e.example.com", // role-less service-provider invite, app security_agency
31
+ OWNER: "se-owner@e2e.example.com", // org B is registered under this address
32
+ ADMIN: "se-admin@e2e.example.com", // member of org B with the owner role
33
+ SUPPORT: "se-support@e2e.example.com",
34
+ STAFF: "se-sevenadmin@e2e.example.com",
35
+ CASED: "se-cased@e2e.example.com", // member-invite stored under a differently-cased address
36
+ };
37
+
38
+ describe("self-enrolment only with a role the caller may give themselves", { concurrency: 1 }, () => {
39
+ let h;
40
+ const id = {};
41
+ const sid = {};
42
+ const result = [];
43
+
44
+ const record = (name, fn) =>
45
+ it(name, async () => {
46
+ try {
47
+ await fn();
48
+ result.push(["PASS", name]);
49
+ } catch (error) {
50
+ result.push(["FAIL", name]);
51
+ throw error;
52
+ }
53
+ });
54
+
55
+ before(async () => {
56
+ h = await startHarness();
57
+ Object.assign(id, await seed(h));
58
+ }, { timeout: 300000 });
59
+
60
+ after(async () => {
61
+ if (h) await h.stop();
62
+ console.log("\n--- members/direct self-enrol role: per-case result ---");
63
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
64
+ });
65
+
66
+ const enrol = (who, userKey, role, app = "organization", extra = {}) =>
67
+ h.api("/members/direct", {
68
+ method: "POST",
69
+ sid: sid[who],
70
+ body: {
71
+ userId: id.users[userKey].toString(),
72
+ orgId: id.orgB.toString(),
73
+ roleId: id[role].toString(),
74
+ app,
75
+ siteId: "",
76
+ siteName: "",
77
+ ...extra,
78
+ },
79
+ });
80
+
81
+ const rowsWith = (userKey, role) =>
82
+ h.db.collection("members").countDocuments({ user: id.users[userKey], org: id.orgB, role: id[role] });
83
+
84
+ record("0. baseline - everybody signs in", async () => {
85
+ for (const [k, e] of Object.entries(U)) sid[k] = await h.login(e, PASSWORD);
86
+ });
87
+
88
+ // ---- the escalation, refused -------------------------------------------------
89
+
90
+ record("1. a narrow member cannot give themselves the owner role", async () => {
91
+ const res = await enrol("NARROW", "NARROW", "ownerRole", "property_management_agency");
92
+ assert.equal(res.status, 401, JSON.stringify(res.body));
93
+ assert.equal(await rowsWith("NARROW", "ownerRole"), 0, "nothing may be written");
94
+ });
95
+
96
+ record("2. an invitee cannot take a role their invitation does not offer", async () => {
97
+ const res = await enrol("NEWCOMER", "NEWCOMER", "ownerRole");
98
+ assert.equal(res.status, 401, JSON.stringify(res.body));
99
+ assert.equal(await rowsWith("NEWCOMER", "ownerRole"), 0);
100
+ });
101
+
102
+ record("3. a cancelled invitation offers nothing", async () => {
103
+ const res = await enrol("CANCELLED", "CANCELLED", "ownerRole");
104
+ assert.equal(res.status, 401, JSON.stringify(res.body));
105
+ });
106
+
107
+ record("4. a role-less SP invite does not offer a role of another type", async () => {
108
+ const res = await enrol("SP", "SP", "ownerRole", "security_agency");
109
+ assert.equal(res.status, 401, JSON.stringify(res.body));
110
+ });
111
+
112
+ // ---- every live self-enrolment, still working ---------------------------------
113
+
114
+ record("5. member-invite sign-up: the invitation's role, org row then app row", async () => {
115
+ assert.equal((await enrol("NEWCOMER", "NEWCOMER", "narrowRole")).status, 201);
116
+ assert.equal((await enrol("NEWCOMER", "NEWCOMER", "narrowRole", "property_management_agency")).status, 201);
117
+ });
118
+
119
+ record("6. SP sign-in: the org's Admin Service Provider role of the invite's app, both rows", async () => {
120
+ const extra = { siteId: id.siteB.toString(), siteName: "SE Site B" };
121
+ assert.equal((await enrol("SP", "SP", "spRole", "organization", extra)).status, 201);
122
+ assert.equal((await enrol("SP", "SP", "spRole", "security_agency", extra)).status, 201);
123
+ });
124
+
125
+ record("7. onboarding owner: organisation row and app row with the owner role", async () => {
126
+ assert.equal((await enrol("OWNER", "OWNER", "ownerRole", "organization", { onboardingRequired: true })).status, 201);
127
+ assert.equal((await enrol("OWNER", "OWNER", "ownerRole", "property_management_agency")).status, 201);
128
+ });
129
+
130
+ record("8. onboarding finish: an app row repeating a role already held here", async () => {
131
+ const res = await enrol("NARROW", "NARROW", "narrowRole", "cleaning_services");
132
+ assert.equal(res.status, 201, JSON.stringify(res.body));
133
+ });
134
+
135
+ record("9. Seven365 staff", async () => {
136
+ const res = await enrol("STAFF", "STAFF", "ownerRole");
137
+ assert.equal(res.status, 201, JSON.stringify(res.body));
138
+ });
139
+
140
+ record("10. UNCHANGED: enrolling somebody else (owner adds the support account)", async () => {
141
+ const res = await enrol("OWNER", "SUPPORT", "ownerRole");
142
+ assert.equal(res.status, 201, JSON.stringify(res.body));
143
+ });
144
+
145
+ record("11. UNCHANGED: an admin adds a colleague", async () => {
146
+ const res = await enrol("ADMIN", "SUPPORT", "narrowRole", "property_management_agency");
147
+ assert.equal(res.status, 201, JSON.stringify(res.body));
148
+ });
149
+
150
+ record("12. a role-less SP invite does not offer ANOTHER org's role of the invite's type", async () => {
151
+ const res = await enrol("SP", "SP", "foreignSpRole", "security_agency");
152
+ assert.equal(res.status, 401, JSON.stringify(res.body));
153
+ assert.equal(await rowsWith("SP", "foreignSpRole"), 0);
154
+ });
155
+
156
+ record("13. the invitation is matched to the account whatever the address's case", async () => {
157
+ const res = await enrol("CASED", "CASED", "narrowRole");
158
+ assert.equal(res.status, 201, JSON.stringify(res.body));
159
+ });
160
+ });
161
+
162
+ async function seed(h) {
163
+ const now = new Date().toISOString();
164
+ const orgB = new ObjectId();
165
+ const siteB = new ObjectId();
166
+ const orgC = new ObjectId();
167
+ const [ownerRole, narrowRole, spRole, staffRole, foreignSpRole] = [0, 1, 2, 3, 4].map(() => new ObjectId());
168
+
169
+ await h.db.collection("organizations").insertOne({
170
+ _id: orgB, name: "SE Org B", email: U.OWNER, type: "org",
171
+ nature: "property_management_agency", status: "active", createdAt: now,
172
+ });
173
+ await h.db.collection("sites").insertOne({ _id: siteB, name: "SE Site B", orgId: orgB, status: "active" });
174
+ await h.db.collection("roles").insertMany([
175
+ { _id: ownerRole, name: "owner", type: "organization", org: orgB, permissions: ["*"], default: true, status: "active" },
176
+ { _id: narrowRole, name: "Viewer", type: "organization", org: orgB, permissions: ["see-all-feedback"], status: "active" },
177
+ { _id: spRole, name: "Admin Service Provider", type: "security_agency", org: orgB, site: siteB, permissions: ["*"], status: "active" },
178
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
179
+ { _id: foreignSpRole, name: "Admin Service Provider", type: "security_agency", org: orgC, permissions: ["*"], status: "active" },
180
+ ]);
181
+
182
+ const emails = Object.values(U);
183
+ const inserted = await h.db.collection("users").insertMany(
184
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
185
+ );
186
+ const hashed = await h.hashPassword(PASSWORD);
187
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
188
+ const users = Object.fromEntries(Object.keys(U).map((k, i) => [k, inserted.insertedIds[i]]));
189
+
190
+ await h.db.collection("members").insertMany([
191
+ { user: users.NARROW, org: orgB, type: "organization", role: narrowRole, status: "active" },
192
+ { user: users.ADMIN, org: orgB, type: "organization", role: ownerRole, status: "active" },
193
+ { user: users.STAFF, type: "admin", role: staffRole, status: "active" },
194
+ ]);
195
+
196
+ await h.db.collection("verifications").insertMany([
197
+ { type: "member-invite", email: U.NEWCOMER, status: "pending", metadata: { org: orgB, role: narrowRole, app: "organization" }, createdAt: now },
198
+ { type: "member-invite", email: U.CANCELLED, status: "cancelled", metadata: { org: orgB, role: ownerRole, app: "organization" }, createdAt: now },
199
+ { type: "service-provider-invite", email: U.SP, status: "pending", metadata: { org: orgB, siteId: siteB, app: "security_agency" }, createdAt: now },
200
+ { type: "member-invite", email: "SE-Cased@E2E.example.com", status: "pending", metadata: { org: orgB, role: narrowRole, app: "organization" }, createdAt: now },
201
+ ]);
202
+
203
+ return { orgB, siteB, ownerRole, narrowRole, spRole, foreignSpRole, users };
204
+ }
@@ -190,7 +190,7 @@ async function seed(h) {
190
190
  },
191
191
  ]);
192
192
 
193
- await h.db.collection("users").insertOne({
193
+ const caller = await h.db.collection("users").insertOne({
194
194
  email: CALLER,
195
195
  name: "Org Lookup Caller",
196
196
  status: "active",
@@ -198,5 +198,15 @@ async function seed(h) {
198
198
  createdAt: now,
199
199
  });
200
200
 
201
+ // The signed-in twin gives the whole document to somebody with a relationship
202
+ // to the organisation (organization-record-reach.e2e), so the caller whose
203
+ // read case 8 checks is a member of it.
204
+ await h.db.collection("members").insertOne({
205
+ user: caller.insertedId,
206
+ org: orgs.insertedIds[0],
207
+ type: "organization",
208
+ status: "active",
209
+ });
210
+
201
211
  return { org: orgs.insertedIds[0] };
202
212
  }
@@ -180,9 +180,12 @@ describe("organisation lookups are scoped to the caller", { concurrency: 1 }, ()
180
180
  assert.notEqual(res.status, 401, JSON.stringify(res.body));
181
181
  });
182
182
 
183
- record("15. and the guarded by-id read is unchanged too", async () => {
183
+ record("15. the guarded by-id read answers another client with its name only", async () => {
184
+ // Still a 200 - the mobile apps sign the user out on a 401 from this read -
185
+ // but no longer the whole record. See organization-record-reach.e2e.
184
186
  const res = await h.api(`/organizations/id/${id.orgB}`, { sid: mallorySid });
185
187
  assert.equal(res.status, 200, JSON.stringify(res.body));
188
+ assert.deepEqual(Object.keys(res.body ?? {}).sort(), ["_id", "name"]);
186
189
  });
187
190
  });
188
191