@7365admin1/core 3.64.5 → 3.65.1

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.
@@ -0,0 +1,241 @@
1
+ // End-to-end proof that `GET /api/organizations/id/:id` hands the WHOLE
2
+ // organisation record only to somebody with a real relationship to it.
3
+ //
4
+ // It returned any client's full document — business e-mail, contact number, UEN,
5
+ // `modules`, `residentAppModules` — to every signed-in account on the platform.
6
+ // Now a caller with no relationship gets the same name-only projection the
7
+ // anonymous twin (`/org/id/:id`) already gives out. Never a 401/403: the RN apps
8
+ // (`src/actions/organization.ts`) sign the user out on a 401 from this read.
9
+ //
10
+ // Relationships that get the whole record, each with a case below:
11
+ // staff · a live member · an invitee · the owner mid-onboarding (by e-mail) ·
12
+ // an ACTIVE resident account (members row, users.site, users.unitId, an
13
+ // active/approved site.people row, or the org's defaultSite being their site) ·
14
+ // an engaged service provider. A rejected/resubmit applicant gets the name.
15
+ //
16
+ // Everything runs against the harness's in-process MongoDB replica set. No
17
+ // staging or production database, Redis, device or endpoint is touched. Seeded
18
+ // values are obviously fake; assertions are on the presence of keys.
19
+ //
20
+ // Run with: yarn test:e2e
21
+
22
+ import { after, before, describe, it } from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import { ObjectId } from "mongodb";
25
+
26
+ import { startHarness } from "./harness.mjs";
27
+
28
+ const PASSWORD = "OrgReach-Passw0rd!";
29
+ const U = {
30
+ STRANGER: "or-stranger@e2e.example.com",
31
+ MEMBER_A: "or-member-a@e2e.example.com",
32
+ MEMBER_B: "or-member-b@e2e.example.com",
33
+ RES_MEMBER: "or-res-member@e2e.example.com",
34
+ RES_SITE: "or-res-site@e2e.example.com",
35
+ RES_UNIT: "or-res-unit@e2e.example.com",
36
+ RES_PEOPLE: "or-res-people@e2e.example.com",
37
+ RES_DEFAULT: "or-res-default@e2e.example.com",
38
+ APPLICANT: "or-applicant@e2e.example.com",
39
+ REJECTED: "or-rejected@e2e.example.com",
40
+ RESUBMIT: "or-resubmit@e2e.example.com",
41
+ RES_LEGACY: "or-res-legacy@e2e.example.com",
42
+ PROVIDER: "or-provider@e2e.example.com",
43
+ EX_PROVIDER: "or-ex-provider@e2e.example.com",
44
+ OWNER: "or-owner@e2e.example.com",
45
+ INVITEE: "or-invitee@e2e.example.com",
46
+ STAFF: "or-sevenadmin@e2e.example.com",
47
+ };
48
+ const PRIVATE = ["email", "contact", "busInst", "residentAppModules", "defaultSite"];
49
+
50
+ describe("the whole organisation record goes only to a real relationship", { concurrency: 1 }, () => {
51
+ let h;
52
+ const id = {};
53
+ const sid = {};
54
+ const result = [];
55
+
56
+ const record = (name, fn) =>
57
+ it(name, async () => {
58
+ try {
59
+ await fn();
60
+ result.push(["PASS", name]);
61
+ } catch (error) {
62
+ result.push(["FAIL", name]);
63
+ throw error;
64
+ }
65
+ });
66
+
67
+ before(async () => {
68
+ h = await startHarness();
69
+ Object.assign(id, await seed(h));
70
+ }, { timeout: 300000 });
71
+
72
+ after(async () => {
73
+ if (h) await h.stop();
74
+ console.log("\n--- organisation record reach: per-case result ---");
75
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
76
+ });
77
+
78
+ const read = (who, org = id.orgB) => h.api(`/organizations/id/${org}`, { sid: sid[who] });
79
+
80
+ const nameOnly = async (who) => {
81
+ const res = await read(who);
82
+ assert.equal(res.status, 200, `${who}: never a 401/403 - ${JSON.stringify(res.status)}`);
83
+ assert.deepEqual(Object.keys(res.body ?? {}).sort(), ["_id", "name"], who);
84
+ assert.equal(res.body.name, "OR Org B");
85
+ };
86
+
87
+ const whole = async (who) => {
88
+ const res = await read(who);
89
+ assert.equal(res.status, 200, `${who}: ${JSON.stringify(res.body)}`);
90
+ for (const key of PRIVATE) assert.ok(key in (res.body ?? {}), `${who} must get ${key}`);
91
+ };
92
+
93
+ record("0. baseline - everybody signs in", async () => {
94
+ for (const [k, e] of Object.entries(U)) {
95
+ if (k === "REJECTED") continue; // gets no session at all - case 18
96
+ sid[k] = await h.login(e, PASSWORD);
97
+ }
98
+ });
99
+
100
+ // ---- no relationship: the name, and only the name ------------------------
101
+
102
+ record("1. a signed-in stranger gets the name only", async () => nameOnly("STRANGER"));
103
+ record("2. a member of ANOTHER client gets the name only", async () => nameOnly("MEMBER_A"));
104
+ record("3. a pending applicant's site.people row buys nothing", async () => nameOnly("APPLICANT"));
105
+ record("4. a provider whose engagement was deleted gets the name only", async () => nameOnly("EX_PROVIDER"));
106
+
107
+ // ---- every real relationship: the whole record ----------------------------
108
+
109
+ record("5. a live member", async () => whole("MEMBER_B"));
110
+ record("6. a resident with a members row", async () => whole("RES_MEMBER"));
111
+ record("7. a resident with no membership, by users.site", async () => whole("RES_SITE"));
112
+ record("8. a resident with no membership, by users.unitId", async () => whole("RES_UNIT"));
113
+ record("9. a resident by an approved site.people row", async () => whole("RES_PEOPLE"));
114
+ record("10. a resident whose site is the org's defaultSite", async () => whole("RES_DEFAULT"));
115
+ record("11. an engaged service provider", async () => whole("PROVIDER"));
116
+ record("12. the owner mid-onboarding, before any membership", async () => whole("OWNER"));
117
+ record("13. an invitee, before any membership", async () => whole("INVITEE"));
118
+ record("14. Seven365 staff", async () => whole("STAFF"));
119
+
120
+ // ---- unchanged edges --------------------------------------------------------
121
+
122
+ record("15. no session: /id/ is still 401, /org/id/ still the name", async () => {
123
+ assert.equal((await h.api(`/organizations/id/${id.orgB}`)).status, 401);
124
+ const anon = await h.api(`/organizations/org/id/${id.orgB}`);
125
+ assert.equal(anon.status, 200);
126
+ assert.deepEqual(Object.keys(anon.body ?? {}).sort(), ["_id", "name"]);
127
+ });
128
+
129
+ record("16. the anonymous /org/id/ mount is name-only even when a session is sent", async () => {
130
+ // It carries no `requireAuth`, so no session is resolved on it: a sent `sid`
131
+ // (layer-common `$api` always attaches one) buys nothing there. Its callers
132
+ // read `org.name` only.
133
+ for (const who of ["STRANGER", "MEMBER_B"]) {
134
+ const res = await h.api(`/organizations/org/id/${id.orgB}`, { sid: sid[who] });
135
+ assert.equal(res.status, 200);
136
+ assert.deepEqual(Object.keys(res.body ?? {}).sort(), ["_id", "name"], who);
137
+ }
138
+ });
139
+
140
+ record("17. unknown id is 404 and malformed id is 400, for anybody", async () => {
141
+ assert.equal((await h.api("/organizations/id/0123456789abcdef01234567", { sid: sid.STRANGER })).status, 404);
142
+ assert.equal((await h.api("/organizations/id/not-hex", { sid: sid.STRANGER })).status, 400);
143
+ });
144
+
145
+ // ---- a reviewed applicant can sign in, with the site they applied for ------
146
+
147
+ record("18. a REJECTED applicant gets no session at all (assertAccountLive)", async () => {
148
+ await assert.rejects(h.login(U.REJECTED, PASSWORD));
149
+ });
150
+ record("19. a RESUBMIT applicant (site, unit AND self-signup's resident members row) gets the name only", async () =>
151
+ nameOnly("RESUBMIT"));
152
+ record("20. an older resident account with a BLANK status still gets the whole record", async () =>
153
+ whole("RES_LEGACY"));
154
+ });
155
+
156
+ async function seed(h) {
157
+ const now = new Date().toISOString();
158
+ const [orgA, orgB, orgP, orgQ, orgX] = [0, 1, 2, 3, 4].map(() => new ObjectId());
159
+ const [siteB, siteStray] = [new ObjectId(), new ObjectId()];
160
+ const unitB = new ObjectId();
161
+ const staffRole = new ObjectId();
162
+
163
+ await h.db.collection("organizations").insertMany([
164
+ { _id: orgA, name: "OR Org A", email: "or-a@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
165
+ {
166
+ _id: orgB,
167
+ name: "OR Org B",
168
+ email: U.OWNER, // the owner's own account address: `hasOrgOwnership`
169
+ contact: "+650000001",
170
+ busInst: "E2E-OR-UEN",
171
+ residentAppModules: { feedback: false },
172
+ defaultSite: siteStray, // the staging shape: a default site owned elsewhere
173
+ type: "org",
174
+ nature: "property_management_agency",
175
+ status: "active",
176
+ createdAt: now,
177
+ },
178
+ { _id: orgP, name: "OR Provider", email: "or-p@e2e.example.com", type: "org", nature: "security_agency", status: "active", createdAt: now },
179
+ { _id: orgQ, name: "OR Ex Provider", email: "or-q@e2e.example.com", type: "org", nature: "cleaning_services", status: "active", createdAt: now },
180
+ { _id: orgX, name: "OR Org X", email: "or-x@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
181
+ ]);
182
+
183
+ await h.db.collection("sites").insertMany([
184
+ { _id: siteB, name: "OR Site B", orgId: orgB, status: "active" },
185
+ { _id: siteStray, name: "OR Stray Site", orgId: orgX, status: "active" },
186
+ ]);
187
+ await h.db.collection("building-units").insertOne({ _id: unitB, name: "OR #01-01", site: siteB, status: "active" });
188
+ await h.db.collection("roles").insertOne({ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" });
189
+
190
+ const emails = Object.values(U);
191
+ const users = await h.db.collection("users").insertMany(
192
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
193
+ );
194
+ const hashed = await h.hashPassword(PASSWORD);
195
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
196
+ const uid = Object.fromEntries(Object.keys(U).map((k, i) => [k, users.insertedIds[i]]));
197
+
198
+ await h.db.collection("users").updateOne({ _id: uid.RES_SITE }, { $set: { site: siteB } });
199
+ await h.db.collection("users").updateOne({ _id: uid.RES_UNIT }, { $set: { unitId: unitB } });
200
+ await h.db.collection("users").updateOne({ _id: uid.RES_DEFAULT }, { $set: { site: siteStray } });
201
+ for (const who of ["REJECTED", "RESUBMIT"]) {
202
+ await h.db.collection("users").updateOne(
203
+ { _id: uid[who] },
204
+ { $set: { site: siteB, unitId: unitB, status: who.toLowerCase() } },
205
+ );
206
+ }
207
+ await h.db.collection("users").updateOne({ _id: uid.RES_LEGACY }, { $set: { site: siteB, status: "" } });
208
+
209
+ await h.db.collection("members").insertMany([
210
+ { user: uid.MEMBER_A, org: orgA, type: "organization", status: "active" },
211
+ { user: uid.MEMBER_B, org: orgB, type: "organization", status: "active" },
212
+ { user: uid.RES_MEMBER, org: orgB, siteId: siteB, type: "resident", status: "active" },
213
+ // self-signup writes this row, defaulting to active, and review never changes it
214
+ { user: uid.RESUBMIT, org: orgB, siteId: siteB, type: "resident", status: "active" },
215
+ { user: uid.PROVIDER, org: orgP, type: "security_agency", status: "active" },
216
+ { user: uid.EX_PROVIDER, org: orgQ, type: "cleaning_services", status: "active" },
217
+ { user: uid.STAFF, type: "admin", role: staffRole, status: "active" },
218
+ ]);
219
+
220
+ await h.db.collection("site.people").insertMany([
221
+ { user: uid.RES_PEOPLE, site: siteB, unit: unitB, status: "approved", createdAt: now },
222
+ { user: uid.APPLICANT, site: siteB, unit: unitB, status: "pending", createdAt: now },
223
+ { user: uid.REJECTED, site: siteB, unit: unitB, status: "rejected", createdAt: now },
224
+ { user: uid.RESUBMIT, site: siteB, unit: unitB, status: "resubmit", createdAt: now },
225
+ ]);
226
+
227
+ await h.db.collection("customer.sites").insertMany([
228
+ { name: "OR Site B", site: siteB, siteOrg: orgB, org: orgP, status: "active", deletedAt: "", createdAt: now },
229
+ { name: "OR Site B", site: siteB, siteOrg: orgB, org: orgQ, status: "deleted", deletedAt: now, createdAt: now },
230
+ ]);
231
+
232
+ await h.db.collection("verifications").insertOne({
233
+ type: "member-invite",
234
+ email: U.INVITEE,
235
+ status: "pending",
236
+ metadata: { org: orgB, app: "organization" },
237
+ createdAt: now,
238
+ });
239
+
240
+ return { orgA, orgB, orgP, orgQ, orgX, siteB, siteStray, unitB };
241
+ }
@@ -0,0 +1,172 @@
1
+ // End-to-end proof that a person can change only harmless profile fields on
2
+ // their own account through `PATCH /api/users/field/:id` (and the v2 twin).
3
+ //
4
+ // The route's allow-list includes `status`, and self-service took every field
5
+ // except `email`, so any signed-in account could rewrite its own `status` -
6
+ // un-suspend, un-reject or self-approve - and login trusts that column.
7
+ //
8
+ // Self-service now: name, contact, nric, dateOfBirth, profile, gender; and
9
+ // `defaultOrg` only to an organisation the caller reaches (member, invitee,
10
+ // owner) - the web-app-main sign-up flows set it to the org just created or
11
+ // joined. Everything else (`status`, `email`, `plateNumber`) is staff-only.
12
+ //
13
+ // Everything runs against the harness's in-process MongoDB replica set. No
14
+ // staging or production database, Redis, device or endpoint is touched.
15
+ //
16
+ // Run with: yarn test:e2e
17
+
18
+ import { after, before, describe, it } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { ObjectId } from "mongodb";
21
+
22
+ import { startHarness } from "./harness.mjs";
23
+
24
+ const PASSWORD = "UserField-Passw0rd!";
25
+ const U = {
26
+ MEMBER: "uf-member@e2e.example.com", // member of org A
27
+ INVITEE: "uf-invitee@e2e.example.com", // invited into org B
28
+ OWNER: "uf-owner@e2e.example.com", // org C is registered under this address
29
+ APPLICANT: "uf-applicant@e2e.example.com", // status "resubmit": can sign in, not yet approved
30
+ STAFF: "uf-sevenadmin@e2e.example.com",
31
+ };
32
+
33
+ describe("self-service user fields are an allow-list", { concurrency: 1 }, () => {
34
+ let h;
35
+ const id = {};
36
+ const sid = {};
37
+ const result = [];
38
+
39
+ const record = (name, fn) =>
40
+ it(name, async () => {
41
+ try {
42
+ await fn();
43
+ result.push(["PASS", name]);
44
+ } catch (error) {
45
+ result.push(["FAIL", name]);
46
+ throw error;
47
+ }
48
+ });
49
+
50
+ before(async () => {
51
+ h = await startHarness();
52
+ Object.assign(id, await seed(h));
53
+ }, { timeout: 300000 });
54
+
55
+ after(async () => {
56
+ if (h) await h.stop();
57
+ console.log("\n--- users/field self allow-list: per-case result ---");
58
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
59
+ });
60
+
61
+ const patch = (who, target, field, value, base = "/users/field") =>
62
+ h.api(`${base}/${id.users[target]}`, { method: "PATCH", sid: sid[who], body: { field, value } });
63
+
64
+ const stored = async (key) => h.db.collection("users").findOne({ _id: id.users[key] });
65
+
66
+ record("0. baseline - everybody signs in", async () => {
67
+ for (const [k, e] of Object.entries(U)) sid[k] = await h.login(e, PASSWORD);
68
+ });
69
+
70
+ // ---- refused ----------------------------------------------------------------
71
+
72
+ record("1. an applicant cannot approve themselves by setting their own status", async () => {
73
+ // "resubmit" can sign in (it is not session-blocking); "active" is approval.
74
+ const res = await patch("APPLICANT", "APPLICANT", "status", "active");
75
+ assert.equal(res.status, 401, JSON.stringify(res.body));
76
+ assert.equal((await stored("APPLICANT")).status, "resubmit", "status must be unchanged");
77
+ });
78
+
79
+ record("2. nor their own plateNumber", async () => {
80
+ const res = await patch("MEMBER", "MEMBER", "plateNumber", "SXX1234Z");
81
+ assert.equal(res.status, 401, JSON.stringify(res.body));
82
+ });
83
+
84
+ record("3. nor point defaultOrg at an organisation they do not reach", async () => {
85
+ const res = await patch("MEMBER", "MEMBER", "defaultOrg", id.orgB.toString());
86
+ assert.equal(res.status, 401, JSON.stringify(res.body));
87
+ assert.equal(String((await stored("MEMBER")).defaultOrg ?? ""), "");
88
+ });
89
+
90
+ record("4. and the v2 twin refuses the same defaultOrg", async () => {
91
+ const res = await patch("MEMBER", "MEMBER", "defaultOrg", id.orgB.toString(), "/users/v2/field");
92
+ assert.equal(res.status, 401, JSON.stringify(res.body));
93
+ });
94
+
95
+ record("5. UNCHANGED: email is still staff-only", async () => {
96
+ const res = await patch("MEMBER", "MEMBER", "email", "uf-other@e2e.example.com");
97
+ assert.equal(res.status, 401, JSON.stringify(res.body));
98
+ });
99
+
100
+ // ---- still works ------------------------------------------------------------
101
+
102
+ record("6. every profile field the apps send is still self-service", async () => {
103
+ for (const [field, value] of [
104
+ ["name", "UF Member"],
105
+ ["contact", "61234567"],
106
+ ["gender", "female"],
107
+ ["profile", "e2e-profile-file-id"],
108
+ ["nric", "S0000000A"],
109
+ ["dateOfBirth", "1990-01-01"],
110
+ ]) {
111
+ const res = await patch("MEMBER", "MEMBER", field, value);
112
+ assert.equal(res.status, 200, `${field}: ${JSON.stringify(res.body)}`);
113
+ }
114
+ });
115
+
116
+ record("7. defaultOrg to your own member organisation", async () => {
117
+ const res = await patch("MEMBER", "MEMBER", "defaultOrg", id.orgA.toString());
118
+ assert.equal(res.status, 200, JSON.stringify(res.body));
119
+ });
120
+
121
+ record("8. defaultOrg to the organisation you were invited into (sign-up flow)", async () => {
122
+ const res = await patch("INVITEE", "INVITEE", "defaultOrg", id.orgB.toString());
123
+ assert.equal(res.status, 200, JSON.stringify(res.body));
124
+ });
125
+
126
+ record("9. defaultOrg to the organisation you just created (owner sign-up)", async () => {
127
+ const res = await patch("OWNER", "OWNER", "defaultOrg", id.orgC.toString(), "/users/v2/field");
128
+ assert.equal(res.status, 200, JSON.stringify(res.body));
129
+ });
130
+
131
+ record("10. Seven365 staff can still set somebody's status", async () => {
132
+ const res = await patch("STAFF", "MEMBER", "status", "active");
133
+ assert.equal(res.status, 200, JSON.stringify(res.body));
134
+ });
135
+
136
+ record("11. UNCHANGED: nobody edits another person's profile", async () => {
137
+ const res = await patch("INVITEE", "MEMBER", "name", "renamed");
138
+ assert.equal(res.status, 401, JSON.stringify(res.body));
139
+ });
140
+ });
141
+
142
+ async function seed(h) {
143
+ const now = new Date().toISOString();
144
+ const [orgA, orgB, orgC] = [0, 1, 2].map(() => new ObjectId());
145
+ const staffRole = new ObjectId();
146
+
147
+ await h.db.collection("organizations").insertMany([
148
+ { _id: orgA, name: "UF Org A", email: "uf-a@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
149
+ { _id: orgB, name: "UF Org B", email: "uf-b@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
150
+ { _id: orgC, name: "UF Org C", email: U.OWNER, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
151
+ ]);
152
+ await h.db.collection("roles").insertOne({ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" });
153
+
154
+ const emails = Object.values(U);
155
+ const inserted = await h.db.collection("users").insertMany(
156
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
157
+ );
158
+ const hashed = await h.hashPassword(PASSWORD);
159
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
160
+ const users = Object.fromEntries(Object.keys(U).map((k, i) => [k, inserted.insertedIds[i]]));
161
+ await h.db.collection("users").updateOne({ _id: users.APPLICANT }, { $set: { status: "resubmit" } });
162
+
163
+ await h.db.collection("members").insertMany([
164
+ { user: users.MEMBER, org: orgA, type: "organization", status: "active" },
165
+ { user: users.STAFF, type: "admin", role: staffRole, status: "active" },
166
+ ]);
167
+ await h.db.collection("verifications").insertOne({
168
+ type: "member-invite", email: U.INVITEE, status: "pending", metadata: { org: orgB, app: "organization" }, createdAt: now,
169
+ });
170
+
171
+ return { orgA, orgB, orgC, users };
172
+ }
@@ -0,0 +1,33 @@
1
+ /*
2
+ * POST /api/members/direct only hands out a role of the organisation being
3
+ * joined (or one the caller already holds there). Proved end to end in
4
+ * test/e2e/member-direct-role-org (needs a local mongod, so CI does not run
5
+ * it); these source checks keep the wiring in the suite CI does run.
6
+ */
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { readFileSync } from "node:fs";
10
+
11
+ const SERVICE = readFileSync("src/services/member.service.ts", "utf8");
12
+ const ACTOR = readFileSync("src/utils/invite-actor.util.ts", "utf8");
13
+
14
+ test("createMemberDirect refuses another organisation's role before any write", () => {
15
+ const start = SERVICE.indexOf("async function createMemberDirect(");
16
+ const fn = SERVICE.slice(start, start + 4000);
17
+ // Lower-cased: Joi's .hex() accepts upper-case, and an ObjectId prints lower.
18
+ const check = fn.indexOf('roleOrg !== (orgId?.toString() ?? "").toLowerCase()');
19
+ const exception = fn.indexOf("!(await holdsRole(callerId, roleId, orgId))");
20
+ const refusal = fn.indexOf('"That role belongs to a different organisation."');
21
+ const tx = fn.indexOf("startTransaction()");
22
+ assert.ok(check > 0 && exception > check && refusal > exception, "org check missing");
23
+ assert.ok(tx > refusal, "the refusal must come before the transaction opens");
24
+ // CONTROL: an org-less template role has no roleOrg and is left alone.
25
+ assert.match(fn, /roleOrg &&\s*roleOrg !== \(orgId/);
26
+ });
27
+
28
+ test("holdsRole takes an optional organisation and matches both id shapes", () => {
29
+ const start = ACTOR.indexOf("export async function holdsRole(");
30
+ const fn = ACTOR.slice(start, start + 1400);
31
+ assert.match(fn, /orgId\?: string \| ObjectId \| null/);
32
+ assert.match(fn, /org: \{ \$in: \[new ObjectId\(org\), org\] \}/);
33
+ });
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Self-enrolment through POST /api/members/direct is limited to a role the
3
+ * caller may give themselves. Proved end to end in
4
+ * test/e2e/member-direct-self-enrol-role (needs a local mongod, so CI does not
5
+ * run it); these source checks keep the wiring in the suite CI does run.
6
+ */
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { readFileSync } from "node:fs";
10
+
11
+ const CONTROLLER = readFileSync("src/controllers/member.controller.ts", "utf8");
12
+ const AUTHZ = readFileSync("src/utils/console-authz.util.ts", "utf8");
13
+ const ACTOR = readFileSync("src/utils/invite-actor.util.ts", "utf8");
14
+
15
+ const slice = (src, from, len) => src.slice(src.indexOf(from), src.indexOf(from) + len);
16
+
17
+ test("the SELF branch of createMemberDirect asks for the role as well as the org", () => {
18
+ const fn = slice(CONTROLLER, "async function createMemberDirect(", 3000);
19
+ const self = fn.indexOf("if (userId === callerId(req)) {");
20
+ const reach = fn.indexOf("await requireOrgReach(req, orgId);", self);
21
+ const role = fn.indexOf("await requireSelfEnrolRole(req, orgId, roleId);", self);
22
+ const other = fn.indexOf("} else {", self);
23
+ assert.ok(self > 0 && reach > self && role > reach && other > role, "self-enrol role gate missing");
24
+ });
25
+
26
+ test("the four roads, in the order the comment names them, then a refusal", () => {
27
+ const fn = slice(AUTHZ, "export async function requireSelfEnrolRole(", 900);
28
+ const order = [
29
+ "(await resolveInviteActor(id)).isSuperAdmin",
30
+ "await hasOrgOwnership(id, org)",
31
+ "await holdsRole(id, role, org)",
32
+ "await invitationOffersRole(id, org, role)",
33
+ 'throw new UnauthorizedError("Not authorized.")',
34
+ ].map((s) => fn.indexOf(s));
35
+ assert.ok(order.every((i, n) => i > 0 && (n === 0 || i > order[n - 1])), String(order));
36
+ });
37
+
38
+ test("a cancelled invitation offers nothing; a role-less one only its app's type in its org", () => {
39
+ const fn = slice(ACTOR, "export async function invitationOffersRole(", 2200);
40
+ assert.match(fn, /status: \{ \$ne: "cancelled" \}/);
41
+ assert.match(fn, /stored\.org\?\.toString\?\.\(\) === org/);
42
+ assert.match(fn, /v\.metadata\.app === stored\.type/);
43
+ });
@@ -0,0 +1,128 @@
1
+ /*
2
+ * Q3 (owner, 2026-09-11): the server also refuses modules a client has not
3
+ * been given. Shipped in LOG-ONLY mode: it never refuses, it logs. `enforce`
4
+ * refuses only listed keys. Every error allows. Work done while the person
5
+ * still had access (an offline item's frozen timestamp) is accepted.
6
+ */
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import {
11
+ moduleDeniedForCaller,
12
+ moduleGateEnforceKeys,
13
+ moduleGateMode,
14
+ } from "./.build/utils/module-access.util.mjs";
15
+ import {
16
+ ORG,
17
+ SITE,
18
+ USER,
19
+ CLIENT_SAVE,
20
+ SAVED_AT,
21
+ ackRow,
22
+ oldRow,
23
+ member,
24
+ fakeGate,
25
+ withEnv,
26
+ } from "./module-gate.fixtures.mjs";
27
+
28
+ const SITE_DOC = { _id: SITE, orgId: ORG, metadata: {} };
29
+ const LIST = ["visitor-mgmt"];
30
+ const enforced = (over = {}) =>
31
+ fakeGate({
32
+ rows: [member()],
33
+ orgs: { [ORG]: { _id: ORG, modules: LIST } },
34
+ sites: { [SITE]: SITE_DOC },
35
+ saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
36
+ ...over,
37
+ });
38
+ const check = (data, over = {}) =>
39
+ moduleDeniedForCaller({ userId: USER, site: SITE, module: "workOrder", ...over }, data);
40
+ const ENFORCE = { MODULE_GATE_MODE: "enforce", MODULE_GATE_ENFORCE_KEYS: "work_orders", MODULE_LIST_GATE: undefined };
41
+ const ALLOW = { refuse: false, denied: false };
42
+
43
+ test("the code default is LOG; enforce keys are read in any spelling", () => {
44
+ assert.equal(moduleGateMode({}), "log");
45
+ assert.equal(moduleGateMode({ MODULE_GATE_MODE: "nonsense" }), "log");
46
+ assert.equal(moduleGateMode({ MODULE_GATE_MODE: "ENFORCE" }), "enforce");
47
+ assert.ok(moduleGateEnforceKeys({ MODULE_GATE_ENFORCE_KEYS: "work_orders, visitorManagement" }).includes("workOrder"));
48
+ assert.deepEqual(moduleGateEnforceKeys({}), []);
49
+ });
50
+
51
+ test("LOG never refuses: the denial is reported, the request goes through", async () => {
52
+ const result = await withEnv({ MODULE_GATE_MODE: undefined }, () => check(enforced().data));
53
+ assert.deepEqual(result, { refuse: false, denied: true });
54
+ });
55
+
56
+ test("ENFORCE refuses only a listed key", async () => {
57
+ assert.deepEqual(await withEnv(ENFORCE, () => check(enforced().data)), { refuse: true, denied: true });
58
+ const unlisted = await withEnv({ ...ENFORCE, MODULE_GATE_ENFORCE_KEYS: "nfc-patrol" }, () => check(enforced().data));
59
+ assert.deepEqual(unlisted, { refuse: false, denied: true });
60
+ // CONTROL: a module the list gives is allowed.
61
+ assert.deepEqual(await withEnv(ENFORCE, () => check(enforced().data, { module: "visitor-mgmt" })), ALLOW);
62
+ });
63
+
64
+ test("the kill switch and OFF allow everything and read nothing", async () => {
65
+ for (const env of [{ ...ENFORCE, MODULE_LIST_GATE: "off" }, { ...ENFORCE, MODULE_GATE_MODE: "off" }]) {
66
+ const { data, calls } = enforced();
67
+ assert.deepEqual(await withEnv(env, () => check(data)), ALLOW);
68
+ assert.deepEqual(calls, {}, JSON.stringify(env));
69
+ }
70
+ });
71
+
72
+ test("ungoverned modules, staff, residents and strangers are allowed", async () => {
73
+ await withEnv(ENFORCE, async () => {
74
+ const quiet = enforced();
75
+ assert.deepEqual(await check(quiet.data, { module: "dashboard" }), ALLOW);
76
+ assert.deepEqual(quiet.calls, {}, "an ungoverned module reads nothing");
77
+ assert.deepEqual(await check(enforced({ rows: [member(), member({ type: "admin", org: "" })] }).data), ALLOW);
78
+ assert.deepEqual(await check(enforced({ rows: [member({ type: "resident", siteId: SITE })] }).data), ALLOW);
79
+ assert.deepEqual(await check(enforced({ rows: [] }).data), ALLOW);
80
+ });
81
+ });
82
+
83
+ test("allowed when ANY membership at the site allows it", async () => {
84
+ const provider = member({ _id: "p", org: "9".repeat(24), siteId: SITE });
85
+ assert.deepEqual(await withEnv(ENFORCE, () => check(enforced({ rows: [member(), provider] }).data)), ALLOW);
86
+ });
87
+
88
+ test("an old, never-acknowledged list refuses nothing", async () => {
89
+ const { data } = enforced({ saves: { [`${CLIENT_SAVE}|${ORG}`]: oldRow(LIST) } });
90
+ assert.deepEqual(await withEnv(ENFORCE, () => check(data)), ALLOW);
91
+ });
92
+
93
+ test("every error allows, even in ENFORCE", async () => {
94
+ for (const broken of ["membershipsOf", "site", "org", "latestSave"]) {
95
+ const { data } = enforced({ throwOn: [broken] });
96
+ assert.deepEqual(await withEnv(ENFORCE, () => check(data)), ALLOW, broken);
97
+ }
98
+ });
99
+
100
+ test("OFFLINE: work stamped before the acknowledged save is accepted; after it, refused", async () => {
101
+ await withEnv(ENFORCE, async () => {
102
+ const before = new Date(Date.parse(SAVED_AT) - 60_000).toISOString();
103
+ const after = new Date(Date.parse(SAVED_AT) + 60_000).toISOString();
104
+ assert.deepEqual(await check(enforced().data, { at: before }), ALLOW, "done while it was still given");
105
+ assert.deepEqual(await check(enforced().data, { at: Date.parse(before) }), ALLOW, "epoch ms too");
106
+ assert.deepEqual(await check(enforced().data, { at: after }), { refuse: true, denied: true });
107
+ // No timestamp, an unreadable one, or one in the future: treated as now.
108
+ for (const at of [undefined, "not-a-date", "2999-01-01T00:00:00.000Z"]) {
109
+ assert.deepEqual(await check(enforced().data, { at }), { refuse: true, denied: true }, String(at));
110
+ }
111
+ });
112
+ });
113
+
114
+ test("OFFLINE: when the save time is unknown, stamped work is accepted", async () => {
115
+ const noTime = { after: ackRow(LIST).after, createdAt: null };
116
+ const { data } = enforced({ saves: { [`${CLIENT_SAVE}|${ORG}`]: noTime } });
117
+ await withEnv(ENFORCE, async () => {
118
+ assert.deepEqual(await check(data, { at: SAVED_AT }), ALLOW);
119
+ assert.deepEqual(await check(data), { refuse: true, denied: true }, "control: live work is refused");
120
+ });
121
+ });
122
+
123
+ test("a site document passed in is used as is", async () => {
124
+ const { data, calls } = enforced();
125
+ const result = await withEnv(ENFORCE, () => check(data, { site: SITE_DOC }));
126
+ assert.deepEqual(result, { refuse: true, denied: true });
127
+ assert.equal(calls.site ?? 0, 0);
128
+ });