@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.134.0",
4
+ "version": "3.136.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -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. SP sign-in: the org's Admin Service Provider role of the invite's app, both rows", async () => {
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, 201);
122
- assert.equal((await enrol("SP", "SP", "spRole", "security_agency", extra)).status, 201);
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
- // the service-provider invitation the sign-in flow reads: addressed to the
445
- // newcomer, naming org B, stored as an ObjectId — the shape MVerification writes
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: "service-provider-invite",
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
- assert.deepEqual(actions.slice(0, 3), ["created", "approved", "provider-notified"]);
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`, {