@7365admin1/core 3.59.2 → 3.60.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 +6 -0
- package/dist/index.d.ts +173 -1
- package/dist/index.js +6419 -5736
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3106 -2423
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/resident-self-signup-pinning.e2e.test.mjs +33 -0
- package/test/org-role-selfheal.test.mjs +282 -0
package/package.json
CHANGED
|
@@ -362,6 +362,39 @@ describe("pre-login resident sign-up cannot manufacture access", { concurrency:
|
|
|
362
362
|
assert.equal(file.status, "active");
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
+
record("D. a double-tapped Confirm cannot damage the registration that won", async () => {
|
|
366
|
+
// Defect 4 in the field: nothing disabled the Confirm button, so every tap
|
|
367
|
+
// sent another POST. This measures what the SECOND one actually does.
|
|
368
|
+
const email = "pin-double@e2e.example.com";
|
|
369
|
+
const fileId = new ObjectId();
|
|
370
|
+
await h.db.collection("files").insertOne({ _id: fileId, name: "lease.pdf", status: "draft" });
|
|
371
|
+
|
|
372
|
+
const body = {
|
|
373
|
+
...genuine(id, email),
|
|
374
|
+
files: [{ id: fileId.toString(), name: "lease.pdf", mimeType: "application/pdf" }],
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const first = await h.api("/people/resident/create", { method: "POST", body });
|
|
378
|
+
const second = await h.api("/people/resident/create", { method: "POST", body });
|
|
379
|
+
const third = await h.api("/people/resident/create", { method: "POST", body });
|
|
380
|
+
|
|
381
|
+
assert.equal(first.status, 201, JSON.stringify(first.status));
|
|
382
|
+
// Every later tap dies on the duplicate-account check...
|
|
383
|
+
assert.equal(second.status, 400, JSON.stringify(second.body));
|
|
384
|
+
assert.equal(third.status, 400, JSON.stringify(third.body));
|
|
385
|
+
assert.match(JSON.stringify(second.body), /User already exists/);
|
|
386
|
+
|
|
387
|
+
// ...and the registration that won is untouched: one row, its document
|
|
388
|
+
// still linked and still active. A double submit is NOT what empties Files.
|
|
389
|
+
const rows = await h.db.collection("site.people").find({ email }).toArray();
|
|
390
|
+
assert.equal(rows.length, 1);
|
|
391
|
+
assert.equal(rows[0].files?.length, 1);
|
|
392
|
+
assert.equal(rows[0].files[0].id?.toString(), fileId.toString());
|
|
393
|
+
assert.equal("filesNotUploaded" in rows[0], false);
|
|
394
|
+
assert.equal((await h.db.collection("files").findOne({ _id: fileId })).status, "active");
|
|
395
|
+
assert.equal(await h.db.collection("users").countDocuments({ email }), 1);
|
|
396
|
+
});
|
|
397
|
+
|
|
365
398
|
record("15. the caller cannot plant the report itself", async () => {
|
|
366
399
|
const email = "pin-planted@e2e.example.com";
|
|
367
400
|
const res = await h.api("/people/resident/create", {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default-role SELF-HEAL: a write triggered by a read.
|
|
3
|
+
*
|
|
4
|
+
* `seedDefaultOrgRoles` only runs when an organisation is CREATED, so the 73
|
|
5
|
+
* organisations that were created before it shipped still hold not one role.
|
|
6
|
+
* `healOrgDefaultRoles` gives them the same two roles the first time somebody
|
|
7
|
+
* reads their empty roles list.
|
|
8
|
+
*
|
|
9
|
+
* That is an insert on a read path in a live multi-tenant system running on
|
|
10
|
+
* more than one instance, so every property that makes it safe is asserted
|
|
11
|
+
* here, and each one is a way this could take the platform down:
|
|
12
|
+
*
|
|
13
|
+
* 1. it fires ONLY on an organisation holding zero roles — one role means
|
|
14
|
+
* skip, no exceptions, the same rule the backfill tool uses;
|
|
15
|
+
* 2. two simultaneous readers of the same organisation produce TWO roles, not
|
|
16
|
+
* four — the claim is the guard, not a non-atomic read;
|
|
17
|
+
* 3. it never emits an `org`-less role: that is a CLIENT TEMPLATE, one
|
|
18
|
+
* document shared by every client invited with it, and one of those is
|
|
19
|
+
* what took every client's modules away on 2026-09-07;
|
|
20
|
+
* 4. it cannot fail the read it hangs off — a claim, count, nature lookup or
|
|
21
|
+
* insert that throws returns 0 and throws nothing;
|
|
22
|
+
* 5. it is insert-only: no role, member, invitation, user or organisation is
|
|
23
|
+
* updated, deleted or repointed.
|
|
24
|
+
*
|
|
25
|
+
* Nothing here opens a socket or a database. Every dependency is passed in,
|
|
26
|
+
* which is also how the production code is written.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from "node:assert";
|
|
30
|
+
import test from "node:test";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
|
|
33
|
+
import { healOrgDefaultRoles } from "./.build/utils/org-default-roles.util.mjs";
|
|
34
|
+
|
|
35
|
+
const ORG = "68b0c1d2e3f4a5b6c7d8e9f0";
|
|
36
|
+
|
|
37
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A stand-in for `org-role-seed.repo.ts`.
|
|
41
|
+
*
|
|
42
|
+
* `claimSeed` is an `insertOne` of a row whose `_id` IS the organisation id, so
|
|
43
|
+
* MongoDB decides the winner atomically, inside the collection's `_id` index,
|
|
44
|
+
* before any caller can observe anything. The fake therefore decides and
|
|
45
|
+
* records SYNCHRONOUSLY and only then yields — modelling the database, not a
|
|
46
|
+
* read-then-write in application code, which is precisely the thing that would
|
|
47
|
+
* not be safe.
|
|
48
|
+
*/
|
|
49
|
+
function fakeWorld({
|
|
50
|
+
roleCount = 0,
|
|
51
|
+
nature = "security_agency",
|
|
52
|
+
failOn = null,
|
|
53
|
+
} = {}) {
|
|
54
|
+
const claimed = new Set();
|
|
55
|
+
const written = [];
|
|
56
|
+
const released = [];
|
|
57
|
+
|
|
58
|
+
const boom = (name) => {
|
|
59
|
+
if (failOn === name) throw new Error(`${name} exploded`);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
written,
|
|
64
|
+
released,
|
|
65
|
+
claimed,
|
|
66
|
+
deps: {
|
|
67
|
+
async claim(org) {
|
|
68
|
+
boom("claim");
|
|
69
|
+
const key = String(org);
|
|
70
|
+
const won = !claimed.has(key);
|
|
71
|
+
claimed.add(key);
|
|
72
|
+
await tick();
|
|
73
|
+
return won;
|
|
74
|
+
},
|
|
75
|
+
async release(org) {
|
|
76
|
+
released.push(String(org));
|
|
77
|
+
claimed.delete(String(org));
|
|
78
|
+
},
|
|
79
|
+
async countRoles() {
|
|
80
|
+
boom("countRoles");
|
|
81
|
+
await tick();
|
|
82
|
+
return roleCount;
|
|
83
|
+
},
|
|
84
|
+
async getNature() {
|
|
85
|
+
boom("getNature");
|
|
86
|
+
await tick();
|
|
87
|
+
return nature;
|
|
88
|
+
},
|
|
89
|
+
async addRole(role) {
|
|
90
|
+
boom("addRole");
|
|
91
|
+
await tick();
|
|
92
|
+
written.push(role);
|
|
93
|
+
return role;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test("an organisation holding zero roles gets the same two roles a new one gets", async () => {
|
|
100
|
+
const world = fakeWorld();
|
|
101
|
+
|
|
102
|
+
const seeded = await healOrgDefaultRoles(world.deps, ORG);
|
|
103
|
+
|
|
104
|
+
assert.equal(seeded, 2, "two roles, exactly as a paid signup writes");
|
|
105
|
+
assert.equal(world.written.length, 2);
|
|
106
|
+
|
|
107
|
+
for (const role of world.written) {
|
|
108
|
+
assert.equal(role.name, "owner");
|
|
109
|
+
assert.deepEqual(role.permissions, ["*"]);
|
|
110
|
+
assert.equal(role.default, true, "the repository path writes `default: true`");
|
|
111
|
+
assert.equal(role.org, ORG, "every seeded role carries the organisation");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
world.written.map((r) => r.type).sort(),
|
|
116
|
+
["organization", "security_agency"],
|
|
117
|
+
"one organisation role and one typed by the organisation's nature",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("an organisation holding one role is left completely alone", async () => {
|
|
122
|
+
const world = fakeWorld({ roleCount: 1 });
|
|
123
|
+
|
|
124
|
+
const seeded = await healOrgDefaultRoles(world.deps, ORG);
|
|
125
|
+
|
|
126
|
+
assert.equal(seeded, 0, "one role means skip — no exceptions");
|
|
127
|
+
assert.deepEqual(world.written, [], "nothing was written");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the count is re-read UNDER the claim, so a role created meanwhile still stops it", async () => {
|
|
131
|
+
// The caller's empty list can be up to 15 minutes stale (Redis), and a role
|
|
132
|
+
// can be created between that list and this insert. The count that decides
|
|
133
|
+
// is the one taken after the claim is won, straight from the collection.
|
|
134
|
+
let roles = 0;
|
|
135
|
+
const world = fakeWorld();
|
|
136
|
+
world.deps.countRoles = async () => {
|
|
137
|
+
await tick();
|
|
138
|
+
return roles;
|
|
139
|
+
};
|
|
140
|
+
roles = 1;
|
|
141
|
+
|
|
142
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 0);
|
|
143
|
+
assert.deepEqual(world.written, []);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a second load of the same organisation is a no-op", async () => {
|
|
147
|
+
const world = fakeWorld();
|
|
148
|
+
|
|
149
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 2);
|
|
150
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 0, "the claim is already held");
|
|
151
|
+
assert.equal(world.written.length, 2, "still two roles, not four");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("two simultaneous loads of the same organisation do not create four roles", async () => {
|
|
155
|
+
// Both callers see a count of zero — the exact race the claim exists for.
|
|
156
|
+
const world = fakeWorld({ roleCount: 0 });
|
|
157
|
+
|
|
158
|
+
const results = await Promise.all([
|
|
159
|
+
healOrgDefaultRoles(world.deps, ORG),
|
|
160
|
+
healOrgDefaultRoles(world.deps, ORG),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
results.sort(),
|
|
165
|
+
[0, 2],
|
|
166
|
+
"exactly one caller seeded; the other did nothing at all",
|
|
167
|
+
);
|
|
168
|
+
assert.equal(world.written.length, 2, "two roles in total, not four");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("ten simultaneous loads still produce exactly one set", async () => {
|
|
172
|
+
const world = fakeWorld({ roleCount: 0 });
|
|
173
|
+
|
|
174
|
+
const results = await Promise.all(
|
|
175
|
+
Array.from({ length: 10 }, () => healOrgDefaultRoles(world.deps, ORG)),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
assert.equal(
|
|
179
|
+
results.filter((n) => n > 0).length,
|
|
180
|
+
1,
|
|
181
|
+
"one winner out of ten",
|
|
182
|
+
);
|
|
183
|
+
assert.equal(world.written.length, 2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("it never emits an org-less role", async () => {
|
|
187
|
+
for (const org of [null, undefined, ""]) {
|
|
188
|
+
const world = fakeWorld();
|
|
189
|
+
|
|
190
|
+
assert.equal(await healOrgDefaultRoles(world.deps, org), 0);
|
|
191
|
+
assert.deepEqual(world.written, [], "no organisation, no role — ever");
|
|
192
|
+
assert.equal(world.claimed.size, 0, "it does not even take a claim");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a nature that is not an app produces the organisation role only, still org-scoped", async () => {
|
|
197
|
+
const world = fakeWorld({ nature: "property_owner" });
|
|
198
|
+
|
|
199
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 1);
|
|
200
|
+
assert.equal(world.written[0].type, "organization");
|
|
201
|
+
assert.equal(world.written[0].org, ORG);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("an insert that fails does not break the read, and gives the claim back", async () => {
|
|
205
|
+
const world = fakeWorld({ failOn: "addRole" });
|
|
206
|
+
|
|
207
|
+
const seeded = await healOrgDefaultRoles(world.deps, ORG);
|
|
208
|
+
|
|
209
|
+
assert.equal(seeded, 0, "it resolves rather than rejecting");
|
|
210
|
+
assert.deepEqual(world.released, [ORG], "the claim is released so a later read may retry");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a count that fails does not break the read", async () => {
|
|
214
|
+
const world = fakeWorld({ failOn: "countRoles" });
|
|
215
|
+
|
|
216
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 0);
|
|
217
|
+
assert.deepEqual(world.written, []);
|
|
218
|
+
assert.deepEqual(world.released, [ORG]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a claim that fails does not break the read, and nothing is released", async () => {
|
|
222
|
+
const world = fakeWorld({ failOn: "claim" });
|
|
223
|
+
|
|
224
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 0);
|
|
225
|
+
assert.deepEqual(world.written, []);
|
|
226
|
+
assert.deepEqual(world.released, [], "no claim was taken, so there is none to give back");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a nature lookup that fails does not break the read", async () => {
|
|
230
|
+
const world = fakeWorld({ failOn: "getNature" });
|
|
231
|
+
|
|
232
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 0);
|
|
233
|
+
assert.deepEqual(world.written, []);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a release that itself fails is still swallowed", async () => {
|
|
237
|
+
const world = fakeWorld({ failOn: "addRole" });
|
|
238
|
+
world.deps.release = async () => {
|
|
239
|
+
throw new Error("release exploded");
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
assert.equal(
|
|
243
|
+
await healOrgDefaultRoles(world.deps, ORG),
|
|
244
|
+
0,
|
|
245
|
+
"the read survives even when the cleanup cannot run",
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("the trigger fires on a type-filtered list, and not on a search, a site or page two", () => {
|
|
250
|
+
// The write hangs off `role.controller.ts getRoles`.
|
|
251
|
+
//
|
|
252
|
+
// `type` is deliberately ALLOWED. Every live call site passes one —
|
|
253
|
+
// `RolePermissionMain type="organization"`, `InvitationForm props.app`,
|
|
254
|
+
// `MemberMain props.type` — so a trigger that required an unfiltered list
|
|
255
|
+
// would never fire in production, and the whole self-heal would be dead code
|
|
256
|
+
// that reads exactly like a working one. An empty type-filtered page is not
|
|
257
|
+
// proof of zero roles, and it is not asked to be: the unfiltered count taken
|
|
258
|
+
// UNDER the claim is what decides, and that is asserted above.
|
|
259
|
+
//
|
|
260
|
+
// A search miss is user-driven and high-cardinality, a site-scoped page is
|
|
261
|
+
// empty for organisation-wide roles that plainly exist, and page two of
|
|
262
|
+
// anything is empty by arithmetic. None of those may trigger a write.
|
|
263
|
+
const source = readFileSync(
|
|
264
|
+
new URL("../src/controllers/role.controller.ts", import.meta.url),
|
|
265
|
+
"utf8",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const guard = source.slice(
|
|
269
|
+
source.indexOf("const listIsWholeOrg ="),
|
|
270
|
+
source.indexOf("const seeded = await healDefaultRoles("),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
assert.ok(guard.includes("org &&"), "only when an organisation was named");
|
|
274
|
+
assert.ok(guard.includes("!search"), "not a search result");
|
|
275
|
+
assert.ok(guard.includes("!site"), "not a site-scoped list");
|
|
276
|
+
assert.ok(guard.includes("page === 1"), "not page two of a list");
|
|
277
|
+
assert.ok(guard.includes("?.length === 0"), "only when the page is empty");
|
|
278
|
+
assert.ok(
|
|
279
|
+
!guard.includes("!type"),
|
|
280
|
+
"a type filter must NOT block the heal - every live call site sends one",
|
|
281
|
+
);
|
|
282
|
+
});
|