@7365admin1/core 3.65.1 → 3.66.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 +22 -0
- package/dist/index.d.ts +277 -2
- package/dist/index.js +5638 -5259
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2477 -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 +5 -2
- package/test/e2e/harness.mjs +5 -0
- package/test/e2e/member-by-user-reach.e2e.test.mjs +135 -0
- package/test/e2e/org-owner-repair.e2e.test.mjs +469 -0
- package/test/org-owner-repair.test.mjs +726 -0
- package/test/resident-ownership-scope.test.mjs +8 -4
- 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,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
|
+
}
|