@7365admin1/core 3.65.2 → 3.67.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.
@@ -0,0 +1,469 @@
1
+ /**
2
+ * THE ORGANISATION WITH NO OWNER, end to end.
3
+ *
4
+ * ## What this proves
5
+ *
6
+ * An organisation created before 3.60.0 can exist with **no `members` row at
7
+ * all** — the wizard step that wrote the owner's own membership was skippable.
8
+ * Every console landing page then calls
9
+ * `GET /api/members/user/:id/app/organization`, gets a 404, and tells the real
10
+ * owner that no organisation exists. Measured on production 2026-09-09: three
11
+ * active organisations in that state, "JLL Singapore" among them.
12
+ *
13
+ * So the shape of the proof is: the landing lookup is **404 BEFORE**, a Seven365
14
+ * staff member calls the repair, and the same lookup **answers the membership
15
+ * after**. Everything else here is a way the repair could do harm — a second
16
+ * click, two simultaneous clicks, a non-staff caller, an organisation that
17
+ * already has members, a suspended one, an address nobody holds — and each one
18
+ * must write nothing.
19
+ *
20
+ * Everything runs against the harness's in-process MongoDB replica set, a
21
+ * loopback Redis and a loopback mail sink. **No staging or production database,
22
+ * Redis, mailbox or endpoint is touched.**
23
+ *
24
+ * Run with: npx tsup src/index.ts --format cjs,esm --no-dts
25
+ * node --test --test-concurrency=1 --test-timeout=600000 \
26
+ * test/e2e/org-owner-repair.e2e.test.mjs
27
+ */
28
+
29
+ import { after, before, describe, it } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { ObjectId } from "mongodb";
32
+
33
+ import { startHarness } from "./harness.mjs";
34
+
35
+ const PASSWORD = "OwnerRepair-Passw0rd!";
36
+
37
+ const U = {
38
+ STAFF: "oor-staff@e2e.example.com", // Seven365 console
39
+ OWNER: "oor-owner@e2e.example.com", // holds the broken org's address
40
+ ROLELESS: "oor-roleless@e2e.example.com", // its org holds no role at all
41
+ CASED: "oor-cased@e2e.example.com", // org address differs only in case
42
+ RACER: "oor-racer@e2e.example.com", // two simultaneous repairs
43
+ MEMBERED: "oor-membered@e2e.example.com", // its org already has a member
44
+ SUSPENDED: "oor-suspended@e2e.example.com", // its org is suspended
45
+ STALE: "oor-stale@e2e.example.com", // a claim left behind by a killed process
46
+ FRESHCLAIM: "oor-freshclaim@e2e.example.com", // a repair genuinely in flight
47
+ STALERACE: "oor-stalerace@e2e.example.com", // two racers over a stale claim
48
+ };
49
+
50
+ /** The claim ledger, and how old a row has to be to count as abandoned. */
51
+ const CLAIMS = "org-owner-repair-claims";
52
+ const STALE_AGO = 11 * 60 * 1000;
53
+
54
+ /** Nobody holds this one — the "no account" refusal. */
55
+ const ORPHAN_ADDRESS = "oor-nobody@e2e.example.com";
56
+
57
+ const repair = (h, sid, orgId) =>
58
+ h.api(`/organizations/${orgId}/owner-membership`, { method: "POST", sid });
59
+
60
+ describe("an organisation with no owner membership repairs, once", { concurrency: 1 }, () => {
61
+ let h;
62
+ const id = {};
63
+ const sid = {};
64
+ const result = [];
65
+
66
+ const record = (name, fn) =>
67
+ it(name, async () => {
68
+ try {
69
+ await fn();
70
+ result.push(["PASS", name]);
71
+ } catch (error) {
72
+ result.push(["FAIL", name]);
73
+ throw error;
74
+ }
75
+ });
76
+
77
+ before(async () => {
78
+ h = await startHarness();
79
+ Object.assign(id, await seed(h));
80
+ }, { timeout: 300000 });
81
+
82
+ after(async () => {
83
+ if (h) await h.stop();
84
+ console.log("\n--- organizations owner-membership repair: per-case result ---");
85
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
86
+ });
87
+
88
+ /** `organization` membership rows that exist (every status but deleted). */
89
+ const rows = (orgKey) =>
90
+ h.db.collection("members").countDocuments({
91
+ org: id[orgKey],
92
+ type: "organization",
93
+ status: { $ne: "deleted" },
94
+ });
95
+
96
+ /** The call every console landing page makes on load. */
97
+ const landing = (userKey) =>
98
+ h.api(`/members/user/${id.users[userKey].toString()}/app/organization`, {
99
+ sid: sid[userKey],
100
+ });
101
+
102
+ record("0. everybody signs in", async () => {
103
+ for (const [k, e] of Object.entries(U)) sid[k] = await h.login(e, PASSWORD);
104
+ });
105
+
106
+ // ── the defect, and the repair ───────────────────────────────────────────
107
+
108
+ record("1. BEFORE: the owner's landing lookup is 404 — 'no organisation exists'", async () => {
109
+ const res = await landing("OWNER");
110
+ assert.equal(res.status, 404, JSON.stringify(res.body));
111
+ assert.equal(await rows("orgBroken"), 0, "the organisation has no member at all");
112
+ });
113
+
114
+ record("2. a non-staff caller is refused, and writes nothing", async () => {
115
+ // The organisation's own owner is not Seven365 staff. This is the endpoint's
116
+ // whole authorization story: it is a console operation, not a self-service
117
+ // way into an organisation.
118
+ const res = await repair(h, sid.OWNER, id.orgBroken);
119
+ assert.equal(res.status, 401, JSON.stringify(res.body));
120
+ assert.equal(await rows("orgBroken"), 0, "nothing may be written");
121
+ });
122
+
123
+ record("3. no session at all is refused", async () => {
124
+ const res = await h.api(`/organizations/${id.orgBroken}/owner-membership`, {
125
+ method: "POST",
126
+ });
127
+ assert.equal(res.status, 401);
128
+ assert.equal(await rows("orgBroken"), 0);
129
+ });
130
+
131
+ record("4. staff repair writes exactly the row onboarding writes", async () => {
132
+ const res = await repair(h, sid.STAFF, id.orgBroken);
133
+ assert.equal(res.status, 200, JSON.stringify(res.body));
134
+ assert.equal(await rows("orgBroken"), 1, "exactly one row");
135
+
136
+ const row = await h.db
137
+ .collection("members")
138
+ .findOne({ org: id.orgBroken, type: "organization" });
139
+
140
+ assert.equal(row.user.toString(), id.users.OWNER.toString(), "the owner, not anybody else");
141
+ assert.equal(row.role.toString(), id.ownerRole.toString(), "the organisation's own owner role");
142
+ assert.equal(row.type, "organization", "the type the landing page asks for");
143
+ assert.equal(row.status, "active");
144
+ assert.equal(row.onboardingRequired, true, "the wizard is still ahead of them");
145
+ assert.equal(row.onboardingCompleted, false);
146
+ });
147
+
148
+ record("5. AFTER: the same landing lookup now answers the membership", async () => {
149
+ const res = await landing("OWNER");
150
+ assert.equal(res.status, 200, JSON.stringify(res.body));
151
+ assert.equal(res.body.org.toString(), id.orgBroken.toString());
152
+ assert.equal(res.body.type, "organization");
153
+ assert.equal(res.body.role.toString(), id.ownerRole.toString());
154
+ });
155
+
156
+ record("6. the repair is audited: who, which client, which account and role", async () => {
157
+ const row = await h.db
158
+ .collection("console-audit")
159
+ .findOne({ action: "client.owner-repaired", org: id.orgBroken });
160
+
161
+ assert.ok(row, "no audit row was written");
162
+ assert.equal(row.actor.toString(), id.users.STAFF.toString(), "the actor is the session");
163
+ assert.equal(row.targetType, "organization");
164
+ assert.equal(row.after.member, id.users.OWNER.toString());
165
+ assert.equal(row.after.role, id.ownerRole.toString());
166
+ assert.equal(row.after.memberType, "organization");
167
+ // An audit trail is the last place an address belongs.
168
+ assert.ok(!("email" in row.after), "the owner's e-mail was recorded");
169
+ });
170
+
171
+ // ── idempotence ──────────────────────────────────────────────────────────
172
+
173
+ record("7. a second click writes nothing, and the count is still 1", async () => {
174
+ const res = await repair(h, sid.STAFF, id.orgBroken);
175
+ assert.equal(res.status, 400, JSON.stringify(res.body));
176
+ assert.equal(await rows("orgBroken"), 1, "a second owner was written");
177
+
178
+ // ... and a third, for good measure.
179
+ await repair(h, sid.STAFF, id.orgBroken);
180
+ assert.equal(await rows("orgBroken"), 1);
181
+ });
182
+
183
+ record("8. two SIMULTANEOUS repairs write exactly one row", async () => {
184
+ // The race the claim exists for: both callers read a count of zero before
185
+ // either writes. Two clicks on a slow console do exactly this.
186
+ const [a, b] = await Promise.all([
187
+ repair(h, sid.STAFF, id.orgRace),
188
+ repair(h, sid.STAFF, id.orgRace),
189
+ ]);
190
+
191
+ assert.equal(await rows("orgRace"), 1, "the race wrote two owner rows");
192
+
193
+ const statuses = [a.status, b.status].sort();
194
+ assert.deepEqual(statuses, [200, 400], `one winner, one refusal: ${JSON.stringify(statuses)}`);
195
+
196
+ const res = await landing("RACER");
197
+ assert.equal(res.status, 200, "the winner's row is the one the landing page reads");
198
+ });
199
+
200
+ // ── every refusal, and each one writes nothing ───────────────────────────
201
+
202
+ record("9. an organisation that already has a member is refused", async () => {
203
+ const before = await rows("orgMembered");
204
+ assert.equal(before, 1, "seeded with one member");
205
+
206
+ const res = await repair(h, sid.STAFF, id.orgMembered);
207
+ assert.equal(res.status, 400, JSON.stringify(res.body));
208
+ assert.match(String(res.body.message ?? res.body), /already has/);
209
+ assert.equal(await rows("orgMembered"), 1, "nothing may be added beside it");
210
+ });
211
+
212
+ record("10. a SUSPENDED organisation is refused", async () => {
213
+ const res = await repair(h, sid.STAFF, id.orgSuspended);
214
+ assert.equal(res.status, 400, JSON.stringify(res.body));
215
+ assert.match(String(res.body.message ?? res.body), /active/);
216
+ assert.equal(await rows("orgSuspended"), 0);
217
+ });
218
+
219
+ record("11. an address no account holds is refused — the owner is never guessed", async () => {
220
+ const res = await repair(h, sid.STAFF, id.orgOrphan);
221
+ assert.equal(res.status, 400, JSON.stringify(res.body));
222
+ assert.match(String(res.body.message ?? res.body), /No account holds/);
223
+ assert.equal(await rows("orgOrphan"), 0);
224
+ });
225
+
226
+ record("12. an organisation that does not exist is a 404, not a write", async () => {
227
+ const res = await repair(h, sid.STAFF, new ObjectId().toString());
228
+ assert.equal(res.status, 404, JSON.stringify(res.body));
229
+ });
230
+
231
+ record("13. a malformed id is a 400, not a 500", async () => {
232
+ const res = await repair(h, sid.STAFF, "not-an-id");
233
+ assert.equal(res.status, 400, JSON.stringify(res.body));
234
+ });
235
+
236
+ // ── the cases that must still work ───────────────────────────────────────
237
+
238
+ record("14. the address is matched whatever its case", async () => {
239
+ // The organisation is registered under `OOR-Cased@E2E.example.com` and the
240
+ // account under `oor-cased@e2e.example.com`. `hasOrgOwnership` compares by
241
+ // collation, and so must the lookup that finds the candidate.
242
+ const res = await repair(h, sid.STAFF, id.orgCased);
243
+ assert.equal(res.status, 200, JSON.stringify(res.body));
244
+ assert.equal(await rows("orgCased"), 1);
245
+
246
+ const landed = await landing("CASED");
247
+ assert.equal(landed.status, 200);
248
+ });
249
+
250
+ record("15. an organisation with no ROLE at all is given one, then repaired", async () => {
251
+ // 74 of 195 staging organisations hold not one role. The repair seeds the
252
+ // two owner roles in process — the same self-heal the roles list uses — and
253
+ // then points the membership at the `organization` one. Never over HTTP:
254
+ // `GET /api/roles` self-heals, so reading it would itself be a write.
255
+ assert.equal(
256
+ await h.db.collection("roles").countDocuments({ org: id.orgRoleless }),
257
+ 0,
258
+ "seeded with no roles",
259
+ );
260
+
261
+ const res = await repair(h, sid.STAFF, id.orgRoleless);
262
+ assert.equal(res.status, 200, JSON.stringify(res.body));
263
+ assert.equal(await rows("orgRoleless"), 1);
264
+
265
+ const role = await h.db
266
+ .collection("roles")
267
+ .findOne({ org: id.orgRoleless, type: "organization", name: "owner" });
268
+ assert.ok(role, "the owner role was not seeded");
269
+ assert.deepEqual(role.permissions, ["*"]);
270
+ assert.equal(role.default, true);
271
+
272
+ const row = await h.db
273
+ .collection("members")
274
+ .findOne({ org: id.orgRoleless, type: "organization" });
275
+ assert.equal(row.role.toString(), role._id.toString());
276
+
277
+ const landed = await landing("ROLELESS");
278
+ assert.equal(landed.status, 200);
279
+ });
280
+
281
+ // ── the switch ───────────────────────────────────────────────────────────
282
+
283
+ record("16. ORG_OWNER_REPAIR=off refuses, and writes nothing", async () => {
284
+ // Read on every call, so a lead's override takes effect without a rebuild.
285
+ const restore = process.env.ORG_OWNER_REPAIR;
286
+ process.env.ORG_OWNER_REPAIR = "off";
287
+
288
+ try {
289
+ const res = await repair(h, sid.STAFF, id.orgSwitch);
290
+ assert.equal(res.status, 400, JSON.stringify(res.body));
291
+ assert.match(String(res.body.message ?? res.body), /switched off/);
292
+ assert.equal(await rows("orgSwitch"), 0, "the switch did not stop the write");
293
+ } finally {
294
+ if (restore === undefined) delete process.env.ORG_OWNER_REPAIR;
295
+ else process.env.ORG_OWNER_REPAIR = restore;
296
+ }
297
+
298
+ // ... and with the switch back on, the same organisation repairs — so case
299
+ // 16 proved the switch, not a broken organisation.
300
+ const res = await repair(h, sid.STAFF, id.orgSwitch);
301
+ assert.equal(res.status, 200, JSON.stringify(res.body));
302
+ assert.equal(await rows("orgSwitch"), 1);
303
+ });
304
+
305
+ // ── a claim that outlived the request that took it ───────────────────────
306
+
307
+ record("17. a claim left behind by a killed process does NOT block forever", async () => {
308
+ // The failure mode: the claim is inserted before the membership is written,
309
+ // so a process killed in between leaves a row that refuses every later
310
+ // attempt. The only remedy would be deleting a row by hand in the database,
311
+ // which this project forbids — so one unlucky restart would permanently
312
+ // block the repair for exactly the organisations this exists for.
313
+ await h.db.collection(CLAIMS).insertOne({
314
+ _id: id.orgStale,
315
+ repairedAt: new Date(Date.now() - STALE_AGO),
316
+ });
317
+
318
+ const res = await repair(h, sid.STAFF, id.orgStale);
319
+ assert.equal(res.status, 200, JSON.stringify(res.body));
320
+ assert.equal(await rows("orgStale"), 1, "the stale claim was not taken over");
321
+
322
+ // The claim it leaves behind is FRESH, so the next attempt stands down.
323
+ const claim = await h.db.collection(CLAIMS).findOne({ _id: id.orgStale });
324
+ assert.ok(Date.now() - new Date(claim.repairedAt).getTime() < STALE_AGO);
325
+
326
+ const again = await repair(h, sid.STAFF, id.orgStale);
327
+ assert.equal(again.status, 400, "the replacement claim does not hold");
328
+ assert.equal(await rows("orgStale"), 1);
329
+
330
+ const landed = await landing("STALE");
331
+ assert.equal(landed.status, 200);
332
+ });
333
+
334
+ record("18. a claim that is still FRESH is respected — a live repair is not raced", async () => {
335
+ await h.db.collection(CLAIMS).insertOne({
336
+ _id: id.orgFresh,
337
+ repairedAt: new Date(),
338
+ });
339
+
340
+ const res = await repair(h, sid.STAFF, id.orgFresh);
341
+ assert.equal(res.status, 400, JSON.stringify(res.body));
342
+ assert.match(String(res.body.message ?? res.body), /already running|already run/);
343
+ assert.equal(await rows("orgFresh"), 0, "a fresh claim was overrun");
344
+ });
345
+
346
+ record("19. two requests over ONE stale claim still write exactly one row", async () => {
347
+ // Both find the same abandoned claim. The takeover is a conditional
348
+ // compare-and-delete on the `_id` AND the stale timestamp, so only one can
349
+ // win it; the loser's insert collides with the winner's fresh claim.
350
+ await h.db.collection(CLAIMS).insertOne({
351
+ _id: id.orgStaleRace,
352
+ repairedAt: new Date(Date.now() - STALE_AGO),
353
+ });
354
+
355
+ const [a, b] = await Promise.all([
356
+ repair(h, sid.STAFF, id.orgStaleRace),
357
+ repair(h, sid.STAFF, id.orgStaleRace),
358
+ ]);
359
+
360
+ assert.equal(await rows("orgStaleRace"), 1, "the stale-claim race wrote two owners");
361
+ assert.deepEqual(
362
+ [a.status, b.status].sort(),
363
+ [200, 400],
364
+ `one winner, one refusal: ${JSON.stringify([a.status, b.status])}`,
365
+ );
366
+ });
367
+
368
+ record("20. no organisation in this run gained a second owner", async () => {
369
+ for (const key of [
370
+ "orgBroken", "orgRace", "orgCased", "orgRoleless", "orgSwitch", "orgMembered",
371
+ "orgStale", "orgStaleRace",
372
+ ]) {
373
+ assert.equal(await rows(key), 1, `${key} holds more than one owner`);
374
+ }
375
+ for (const key of ["orgSuspended", "orgOrphan", "orgFresh"]) {
376
+ assert.equal(await rows(key), 0, `${key} was written to`);
377
+ }
378
+ });
379
+ });
380
+
381
+ async function seed(h) {
382
+ const now = new Date().toISOString();
383
+
384
+ const orgBroken = new ObjectId();
385
+ const orgRace = new ObjectId();
386
+ const orgCased = new ObjectId();
387
+ const orgRoleless = new ObjectId();
388
+ const orgSwitch = new ObjectId();
389
+ const orgMembered = new ObjectId();
390
+ const orgSuspended = new ObjectId();
391
+ const orgOrphan = new ObjectId();
392
+ const orgStale = new ObjectId();
393
+ const orgFresh = new ObjectId();
394
+ const orgStaleRace = new ObjectId();
395
+
396
+ const ownerRole = new ObjectId();
397
+ const raceRole = new ObjectId();
398
+ const casedRole = new ObjectId();
399
+ const switchRole = new ObjectId();
400
+ const memberedRole = new ObjectId();
401
+ const suspendedRole = new ObjectId();
402
+ const orphanRole = new ObjectId();
403
+ const staleRole = new ObjectId();
404
+ const freshRole = new ObjectId();
405
+ const staleRaceRole = new ObjectId();
406
+ const staffRole = new ObjectId();
407
+
408
+ await h.db.collection("organizations").insertMany([
409
+ // active, NO members, an account holds its address — the defect.
410
+ { _id: orgBroken, name: "OOR Broken", email: U.OWNER, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
411
+ { _id: orgRace, name: "OOR Race", email: U.RACER, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
412
+ // the organisation's address differs from the account's only in case
413
+ { _id: orgCased, name: "OOR Cased", email: "OOR-Cased@E2E.example.com", type: "org", nature: "security_agency", status: "active", createdAt: now },
414
+ // active, no members AND no roles at all
415
+ { _id: orgRoleless, name: "OOR Roleless", email: U.ROLELESS, type: "org", nature: "security_agency", status: "active", createdAt: now },
416
+ { _id: orgSwitch, name: "OOR Switch", email: U.SUSPENDED, type: "org", nature: "cleaning_services", status: "active", createdAt: now },
417
+ // already has an organization member — must never gain a second
418
+ { _id: orgMembered, name: "OOR Membered", email: U.MEMBERED, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
419
+ // suspended
420
+ { _id: orgSuspended, name: "OOR Suspended", email: U.SUSPENDED, type: "org", nature: "property_management_agency", status: "suspended", createdAt: now },
421
+ // nobody holds this address
422
+ { _id: orgOrphan, name: "OOR Orphan", email: ORPHAN_ADDRESS, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
423
+ // a claim row is planted on each of these three by cases 17-19
424
+ { _id: orgStale, name: "OOR Stale", email: U.STALE, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
425
+ { _id: orgFresh, name: "OOR Fresh", email: U.FRESHCLAIM, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
426
+ { _id: orgStaleRace, name: "OOR Stale Race", email: U.STALERACE, type: "org", nature: "property_management_agency", status: "active", createdAt: now },
427
+ ]);
428
+
429
+ await h.db.collection("roles").insertMany([
430
+ { _id: ownerRole, name: "owner", type: "organization", org: orgBroken, permissions: ["*"], default: true, status: "active" },
431
+ { _id: raceRole, name: "owner", type: "organization", org: orgRace, permissions: ["*"], default: true, status: "active" },
432
+ { _id: casedRole, name: "owner", type: "organization", org: orgCased, permissions: ["*"], default: true, status: "active" },
433
+ { _id: switchRole, name: "owner", type: "organization", org: orgSwitch, permissions: ["*"], default: true, status: "active" },
434
+ { _id: memberedRole, name: "owner", type: "organization", org: orgMembered, permissions: ["*"], default: true, status: "active" },
435
+ { _id: suspendedRole, name: "owner", type: "organization", org: orgSuspended, permissions: ["*"], default: true, status: "active" },
436
+ { _id: orphanRole, name: "owner", type: "organization", org: orgOrphan, permissions: ["*"], default: true, status: "active" },
437
+ { _id: staleRole, name: "owner", type: "organization", org: orgStale, permissions: ["*"], default: true, status: "active" },
438
+ { _id: freshRole, name: "owner", type: "organization", org: orgFresh, permissions: ["*"], default: true, status: "active" },
439
+ { _id: staleRaceRole, name: "owner", type: "organization", org: orgStaleRace, permissions: ["*"], default: true, status: "active" },
440
+ // Seven365 staff: a members row of type "admin" whose role is ALSO type
441
+ // "admin". An empty permission list means everything, exactly as
442
+ // `createDefaultUser` seeds it.
443
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
444
+ ]);
445
+
446
+ const emails = Object.values(U);
447
+ const inserted = await h.db.collection("users").insertMany(
448
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
449
+ );
450
+ const hashed = await h.hashPassword(PASSWORD);
451
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
452
+ const users = Object.fromEntries(Object.keys(U).map((k, i) => [k, inserted.insertedIds[i]]));
453
+
454
+ await h.db.collection("members").insertMany([
455
+ { user: users.STAFF, type: "admin", role: staffRole, status: "active" },
456
+ // the one organisation that already has its owner
457
+ {
458
+ user: users.MEMBERED, org: orgMembered, orgName: "OOR Membered", type: "organization",
459
+ role: memberedRole, status: "active", email: U.MEMBERED, name: U.MEMBERED,
460
+ },
461
+ ]);
462
+
463
+ return {
464
+ users,
465
+ orgBroken, orgRace, orgCased, orgRoleless, orgSwitch, orgMembered, orgSuspended, orgOrphan,
466
+ orgStale, orgFresh, orgStaleRace,
467
+ ownerRole, raceRole, casedRole, switchRole,
468
+ };
469
+ }
@@ -41,21 +41,50 @@ const enforced = (over = {}) =>
41
41
  const WO = { category: "workOrder", siteId: SITE };
42
42
  const on = (fn) => withEnv({ NOTIFY_ACCESS_FILTER: "on", MODULE_LIST_GATE: undefined }, fn);
43
43
 
44
- test("the code default is LOG; anything unrecognised is log", () => {
45
- assert.equal(notifyAccessMode({}), "log");
46
- assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "garbage" }), "log");
47
- assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "ON" }), "on");
44
+ test("the code default is ON; anything unrecognised falls back to that default", () => {
45
+ // Owner, 2026-09-13: turn the filter on. Unset env => the code default.
46
+ assert.equal(notifyAccessMode({}), "on");
47
+ assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "garbage" }), "on");
48
+ assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "LOG" }), "log");
48
49
  assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "off" }), "off");
49
50
  });
50
51
 
51
- test("LOG never drops: the denied recipient is counted and still sent to", async () => {
52
+ test("the DEFAULT drops, with no env var set at all", async () => {
53
+ // The flip itself: this is the test that fails if the default goes back to
54
+ // `log`. `undefined` means "nothing set on the server", i.e. production.
55
+ const { data } = enforced({ rows: [member()] });
56
+ const sent = await withEnv({ NOTIFY_ACCESS_FILTER: undefined, MODULE_LIST_GATE: undefined }, () =>
57
+ filterByAccess([USER], WO, data),
58
+ );
59
+ assert.deepEqual(sent, [], "the denied recipient is dropped by the default alone");
60
+ });
61
+
62
+ test("LOG still never drops: the denied recipient is counted and still sent to", async () => {
52
63
  const { data } = enforced({ rows: [member()] });
53
64
  const report = await notificationAccessReport([USER], WO, data);
54
65
  assert.deepEqual(report.dropByModule, [USER], "control: the list does deny work orders");
55
- const sent = await withEnv({ NOTIFY_ACCESS_FILTER: undefined }, () => filterByAccess([USER], WO, data));
66
+ // A lead putting it back to `log` must still send to everyone, with no deploy.
67
+ const sent = await withEnv({ NOTIFY_ACCESS_FILTER: "log" }, () => filterByAccess([USER], WO, data));
56
68
  assert.deepEqual(sent, [USER]);
57
69
  });
58
70
 
71
+ test("a client with NO enforced list is untouched by the default being on", async () => {
72
+ // Why the flip is safe on the day it ships: no organisation and no site had an
73
+ // enforced list, so the filter returns early and reads nothing.
74
+ const { data, calls } = fakeGate({
75
+ orgs: { [ORG]: { _id: ORG } },
76
+ sites: { [SITE]: SITE_DOC },
77
+ roles: { r1: { permissions: ["*"] } },
78
+ rows: [member()],
79
+ });
80
+ assert.deepEqual(await filterByAccess([USER], WO, data), [USER]);
81
+ // It does read the org and the site to find out whether a list is enforced.
82
+ // What it must not do is the expensive part: `members.user` has no index to
83
+ // use, so a members/roles read on every send is the cost worth avoiding.
84
+ assert.equal(calls.members, undefined, "no members read while nothing is enforced");
85
+ assert.equal(calls.roles, undefined, "no roles read while nothing is enforced");
86
+ });
87
+
59
88
  test("ON drops only the member whose client list denies the module", async () => {
60
89
  const other = member({ user: USER_2, org: "9".repeat(24), siteId: SITE }); // a provider: own org, no list
61
90
  const { data } = enforced({ rows: [member(), other] });