@7365admin1/core 3.59.1 → 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 +16 -0
- package/dist/index.d.ts +186 -6
- package/dist/index.js +6443 -5737
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3130 -2424
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/resident-self-signup-pinning.e2e.test.mjs +98 -0
- package/test/org-role-selfheal.test.mjs +282 -0
- package/test/self-signup-files.test.mjs +125 -3
package/package.json
CHANGED
|
@@ -309,6 +309,104 @@ describe("pre-login resident sign-up cannot manufacture access", { concurrency:
|
|
|
309
309
|
assert.equal(person?.status, "active");
|
|
310
310
|
assert.equal(person?.remarks, "e2e staff may set this");
|
|
311
311
|
});
|
|
312
|
+
|
|
313
|
+
// ---- a document that never uploaded is dropped, AND reported -------------
|
|
314
|
+
|
|
315
|
+
record("13. a sign-up whose document upload failed still completes", async () => {
|
|
316
|
+
// Exactly what the wizard sends after a failed upload: the file is named
|
|
317
|
+
// and listed, and has no `id` because the upload never returned one.
|
|
318
|
+
const email = "pin-unsent@e2e.example.com";
|
|
319
|
+
const res = await h.api("/people/resident/create", {
|
|
320
|
+
method: "POST",
|
|
321
|
+
body: {
|
|
322
|
+
...genuine(id, email),
|
|
323
|
+
files: [{ name: "tenancy-agreement.pdf", mimeType: "application/pdf" }],
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
assert.equal(res.status, 201, JSON.stringify(res.status));
|
|
328
|
+
|
|
329
|
+
const person = await stored(email);
|
|
330
|
+
assert.ok(person, "the applicant must not be lost over an attachment we never received");
|
|
331
|
+
assert.deepEqual(person.files, []);
|
|
332
|
+
// ...and the reviewer can now tell this apart from "attached nothing".
|
|
333
|
+
assert.deepEqual(person.filesNotUploaded, ["tenancy-agreement.pdf"]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
record("14. a sign-up whose uploads all landed carries no report at all", async () => {
|
|
337
|
+
const email = "pin-uploaded@e2e.example.com";
|
|
338
|
+
const fileId = new ObjectId();
|
|
339
|
+
await h.db.collection("files").insertOne({
|
|
340
|
+
_id: fileId,
|
|
341
|
+
name: "tenancy-agreement.pdf",
|
|
342
|
+
status: "draft",
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const res = await h.api("/people/resident/create", {
|
|
346
|
+
method: "POST",
|
|
347
|
+
body: {
|
|
348
|
+
...genuine(id, email),
|
|
349
|
+
files: [{ id: fileId.toString(), name: "tenancy-agreement.pdf", mimeType: "application/pdf" }],
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
assert.equal(res.status, 201, JSON.stringify(res.status));
|
|
354
|
+
|
|
355
|
+
const person = await stored(email);
|
|
356
|
+
assert.equal(person.files?.length, 1);
|
|
357
|
+
assert.equal(person.files[0].id?.toString(), fileId.toString());
|
|
358
|
+
// Absent, not empty: a normal registration is written exactly as before.
|
|
359
|
+
assert.equal("filesNotUploaded" in person, false);
|
|
360
|
+
// And the document was linked - its status flipped out of draft.
|
|
361
|
+
const file = await h.db.collection("files").findOne({ _id: fileId });
|
|
362
|
+
assert.equal(file.status, "active");
|
|
363
|
+
});
|
|
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
|
+
|
|
398
|
+
record("15. the caller cannot plant the report itself", async () => {
|
|
399
|
+
const email = "pin-planted@e2e.example.com";
|
|
400
|
+
const res = await h.api("/people/resident/create", {
|
|
401
|
+
method: "POST",
|
|
402
|
+
body: { ...genuine(id, email), filesNotUploaded: ["planted-by-the-caller.pdf"] },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
assert.equal(res.status, 201, JSON.stringify(res.status));
|
|
406
|
+
|
|
407
|
+
const person = await stored(email);
|
|
408
|
+
assert.equal("filesNotUploaded" in person, false);
|
|
409
|
+
});
|
|
312
410
|
});
|
|
313
411
|
|
|
314
412
|
async function seed(h) {
|
|
@@ -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
|
+
});
|
|
@@ -3,8 +3,13 @@ import test from "node:test";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
dropUnsentFiles,
|
|
8
|
+
unsentFileNames,
|
|
9
|
+
MAX_UNSENT_FILE_NAMES,
|
|
10
|
+
MAX_UNSENT_FILE_NAME_LENGTH,
|
|
11
|
+
} from "./.build/utils/self-signup-files.util.mjs";
|
|
12
|
+
import { schemaResidentSelfSignUp, schemaPerson, MPerson } from "./.build/models/person.model.mjs";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Resident sign-up outage, 2026-09-08.
|
|
@@ -150,5 +155,122 @@ test("the filter runs on the self-signup path, before validation, and nowhere el
|
|
|
150
155
|
// authenticated `add`/`updateById` paths, staff stop being told about a
|
|
151
156
|
// client that is silently losing their attachments.
|
|
152
157
|
assert.equal(controller.match(/dropUnsentFiles\(/g)?.length, 1);
|
|
153
|
-
|
|
158
|
+
// Both helpers come from that one module, whatever shape the import takes.
|
|
159
|
+
const importBlock = controller.match(/import \{[\s\S]*?\} from "\.\.\/utils\/self-signup-files\.util";/);
|
|
160
|
+
assert.ok(importBlock, "self-signup-files.util is no longer imported by the controller");
|
|
161
|
+
assert.ok(importBlock[0].includes("dropUnsentFiles"));
|
|
162
|
+
assert.ok(importBlock[0].includes("unsentFileNames"));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 2026-09-08, second report. The fix above kept the applicant, and then hid the
|
|
167
|
+
* problem: `Files:` renders empty whether the resident attached nothing or
|
|
168
|
+
* attached something we never received. The owner's own registration was
|
|
169
|
+
* APPROVED in that state, without its tenancy agreement. So the entries we drop
|
|
170
|
+
* are now recorded on the person as `filesNotUploaded`, and the console has
|
|
171
|
+
* something to show.
|
|
172
|
+
*/
|
|
173
|
+
|
|
174
|
+
test("what was dropped is reported, by name, in order", () => {
|
|
175
|
+
const second = { name: "vehicle-registration.pdf", mimeType: "application/pdf" };
|
|
176
|
+
const body = { ...base(), files: [validFile, unsentFile, second] };
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(unsentFileNames(body), [unsentFile.name, second.name]);
|
|
179
|
+
// And the two halves agree: what is kept plus what is reported is the input.
|
|
180
|
+
assert.deepEqual(dropUnsentFiles(body).files, [validFile]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a registration where every upload landed reports nothing", () => {
|
|
184
|
+
assert.deepEqual(unsentFileNames({ ...base(), files: [validFile] }), []);
|
|
185
|
+
assert.deepEqual(unsentFileNames({ ...base(), files: [] }), []);
|
|
186
|
+
assert.deepEqual(unsentFileNames(base()), []);
|
|
187
|
+
assert.deepEqual(unsentFileNames({ ...base(), files: null }), []);
|
|
188
|
+
assert.deepEqual(unsentFileNames({ ...base(), files: "nonsense" }), []);
|
|
189
|
+
assert.deepEqual(unsentFileNames(undefined), []);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("an unnamed dropped entry still reports something a reviewer can read", () => {
|
|
193
|
+
assert.deepEqual(unsentFileNames({ files: [{ mimeType: "application/pdf" }] }), ["Unnamed document"]);
|
|
194
|
+
assert.deepEqual(unsentFileNames({ files: [{ name: " " }] }), ["Unnamed document"]);
|
|
195
|
+
assert.deepEqual(unsentFileNames({ files: [{ name: 42 }] }), ["Unnamed document"]);
|
|
196
|
+
// A null entry is not an unsent file, it is nonsense - and must not crash.
|
|
197
|
+
assert.deepEqual(unsentFileNames({ files: [null, undefined] }), []);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("the caller cannot use it as free storage: bounded in count and in length", () => {
|
|
201
|
+
const many = Array.from({ length: MAX_UNSENT_FILE_NAMES + 5 }, (_, i) => ({ name: `doc-${i}.pdf` }));
|
|
202
|
+
assert.equal(unsentFileNames({ files: many }).length, MAX_UNSENT_FILE_NAMES);
|
|
203
|
+
|
|
204
|
+
const long = unsentFileNames({ files: [{ name: "x".repeat(5000) }] });
|
|
205
|
+
assert.equal(long[0].length, MAX_UNSENT_FILE_NAME_LENGTH);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("the report is display text only - it is not accepted from the caller", () => {
|
|
209
|
+
// stripUnknown means a caller who sends `filesNotUploaded` has it dropped,
|
|
210
|
+
// exactly like `status` and `platform`. Only the controller sets it.
|
|
211
|
+
const { error, value } = selfSignUp({ ...base(), filesNotUploaded: ["planted.pdf"] });
|
|
212
|
+
assert.equal(error, undefined);
|
|
213
|
+
assert.equal("filesNotUploaded" in value, false);
|
|
214
|
+
|
|
215
|
+
// But the finished payload the controller builds must survive MPerson, which
|
|
216
|
+
// runs schemaPerson over it and refuses unknown keys.
|
|
217
|
+
const finished = { ...base(), isOwner: true, status: "pending", platform: "mobile", filesNotUploaded: ["a.pdf"] };
|
|
218
|
+
assert.equal(schemaPerson.validate(finished, { abortEarly: false }).error, undefined);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("the controller records the dropped names from the ORIGINAL body, and only when there are some", () => {
|
|
222
|
+
const selfSignUpFn = controller.slice(
|
|
223
|
+
controller.indexOf("async function addResidentSelfSignUp"),
|
|
224
|
+
controller.indexOf("async function add("),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
assert.match(selfSignUpFn, /unsentFileNames\(req\.body\)/);
|
|
228
|
+
// Read from req.body, not from the already-filtered `body` - which would
|
|
229
|
+
// always be empty and make the whole thing a silent no-op.
|
|
230
|
+
assert.equal(selfSignUpFn.includes("unsentFileNames(body)"), false);
|
|
231
|
+
assert.match(selfSignUpFn, /filesNotUploaded: notUploaded/);
|
|
232
|
+
assert.match(selfSignUpFn, /notUploaded\.length > 0 \?/);
|
|
233
|
+
|
|
234
|
+
// Self-signup only. The authenticated staff path must not start writing it.
|
|
235
|
+
assert.equal(controller.match(/unsentFileNames\(/g)?.length, 1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `MPerson` does not return the object it was given: it returns an explicit
|
|
240
|
+
* whitelist, and anything not named there is dropped on the way to the
|
|
241
|
+
* database. Every unit test above passed with `filesNotUploaded` never reaching
|
|
242
|
+
* Mongo at all - only the end-to-end run caught it. Pin it here so the next
|
|
243
|
+
* field added to the payload is not lost the same way.
|
|
244
|
+
*/
|
|
245
|
+
test("MPerson carries the report through to the document it writes", () => {
|
|
246
|
+
const person = {
|
|
247
|
+
name: "Resident Test",
|
|
248
|
+
email: "resident@example.com",
|
|
249
|
+
isOwner: true,
|
|
250
|
+
site: "0123456789abcdef01234567",
|
|
251
|
+
status: "pending",
|
|
252
|
+
platform: "mobile",
|
|
253
|
+
files: [],
|
|
254
|
+
filesNotUploaded: ["tenancy-agreement.pdf"],
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(MPerson(person).filesNotUploaded, ["tenancy-agreement.pdf"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a person with nothing to report gets no key at all, not an undefined one", () => {
|
|
261
|
+
// A fresh object each time: MPerson rewrites `site` into an ObjectId in
|
|
262
|
+
// place, so a second call on the same literal fails on "site must be a string".
|
|
263
|
+
const person = (extra) => ({
|
|
264
|
+
name: "Resident Test",
|
|
265
|
+
email: "resident@example.com",
|
|
266
|
+
isOwner: true,
|
|
267
|
+
site: "0123456789abcdef01234567",
|
|
268
|
+
files: [],
|
|
269
|
+
...extra,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// `filesNotUploaded: undefined` would be written into every person document
|
|
273
|
+
// ever created, on every path, which is exactly what the conditional avoids.
|
|
274
|
+
assert.equal("filesNotUploaded" in MPerson(person()), false);
|
|
275
|
+
assert.equal("filesNotUploaded" in MPerson(person({ filesNotUploaded: [] })), false);
|
|
154
276
|
});
|