@7365admin1/core 3.53.9 → 3.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,373 @@
1
+ // End-to-end proof, over real HTTP, that a person's OWN emergency contacts are
2
+ // theirs — readable and writable by them and by Seven365 staff, and by nobody
3
+ // else — and that the ordering of the chain cannot be corrupted.
4
+ //
5
+ // This is a NEW collection (`personal-emergency-contacts`). It does not touch
6
+ // the site's published directory (`emergency-contacts`, in
7
+ // `iservice365-API-core`), which is a different list with a different owner and
8
+ // a different gate. The cases below prove that separation is real: a site's own
9
+ // administrator, with a wildcard role over that site, still cannot read a
10
+ // resident's family numbers.
11
+ //
12
+ // The two rules under test:
13
+ //
14
+ // 1. WHO. `requireSelfOrPlatformStaff` — the same helper `/api/users` and
15
+ // `/api/users/v2` already use. The owner's decision: the person, plus
16
+ // Seven365 platform staff. NOT the site admin, NOT the org admin.
17
+ // 2. ORDER. Two contacts cannot hold the same position, and a reorder either
18
+ // applies completely or leaves the stored chain exactly as it was. There
19
+ // is no half-applied order for a later read to find.
20
+ //
21
+ // Every case has its positive control beside its negative one: a refusal that
22
+ // is never contrasted with a success would also pass against an endpoint that
23
+ // refuses everybody, and would prove nothing.
24
+ //
25
+ // Everything it talks to is created and thrown away by the harness: an
26
+ // in-process MongoDB replica set, a loopback Redis, a loopback mail sink. No
27
+ // staging or production database, Redis, device or endpoint is touched.
28
+ //
29
+ // Run with: yarn test:e2e
30
+
31
+ import { after, before, describe, it } from "node:test";
32
+ import assert from "node:assert/strict";
33
+ import { ObjectId } from "mongodb";
34
+
35
+ import { startHarness } from "./harness.mjs";
36
+
37
+ const PASSWORD = "PersonalChain-Passw0rd!";
38
+ const OWNER = "pec-owner@e2e.example.com"; // the resident whose chain it is
39
+ const STRANGER = "pec-stranger@e2e.example.com"; // another org entirely
40
+ const SITE_ADMIN = "pec-siteadmin@e2e.example.com"; // wildcard role AT the owner's org
41
+ const STAFF = "pec-sevenadmin@e2e.example.com"; // Seven365 platform staff
42
+
43
+ const CHAIN = "/personal-emergency-contacts";
44
+
45
+ const wife = { name: "Wife", phone: "+6591234567", relationship: "Spouse", order: 1, active: true };
46
+ const son = { name: "Son", phone: "+6598765432", relationship: "Son", order: 2, active: true };
47
+ const doctor = { name: "Dr Tan", phone: "+6567001122", relationship: "Doctor", order: 3, active: true };
48
+
49
+ describe("a person's own emergency chain", { concurrency: 1 }, () => {
50
+ let h;
51
+ const id = {};
52
+ const result = [];
53
+
54
+ const record = (name, fn) =>
55
+ it(name, async () => {
56
+ try {
57
+ await fn();
58
+ result.push(["PASS", name]);
59
+ } catch (error) {
60
+ result.push(["FAIL", name]);
61
+ throw error;
62
+ }
63
+ });
64
+
65
+ before(async () => {
66
+ h = await startHarness();
67
+ Object.assign(id, await seed(h));
68
+ }, { timeout: 300000 });
69
+
70
+ after(async () => {
71
+ if (h) await h.stop();
72
+ console.log("\n--- personal emergency chain: per-case result ---");
73
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
74
+ });
75
+
76
+ let ownerSid;
77
+ let strangerSid;
78
+ let siteAdminSid;
79
+ let staffSid;
80
+
81
+ const stored = async (user) =>
82
+ await h.db.collection("personal-emergency-contacts").findOne({ user });
83
+
84
+ record("0. baseline — four sessions, and no chain stored for anybody", async () => {
85
+ ownerSid = await h.login(OWNER, PASSWORD);
86
+ strangerSid = await h.login(STRANGER, PASSWORD);
87
+ siteAdminSid = await h.login(SITE_ADMIN, PASSWORD);
88
+ staffSid = await h.login(STAFF, PASSWORD);
89
+
90
+ assert.equal(await stored(id.owner), null);
91
+ assert.equal(
92
+ await h.db.collection("personal-emergency-contacts").countDocuments({}),
93
+ 0,
94
+ );
95
+ });
96
+
97
+ // ---- reading and writing your own ---------------------------------------
98
+
99
+ record("1. an empty chain reads as empty, not as an error, before anything is saved", async () => {
100
+ const res = await h.api(`${CHAIN}/${id.owner}`, { sid: ownerSid });
101
+
102
+ assert.equal(res.status, 200);
103
+ assert.deepEqual(res.body?.data?.contacts, []);
104
+ assert.equal(res.body?.data?.ringSeconds, 20);
105
+ assert.equal(res.body?.data?.siteDirectoryFallback, true);
106
+ // Reading must not have written a row.
107
+ assert.equal(await stored(id.owner), null);
108
+ });
109
+
110
+ record("2. he saves his own chain — the positive control the refusals are measured against", async () => {
111
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
112
+ method: "PUT",
113
+ sid: ownerSid,
114
+ body: { contacts: [wife, son, doctor], ringSeconds: 25, siteDirectoryFallback: false },
115
+ });
116
+
117
+ assert.equal(res.status, 200);
118
+ assert.equal(res.body?.data?.contacts?.length, 3);
119
+
120
+ const row = await stored(id.owner);
121
+ assert.notEqual(row, null, "nothing was written");
122
+ assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
123
+ assert.equal(row.ringSeconds, 25);
124
+ assert.equal(row.siteDirectoryFallback, false);
125
+ });
126
+
127
+ record("3. he reads back exactly what he saved", async () => {
128
+ const res = await h.api(`${CHAIN}/${id.owner}`, { sid: ownerSid });
129
+
130
+ assert.equal(res.status, 200);
131
+ assert.deepEqual(res.body.data.contacts.map((c) => c.phone), [
132
+ wife.phone,
133
+ son.phone,
134
+ doctor.phone,
135
+ ]);
136
+ });
137
+
138
+ // ---- somebody else's ----------------------------------------------------
139
+
140
+ record("4. a stranger cannot READ his chain", async () => {
141
+ const res = await h.api(`${CHAIN}/${id.owner}`, { sid: strangerSid });
142
+
143
+ assert.equal(res.status, 401);
144
+ assert.equal(res.body?.contacts, undefined);
145
+ });
146
+
147
+ record("5. a stranger cannot WRITE his chain", async () => {
148
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
149
+ method: "PUT",
150
+ sid: strangerSid,
151
+ body: { contacts: [{ ...wife, name: "Planted", phone: "+6500000000" }] },
152
+ });
153
+
154
+ assert.equal(res.status, 401);
155
+ const row = await stored(id.owner);
156
+ assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
157
+ });
158
+
159
+ record("6. the SITE ADMIN of his own organisation cannot read it either", async () => {
160
+ // This is the case that separates a personal chain from the site's
161
+ // published directory. The same person holds a wildcard role over this
162
+ // organisation and can administer the site's own emergency numbers.
163
+ const res = await h.api(`${CHAIN}/${id.owner}`, { sid: siteAdminSid });
164
+
165
+ assert.equal(res.status, 401);
166
+ });
167
+
168
+ record("7. the SITE ADMIN cannot write it either", async () => {
169
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
170
+ method: "PUT",
171
+ sid: siteAdminSid,
172
+ body: { contacts: [{ ...wife, name: "Planted by admin" }] },
173
+ });
174
+
175
+ assert.equal(res.status, 401);
176
+ const row = await stored(id.owner);
177
+ assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
178
+ });
179
+
180
+ record("8. the site admin CAN still keep his own chain — he is not locked out of the feature", async () => {
181
+ const res = await h.api(`${CHAIN}/${id.siteAdmin}`, {
182
+ method: "PUT",
183
+ sid: siteAdminSid,
184
+ body: { contacts: [{ ...wife, name: "Admin's wife" }] },
185
+ });
186
+
187
+ assert.equal(res.status, 200);
188
+ assert.equal((await stored(id.siteAdmin))?.contacts?.length, 1);
189
+ });
190
+
191
+ // ---- Seven365 staff -----------------------------------------------------
192
+
193
+ record("9. Seven365 staff CAN read it — the precedent a user record already sets", async () => {
194
+ const res = await h.api(`${CHAIN}/${id.owner}`, { sid: staffSid });
195
+
196
+ assert.equal(res.status, 200);
197
+ assert.equal(res.body?.data?.contacts?.length, 3);
198
+ });
199
+
200
+ record("10. Seven365 staff CAN write it", async () => {
201
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
202
+ method: "PUT",
203
+ sid: staffSid,
204
+ body: { contacts: [wife, son, doctor], ringSeconds: 30, siteDirectoryFallback: true },
205
+ });
206
+
207
+ assert.equal(res.status, 200);
208
+ assert.equal((await stored(id.owner))?.ringSeconds, 30);
209
+ });
210
+
211
+ // ---- ordering integrity -------------------------------------------------
212
+
213
+ record("11. two contacts cannot hold the same position", async () => {
214
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
215
+ method: "PUT",
216
+ sid: ownerSid,
217
+ body: { contacts: [{ ...wife, order: 1 }, { ...son, order: 1 }] },
218
+ });
219
+
220
+ assert.equal(res.status, 400);
221
+ assert.match(String(res.body?.message ?? res.body), /same position/i);
222
+ });
223
+
224
+ record("12. the refused reorder wrote NOTHING — the stored chain is untouched", async () => {
225
+ const row = await stored(id.owner);
226
+
227
+ assert.equal(row.contacts.length, 3, "a rejected write changed the chain");
228
+ assert.deepEqual(row.contacts.map((c) => c.order), [1, 2, 3]);
229
+ assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
230
+ assert.equal(row.ringSeconds, 30, "settings moved on a rejected write");
231
+ });
232
+
233
+ record("13. a valid reorder applies completely, in one write", async () => {
234
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
235
+ method: "PUT",
236
+ sid: ownerSid,
237
+ body: {
238
+ contacts: [
239
+ { ...doctor, order: 1 },
240
+ { ...wife, order: 2 },
241
+ { ...son, order: 3 },
242
+ ],
243
+ },
244
+ });
245
+
246
+ assert.equal(res.status, 200);
247
+ const row = await stored(id.owner);
248
+ assert.deepEqual(row.contacts.map((c) => c.name), ["Dr Tan", "Wife", "Son"]);
249
+ assert.deepEqual(row.contacts.map((c) => c.order), [1, 2, 3]);
250
+ });
251
+
252
+ record("14. concurrent reorders leave ONE chain in ONE of the two orders, never a mixture", async () => {
253
+ const a = [
254
+ { ...wife, order: 1 },
255
+ { ...son, order: 2 },
256
+ { ...doctor, order: 3 },
257
+ ];
258
+ const b = [
259
+ { ...doctor, order: 1 },
260
+ { ...son, order: 2 },
261
+ { ...wife, order: 3 },
262
+ ];
263
+
264
+ await Promise.all([
265
+ h.api(`${CHAIN}/${id.owner}`, { method: "PUT", sid: ownerSid, body: { contacts: a } }),
266
+ h.api(`${CHAIN}/${id.owner}`, { method: "PUT", sid: ownerSid, body: { contacts: b } }),
267
+ ]);
268
+
269
+ assert.equal(
270
+ await h.db.collection("personal-emergency-contacts").countDocuments({ user: id.owner }),
271
+ 1,
272
+ "a second row was created for the same person",
273
+ );
274
+
275
+ const names = (await stored(id.owner)).contacts.map((c) => c.name);
276
+ const options = [a, b].map((set) => set.map((c) => c.name));
277
+ assert.ok(
278
+ options.some((option) => option.join() === names.join()),
279
+ `chain is a mixture of both writes: ${names.join()}`,
280
+ );
281
+ });
282
+
283
+ // ---- validation at the trust boundary -----------------------------------
284
+
285
+ record("15. a number that is not dialable internationally is refused", async () => {
286
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
287
+ method: "PUT",
288
+ sid: ownerSid,
289
+ body: { contacts: [{ ...wife, phone: "91234567" }] },
290
+ });
291
+
292
+ assert.equal(res.status, 400);
293
+ assert.match(String(res.body?.message ?? res.body), /international format/i);
294
+ });
295
+
296
+ record("16. a user id in the BODY cannot redirect the write", async () => {
297
+ const res = await h.api(`${CHAIN}/${id.owner}`, {
298
+ method: "PUT",
299
+ sid: ownerSid,
300
+ body: { contacts: [wife], user: id.stranger.toString() },
301
+ });
302
+
303
+ // The schema does not accept it at all, so there is nothing to strip and
304
+ // nothing to get wrong later.
305
+ assert.equal(res.status, 400);
306
+ assert.equal(await stored(id.stranger), null);
307
+ });
308
+
309
+ record("17. an unauthenticated caller reaches nothing", async () => {
310
+ assert.equal((await h.api(`${CHAIN}/${id.owner}`)).status, 401);
311
+ assert.equal(
312
+ (await h.api(`${CHAIN}/${id.owner}`, { method: "PUT", body: { contacts: [] } })).status,
313
+ 401,
314
+ );
315
+ });
316
+
317
+ // ---- the site's own directory is a different list ------------------------
318
+
319
+ record("18. nothing here touched the site's published emergency directory", async () => {
320
+ assert.equal(
321
+ await h.db.collection("emergency-contacts").countDocuments({}),
322
+ 0,
323
+ "the site directory collection was written to",
324
+ );
325
+ });
326
+ });
327
+
328
+ async function seed(h) {
329
+ const now = new Date().toISOString();
330
+ const orgA = new ObjectId();
331
+ const orgB = new ObjectId();
332
+ const siteA = new ObjectId();
333
+ const staffRole = new ObjectId();
334
+ const roleA = new ObjectId();
335
+ const roleB = new ObjectId();
336
+
337
+ await h.db.collection("organizations").insertMany([
338
+ { _id: orgA, name: "PEC Org A", email: "pec-a@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
339
+ { _id: orgB, name: "PEC Org B", email: "pec-b@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
340
+ ]);
341
+
342
+ await h.db.collection("sites").insertOne({
343
+ _id: siteA, name: "PEC Site A", orgId: orgA, status: "active", createdAt: now,
344
+ });
345
+
346
+ await h.db.collection("roles").insertMany([
347
+ { _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
348
+ // A wildcard role at the OWNER's own organisation: this is the strongest
349
+ // tenant role there is, and case 6 proves even it does not reach a
350
+ // resident's personal chain.
351
+ { _id: roleA, name: "Site Admin A", org: orgA, type: "organization", permissions: ["*"], status: "active" },
352
+ { _id: roleB, name: "Admin B", org: orgB, type: "organization", permissions: ["*"], status: "active" },
353
+ ]);
354
+
355
+ const emails = [OWNER, STRANGER, SITE_ADMIN, STAFF];
356
+ const users = await h.db.collection("users").insertMany(
357
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
358
+ );
359
+
360
+ const hashed = await h.hashPassword(PASSWORD);
361
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
362
+
363
+ const [owner, stranger, siteAdmin, staff] = [0, 1, 2, 3].map((i) => users.insertedIds[i]);
364
+
365
+ await h.db.collection("members").insertMany([
366
+ { user: owner, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
367
+ { user: stranger, org: orgB, type: "organization", role: roleB, status: "active" },
368
+ { user: siteAdmin, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
369
+ { user: staff, type: "admin", role: staffRole, status: "active" },
370
+ ]);
371
+
372
+ return { orgA, orgB, siteA, owner, stranger, siteAdmin, staff };
373
+ }
@@ -407,7 +407,7 @@ describe("safety alerts, end to end", { concurrency: 1 }, () => {
407
407
  assert.equal(categorySupportsChannel(category, "email"), false, category);
408
408
  assert.ok(
409
409
  notificationCategory(category).channelNote?.email,
410
- `${category} must explain the dimmed email switch`,
410
+ `${category} must record why it offers no email switch`,
411
411
  );
412
412
  }
413
413
  },
@@ -180,7 +180,7 @@ describe("notification categories", () => {
180
180
  // -------------------------------------------------------------------------
181
181
  // What the September 2026 audit fixed. Both of these FAIL against the
182
182
  // catalogue as it stood before it: `serviceProviderInvite` declared
183
- // `email: true` and `emergencyContact` declared `rendered: true`.
183
+ // `email: true`, and `emergencyContact` was offered with no sender at all.
184
184
  // -------------------------------------------------------------------------
185
185
 
186
186
  it("no category offers a switchable Email row, because no sender reads one", () => {
@@ -199,22 +199,37 @@ describe("notification categories", () => {
199
199
  }
200
200
  });
201
201
 
202
- it("emergency contacts are not offered, because nothing calls their sender", () => {
203
- // The sender exists in `notification.service.ts`, but emergency contacts
204
- // are written in `iservice365-API-core` and the caller there was never
205
- // merged. A switch over a message that cannot arrive is the defect this
206
- // whole catalogue exists to prevent.
202
+ it("emergency contacts ARE offered now that a caller exists", () => {
203
+ // `iservice365-API-core`'s `emergency.service.ts` calls
204
+ // `NotificationService.emergencyContactChanged` on each of its three
205
+ // writes, and reads this flag before it sends. So the switch governs a
206
+ // message that really arrives — which is what this catalogue exists to
207
+ // guarantee, and the reason the flag was false until the caller was written.
207
208
  const category = notificationCategory("emergencyContact");
208
- assert.equal(category.rendered, false);
209
+ assert.equal(category.rendered, true);
209
210
 
210
- for (const permissions of [["emergency-contact:update"], ["*"]]) {
211
+ // The gate is the `emergency-contact` RESOURCE, so any action on it counts.
212
+ for (const permissions of [
213
+ ["emergency-contact:update-emergency-contact"],
214
+ ["emergency-contact:see-all"],
215
+ ["*"],
216
+ ]) {
211
217
  assert.ok(
212
- !keys(categoriesForPermissions({ permissions, isResident: true })).includes(
218
+ keys(categoriesForPermissions({ permissions, isResident: true })).includes(
213
219
  "emergencyContact",
214
220
  ),
215
- `offered to ${permissions.join()}`,
221
+ `not offered to ${permissions.join()}`,
216
222
  );
217
223
  }
224
+
225
+ // Negative control: holding an unrelated resource must NOT offer it, or the
226
+ // assertion above would pass for everybody and prove nothing.
227
+ assert.ok(
228
+ !keys(
229
+ categoriesForPermissions({ permissions: ["bulletin-board:see-all"] }),
230
+ ).includes("emergencyContact"),
231
+ "offered to a role that holds no emergency-contact permission",
232
+ );
218
233
  });
219
234
 
220
235
  it("category keys are unique", () => {
@@ -371,6 +386,9 @@ const BEFORE = {
371
386
  "prelovedMarketplace",
372
387
  "serviceProviderInvite",
373
388
  "incidentReport",
389
+ // Added by the emergency-contact re-land: the sender in API-core exists
390
+ // again, so the switch is rendered again. See `rendered` on the category.
391
+ "emergencyContact",
374
392
  "virtualPatrol",
375
393
  "cameraFault",
376
394
  ],
@@ -378,7 +396,7 @@ const BEFORE = {
378
396
  "bulletin-board": ["bulletinBoard"],
379
397
  "bulletin-board-mgmt": ["bulletinBoard"],
380
398
  "bulletin-videos-mgmt": ["bulletinBoard"],
381
- "emergency-contact": [],
399
+ "emergency-contact": ["emergencyContact"],
382
400
  "event-mgmt": ["event"],
383
401
  "facility-booking-mgmt": ["facilityBooking"],
384
402
  "facility-mgmt": ["facilityBooking"],