@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.
- package/CHANGELOG.md +41 -0
- package/dist/index.d.ts +297 -6
- package/dist/index.js +5609 -5259
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2448 -2108
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/backfill-org-owner-roles.test.mjs +214 -1
- package/test/console-permission.test.mjs +4 -2
- package/test/e2e/harness.mjs +5 -0
- package/test/e2e/org-owner-repair.e2e.test.mjs +469 -0
- package/test/notification-access.test.mjs +35 -6
- package/test/org-owner-repair.test.mjs +726 -0
- package/test/staff-console-authz.test.mjs +12 -0
- package/tools/backfill-org-owner-roles/backfill.mjs +253 -11
- package/tools/backfill-org-owner-roles/dry-run-devtools-snippet.js +10 -2
- package/tools/backfill-org-owner-roles/repair-owner-membership-devtools-snippet.js +175 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ORGANISATION WITH NO OWNER — the decision, and the wiring around it.
|
|
3
|
+
*
|
|
4
|
+
* Two halves, tested two ways, the same split the console-audit and
|
|
5
|
+
* default-role work uses. The DECISION (may this organisation be repaired) is
|
|
6
|
+
* pure and is executed here. The WIRING (staff gate, claim before write, the
|
|
7
|
+
* owner never coming from the request, the role resolved only after the count
|
|
8
|
+
* says zero) is read off the source, because constructing the controller needs a
|
|
9
|
+
* live Atlas connection. The end-to-end half is
|
|
10
|
+
* `test/e2e/org-owner-repair.e2e.test.mjs`.
|
|
11
|
+
*
|
|
12
|
+
* Every assertion has a POSITIVE CONTROL beside it. Without them a planner that
|
|
13
|
+
* refused everything would pass "never writes a second owner" and "fails closed"
|
|
14
|
+
* perfectly while repairing nothing at all.
|
|
15
|
+
*
|
|
16
|
+
* **Nothing here opens a socket or a database.**
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from "node:assert";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
ORG_OWNER_REPAIR_CLAIM_STALE_MS,
|
|
26
|
+
ORG_OWNER_REPAIR_CLAIM_TAKEN_REASON,
|
|
27
|
+
ORG_OWNER_REPAIR_DEFAULT,
|
|
28
|
+
ORG_OWNER_REPAIR_OFF_REASON,
|
|
29
|
+
claimReadsAsStale,
|
|
30
|
+
orgOwnerRepairOn,
|
|
31
|
+
orgQualifiesForOwnerRepair,
|
|
32
|
+
planOrgOwnerRepair,
|
|
33
|
+
} from "./.build/utils/org-owner-repair.util.mjs";
|
|
34
|
+
import { MEMBER_TYPES } from "./.build/models/member.model.mjs";
|
|
35
|
+
import {
|
|
36
|
+
ConsoleAuditAction,
|
|
37
|
+
CONSOLE_AUDIT_LABELS,
|
|
38
|
+
pickAuditFields,
|
|
39
|
+
} from "./.build/utils/console-audit.util.mjs";
|
|
40
|
+
import { orgOwnerRepairHandlers } from "./.build/controllers/organization.controller.mjs";
|
|
41
|
+
|
|
42
|
+
const ORG = "68b0c1d2e3f4a5b6c7d8e9f0";
|
|
43
|
+
const USER = "6a951ce503be44033d434f67";
|
|
44
|
+
const ROLE = "6aa0c7308d25d0ba14fc5c12";
|
|
45
|
+
|
|
46
|
+
const org = (extra = {}) => ({ _id: ORG, status: "active", email: "owner@example.com", ...extra });
|
|
47
|
+
const plan = (extra = {}) =>
|
|
48
|
+
planOrgOwnerRepair({
|
|
49
|
+
gateOn: true,
|
|
50
|
+
org: org(),
|
|
51
|
+
orgMemberCount: 0,
|
|
52
|
+
ownerUserId: USER,
|
|
53
|
+
ownerRoleId: ROLE,
|
|
54
|
+
...extra,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── the row it writes ───────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test("the row it plans is the one the console's landing page reads", () => {
|
|
60
|
+
assert.deepEqual(plan().payload, {
|
|
61
|
+
userId: USER,
|
|
62
|
+
orgId: ORG,
|
|
63
|
+
roleId: ROLE,
|
|
64
|
+
app: "organization",
|
|
65
|
+
onboardingRequired: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// `GET /api/members/user/:id/app/organization` is the query every console
|
|
69
|
+
// landing page makes, so a row of any other type leaves the owner locked out
|
|
70
|
+
// exactly as they are now. The string is the contract, not a default.
|
|
71
|
+
assert.equal(plan().payload.app, "organization");
|
|
72
|
+
assert.ok(MEMBER_TYPES.includes(plan().payload.app));
|
|
73
|
+
|
|
74
|
+
// `onboardingRequired: true` is what POST /organizations/onboarding writes for
|
|
75
|
+
// a brand-new owner, and the wizard is still ahead of these organisations.
|
|
76
|
+
assert.equal(plan().payload.onboardingRequired, true);
|
|
77
|
+
|
|
78
|
+
// POSITIVE CONTROL: the app-typed row the wizard writes for a SITE person is
|
|
79
|
+
// not this row, and must not satisfy the assertion above.
|
|
80
|
+
assert.throws(() => assert.equal("property_management_agency", "organization"));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── 1. the zero-member gate ─────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
test("an organisation that already has a member is refused, with no exceptions", () => {
|
|
86
|
+
for (const count of [1, 2, 3, 9, 146]) {
|
|
87
|
+
const refused = plan({ orgMemberCount: count });
|
|
88
|
+
assert.equal(refused.payload, undefined, `count ${count} must plan nothing`);
|
|
89
|
+
assert.match(refused.skip, /already has/);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// POSITIVE CONTROL: zero is the one count that must NOT be refused, or this
|
|
93
|
+
// repairs nothing at all.
|
|
94
|
+
assert.ok(plan({ orgMemberCount: 0 }).payload);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("an unreadable member count fails CLOSED", () => {
|
|
98
|
+
// Not knowing is not the same as knowing there are none. -1 is what the
|
|
99
|
+
// handler substitutes when the count read throws.
|
|
100
|
+
for (const count of [-1, -99, null, undefined, NaN, "0", 0.5, 1.5, Infinity, [], {}]) {
|
|
101
|
+
const refused = plan({ orgMemberCount: count });
|
|
102
|
+
assert.equal(
|
|
103
|
+
refused.payload,
|
|
104
|
+
undefined,
|
|
105
|
+
`count ${JSON.stringify(count)} must plan nothing`,
|
|
106
|
+
);
|
|
107
|
+
assert.match(refused.skip, /could not be read/);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// POSITIVE CONTROL: a real integer zero still plans.
|
|
111
|
+
assert.ok(plan({ orgMemberCount: 0 }).payload);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── 2. active organisations only ────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
test("only an active organisation is repaired", () => {
|
|
117
|
+
for (const status of ["suspended", "deleted", "inactive", "pending", "", " ", null, undefined]) {
|
|
118
|
+
const refused = plan({ org: org({ status }) });
|
|
119
|
+
assert.equal(refused.payload, undefined, `status ${JSON.stringify(status)} must plan nothing`);
|
|
120
|
+
assert.match(refused.skip, /Only an active organisation/);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Case and padding must not be a way past it, in either direction.
|
|
124
|
+
assert.ok(plan({ org: org({ status: "ACTIVE" }) }).payload);
|
|
125
|
+
assert.ok(plan({ org: org({ status: " active " }) }).payload);
|
|
126
|
+
|
|
127
|
+
// POSITIVE CONTROL: the ordinary active organisation plans.
|
|
128
|
+
assert.ok(plan().payload);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("never an organisation without a 24-hex id", () => {
|
|
132
|
+
for (const bad of [undefined, null, "", " ", "not-hex", "68b0c1d2", `${ORG}00`, 12345, {}]) {
|
|
133
|
+
assert.equal(
|
|
134
|
+
plan({ org: { _id: bad, status: "active" } }).payload,
|
|
135
|
+
undefined,
|
|
136
|
+
`id ${JSON.stringify(bad)} must plan nothing`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
assert.equal(plan({ org: null }).payload, undefined);
|
|
140
|
+
assert.equal(plan({ org: undefined }).payload, undefined);
|
|
141
|
+
|
|
142
|
+
// POSITIVE CONTROL: a good id does plan, and the payload carries it.
|
|
143
|
+
assert.equal(plan().payload.orgId, ORG);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── 3. the owner is never guessed ───────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
test("no account holding the organisation's address means nothing is written", () => {
|
|
149
|
+
for (const bad of ["", null, undefined, " ", "not-an-id", "68b0c1d2", 12345]) {
|
|
150
|
+
const refused = plan({ ownerUserId: bad });
|
|
151
|
+
assert.equal(refused.payload, undefined, `owner ${JSON.stringify(bad)} must plan nothing`);
|
|
152
|
+
assert.match(refused.skip, /No account holds/);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// POSITIVE CONTROL: a real account id does plan.
|
|
156
|
+
assert.ok(plan().payload);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a membership is never pointed at no role", () => {
|
|
160
|
+
for (const bad of ["", null, undefined, "nope", 12345]) {
|
|
161
|
+
const refused = plan({ ownerRoleId: bad });
|
|
162
|
+
assert.equal(refused.payload, undefined, `role ${JSON.stringify(bad)} must plan nothing`);
|
|
163
|
+
assert.match(refused.skip, /no .?organization.? owner role/);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// POSITIVE CONTROL
|
|
167
|
+
assert.ok(plan().payload);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── 4. the switch ───────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
test("the switch off refuses everything, whatever else is true", () => {
|
|
173
|
+
const refused = plan({ gateOn: false });
|
|
174
|
+
assert.equal(refused.payload, undefined);
|
|
175
|
+
assert.equal(refused.skip, ORG_OWNER_REPAIR_OFF_REASON);
|
|
176
|
+
|
|
177
|
+
// It is checked FIRST, so an otherwise perfect organisation is still refused.
|
|
178
|
+
assert.equal(orgQualifiesForOwnerRepair({ gateOn: false }), ORG_OWNER_REPAIR_OFF_REASON);
|
|
179
|
+
|
|
180
|
+
// POSITIVE CONTROL: the same input with the switch on plans.
|
|
181
|
+
assert.ok(plan({ gateOn: true }).payload);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the switch reads off/false/0/no, defaults on, and is read per call", () => {
|
|
185
|
+
assert.equal(ORG_OWNER_REPAIR_DEFAULT, "on");
|
|
186
|
+
assert.equal(orgOwnerRepairOn({}), true, "absent means on");
|
|
187
|
+
|
|
188
|
+
for (const off of ["off", "OFF", " off ", "false", "FALSE", "0", "no", "No"]) {
|
|
189
|
+
assert.equal(orgOwnerRepairOn({ ORG_OWNER_REPAIR: off }), false, off);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const on of ["on", "true", "1", "yes", "", "anything"]) {
|
|
193
|
+
assert.equal(orgOwnerRepairOn({ ORG_OWNER_REPAIR: on }), true, on);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The same spellings `MODULE_LIST_GATE` accepts, so a lead has one vocabulary.
|
|
197
|
+
const source = read("src/utils/org-owner-repair.util.ts");
|
|
198
|
+
assert.match(source, /\["off", "false", "0", "no"\]/);
|
|
199
|
+
assert.match(source, /env\.ORG_OWNER_REPAIR \?\? ORG_OWNER_REPAIR_DEFAULT/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── 5. the short-circuit that keeps a refusal from writing ──────────────────
|
|
203
|
+
|
|
204
|
+
test("the organisation-level refusals need nothing looked up", () => {
|
|
205
|
+
// This is the function the handler calls BEFORE resolving the owner role,
|
|
206
|
+
// because resolving that role can SEED roles. It must decide the switch, the
|
|
207
|
+
// id, the status and the count on its own.
|
|
208
|
+
assert.equal(orgQualifiesForOwnerRepair({ gateOn: true, org: org(), orgMemberCount: 0 }), undefined);
|
|
209
|
+
assert.match(orgQualifiesForOwnerRepair({ gateOn: true, org: org(), orgMemberCount: 1 }), /already has/);
|
|
210
|
+
assert.match(orgQualifiesForOwnerRepair({ gateOn: true, org: org({ status: "suspended" }), orgMemberCount: 0 }), /active/);
|
|
211
|
+
assert.match(orgQualifiesForOwnerRepair({ gateOn: true, org: org(), orgMemberCount: -1 }), /could not be read/);
|
|
212
|
+
|
|
213
|
+
// ... and `planOrgOwnerRepair` re-checks every one of them, so the
|
|
214
|
+
// short-circuit is never the only guard.
|
|
215
|
+
for (const [input, pattern] of [
|
|
216
|
+
[{ orgMemberCount: 1 }, /already has/],
|
|
217
|
+
[{ org: org({ status: "suspended" }) }, /active/],
|
|
218
|
+
[{ orgMemberCount: -1 }, /could not be read/],
|
|
219
|
+
[{ gateOn: false }, /switched off/],
|
|
220
|
+
]) {
|
|
221
|
+
assert.match(plan(input).skip, pattern);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── 6. the audit row ────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
test("the repair records ids and a type, and cannot record a person", () => {
|
|
228
|
+
const action = ConsoleAuditAction.CLIENT_OWNER_REPAIRED;
|
|
229
|
+
|
|
230
|
+
assert.equal(action, "client.owner-repaired");
|
|
231
|
+
assert.ok(CONSOLE_AUDIT_LABELS[action] && CONSOLE_AUDIT_LABELS[action] !== action);
|
|
232
|
+
|
|
233
|
+
const picked = pickAuditFields(action, {
|
|
234
|
+
member: USER,
|
|
235
|
+
role: ROLE,
|
|
236
|
+
memberType: "organization",
|
|
237
|
+
// everything below is NOT on the allow-list
|
|
238
|
+
email: "owner@example.com",
|
|
239
|
+
name: "A Person",
|
|
240
|
+
password: "hashed-secret-value",
|
|
241
|
+
contact: "+65 8000 0000",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(Object.keys(picked).sort(), ["member", "memberType", "role"]);
|
|
245
|
+
|
|
246
|
+
// POSITIVE CONTROL: the allow-list really is doing the work.
|
|
247
|
+
assert.equal(pickAuditFields(action, { email: "owner@example.com" }), undefined);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── 7. the wiring, read off the source ──────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
const read = (path) =>
|
|
253
|
+
readFileSync(fileURLToPath(new URL(`../${path}`, import.meta.url)), "utf8");
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The source with its comments taken out.
|
|
257
|
+
*
|
|
258
|
+
* Every "this file must not do X" assertion below needs it: these files
|
|
259
|
+
* deliberately NAME the thing they refuse to do, in prose, and a check run over
|
|
260
|
+
* the comments cannot tell "does not use $regex" from "explains why it does not
|
|
261
|
+
* use $regex".
|
|
262
|
+
*/
|
|
263
|
+
const stripComments = (src) =>
|
|
264
|
+
src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
265
|
+
|
|
266
|
+
const CONTROLLER = read("src/controllers/organization.controller.ts");
|
|
267
|
+
const REPO = read("src/repositories/org-owner-repair.repo.ts");
|
|
268
|
+
|
|
269
|
+
/** The body of the repair handler. */
|
|
270
|
+
const HANDLER = (() => {
|
|
271
|
+
const from = CONTROLLER.indexOf(" async function repairOwnerMembership(");
|
|
272
|
+
assert.ok(from > 0, "repairOwnerMembership is gone");
|
|
273
|
+
const to = CONTROLLER.indexOf("\n return { repairOwnerMembership };", from);
|
|
274
|
+
assert.ok(to > from);
|
|
275
|
+
return CONTROLLER.slice(from, to);
|
|
276
|
+
})();
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The same two, with the prose removed. Every "this must not do X" assertion
|
|
280
|
+
* runs over these: both files deliberately NAME the thing they refuse to do
|
|
281
|
+
* (`$regex`, `countByOrg`, `GET /api/roles`, a unique index) to say why it is
|
|
282
|
+
* not used, and a check that cannot tell code from comment fails for the wrong
|
|
283
|
+
* reason.
|
|
284
|
+
*/
|
|
285
|
+
const REPO_CODE = stripComments(REPO);
|
|
286
|
+
const HANDLER_CODE = stripComments(HANDLER);
|
|
287
|
+
|
|
288
|
+
test("the endpoint is staff-gated, and the gate runs before anything else", () => {
|
|
289
|
+
assert.match(
|
|
290
|
+
HANDLER,
|
|
291
|
+
/const staffId = await requireConsolePermission\(req, "organizations"\)/,
|
|
292
|
+
"the repair is not behind the console permission",
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const gate = HANDLER.indexOf("await requireConsolePermission(req");
|
|
296
|
+
for (const [what, needle] of [
|
|
297
|
+
["the switch", "orgOwnerRepairOn"],
|
|
298
|
+
["the organisation read", "await getOrgUncached(orgId)"],
|
|
299
|
+
["the claim", "await claim(orgId)"],
|
|
300
|
+
["the write", "await createMemberDirect("],
|
|
301
|
+
]) {
|
|
302
|
+
const at = HANDLER.indexOf(needle);
|
|
303
|
+
assert.ok(at > gate, `${what} happens before the caller is judged`);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("the user id is derived server-side and never taken from the request", () => {
|
|
308
|
+
// This is what stops the repair becoming a way to join an organisation you do
|
|
309
|
+
// not own, and what keeps the 3.65.0 self-enrolment guards (S2, G1) intact:
|
|
310
|
+
// the only input is the organisation id in the URL.
|
|
311
|
+
assert.doesNotMatch(HANDLER, /req\.body/, "the handler reads the request body");
|
|
312
|
+
assert.doesNotMatch(HANDLER, /userId:\s*req\./, "the user id comes from the request");
|
|
313
|
+
assert.match(
|
|
314
|
+
HANDLER,
|
|
315
|
+
/Joi\.object\(\{\s*id: Joi\.string\(\)\.hex\(\)\.length\(24\)\.required\(\),\s*\}\)/,
|
|
316
|
+
"the only validated input is the organisation id",
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// The owner is the account the organisation is registered under, confirmed by
|
|
320
|
+
// `hasOrgOwnership` rather than trusted from the lookup.
|
|
321
|
+
assert.match(HANDLER, /findUserByEmail\(\s*org\?\.email/);
|
|
322
|
+
assert.match(HANDLER, /await confirmOwnership\(candidateId, orgId\)\.catch\(\(\) => false\)/);
|
|
323
|
+
assert.match(
|
|
324
|
+
CONTROLLER,
|
|
325
|
+
/confirmOwnership: hasOrgOwnership/,
|
|
326
|
+
"ownership is decided by something other than hasOrgOwnership",
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// ... and the membership is attributed to the session the gate resolved.
|
|
330
|
+
assert.match(HANDLER, /createMemberDirect\(\{ \.\.\.payload, callerId: staffId \}\)/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("one writer: the claim is taken before the write and the count re-read under it", () => {
|
|
334
|
+
const claim = HANDLER.indexOf("claimToken = await claim(orgId)");
|
|
335
|
+
const recount = HANDLER.indexOf("const orgMemberCount = await countOrgMembers(orgId)");
|
|
336
|
+
const write = HANDLER.indexOf("await createMemberDirect(");
|
|
337
|
+
|
|
338
|
+
assert.ok(claim > 0 && recount > claim, "the count is not re-read UNDER the claim");
|
|
339
|
+
assert.ok(write > recount, "the write happens before the count that decides");
|
|
340
|
+
|
|
341
|
+
// The loser of the race writes nothing at all.
|
|
342
|
+
assert.match(
|
|
343
|
+
HANDLER,
|
|
344
|
+
/if \(!claimToken\) \{[\s\S]*?ORG_OWNER_REPAIR_CLAIM_TAKEN_REASON/,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// A refusal or a throw gives the claim back, so the ledger row always means
|
|
348
|
+
// "we wrote this organisation's owner membership". The release is conditional
|
|
349
|
+
// on the stamp we inserted, so it can never delete somebody else's claim.
|
|
350
|
+
assert.match(HANDLER, /if \(!payload\) \{[\s\S]*?await release\(orgId, claimToken\)/);
|
|
351
|
+
assert.match(HANDLER, /if \(claimToken\) await release\(orgId, claimToken\)/);
|
|
352
|
+
assert.match(REPO_CODE, /claims\.deleteOne\(\{ _id: toId\(org\), repairedAt \}\)/);
|
|
353
|
+
|
|
354
|
+
// The claim is an `_id` insert — atomic in a collection that already exists.
|
|
355
|
+
// No index is created and no unique constraint is added to `members`.
|
|
356
|
+
// `id` is `toId(org)`, hoisted once so the takeover below reuses it.
|
|
357
|
+
assert.match(REPO_CODE, /const id = toId\(org\);/);
|
|
358
|
+
// The stamp is created once and RETURNED, so the holder can prove later that
|
|
359
|
+
// the claim in the ledger is still the one it inserted (`holdsClaim`).
|
|
360
|
+
assert.match(REPO_CODE, /const repairedAt = new Date\(\);/);
|
|
361
|
+
assert.match(REPO_CODE, /claims\.insertOne\(\{ _id: id, repairedAt \}\)/);
|
|
362
|
+
assert.match(REPO_CODE, /return repairedAt;/);
|
|
363
|
+
assert.match(REPO, /error\?\.code === 11000/);
|
|
364
|
+
assert.doesNotMatch(REPO_CODE, /createIndex|createIndexes|unique/i);
|
|
365
|
+
|
|
366
|
+
// A SEPARATE ledger from the default-role self-heal: sharing it would mean an
|
|
367
|
+
// organisation whose roles had already healed could never be repaired.
|
|
368
|
+
assert.match(REPO, /org-owner-repair-claims/);
|
|
369
|
+
assert.doesNotMatch(REPO_CODE, /org-default-role-seeds/);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── 8. a claim that outlived its request must not block forever ─────────────
|
|
373
|
+
|
|
374
|
+
test("a claim is stale only after the full window, and unreadable is NOT stale", () => {
|
|
375
|
+
assert.equal(ORG_OWNER_REPAIR_CLAIM_STALE_MS, 10 * 60 * 1000);
|
|
376
|
+
|
|
377
|
+
const now = Date.UTC(2026, 8, 12, 12, 0, 0);
|
|
378
|
+
const ago = (ms) => new Date(now - ms);
|
|
379
|
+
|
|
380
|
+
// Still running, or only just finished.
|
|
381
|
+
for (const ms of [0, 1000, 60 * 1000, 9 * 60 * 1000, ORG_OWNER_REPAIR_CLAIM_STALE_MS - 1]) {
|
|
382
|
+
assert.equal(claimReadsAsStale(ago(ms), now), false, `${ms}ms must not be stale`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Old enough to be a request that died.
|
|
386
|
+
for (const ms of [ORG_OWNER_REPAIR_CLAIM_STALE_MS, 11 * 60 * 1000, 24 * 60 * 60 * 1000]) {
|
|
387
|
+
assert.equal(claimReadsAsStale(ago(ms), now), true, `${ms}ms must be stale`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// An ISO string reads the same as a Date, because that is what a row can hold.
|
|
391
|
+
assert.equal(claimReadsAsStale(ago(11 * 60 * 1000).toISOString(), now), true);
|
|
392
|
+
assert.equal(claimReadsAsStale(ago(60 * 1000).toISOString(), now), false);
|
|
393
|
+
|
|
394
|
+
// FAILS CLOSED: anything unreadable is not stale, so a live repair can never
|
|
395
|
+
// acquire a second writer through a malformed row.
|
|
396
|
+
for (const bad of [undefined, null, "", " ", "not-a-date", NaN, {}, []]) {
|
|
397
|
+
assert.equal(
|
|
398
|
+
claimReadsAsStale(bad, now),
|
|
399
|
+
false,
|
|
400
|
+
`${JSON.stringify(bad)} must not read as abandoned`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// A clock that has gone backwards is not a stale claim either.
|
|
405
|
+
assert.equal(claimReadsAsStale(new Date(now + 60 * 1000), now), false);
|
|
406
|
+
|
|
407
|
+
// POSITIVE CONTROL: the function really can say yes.
|
|
408
|
+
assert.equal(claimReadsAsStale(ago(20 * 60 * 1000), now), true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("a stale claim is taken over by ONE request, and only while nobody is in the org", () => {
|
|
412
|
+
// Why this exists: the claim is inserted BEFORE the membership is written, so a
|
|
413
|
+
// process killed in between left a row that blocked the repair forever — and
|
|
414
|
+
// the only remedy would be deleting a row by hand in the database, which this
|
|
415
|
+
// project forbids. One unlucky restart would permanently block the repair for
|
|
416
|
+
// exactly the organisations this was built for.
|
|
417
|
+
const fn = REPO_CODE.slice(
|
|
418
|
+
REPO_CODE.indexOf("async function claimRepair("),
|
|
419
|
+
REPO_CODE.indexOf("async function releaseRepair("),
|
|
420
|
+
);
|
|
421
|
+
assert.ok(fn.length > 0, "claimRepair is gone");
|
|
422
|
+
|
|
423
|
+
// A fresh claim still stands the caller down, exactly as before.
|
|
424
|
+
assert.match(fn, /if \(!existing \|\| !claimReadsAsStale\(existing\.repairedAt\)\) return null/);
|
|
425
|
+
|
|
426
|
+
// The organisation must still have NOBODY in it: if the dead request got its
|
|
427
|
+
// write in after all, there is nothing to repair.
|
|
428
|
+
const staleCheck = fn.indexOf("claimReadsAsStale(");
|
|
429
|
+
const countCheck = fn.indexOf("await countOrgMemberships(id)) !== 0");
|
|
430
|
+
const del = fn.indexOf("claims.deleteOne(");
|
|
431
|
+
assert.ok(countCheck > staleCheck, "the count is not checked before the takeover");
|
|
432
|
+
assert.ok(del > countCheck, "the claim is deleted before the count is checked");
|
|
433
|
+
|
|
434
|
+
// THE RACE GUARD: the delete is conditional on the `_id` AND the stale
|
|
435
|
+
// timestamp in ONE filter, so it is a single atomic compare-and-delete. Two
|
|
436
|
+
// requests here cannot both have deletedCount 1.
|
|
437
|
+
assert.match(
|
|
438
|
+
fn,
|
|
439
|
+
/claims\.deleteOne\(\{\s*_id: id,\s*repairedAt: \{ \$lte: cutoff \},\s*\}\)/,
|
|
440
|
+
"the takeover delete is not a conditional compare-and-delete",
|
|
441
|
+
);
|
|
442
|
+
assert.match(fn, /if \(!taken\.deletedCount\) return null/);
|
|
443
|
+
|
|
444
|
+
// Retried exactly ONCE: the loser collides with the winner's FRESH claim, gets
|
|
445
|
+
// 11000 again and stands down rather than looping.
|
|
446
|
+
assert.equal(
|
|
447
|
+
(fn.match(/claims\.insertOne\(/g) ?? []).length,
|
|
448
|
+
2,
|
|
449
|
+
"the claim insert is attempted more than twice",
|
|
450
|
+
);
|
|
451
|
+
assert.doesNotMatch(fn, /while \(|for \(/, "the takeover loops");
|
|
452
|
+
|
|
453
|
+
// A real error is still thrown rather than read as "nobody else is here".
|
|
454
|
+
assert.match(fn, /if \(error\?\.code !== 11000\) throw error/);
|
|
455
|
+
|
|
456
|
+
// The claim is STILL not what authorises the write - the count is re-read under
|
|
457
|
+
// it by the caller, which is asserted in the one-writer test above.
|
|
458
|
+
assert.match(HANDLER_CODE, /const orgMemberCount = await countOrgMembers\(orgId\)/);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("the role is resolved only after the count says zero, so a refusal writes nothing", () => {
|
|
462
|
+
// Resolving the owner role can SEED an organisation's default roles. If it ran
|
|
463
|
+
// before the count, an organisation that already has a member would be written
|
|
464
|
+
// to by a request that then refuses.
|
|
465
|
+
assert.match(
|
|
466
|
+
HANDLER,
|
|
467
|
+
/const qualifies = orgMemberCount === 0 && Boolean\(ownerUserId\)/,
|
|
468
|
+
);
|
|
469
|
+
const qualifies = HANDLER.indexOf("const qualifies =");
|
|
470
|
+
const heal = HANDLER.indexOf("await healDefaultRoles(orgId)");
|
|
471
|
+
const recount = HANDLER.indexOf("const orgMemberCount = await countOrgMembers(orgId)");
|
|
472
|
+
|
|
473
|
+
assert.ok(recount < qualifies, "the seed guard is computed before the count");
|
|
474
|
+
assert.ok(heal > qualifies, "roles can be seeded before the count is known");
|
|
475
|
+
assert.match(HANDLER, /if \(qualifies && !ownerRoleId\) \{/);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("the counts and the role are read UNCACHED, and never through GET /api/roles", () => {
|
|
479
|
+
// `member.repo countByOrg` answers from a 15-minute Redis cache and counts
|
|
480
|
+
// `status: "active"` only; a cached zero is exactly the answer that must not
|
|
481
|
+
// be trusted by something about to write, and a SUSPENDED owner is a row that
|
|
482
|
+
// exists.
|
|
483
|
+
assert.match(REPO, /members\.countDocuments\(\{/);
|
|
484
|
+
assert.match(REPO, /type: "organization"/);
|
|
485
|
+
assert.match(REPO, /status: \{ \$ne: "deleted" \}/);
|
|
486
|
+
assert.doesNotMatch(REPO_CODE, /countByOrg|getCache|setCache|makeCacheKey/);
|
|
487
|
+
|
|
488
|
+
// `GET /api/roles` self-heals, so reading it is a write. The role is read from
|
|
489
|
+
// the collection with the same filter `getOwnerRolesByTypeOrg` uses.
|
|
490
|
+
assert.doesNotMatch(REPO_CODE, /api\/roles|fetch\(/);
|
|
491
|
+
assert.doesNotMatch(HANDLER_CODE, /api\/roles|fetch\(/);
|
|
492
|
+
assert.match(REPO_CODE, /const roles = db\.collection\("roles"\)/);
|
|
493
|
+
assert.match(REPO_CODE, /roles\.findOne</);
|
|
494
|
+
assert.match(REPO_CODE, /permissions: "\*"/);
|
|
495
|
+
|
|
496
|
+
// The ORGANISATION too. `organization.repo getById` is Redis-cached, and
|
|
497
|
+
// `status` and `email` are exactly what decide whether to repair and who the
|
|
498
|
+
// owner is — a stale copy could repair a SUSPENDED client, or point the
|
|
499
|
+
// membership at an address the organisation no longer uses.
|
|
500
|
+
assert.match(REPO_CODE, /const organizations = db\.collection\("organizations"\)/);
|
|
501
|
+
assert.match(REPO_CODE, /organizations\.findOne</);
|
|
502
|
+
assert.match(HANDLER_CODE, /await getOrgUncached\(orgId\)/);
|
|
503
|
+
assert.doesNotMatch(
|
|
504
|
+
HANDLER_CODE,
|
|
505
|
+
/_getById/,
|
|
506
|
+
"the repair reads the organisation through the CACHED repository",
|
|
507
|
+
);
|
|
508
|
+
// ... and the 404 for an organisation that is not there is preserved.
|
|
509
|
+
assert.match(HANDLER_CODE, /if \(!org\) throw new NotFoundError\(/);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("the e-mail is matched case-insensitively BY COLLATION, never by a regex", () => {
|
|
513
|
+
// The reason `hasOrgOwnership` and `hasOrgInvitation` both give: an address may
|
|
514
|
+
// contain `+` or `.`, and building a pattern out of one invites an escaping
|
|
515
|
+
// bug. Same collation, same strength.
|
|
516
|
+
assert.match(REPO, /collation: \{ locale: "en", strength: 2 \}/);
|
|
517
|
+
// Against the CODE, not the prose: the doc comment names `$regex` to say why
|
|
518
|
+
// it is not used, and a check that cannot tell the two apart is a check that
|
|
519
|
+
// fails for the wrong reason.
|
|
520
|
+
assert.doesNotMatch(stripComments(REPO), /\$regex|new RegExp/);
|
|
521
|
+
|
|
522
|
+
// POSITIVE CONTROL: `hasOrgOwnership` is where that rule comes from, and it
|
|
523
|
+
// must still be spelled the same way.
|
|
524
|
+
assert.match(
|
|
525
|
+
read("src/utils/invite-actor.util.ts").slice(
|
|
526
|
+
read("src/utils/invite-actor.util.ts").indexOf("export async function hasOrgOwnership("),
|
|
527
|
+
),
|
|
528
|
+
/collation: \{ locale: "en", strength: 2 \}/,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("the repair records an audit row, after the write and before the reply", () => {
|
|
533
|
+
const write = HANDLER.indexOf("await createMemberDirect(");
|
|
534
|
+
const record = HANDLER.indexOf("await recordConsoleAction(");
|
|
535
|
+
const reply = HANDLER.search(/res\.json\(/);
|
|
536
|
+
|
|
537
|
+
assert.ok(write !== -1 && record !== -1 && reply !== -1, "nothing to compare");
|
|
538
|
+
assert.ok(write < record, "it records before it writes");
|
|
539
|
+
assert.ok(record < reply, "it replies before it records");
|
|
540
|
+
|
|
541
|
+
assert.match(HANDLER, /action: ConsoleAuditAction\.CLIENT_OWNER_REPAIRED/);
|
|
542
|
+
assert.match(HANDLER, /actor: staffId/);
|
|
543
|
+
assert.doesNotMatch(HANDLER, /actor: (req|value|payload|plan)/);
|
|
544
|
+
|
|
545
|
+
// A lost audit row cannot undo the repair.
|
|
546
|
+
const call = HANDLER.slice(record, reply);
|
|
547
|
+
assert.doesNotMatch(call, /\.catch\(|throw /);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("no schema change, no migration, no backfill, no index", () => {
|
|
551
|
+
for (const [name, src] of [["controller", HANDLER_CODE], ["repository", REPO_CODE]]) {
|
|
552
|
+
assert.doesNotMatch(src, /createIndex|dropIndex/, `${name} touches an index`);
|
|
553
|
+
assert.doesNotMatch(src, /updateMany|deleteMany|drop\(/, `${name} writes in bulk`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The only write to a live collection is the membership itself, through the
|
|
557
|
+
// onboarding service call. The claim ledger is this feature's own collection.
|
|
558
|
+
assert.doesNotMatch(HANDLER_CODE, /insertOne|updateOne|deleteOne/);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("nothing on the landing or sign-in path is touched", () => {
|
|
562
|
+
// The landing page starts working because the row it always asked for now
|
|
563
|
+
// exists, not because the read changed.
|
|
564
|
+
const member = read("src/controllers/member.controller.ts");
|
|
565
|
+
const repo = read("src/repositories/member.repo.ts");
|
|
566
|
+
|
|
567
|
+
for (const [name, src] of [["member.controller", member], ["member.repo", repo]]) {
|
|
568
|
+
assert.doesNotMatch(
|
|
569
|
+
src,
|
|
570
|
+
/org-owner-repair|repairOwnerMembership|ORG_OWNER_REPAIR/,
|
|
571
|
+
`${name} was changed by the repair`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ── 9. the handler itself, driven with stubs ────────────────────────────────
|
|
577
|
+
//
|
|
578
|
+
// THE INTERLEAVING the claim alone does not stop. A holder that is alive but
|
|
579
|
+
// STALLED past the staleness window is not dead:
|
|
580
|
+
//
|
|
581
|
+
// A claims -> A stalls >10 min -> B sees a stale claim, takes it over,
|
|
582
|
+
// re-reads the count as 0 under its own fresh claim, WRITES -> A resumes and
|
|
583
|
+
// writes too, on the strength of a count it read before any of that.
|
|
584
|
+
//
|
|
585
|
+
// `members` has no unique index for this shape (the declared one is
|
|
586
|
+
// {org, siteId, user, type} with a partial filter, and nothing in this package
|
|
587
|
+
// creates it), so nothing downstream would catch the second row. The handler
|
|
588
|
+
// therefore re-reads its own claim immediately before writing. That is behaviour,
|
|
589
|
+
// not wiring, so it is executed here rather than read off the source - the
|
|
590
|
+
// handler's dependencies are injected for exactly this.
|
|
591
|
+
|
|
592
|
+
const STAFF = "6b0c1d2e3f4a5b6c7d8e9f01";
|
|
593
|
+
|
|
594
|
+
function fakeRepair(over = {}) {
|
|
595
|
+
const calls = { written: [], released: [], audited: [], order: [] };
|
|
596
|
+
const token = new Date("2026-09-12T12:00:00.000Z");
|
|
597
|
+
|
|
598
|
+
const deps = {
|
|
599
|
+
requireConsolePermission: async () => STAFF,
|
|
600
|
+
getOrgUncached: async () => ({
|
|
601
|
+
_id: ORG,
|
|
602
|
+
status: "active",
|
|
603
|
+
email: "owner@example.com",
|
|
604
|
+
}),
|
|
605
|
+
countOrgMembers: async () => 0,
|
|
606
|
+
claim: async () => token,
|
|
607
|
+
release: async (org, repairedAt) => calls.released.push([org, repairedAt]),
|
|
608
|
+
holdsClaim: async () => {
|
|
609
|
+
calls.order.push("revalidate");
|
|
610
|
+
return true;
|
|
611
|
+
},
|
|
612
|
+
findUserByEmail: async () => ({ _id: USER }),
|
|
613
|
+
confirmOwnership: async () => true,
|
|
614
|
+
findOwnerRoleId: async () => ROLE,
|
|
615
|
+
healDefaultRoles: async () => 0,
|
|
616
|
+
createMemberDirect: async (input) => {
|
|
617
|
+
calls.order.push("write");
|
|
618
|
+
calls.written.push(input);
|
|
619
|
+
},
|
|
620
|
+
recordConsoleAction: async (entry) => calls.audited.push(entry),
|
|
621
|
+
gateOn: () => true,
|
|
622
|
+
...over,
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
return { calls, token, handlers: orgOwnerRepairHandlers(deps) };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function run(world, params = { id: ORG }) {
|
|
629
|
+
const out = {};
|
|
630
|
+
const res = {
|
|
631
|
+
json: (value) => ((out.json = value), res),
|
|
632
|
+
status: () => res,
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
await world.handlers.repairOwnerMembership(
|
|
636
|
+
{ params, body: {}, query: {} },
|
|
637
|
+
res,
|
|
638
|
+
(error) => (out.error = error),
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
test("the ordinary repair writes once, records once, and keeps its claim", () => {
|
|
645
|
+
return (async () => {
|
|
646
|
+
const world = fakeRepair();
|
|
647
|
+
const out = await run(world);
|
|
648
|
+
|
|
649
|
+
assert.equal(out.error, undefined, String(out.error?.message));
|
|
650
|
+
assert.equal(world.calls.written.length, 1);
|
|
651
|
+
assert.deepEqual(world.calls.written[0], {
|
|
652
|
+
userId: USER,
|
|
653
|
+
orgId: ORG,
|
|
654
|
+
roleId: ROLE,
|
|
655
|
+
app: "organization",
|
|
656
|
+
onboardingRequired: true,
|
|
657
|
+
callerId: STAFF,
|
|
658
|
+
});
|
|
659
|
+
assert.equal(world.calls.audited.length, 1);
|
|
660
|
+
assert.equal(world.calls.released.length, 0, "a successful repair keeps its claim");
|
|
661
|
+
|
|
662
|
+
// The revalidation is the LAST thing before the write.
|
|
663
|
+
assert.deepEqual(world.calls.order, ["revalidate", "write"]);
|
|
664
|
+
})();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("a claim taken over while this request stalled writes NOTHING", async () => {
|
|
668
|
+
// B stole the claim and has already written. A resumes here.
|
|
669
|
+
const world = fakeRepair({ holdsClaim: async () => false });
|
|
670
|
+
const out = await run(world);
|
|
671
|
+
|
|
672
|
+
assert.equal(world.calls.written.length, 0, "it wrote a SECOND owner membership");
|
|
673
|
+
assert.equal(world.calls.audited.length, 0, "it recorded a write that did not happen");
|
|
674
|
+
assert.ok(out.error, "a stolen claim must be refused");
|
|
675
|
+
assert.equal(out.error.message, ORG_OWNER_REPAIR_CLAIM_TAKEN_REASON);
|
|
676
|
+
assert.equal(out.json, undefined);
|
|
677
|
+
|
|
678
|
+
// ... and it must NOT release: that row belongs to the other request now, and
|
|
679
|
+
// deleting it would hand a third request a free claim on a live repair.
|
|
680
|
+
assert.equal(
|
|
681
|
+
world.calls.released.length,
|
|
682
|
+
0,
|
|
683
|
+
"it released a claim that now belongs to another request",
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("a revalidation that cannot be read fails CLOSED", async () => {
|
|
688
|
+
const world = fakeRepair({
|
|
689
|
+
holdsClaim: async () => {
|
|
690
|
+
throw new Error("mongo is gone");
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
const out = await run(world);
|
|
694
|
+
|
|
695
|
+
assert.equal(world.calls.written.length, 0, "an unreadable claim let the write through");
|
|
696
|
+
assert.equal(out.error.message, ORG_OWNER_REPAIR_CLAIM_TAKEN_REASON);
|
|
697
|
+
assert.equal(world.calls.released.length, 0);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("a refusal after the claim gives the claim back, with the stamp it inserted", async () => {
|
|
701
|
+
// Somebody is already a member: refused, and the ledger row must not be left
|
|
702
|
+
// behind claiming we repaired this organisation.
|
|
703
|
+
const world = fakeRepair({ countOrgMembers: async () => 2 });
|
|
704
|
+
const out = await run(world);
|
|
705
|
+
|
|
706
|
+
assert.equal(world.calls.written.length, 0);
|
|
707
|
+
assert.match(out.error.message, /already has 2/);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("losing the claim outright refuses, writes nothing and releases nothing", async () => {
|
|
711
|
+
const world = fakeRepair({ claim: async () => null });
|
|
712
|
+
const out = await run(world);
|
|
713
|
+
|
|
714
|
+
assert.equal(world.calls.written.length, 0);
|
|
715
|
+
assert.equal(world.calls.released.length, 0);
|
|
716
|
+
assert.equal(out.error.message, ORG_OWNER_REPAIR_CLAIM_TAKEN_REASON);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("the switch off refuses before the claim is even taken", async () => {
|
|
720
|
+
const world = fakeRepair({ gateOn: () => false });
|
|
721
|
+
const out = await run(world);
|
|
722
|
+
|
|
723
|
+
assert.equal(world.calls.written.length, 0);
|
|
724
|
+
assert.equal(world.calls.order.length, 0);
|
|
725
|
+
assert.equal(out.error.message, ORG_OWNER_REPAIR_OFF_REASON);
|
|
726
|
+
});
|