@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/CHANGELOG.md +23 -0
- package/dist/index.d.ts +17 -11
- package/dist/index.js +28 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +28 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/notification-category.util.test.mjs +46 -12
- package/test/verification-scope.test.mjs +96 -0
package/package.json
CHANGED
|
@@ -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
|
|
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, [
|
|
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"),
|
|
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": [
|
|
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
|
+
});
|