@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/CHANGELOG.md +46 -0
- package/dist/index.d.ts +1055 -677
- package/dist/index.js +7940 -6995
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6079 -5148
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/console-permission.test.mjs +4 -3
- package/test/e2e/member-direct-role-org.e2e.test.mjs +255 -0
- package/test/e2e/member-direct-self-enrol-role.e2e.test.mjs +204 -0
- package/test/e2e/org-anon-lookup-projection.e2e.test.mjs +11 -1
- package/test/e2e/organization-read-scope.e2e.test.mjs +4 -1
- package/test/e2e/organization-record-reach.e2e.test.mjs +241 -0
- package/test/e2e/user-field-self-allowlist.e2e.test.mjs +172 -0
- package/test/member-direct-role-org.test.mjs +33 -0
- package/test/member-self-enrol-role.test.mjs +43 -0
- package/test/module-access.test.mjs +128 -0
- package/test/module-gate.fixtures.mjs +108 -0
- package/test/module-gate.test.mjs +64 -0
- package/test/modules-endpoint.test.mjs +525 -0
- package/test/notification-access.test.mjs +197 -0
- package/test/org-record-reach.test.mjs +45 -0
- package/test/resident-app-modules.test.mjs +45 -1
- package/test/role-member-org-scope.test.mjs +2 -1
- package/test/staff-console-authz.test.mjs +9 -0
- package/test/user-field-self-allowlist.test.mjs +39 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.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
|
|
214
|
+
test("every console gate call site names a module - all 48 of them", () => {
|
|
215
215
|
const calls = gateCalls();
|
|
216
|
-
//
|
|
217
|
-
|
|
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.
|
|
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
|
|