@7365admin1/core 3.52.13 → 3.52.14

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.52.13",
4
+ "version": "3.52.14",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -0,0 +1,405 @@
1
+ // DV-0248 - "Organization Not Showing as Active for the User Who Created It
2
+ // Without Permission".
3
+ //
4
+ // A client owner who signs themselves up creates the organisation and then
5
+ // walks a five-step wizard. Their own `members` row is not written until the
6
+ // LAST step, so at every step before it they hold no membership - and a
7
+ // self-signup holds no invitation either, because nobody invited them. Both of
8
+ // the organisation gates only knew those two roads, so the owner was refused by
9
+ // their own wizard from step 1, and the organisation never appeared in their
10
+ // list. On staging, 64 of 195 organisations have neither a member nor an
11
+ // invitation.
12
+ //
13
+ // The five steps and the gate each one hits:
14
+ //
15
+ // 1. save org details PUT /api/organizations/:id requireOrgReach
16
+ // 2. create the site POST /api/sites requireOrgAccess
17
+ // POST /api/customer-sites requireOrgAccess
18
+ // 3. create the roles POST /api/roles requireOrgReach
19
+ // 4. invite the admins POST /api/auth/invite/member requireInviteReach
20
+ // -> requireOrgReach
21
+ // 5. finish POST /api/members/direct requireOrgReach
22
+ //
23
+ // Two new roads close it, and this file proves each one on its own:
24
+ //
25
+ // createdBy stamped from the SESSION when the organisation is created.
26
+ // Fixes every new signup. Org N below.
27
+ // email the organisation is registered under the caller's own account
28
+ // address. Repairs the 64 already stuck, with no backfill.
29
+ // Org S below.
30
+ //
31
+ // The e-mail road is only sound because a user can no longer set their own
32
+ // address. Case 12 is the hostile half: Mallory retypes her address as another
33
+ // client's registered one and must be refused, and must gain nothing.
34
+ //
35
+ // Everything is created and thrown away by the harness: an in-process MongoDB
36
+ // replica set, a loopback Redis, a loopback mail sink. No staging or production
37
+ // database, Redis, device or endpoint is touched.
38
+ //
39
+ // Run with: yarn test:e2e
40
+
41
+ import { after, before, describe, it } from "node:test";
42
+ import assert from "node:assert/strict";
43
+ import { ObjectId } from "mongodb";
44
+
45
+ import { startHarness } from "./harness.mjs";
46
+
47
+ const PASSWORD = "OrgCreator-Passw0rd!";
48
+ const FOUNDER = "ocr-founder@e2e.example.com"; // signs up, creates org N
49
+ const STUCK = "ocr-stuck@e2e.example.com"; // owns org S, already stuck
50
+ const INVITED = "ocr-invited@e2e.example.com"; // invited into org S, no member
51
+ const MALLORY = "ocr-mallory@e2e.example.com"; // an outsider, member of org A
52
+ const STAFF = "ocr-sevenadmin@e2e.example.com"; // Seven365 platform staff
53
+
54
+ // Org N's registered address is deliberately NOT the founder's, so the only
55
+ // thing joining them is `createdBy`.
56
+ const ORG_N_EMAIL = "ocr-orgn@e2e.example.com";
57
+
58
+ describe("DV-0248: a client owner reaches the organisation they created", { concurrency: 1 }, () => {
59
+ let h;
60
+ const id = {};
61
+ const result = [];
62
+
63
+ const record = (name, fn) =>
64
+ it(name, async () => {
65
+ try {
66
+ await fn();
67
+ result.push(["PASS", name]);
68
+ } catch (error) {
69
+ result.push(["FAIL", name]);
70
+ throw error;
71
+ }
72
+ });
73
+
74
+ before(async () => {
75
+ h = await startHarness();
76
+ Object.assign(id, await seed(h));
77
+ }, { timeout: 300000 });
78
+
79
+ after(async () => {
80
+ if (h) await h.stop();
81
+ console.log("\n--- DV-0248 org creator reach: per-case result ---");
82
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
83
+ });
84
+
85
+ let founderSid;
86
+ let stuckSid;
87
+ let invitedSid;
88
+ let mallorySid;
89
+ let staffSid;
90
+ let orgN;
91
+ let roleN;
92
+
93
+ const memberCount = async (org) =>
94
+ await h.db.collection("members").countDocuments({ org: new ObjectId(org) });
95
+
96
+ record("0. baseline - the founder holds no membership and no invitation anywhere", async () => {
97
+ founderSid = await h.login(FOUNDER, PASSWORD);
98
+ stuckSid = await h.login(STUCK, PASSWORD);
99
+ invitedSid = await h.login(INVITED, PASSWORD);
100
+ mallorySid = await h.login(MALLORY, PASSWORD);
101
+ staffSid = await h.login(STAFF, PASSWORD);
102
+
103
+ assert.equal(await h.db.collection("members").countDocuments({ user: id.founder }), 0);
104
+ assert.equal(await h.db.collection("verifications").countDocuments({ email: FOUNDER }), 0);
105
+
106
+ // Org S is the shape of the 64: no member, no invitation, no createdBy.
107
+ assert.equal(await memberCount(id.orgS), 0);
108
+ const rowS = await h.db.collection("organizations").findOne({ _id: id.orgS });
109
+ assert.equal(rowS.createdBy, undefined);
110
+ });
111
+
112
+ // ---- the creator road: a brand-new organisation ------------------------
113
+
114
+ record("1. she creates the organisation, and the server records her as its creator", async () => {
115
+ const res = await h.api("/organizations/onboarding", {
116
+ method: "POST",
117
+ sid: founderSid,
118
+ body: {
119
+ name: "OCR New Estates",
120
+ type: "org",
121
+ nature: "property_management_agency",
122
+ email: ORG_N_EMAIL,
123
+ contact: "60000000",
124
+ },
125
+ });
126
+
127
+ assert.equal(res.status, 201, JSON.stringify(res.body));
128
+ orgN = (res.body?.data?._id ?? res.body?.data?.id)?.toString();
129
+ assert.ok(orgN, `no organisation id came back: ${JSON.stringify(res.body)}`);
130
+
131
+ const row = await h.db.collection("organizations").findOne({ _id: new ObjectId(orgN) });
132
+ assert.equal(row.createdBy?.toString(), id.founder.toString());
133
+ // zero sites, zero members - the reported case exactly
134
+ assert.equal(await memberCount(orgN), 0);
135
+ });
136
+
137
+ record("2. the creator cannot be forged from the request body", async () => {
138
+ const res = await h.api("/organizations/onboarding", {
139
+ method: "POST",
140
+ sid: mallorySid,
141
+ body: {
142
+ name: "OCR Forged",
143
+ type: "org",
144
+ nature: "property_management_agency",
145
+ email: "ocr-forged@e2e.example.com",
146
+ createdBy: id.founder.toString(),
147
+ },
148
+ });
149
+
150
+ // Either the body field is rejected outright or it is ignored - what must
151
+ // never happen is Mallory's organisation carrying the founder as creator.
152
+ if (res.status === 201) {
153
+ const row = await h.db.collection("organizations").findOne({ name: "OCR Forged" });
154
+ assert.equal(row.createdBy?.toString(), id.mallory.toString());
155
+ } else {
156
+ assert.equal(res.status, 400, JSON.stringify(res.body));
157
+ }
158
+ });
159
+
160
+ record("3. step 1 - she can save the organisation details (was 401)", async () => {
161
+ const res = await h.api(`/organizations/${orgN}`, {
162
+ method: "PUT",
163
+ sid: founderSid,
164
+ body: { name: "OCR New Estates Pte Ltd", contact: "61111111" },
165
+ });
166
+
167
+ assert.equal(res.status, 200, JSON.stringify(res.body));
168
+ const row = await h.db.collection("organizations").findOne({ _id: new ObjectId(orgN) });
169
+ assert.equal(row.name, "OCR New Estates Pte Ltd");
170
+ });
171
+
172
+ record("4. step 2 - she can create the first site (was 401)", async () => {
173
+ const res = await h.api("/sites", {
174
+ method: "POST",
175
+ sid: founderSid,
176
+ body: { name: "OCR Tower One", orgId: orgN },
177
+ });
178
+
179
+ assert.equal(res.status, 201, JSON.stringify(res.body));
180
+ });
181
+
182
+ record("5. step 2 - and the customer-site twin (was 401)", async () => {
183
+ const res = await h.api("/customer-sites", {
184
+ method: "POST",
185
+ sid: founderSid,
186
+ body: {
187
+ name: "OCR Tower Two",
188
+ org: orgN,
189
+ siteOrg: orgN,
190
+ siteOrgName: "OCR New Estates Pte Ltd",
191
+ },
192
+ });
193
+
194
+ // The gate is what is under test. Before the fix this was a 401 from
195
+ // `requireOrgAccess`; the handler's own downstream requirements (an
196
+ // engagement row this bare body does not carry) are not this file's
197
+ // subject.
198
+ assert.notEqual(res.status, 401, JSON.stringify(res.body));
199
+ });
200
+
201
+ record("6. step 3 - she can create the organisation's roles (was 401)", async () => {
202
+ const res = await h.api("/roles", {
203
+ method: "POST",
204
+ sid: founderSid,
205
+ body: { name: "OCR Owner", permissions: ["*"], org: orgN, type: "organization" },
206
+ });
207
+
208
+ assert.equal(res.status, 201, JSON.stringify(res.body));
209
+ roleN = (res.body?.data?.role?._id ?? res.body?.data?.role)?.toString();
210
+ if (!roleN) {
211
+ const row = await h.db.collection("roles").findOne({ name: "OCR Owner" });
212
+ roleN = row?._id?.toString();
213
+ }
214
+ assert.ok(roleN, `no role id came back: ${JSON.stringify(res.body)}`);
215
+ });
216
+
217
+ record("7. step 4 - she can invite her admins (was 401)", async () => {
218
+ const res = await h.api("/auth/invite/member", {
219
+ method: "POST",
220
+ sid: founderSid,
221
+ body: {
222
+ email: "ocr-colleague@e2e.example.com",
223
+ org: orgN,
224
+ role: roleN,
225
+ app: "organization",
226
+ },
227
+ });
228
+
229
+ // The gate is what is under test. A 401 is the bug; anything else means the
230
+ // caller got past the gate and into the handler's own validation.
231
+ assert.notEqual(res.status, 401, JSON.stringify(res.body));
232
+ });
233
+
234
+ record("8. step 5 - she can finish, which writes her own membership (was 401)", async () => {
235
+ const res = await h.api("/members/direct", {
236
+ method: "POST",
237
+ sid: founderSid,
238
+ body: {
239
+ userId: id.founder.toString(),
240
+ orgId: orgN,
241
+ roleId: roleN,
242
+ app: "organization",
243
+ },
244
+ });
245
+
246
+ assert.equal(res.status, 201, JSON.stringify(res.body));
247
+ assert.equal(await memberCount(orgN), 1);
248
+ });
249
+
250
+ record("9. and the organisation now shows in her own list", async () => {
251
+ const res = await h.api("/organizations?page=1&limit=50", { sid: founderSid });
252
+ assert.equal(res.status, 200, JSON.stringify(res.body));
253
+ const names = (res.body?.data ?? res.body?.items ?? []).map((o) => o.name);
254
+ assert.ok(names.includes("OCR New Estates Pte Ltd"), JSON.stringify(names));
255
+ });
256
+
257
+ // ---- the e-mail road: the 64 already stuck -----------------------------
258
+
259
+ record("10. an ALREADY-STUCK organisation repairs itself with no backfill", async () => {
260
+ // Org S predates `createdBy` and has no member and no invitation. The only
261
+ // thing joining its owner to it is that it is registered under his own
262
+ // account address - which is the same comparison the client list already
263
+ // makes.
264
+ const res = await h.api(`/organizations/${id.orgS}`, {
265
+ method: "PUT",
266
+ sid: stuckSid,
267
+ body: { contact: "62222222" },
268
+ });
269
+
270
+ assert.equal(res.status, 200, JSON.stringify(res.body));
271
+ });
272
+
273
+ record("11. the invited admin reaches it too - including the member-only gate", async () => {
274
+ // `requireOrgAccess` had NO invitation road at all, so this site create
275
+ // refused the invited owner even though the org save let them through.
276
+ const res = await h.api("/sites", {
277
+ method: "POST",
278
+ sid: invitedSid,
279
+ body: { name: "OCR Invited Estate", orgId: id.orgS.toString() },
280
+ });
281
+
282
+ assert.equal(res.status, 201, JSON.stringify(res.body));
283
+ });
284
+
285
+ // ---- the hostile case --------------------------------------------------
286
+
287
+ record("12. HOSTILE - she cannot retype her address as another client's", async () => {
288
+ const res = await h.api(`/users/field/${id.mallory}`, {
289
+ method: "PATCH",
290
+ sid: mallorySid,
291
+ body: { field: "email", value: STUCK },
292
+ });
293
+
294
+ assert.equal(res.status, 401, JSON.stringify(res.body));
295
+
296
+ const row = await h.db.collection("users").findOne({ _id: id.mallory });
297
+ assert.equal(row.email, MALLORY, "her address must be unchanged");
298
+ });
299
+
300
+ record("13. HOSTILE - and so she gains no reach into that client", async () => {
301
+ const res = await h.api(`/organizations/${id.orgS}`, {
302
+ method: "PUT",
303
+ sid: mallorySid,
304
+ body: { name: "Mallory Was Here" },
305
+ });
306
+
307
+ assert.equal(res.status, 401, JSON.stringify(res.body));
308
+ const row = await h.db.collection("organizations").findOne({ _id: id.orgS });
309
+ assert.notEqual(row.name, "Mallory Was Here");
310
+ });
311
+
312
+ record("14. HOSTILE - nor into the organisation somebody else created", async () => {
313
+ const res = await h.api(`/organizations/${orgN}`, {
314
+ method: "PUT",
315
+ sid: mallorySid,
316
+ body: { name: "Mallory Was Here Too" },
317
+ });
318
+
319
+ assert.equal(res.status, 401, JSON.stringify(res.body));
320
+ });
321
+
322
+ record("15. HOSTILE - nor create a site inside it", async () => {
323
+ const res = await h.api("/sites", {
324
+ method: "POST",
325
+ sid: mallorySid,
326
+ body: { name: "Mallory Estate", orgId: orgN },
327
+ });
328
+
329
+ assert.equal(res.status, 401, JSON.stringify(res.body));
330
+ assert.equal(
331
+ await h.db.collection("sites").countDocuments({ name: "Mallory Estate" }),
332
+ 0,
333
+ );
334
+ });
335
+
336
+ // ---- nothing else regressed -------------------------------------------
337
+
338
+ record("16. the rest of the field allow-list is still self-service", async () => {
339
+ const res = await h.api(`/users/field/${id.mallory}`, {
340
+ method: "PATCH",
341
+ sid: mallorySid,
342
+ body: { field: "contact", value: "63333333" },
343
+ });
344
+
345
+ assert.equal(res.status, 200, JSON.stringify(res.body));
346
+ });
347
+
348
+ record("17. Seven365 staff can still change an address on the client's behalf", async () => {
349
+ const res = await h.api(`/users/field/${id.mallory}`, {
350
+ method: "PATCH",
351
+ sid: staffSid,
352
+ body: { field: "email", value: "ocr-mallory-new@e2e.example.com" },
353
+ });
354
+
355
+ assert.equal(res.status, 200, JSON.stringify(res.body));
356
+ });
357
+ });
358
+
359
+ async function seed(h) {
360
+ const now = new Date().toISOString();
361
+ const orgA = new ObjectId();
362
+ const orgS = new ObjectId();
363
+ const staffRole = new ObjectId();
364
+ const ownerRoleA = new ObjectId();
365
+ const roleS = new ObjectId();
366
+
367
+ await h.db.collection("organizations").insertMany([
368
+ { _id: orgA, name: "OCR Org A", email: "ocr-a@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
369
+ // The shape of the 64: registered under its owner's own address, but with
370
+ // no member, no invitation and no creator recorded.
371
+ { _id: orgS, name: "OCR Stuck Estates", email: STUCK, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
372
+ ]);
373
+
374
+ await h.db.collection("roles").insertMany([
375
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
376
+ { _id: ownerRoleA, name: "Owner A", org: orgA, type: "organization", permissions: ["*"], status: "active" },
377
+ { _id: roleS, name: "Owner S", org: orgS, type: "organization", permissions: ["*"], status: "active" },
378
+ ]);
379
+
380
+ const emails = [FOUNDER, STUCK, INVITED, MALLORY, STAFF];
381
+ const users = await h.db.collection("users").insertMany(
382
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
383
+ );
384
+
385
+ const hashed = await h.hashPassword(PASSWORD);
386
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
387
+
388
+ const [founder, stuck, invited, mallory, staff] = emails.map((_, i) => users.insertedIds[i]);
389
+
390
+ await h.db.collection("members").insertMany([
391
+ { user: mallory, org: orgA, type: "organization", role: ownerRoleA, status: "active" },
392
+ { user: staff, type: "admin", role: staffRole, status: "active" },
393
+ ]);
394
+
395
+ // The invited admin's only relationship to org S.
396
+ await h.db.collection("verifications").insertOne({
397
+ email: INVITED,
398
+ type: "member-invite",
399
+ status: "pending",
400
+ metadata: { org: orgS, app: "organization" },
401
+ createdAt: now,
402
+ });
403
+
404
+ return { orgA, orgS, staffRole, ownerRoleA, roleS, founder, stuck, invited, mallory, staff };
405
+ }