@7365admin1/core 3.65.1 → 3.65.2

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.65.1",
4
+ "version": "3.65.2",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -211,11 +211,12 @@ function gateCalls() {
211
211
  return calls;
212
212
  }
213
213
 
214
- test("every console gate call site names a module - all 48 of them", () => {
214
+ test("every console gate call site names a module - all 49 of them", () => {
215
215
  const calls = gateCalls();
216
216
  // 48 since the module gate added `organization.controller.ts previewOrgModules`
217
217
  // and `member.controller.ts getModulesByUserIdType` (self-or-staff, `members`).
218
- assert.equal(calls.length, 48, `gate call sites moved: ${calls.length}`);
218
+ // 49: `member.controller.ts getAllByUser` (self-or-staff, `members`).
219
+ assert.equal(calls.length, 49, `gate call sites moved: ${calls.length}`);
219
220
 
220
221
  const ungoverned = calls.filter(
221
222
  ([, call]) =>
@@ -0,0 +1,135 @@
1
+ // End-to-end proof that `GET /api/members/users/:id` — every membership a
2
+ // person holds, across every client — is no longer readable by anybody.
3
+ //
4
+ // It was `requireAuth` and nothing more, so any signed-in account could read
5
+ // which organisations, apps and sites any other person works for. Now:
6
+ // - the person themselves and Seven365 staff get the whole list;
7
+ // - anybody else gets only the rows in organisations they reach — filtered,
8
+ // never refused, so no screen gets a 401 and no mobile app signs out.
9
+ //
10
+ // Consumers exercised (every call site in the org):
11
+ // - web-app-account / web-app-org layouts/default.vue, web-app-main
12
+ // pages/sign-in.vue: the signed-in user's OWN id (case 1).
13
+ // - layer-common ServiceProviderMain.vue (e-mail check + view dialog): a
14
+ // client admin reading a PROVIDER's id (case 3).
15
+ // - layer-common InvitationClientForm.vue (web-app-org super-admin client
16
+ // list): Seven365 staff reading anybody (case 2).
17
+ //
18
+ // Everything runs against the harness's throwaway in-process MongoDB. Nothing
19
+ // real is touched; assertions compare ids and counts only.
20
+
21
+ import { after, before, describe, it } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { ObjectId } from "mongodb";
24
+
25
+ import { startHarness } from "./harness.mjs";
26
+
27
+ const PASSWORD = "Reach-Passw0rd!";
28
+ const PROVIDER = "provider@e2e.example.com"; // works on client A's and client B's sites
29
+ const ADMIN_A = "admin-a@e2e.example.com"; // client A's own admin
30
+ const STRANGER = "stranger@e2e.example.com"; // a resident of client C only
31
+ const RESUBMIT = "resubmit@e2e.example.com"; // applicant at client A, asked to resubmit
32
+ const STAFF = "sevenstaff@e2e.example.com";
33
+
34
+ describe("GET /members/users/:id is self/staff in full, otherwise filtered to reach", { concurrency: 1 }, () => {
35
+ let h;
36
+ const id = {};
37
+ const sid = {};
38
+
39
+ before(async () => {
40
+ h = await startHarness();
41
+ Object.assign(id, await seed(h));
42
+ for (const [k, email] of Object.entries({ PROVIDER, ADMIN_A, STRANGER, RESUBMIT, STAFF })) {
43
+ sid[k] = await h.login(email, PASSWORD);
44
+ }
45
+ }, { timeout: 300000 });
46
+
47
+ after(async () => {
48
+ if (h) await h.stop();
49
+ });
50
+
51
+ const read = async (who, userId) => {
52
+ const res = await h.api(`/members/users/${userId}`, { sid: sid[who] });
53
+ assert.equal(res.status, 200, JSON.stringify(res.body));
54
+ assert.equal(res.body.total, res.body.items.length);
55
+ return res.body.items.map((m) => String(m.org ?? "")).sort();
56
+ };
57
+
58
+ it("1. the person reads all of their own memberships", async () => {
59
+ assert.deepEqual(await read("PROVIDER", id.provider), [id.orgA, id.orgB, id.orgSP].map(String).sort());
60
+ });
61
+
62
+ it("2. Seven365 staff read anybody's in full (the console's client form)", async () => {
63
+ assert.deepEqual(await read("STAFF", id.provider), [id.orgA, id.orgB, id.orgSP].map(String).sort());
64
+ });
65
+
66
+ it("3. a client admin sees only the provider's row in their own client", async () => {
67
+ // ServiceProviderMain's e-mail check still finds the row at this org/site.
68
+ const res = await h.api(`/members/users/${id.provider}`, { sid: sid.ADMIN_A });
69
+ assert.equal(res.status, 200);
70
+ assert.deepEqual(res.body.items.map((m) => String(m.org)), [String(id.orgA)]);
71
+ assert.equal(res.body.items[0].type, "cleaning");
72
+ assert.equal(String(res.body.items[0].siteId), String(id.siteA));
73
+ const text = JSON.stringify(res.body);
74
+ assert.ok(!text.includes(String(id.orgB)) && !text.includes(String(id.orgSP)), "no other client leaks");
75
+ });
76
+
77
+ it("4. a stranger gets an empty list, not a 401", async () => {
78
+ assert.deepEqual(await read("STRANGER", id.provider), []);
79
+ });
80
+
81
+ it("5. a resubmit applicant with an active resident row reaches nothing", async () => {
82
+ assert.deepEqual(await read("RESUBMIT", id.provider), []);
83
+ });
84
+
85
+ it("6. a Seven365 staff row (no org) is never in a client's reach", async () => {
86
+ assert.deepEqual(await read("ADMIN_A", id.staff), []);
87
+ });
88
+
89
+ it("7. the same person's cached list is still whole for them afterwards", async () => {
90
+ // The filter must never be written into the per-person cache.
91
+ assert.equal((await read("PROVIDER", id.provider)).length, 3);
92
+ });
93
+ });
94
+
95
+ async function seed(h) {
96
+ const now = new Date().toISOString();
97
+ const [orgA, orgB, orgC, orgSP, siteA, siteB] = Array.from({ length: 6 }, () => new ObjectId());
98
+ const [staffRole, roleA, roleB, roleC, roleSP] = Array.from({ length: 5 }, () => new ObjectId());
99
+
100
+ const org = (_id, name) => ({ _id, name, email: `${name.replace(/\W/g, "").toLowerCase()}@e2e.example.com`, type: "org", nature: "property_management_agency", status: "active", createdAt: now });
101
+ await h.db.collection("organizations").insertMany([org(orgA, "Client A"), org(orgB, "Client B"), org(orgC, "Client C"), org(orgSP, "Clean Co")]);
102
+ await h.db.collection("sites").insertMany([
103
+ { _id: siteA, name: "Site A", org: orgA, orgId: orgA, status: "active" },
104
+ { _id: siteB, name: "Site B", org: orgB, orgId: orgB, status: "active" },
105
+ ]);
106
+ await h.db.collection("roles").insertMany([
107
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
108
+ { _id: roleA, name: "Admin", org: orgA, permissions: ["*"], status: "active" },
109
+ { _id: roleB, name: "Admin", org: orgB, permissions: ["*"], status: "active" },
110
+ { _id: roleC, name: "Resident", org: orgC, permissions: [], status: "active" },
111
+ { _id: roleSP, name: "Owner", org: orgSP, permissions: ["*"], status: "active" },
112
+ ]);
113
+
114
+ const people = [
115
+ [PROVIDER, "active"], [ADMIN_A, "active"], [STRANGER, "active"], [RESUBMIT, "resubmit"], [STAFF, "active"],
116
+ ];
117
+ const users = await h.db.collection("users").insertMany(
118
+ people.map(([email, status]) => ({ email, name: email.split("@")[0], status, createdAt: now })),
119
+ );
120
+ const hashed = await h.hashPassword(PASSWORD);
121
+ await h.db.collection("users").updateMany({ email: { $in: people.map(([e]) => e) } }, { $set: { password: hashed } });
122
+ const [provider, adminA, stranger, resubmit, staff] = people.map((_, i) => users.insertedIds[i]);
123
+
124
+ await h.db.collection("members").insertMany([
125
+ { user: provider, org: orgSP, type: "organization", role: roleSP, status: "active" },
126
+ { user: provider, org: orgA, siteId: siteA, type: "cleaning", role: roleA, status: "active" },
127
+ { user: provider, org: orgB, siteId: siteB, type: "cleaning", role: roleB, status: "active" },
128
+ { user: adminA, org: orgA, type: "organization", role: roleA, status: "active" },
129
+ { user: stranger, org: orgC, type: "resident", role: roleC, status: "active" },
130
+ { user: resubmit, org: orgA, type: "resident", role: roleA, status: "active" },
131
+ { user: staff, type: "admin", role: staffRole, status: "active" },
132
+ ]);
133
+
134
+ return { orgA, orgB, orgC, orgSP, siteA, siteB, provider, adminA, stranger, resubmit, staff };
135
+ }
@@ -69,11 +69,15 @@ test("onboarding completion is STOPPED, not tightened, and says so", () => {
69
69
  assert.match(body, /DELIBERATELY LEFT AT ORG REACH/);
70
70
  });
71
71
 
72
- test("the all-clients membership read is deliberately left alone", () => {
72
+ test("the all-clients membership read is whole for self/staff, filtered for others", () => {
73
73
  // `GET /api/members/users/:id` is how layer-common's ServiceProviderMain looks
74
- // somebody else up by e-mail. Self-scoping it would break that screen, and the
75
- // route-to-permission map still lists it as an open judgement call.
76
- assert.doesNotMatch(fn(member, "getAllByUser"), /requireSelfOrPlatformStaff/);
74
+ // somebody else up by e-mail, so it is FILTERED to reached orgs, not refused.
75
+ // Behaviour: test/e2e/member-by-user-reach.e2e.test.mjs.
76
+ const body = fn(member, "getAllByUser");
77
+ assert.match(body, /requireSelfOrPlatformStaff\(req, userId/);
78
+ assert.match(body, /membershipsInReach\(req, data\.items\)/);
79
+ assert.match(fn(member, "membershipsInReach"), /requireOrgReach\(req, org\)/);
80
+ assert.match(fn(member, "membershipsInReach"), /callerAccountApproved/);
77
81
  });
78
82
 
79
83
  /* ------------------------------------------------------------- D3.14 */