@7365admin1/core 3.53.6 → 3.53.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.53.6",
4
+ "version": "3.53.8",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,6 +13,7 @@ import {
13
13
  ORG_MARKETPLACE_VENDOR_FIELD,
14
14
  categoriesForPermissions,
15
15
  categorySupportsChannel,
16
+ notificationCategory,
16
17
  notificationCategoryLabel,
17
18
  } from "./.build/utils/notification-category.util.mjs";
18
19
 
@@ -42,7 +43,7 @@ describe("notification categories", () => {
42
43
  }
43
44
  });
44
45
 
45
- it("the four safety categories now send, and are on by default and switchable", () => {
46
+ it("the four safety categories are on by default and switchable", () => {
46
47
  const safety = NOTIFICATION_CATEGORIES.filter((c) => c.safety);
47
48
 
48
49
  assert.deepEqual(keys(safety), [
@@ -56,7 +57,6 @@ describe("notification categories", () => {
56
57
  // nothing about them is special-cased: no "locked" flag, so they behave
57
58
  // like any other switch.
58
59
  assert.ok(!("locked" in category));
59
- assert.equal(category.rendered, true, category.key);
60
60
 
61
61
  // a safety alert is permission-scoped, never a resident default — a
62
62
  // category with no resource would reach nobody.
@@ -71,7 +71,6 @@ describe("notification categories", () => {
71
71
  it("a role holding only the safety module is offered only that switch", () => {
72
72
  const cases = {
73
73
  "incident-reports:create": "incidentReport",
74
- "emergency-contact:update": "emergencyContact",
75
74
  "virtual-patrol:see-all": "virtualPatrol",
76
75
  "site-settings:update": "cameraFault",
77
76
  };
@@ -143,7 +142,7 @@ describe("notification categories", () => {
143
142
  (c) => c.channels.email,
144
143
  ).map((c) => c.key);
145
144
 
146
- assert.deepEqual(emailCapable, ["serviceProviderInvite"]);
145
+ assert.deepEqual(emailCapable, []);
147
146
  });
148
147
 
149
148
  it("every channel a category cannot use explains itself in plain words", () => {
@@ -174,10 +173,50 @@ describe("notification categories", () => {
174
173
  it("channel support is a fact about the category, not a preference", () => {
175
174
  assert.equal(categorySupportsChannel("workOrder", "push"), true);
176
175
  assert.equal(categorySupportsChannel("workOrder", "email"), false);
177
- assert.equal(categorySupportsChannel("serviceProviderInvite", "email"), true);
176
+ assert.equal(categorySupportsChannel("serviceProviderInvite", "email"), false);
178
177
  assert.equal(categorySupportsChannel("not-a-module", "push"), false);
179
178
  });
180
179
 
180
+ // -------------------------------------------------------------------------
181
+ // What the September 2026 audit fixed. Both of these FAIL against the
182
+ // catalogue as it stood before it: `serviceProviderInvite` declared
183
+ // `email: true` and `emergencyContact` declared `rendered: true`.
184
+ // -------------------------------------------------------------------------
185
+
186
+ it("no category offers a switchable Email row, because no sender reads one", () => {
187
+ // `filterRecipients`/`isChannelEnabled` are never called with "email"
188
+ // anywhere in the estate, so a movable Email switch would govern nothing.
189
+ for (const category of NOTIFICATION_CATEGORIES) {
190
+ assert.equal(
191
+ category.channels.email,
192
+ false,
193
+ `${category.key} offers a live Email switch that no sender consults`,
194
+ );
195
+ assert.ok(
196
+ category.channelNote?.email,
197
+ `${category.key} dims Email without saying why`,
198
+ );
199
+ }
200
+ });
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.
207
+ const category = notificationCategory("emergencyContact");
208
+ assert.equal(category.rendered, false);
209
+
210
+ for (const permissions of [["emergency-contact:update"], ["*"]]) {
211
+ assert.ok(
212
+ !keys(categoriesForPermissions({ permissions, isResident: true })).includes(
213
+ "emergencyContact",
214
+ ),
215
+ `offered to ${permissions.join()}`,
216
+ );
217
+ }
218
+ });
219
+
181
220
  it("category keys are unique", () => {
182
221
  const all = NOTIFICATION_CATEGORIES.map((c) => c.key);
183
222
  assert.equal(new Set(all).size, all.length);
@@ -332,7 +371,6 @@ const BEFORE = {
332
371
  "prelovedMarketplace",
333
372
  "serviceProviderInvite",
334
373
  "incidentReport",
335
- "emergencyContact",
336
374
  "virtualPatrol",
337
375
  "cameraFault",
338
376
  ],
@@ -340,7 +378,7 @@ const BEFORE = {
340
378
  "bulletin-board": ["bulletinBoard"],
341
379
  "bulletin-board-mgmt": ["bulletinBoard"],
342
380
  "bulletin-videos-mgmt": ["bulletinBoard"],
343
- "emergency-contact": ["emergencyContact"],
381
+ "emergency-contact": [],
344
382
  "event-mgmt": ["event"],
345
383
  "facility-booking-mgmt": ["facilityBooking"],
346
384
  "facility-mgmt": ["facilityBooking"],
@@ -413,11 +451,7 @@ describe("existing categories are untouched by the marketplace addition", () =>
413
451
  for (const category of NOTIFICATION_CATEGORIES) {
414
452
  if (NEW_KEYS.includes(category.key)) continue;
415
453
 
416
- assert.equal(
417
- category.channels.email,
418
- category.key === "serviceProviderInvite",
419
- category.key,
420
- );
454
+ assert.equal(category.channels.email, false, category.key);
421
455
  assert.equal(category.channels.inApp, true, category.key);
422
456
  assert.equal(category.channels.push, true, category.key);
423
457
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Listing and cancelling invitations both ask who is calling.
3
+ *
4
+ * `GET /api/verifications` took `orgId` as an OPTIONAL query filter with
5
+ * `requireAuth` in front and nothing else, so omitting it read every client's
6
+ * pending invitations. `PUT /api/verifications/:id/cancel` took the id off the
7
+ * URL with no authorization identifier in the handler at all, so any signed-in
8
+ * account could cancel any other client's invitations.
9
+ *
10
+ * What is pinned here is the wiring — the part that silently rots when somebody
11
+ * adds a filter, or copies one of these handlers to make a v3 — plus the two
12
+ * rules inside the gates themselves, because the rules are what make the fix
13
+ * lockout-free rather than merely closed.
14
+ *
15
+ * Nothing here opens a socket or a database. Every assertion reads a file.
16
+ */
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { readFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const read = (rel) =>
23
+ readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
24
+
25
+ const v1 = read("controllers/verification.controller.ts");
26
+ const v2 = read("controllers/verification-v2.controller.ts");
27
+ const consoleAuthz = read("utils/console-authz.util.ts");
28
+
29
+ /** One `async function name(...)` body out of a source file. */
30
+ function fn(text, name) {
31
+ const start = text.indexOf(`async function ${name}(`);
32
+ assert.notEqual(start, -1, `${name} is no longer defined`);
33
+ const rest = text.slice(start);
34
+ const next = rest.slice(1).search(/\n *async function [a-zA-Z0-9_]+ ?\(/);
35
+ return next === -1 ? rest : rest.slice(0, next + 1);
36
+ }
37
+
38
+ /** [source, label, handler, the gate it must call, the call it guards] */
39
+ const GUARDED = [
40
+ [v1, "v1", "getVerifications", "requireVerificationListReach(", "_getVerifications("],
41
+ [v2, "v2", "getVerifications", "requireVerificationListReach(", "_getVerifications("],
42
+ [v1, "v1", "cancelUserInvitation", "requireVerificationReach(", "_cancelUserInvitation("],
43
+ [v2, "v2", "cancelUserInvitation", "requireVerificationReach(", "_cancelUserInvitation("],
44
+ ];
45
+
46
+ test("both list and both cancel handlers ask, and ask BEFORE the read or write", () => {
47
+ for (const [source, label, handler, gate, guarded] of GUARDED) {
48
+ const body = fn(source, handler);
49
+
50
+ assert.ok(
51
+ body.includes(gate),
52
+ `${label} ${handler} no longer calls ${gate}`
53
+ );
54
+ assert.ok(
55
+ body.indexOf(gate) < body.indexOf(guarded),
56
+ `${label} ${handler} calls ${guarded} before it is authorized`
57
+ );
58
+ }
59
+ });
60
+
61
+ test("the cancel gate reads the STORED verification, never the request", () => {
62
+ for (const [source, label] of [[v1, "v1"], [v2, "v2"]]) {
63
+ const body = fn(source, "cancelUserInvitation");
64
+ assert.match(
65
+ body,
66
+ /requireVerificationReach\(\s*req,\s*await _getVerificationById\(otpId\)/,
67
+ `${label} cancel must authorize against the stored record`
68
+ );
69
+ }
70
+ });
71
+
72
+ test("the v1 list gate is handed the caller's own orgId, not a literal", () => {
73
+ const body = fn(v1, "getVerifications");
74
+ assert.match(body, /requireVerificationListReach\(req, orgId\)/);
75
+ });
76
+
77
+ test("an omitted organisation costs Seven365 staff identity", () => {
78
+ const gate = fn(consoleAuthz, "requireVerificationListReach");
79
+ assert.ok(gate.includes("requireOrgReach(req, orgId)"), "the org branch is gone");
80
+ assert.ok(gate.includes("requirePlatformStaff(req)"), "the estate-wide branch is gone");
81
+ // The org branch must return, or a reaching member would then be asked for
82
+ // staff identity as well and every web app's invitations screen would 401.
83
+ assert.match(gate, /requireOrgReach\(req, orgId\);\s*\n\s*return;/);
84
+ });
85
+
86
+ test("a verification that names no organisation behaves exactly as before", () => {
87
+ const gate = fn(consoleAuthz, "requireVerificationReach");
88
+ // `createUserInvite` writes rows with an empty organisation and they are
89
+ // cancelled from the staff console. Refusing them would break a working
90
+ // screen; this early return is the zero-lockout half of the fix.
91
+ assert.match(gate, /if \(!org\) return;/);
92
+ assert.ok(
93
+ gate.indexOf("if (!org) return;") < gate.indexOf("requireOrgReach"),
94
+ "the org-less carve-out must come before the refusal"
95
+ );
96
+ });