@7365admin1/core 3.134.0 → 3.136.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 +18 -0
- package/dist/index.d.ts +261 -150
- package/dist/index.js +1336 -870
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1538 -1077
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/harness.mjs +11 -0
- package/test/e2e/member-direct-self-enrol-role.e2e.test.mjs +5 -3
- package/test/e2e/member-site-access.e2e.test.mjs +91 -2
- package/test/e2e/org-scoping-roles-members.e2e.test.mjs +5 -3
- package/test/e2e/service-provider-invite-approval.e2e.test.mjs +16 -1
- package/test/e2e/service-provider-invite-email-new-company.e2e.test.mjs +425 -0
- package/test/role-member-org-scope.test.mjs +3 -1
- package/test/sp-invite-email-status.test.mjs +74 -0
package/package.json
CHANGED
package/test/e2e/harness.mjs
CHANGED
|
@@ -205,6 +205,9 @@ function startMailSink() {
|
|
|
205
205
|
sock.write("221 Bye\r\n");
|
|
206
206
|
sock.end();
|
|
207
207
|
return;
|
|
208
|
+
} else if (cmd === "RCPT" && /bounce/i.test(line)) {
|
|
209
|
+
// a mailbox the server refuses - proves a failed send is recorded
|
|
210
|
+
sock.write("550 5.1.1 Mailbox unavailable\r\n");
|
|
208
211
|
} else {
|
|
209
212
|
sock.write("250 2.0.0 Ok\r\n"); // AUTH, MAIL FROM, RCPT TO, RSET, NOOP
|
|
210
213
|
}
|
|
@@ -566,6 +569,10 @@ function buildApp(nsu, core) {
|
|
|
566
569
|
remove,
|
|
567
570
|
decline,
|
|
568
571
|
accept,
|
|
572
|
+
acceptNewCompany,
|
|
573
|
+
wrongJoinPreview,
|
|
574
|
+
wrongJoinRepair,
|
|
575
|
+
wrongJoinUndo,
|
|
569
576
|
linkSite,
|
|
570
577
|
received,
|
|
571
578
|
getOne,
|
|
@@ -583,6 +590,10 @@ function buildApp(nsu, core) {
|
|
|
583
590
|
invitations.get("/:id", requireAuth, getOne);
|
|
584
591
|
invitations.put("/:id/decline", requireAuth, decline);
|
|
585
592
|
invitations.put("/:id/accept", requireAuth, accept);
|
|
593
|
+
invitations.put("/:id/accept-new-company", requireAuth, acceptNewCompany);
|
|
594
|
+
invitations.get("/:id/wrong-join", requireAuth, wrongJoinPreview);
|
|
595
|
+
invitations.put("/:id/wrong-join/repair", requireAuth, wrongJoinRepair);
|
|
596
|
+
invitations.put("/:id/wrong-join/undo", requireAuth, wrongJoinUndo);
|
|
586
597
|
invitations.put("/:id/link-site", requireAuth, linkSite);
|
|
587
598
|
}
|
|
588
599
|
|
|
@@ -116,10 +116,12 @@ describe("self-enrolment only with a role the caller may give themselves", { con
|
|
|
116
116
|
assert.equal((await enrol("NEWCOMER", "NEWCOMER", "narrowRole", "property_management_agency")).status, 201);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
record("6.
|
|
119
|
+
record("6. CLOSED 2026-09-30: an SP invitation never enrols the provider into the org that SENT it", async () => {
|
|
120
|
+
// the provider joins as its own company (accept / accept-new-company); the
|
|
121
|
+
// old sign-in wrote it into the client's organisation with a "*" role
|
|
120
122
|
const extra = { siteId: id.siteB.toString(), siteName: "SE Site B" };
|
|
121
|
-
assert.equal((await enrol("SP", "SP", "spRole", "organization", extra)).status,
|
|
122
|
-
assert.equal((await enrol("SP", "SP", "spRole", "security_agency", extra)).status,
|
|
123
|
+
assert.equal((await enrol("SP", "SP", "spRole", "organization", extra)).status, 401);
|
|
124
|
+
assert.equal((await enrol("SP", "SP", "spRole", "security_agency", extra)).status, 401);
|
|
123
125
|
});
|
|
124
126
|
|
|
125
127
|
record("7. onboarding owner: organisation row and app row with the owner role", async () => {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { after, before, describe, it } from "node:test";
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
15
|
import { ObjectId } from "mongodb";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
16
17
|
|
|
17
18
|
import { startHarness } from "./harness.mjs";
|
|
18
19
|
|
|
@@ -25,6 +26,9 @@ const U = {
|
|
|
25
26
|
orgAdmin: "msa-org-admin@e2e.example.com",
|
|
26
27
|
dual: "msa-dual@e2e.example.com",
|
|
27
28
|
resident: "msa-resident@e2e.example.com",
|
|
29
|
+
tan: "msa-tan@e2e.example.com",
|
|
30
|
+
stray: "msa-stray@e2e.example.com",
|
|
31
|
+
pinnedGuard: "msa-pinned-guard@e2e.example.com",
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
describe("member site access", { concurrency: 1 }, () => {
|
|
@@ -69,7 +73,8 @@ describe("member site access", { concurrency: 1 }, () => {
|
|
|
69
73
|
assert.equal(res.body.sites.length, 3);
|
|
70
74
|
assert.equal(res.body.orgWide.length, 1);
|
|
71
75
|
const a = res.body.sites.find((s) => s.site === String(id.A));
|
|
72
|
-
assert.deepEqual(a.roles.map((r) => r.name).sort(), ["Cleaner at A", "X at A"]);
|
|
76
|
+
assert.deepEqual(a.roles.map((r) => r.name).sort(), ["Cleaner at A", "Security (Org)", "X at A"]);
|
|
77
|
+
assert.deepEqual(a.roles.filter((r) => r.scope === "organisation").map((r) => r.name), ["Security (Org)"], "org console roles are never offered");
|
|
73
78
|
});
|
|
74
79
|
|
|
75
80
|
it("3. A (role X) + B (role Y), organisation role removed: sees only A and B", async () => {
|
|
@@ -146,6 +151,84 @@ describe("member site access", { concurrency: 1 }, () => {
|
|
|
146
151
|
assert.ok(refused(await save("orgAdmin", [[id.A, id.X]], { removeOrgAccess: true }, id.users.owner)));
|
|
147
152
|
assert.equal((await save("owner", [[id.A, id.X]], {}, id.users.resident)).status, 400);
|
|
148
153
|
});
|
|
154
|
+
|
|
155
|
+
// Owner request 2026-09-30: create a role ONCE at organisation level and give it to an
|
|
156
|
+
// organisation member at the sites picked - reaching those sites only, never org-wide.
|
|
157
|
+
describe("organisation roles used per site", () => {
|
|
158
|
+
const readTan = () => h.api(`/members/site-access?org=${id.org}&user=${id.users.tan}`, { sid: sid.tan });
|
|
159
|
+
|
|
160
|
+
it("15. before: Tan (organisation level) reaches every site", async () => {
|
|
161
|
+
assert.deepEqual((await sitesSeen("tan")).sites, sort(id.A, id.B, id.C));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("16. owner gives Tan the organisation role Security (Org) at A and B only: he reaches A and B, nothing else", async () => {
|
|
165
|
+
const res = await save("owner", [[id.A, id.orgSec], [id.B, id.orgSec]], { removeOrgAccess: true }, id.users.tan);
|
|
166
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
167
|
+
const rows = await h.db.collection("members").find({ user: id.users.tan, status: { $ne: "deleted" } }).toArray();
|
|
168
|
+
assert.deepEqual(rows.map((m) => `${m.siteId}:${m.role}:${m.type}`).sort(), [`${id.A}:${id.orgSec}:${SEC}`, `${id.B}:${id.orgSec}:${SEC}`].sort());
|
|
169
|
+
assert.ok(rows.every((m) => String(m.createdBy) === String(id.users.owner)), "rows are stamped by the owner");
|
|
170
|
+
assert.deepEqual((await sitesSeen("tan")).sites, sort(id.A, id.B));
|
|
171
|
+
assert.equal((await invitesAt("tan", id.A)).status, 200, "acts at A");
|
|
172
|
+
assert.ok(refused(await invitesAt("tan", id.C)), "site C refused");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("17. no organisation scope: Tan (role holds *) cannot open site access for anybody, nor list org-wide invitations", async () => {
|
|
176
|
+
assert.ok(refused(await readTan()));
|
|
177
|
+
assert.ok(refused(await view("tan", id.users.dual)));
|
|
178
|
+
assert.ok(refused(await save("tan", [[id.A, id.orgSec]], {}, id.users.dual)));
|
|
179
|
+
const orgInvites = await h.api(`/verifications?status=pending&type=member-invite&orgId=${id.org}`, { sid: sid.tan });
|
|
180
|
+
assert.ok(refused(orgInvites) || (orgInvites.body?.items ?? []).every((v) => String(v.metadata?.siteId ?? "") !== ""), JSON.stringify(orgInvites.body));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("18. the 09-02 rule is untouched: an older pinned row with an organisation role (not placed by anyone) still reaches every site", async () => {
|
|
184
|
+
assert.deepEqual((await sitesSeen("stray")).sites, sort(id.A, id.B, id.C));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("19. choosing the SAME organisation role in Site access re-stamps that row: then it reaches only its site", async () => {
|
|
188
|
+
assert.equal((await save("owner", [[id.A, id.orgSec]], {}, id.users.stray)).status, 200);
|
|
189
|
+
assert.deepEqual((await sitesSeen("stray")).sites, sort(id.A));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("20. an organisation role of another organisation, or the org-console role, is refused for a site", async () => {
|
|
193
|
+
assert.equal((await save("owner", [[id.A, id.foreignOrgSec]], {}, id.users.tan)).status, 400);
|
|
194
|
+
assert.equal((await save("owner", [[id.A, id.orgRole]], {}, id.users.tan)).status, 400);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("21. the owner creates an organisation role in the Organization app (full Security catalogue + inventory staff preset); the org list shows only site-less roles and Site access offers it at every site at once", async () => {
|
|
198
|
+
const catalogue = JSON.parse(readFileSync(new URL("./fixtures/lc-role-catalogues.json", import.meta.url), "utf8"));
|
|
199
|
+
const permissions = catalogue.security_agency ?? catalogue.catalogues?.security_agency;
|
|
200
|
+
assert.ok(Array.isArray(permissions) && permissions.includes("inventory:request-stock"), "fixture has the security catalogue");
|
|
201
|
+
const created = await h.api("/roles", { method: "POST", sid: sid.owner, body: { name: "Guard (Org)", permissions, type: SEC, org: String(id.org) } });
|
|
202
|
+
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
203
|
+
const staff = await h.api("/roles", { method: "POST", sid: sid.owner, body: { name: "Store staff (Org)", permissions: ["inventory:view-inventory", "inventory:request-stock", "inventory:record-usage"], type: SEC, org: String(id.org) } });
|
|
204
|
+
assert.equal(staff.status, 201, JSON.stringify(staff.body));
|
|
205
|
+
|
|
206
|
+
const list = await h.api(`/roles?org=${id.org}&type=${SEC}&scope=organisation&limit=100`, { sid: sid.owner });
|
|
207
|
+
assert.equal(list.status, 200, JSON.stringify(list.body));
|
|
208
|
+
assert.deepEqual(list.body.items.map((r) => r.name).sort(), ["Guard (Org)", "Security (Org)", "Store staff (Org)"]);
|
|
209
|
+
assert.equal((await h.api(`/roles?org=${id.org}&scope=everything`, { sid: sid.owner })).status, 400);
|
|
210
|
+
|
|
211
|
+
const box = await h.api(`/members/site-access?org=${id.org}&user=${id.users.tan}`, { sid: sid.owner });
|
|
212
|
+
for (const site of box.body.sites) {
|
|
213
|
+
assert.deepEqual(site.roles.filter((r) => r.scope === "organisation").map((r) => r.name).sort(), ["Guard (Org)", "Security (Org)", "Store staff (Org)"], site.name);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("23. Assign Role (site Members) giving an organisation role to a member pinned to A keeps him at A", async () => {
|
|
218
|
+
const row = await h.db.collection("members").findOne({ user: id.users.pinnedGuard });
|
|
219
|
+
const res = await h.api(`/members/id/${row._id}/role/${id.orgSec}/type/${SEC}/org/${id.org}`, { method: "PUT", sid: sid.owner });
|
|
220
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
221
|
+
assert.equal(String((await h.db.collection("members").findOne({ _id: row._id })).role), String(id.orgSec));
|
|
222
|
+
assert.deepEqual((await sitesSeen("pinnedGuard")).sites, sort(id.A));
|
|
223
|
+
assert.ok(refused(await invitesAt("pinnedGuard", id.B)));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("22. a site-confined member cannot create or list the organisation's roles for another organisation", async () => {
|
|
227
|
+
const res = await h.api("/roles", { method: "POST", sid: sid.tan, body: { name: "Sneaky", permissions: ["*"], type: SEC, org: String(id.other) } });
|
|
228
|
+
assert.ok(refused(res), JSON.stringify(res.body));
|
|
229
|
+
assert.ok(refused(await h.api(`/roles?org=${id.other}&scope=organisation`, { sid: sid.tan })));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
149
232
|
});
|
|
150
233
|
|
|
151
234
|
async function seed(h) {
|
|
@@ -154,6 +237,7 @@ async function seed(h) {
|
|
|
154
237
|
const A = new ObjectId(), B = new ObjectId(), C = new ObjectId(), foreign = new ObjectId();
|
|
155
238
|
const W = new ObjectId();
|
|
156
239
|
const X = new ObjectId(), Y = new ObjectId(), Z = new ObjectId(), orgRole = new ObjectId(), ownerRole = new ObjectId(), foreignRole = new ObjectId();
|
|
240
|
+
const orgSec = new ObjectId(), foreignOrgSec = new ObjectId();
|
|
157
241
|
|
|
158
242
|
await h.db.collection("organizations").insertMany([
|
|
159
243
|
{ _id: org, name: "Aigle MSA", email: "aigle-msa@e2e.example.com", type: "org", nature: SEC, status: "active", createdAt: now },
|
|
@@ -171,6 +255,8 @@ async function seed(h) {
|
|
|
171
255
|
{ _id: orgRole, name: "Org Admin", type: "organization", org, site: "", permissions: ["*"], status: "active" },
|
|
172
256
|
{ _id: ownerRole, name: "owner", type: "organization", org, site: "", permissions: ["*"], default: true, status: "active" },
|
|
173
257
|
{ _id: foreignRole, name: "Foreign", type: SEC, org: other, site: foreign, permissions: ["*"], status: "active" },
|
|
258
|
+
{ _id: orgSec, name: "Security (Org)", type: SEC, org, site: "", permissions: ["*"], status: "active" },
|
|
259
|
+
{ _id: foreignOrgSec, name: "Foreign (Org)", type: SEC, org: other, site: "", permissions: ["*"], status: "active" },
|
|
174
260
|
]);
|
|
175
261
|
const users = {};
|
|
176
262
|
for (const [k, email] of Object.entries(U)) {
|
|
@@ -190,6 +276,9 @@ async function seed(h) {
|
|
|
190
276
|
{ user: users.resident, email: U.resident, org, type: "resident", role: X, siteId: A, status: "active", deletedAt: "", createdAt: now },
|
|
191
277
|
{ user: users.kanesan, email: U.kanesan, org, type: "organization", role: orgRole, status: "active", deletedAt: "", createdAt: now },
|
|
192
278
|
{ user: users.siteAdmin, email: U.siteAdmin, org, type: SEC, role: X, siteId: A, status: "active", deletedAt: "", createdAt: now },
|
|
279
|
+
{ user: users.tan, email: U.tan, org, type: "organization", role: orgRole, status: "active", deletedAt: "", createdAt: now },
|
|
280
|
+
{ user: users.pinnedGuard, email: U.pinnedGuard, org, type: SEC, role: X, siteId: A, status: "active", deletedAt: "", createdAt: now },
|
|
281
|
+
{ user: users.stray, email: U.stray, org, type: SEC, role: orgSec, siteId: A, status: "active", deletedAt: "", createdAt: now },
|
|
193
282
|
]);
|
|
194
|
-
return { org, other, A, B, C, foreign, X, Y, Z, orgRole, foreignRole, users };
|
|
283
|
+
return { org, other, A, B, C, foreign, X, Y, Z, orgRole, foreignRole, orgSec, foreignOrgSec, users };
|
|
195
284
|
}
|
|
@@ -441,10 +441,12 @@ async function seed(h) {
|
|
|
441
441
|
]);
|
|
442
442
|
|
|
443
443
|
await h.db.collection("verifications").insertMany([
|
|
444
|
-
//
|
|
445
|
-
// newcomer,
|
|
444
|
+
// a member invitation into org B (the admin-member sign-up), addressed to
|
|
445
|
+
// the newcomer, stored as an ObjectId — the shape MVerification writes. A
|
|
446
|
+
// service-provider invitation no longer reaches the org that sent it
|
|
447
|
+
// (2026-09-30), see member-direct-self-enrol-role test 6.
|
|
446
448
|
{
|
|
447
|
-
type: "
|
|
449
|
+
type: "member-invite",
|
|
448
450
|
email: NEWCOMER,
|
|
449
451
|
status: "pending",
|
|
450
452
|
metadata: { org: orgB, siteId: siteB, app: "security_agency", role: roleB },
|
|
@@ -257,7 +257,8 @@ describe("service-provider invitation approval, end to end", { concurrency: 1 },
|
|
|
257
257
|
|
|
258
258
|
// the audit trail
|
|
259
259
|
const actions = after.history.map((e) => e.action);
|
|
260
|
-
|
|
260
|
+
// the send's own outcome is recorded before "provider-notified"
|
|
261
|
+
assert.deepEqual(actions.slice(0, 4), ["created", "approved", "email-sent", "provider-notified"]);
|
|
261
262
|
assert.equal(after.history[1].byName, "Sasha Seven");
|
|
262
263
|
});
|
|
263
264
|
|
|
@@ -431,6 +432,20 @@ describe("service-provider invitation approval, end to end", { concurrency: 1 },
|
|
|
431
432
|
const rowsBefore = await h.db
|
|
432
433
|
.collection("verifications")
|
|
433
434
|
.countDocuments({ email: PROVIDER_EMAIL, "metadata.siteId": id.siteD });
|
|
435
|
+
// right after the approval email, Resend is held back (rate limit)...
|
|
436
|
+
const tooSoon = await h.api(`/service-provider-invitations/${inviteD}/resend`, {
|
|
437
|
+
method: "PUT",
|
|
438
|
+
sid: pmSid,
|
|
439
|
+
});
|
|
440
|
+
assert.equal(tooSoon.status, 400);
|
|
441
|
+
assert.match(tooSoon.body.message, /resend it in/);
|
|
442
|
+
// ...until the last attempt is old enough
|
|
443
|
+
await h.db.collection("verifications").updateOne(
|
|
444
|
+
{ _id: new ObjectId(inviteD) },
|
|
445
|
+
{ $set: { "history.$[e].at": new Date(Date.now() - 10 * 60_000).toISOString() } },
|
|
446
|
+
{ arrayFilters: [{ "e.action": "email-sent" }] },
|
|
447
|
+
);
|
|
448
|
+
|
|
434
449
|
const mailsBefore = h.mails.length;
|
|
435
450
|
|
|
436
451
|
const res = await h.api(`/service-provider-invitations/${inviteD}/resend`, {
|