@7365admin1/core 3.64.3 → 3.64.4
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 +21 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +53 -13
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/module-gate.test.mjs +237 -0
- package/test/org-modules.test.mjs +78 -0
- package/test/site-modules.test.mjs +84 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.64.
|
|
4
|
+
"version": "3.64.4",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
|
|
14
14
|
"release": "yarn run build && changeset publish",
|
|
15
15
|
"lint": "tsc",
|
|
16
|
-
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
17
17
|
"test:camera": "yarn test",
|
|
18
18
|
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
19
19
|
},
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* THE MODULE GATE — the per-caller DENY list every app will AND at its gates.
|
|
3
|
+
*
|
|
4
|
+
* Owner decision 1B, 2026-09-11. 0 of 199 staging organisations and 0 of 254
|
|
5
|
+
* staging sites have a list, so the whole safety argument is the default: every
|
|
6
|
+
* uncertainty must deny NOTHING. A gate that fails closed here hides modules in
|
|
7
|
+
* eight codebases at once — the 2026-09-07 shape.
|
|
8
|
+
*/
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
ALWAYS_ALLOWED,
|
|
14
|
+
SPELLING_GROUPS,
|
|
15
|
+
GOVERNED_MODULES,
|
|
16
|
+
IMPACT_NAME_CAP,
|
|
17
|
+
expandModules,
|
|
18
|
+
deniedModules,
|
|
19
|
+
moduleImpact,
|
|
20
|
+
} from "./.build/utils/module-gate.util.mjs";
|
|
21
|
+
import { MODULE_KEY } from "./.build/utils/org-modules.util.mjs";
|
|
22
|
+
|
|
23
|
+
const WORK_ORDERS = ["work_orders", "workOrder", "work_order", "work-orders"];
|
|
24
|
+
|
|
25
|
+
/* ── absent and empty deny NOTHING ────────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
test("absent, empty or unreadable at both tiers denies nothing", () => {
|
|
28
|
+
for (const org of [undefined, null, [], "visitor-mgmt", 0, {}]) {
|
|
29
|
+
for (const site of [undefined, null, [], "visitor-mgmt", 0, {}]) {
|
|
30
|
+
assert.deepEqual(deniedModules(org, site), [], `${org} / ${site}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("an absent tier does not narrow", () => {
|
|
36
|
+
const orgOnly = deniedModules(["visitor-mgmt"], undefined);
|
|
37
|
+
const siteOnly = deniedModules(undefined, ["visitor-mgmt"]);
|
|
38
|
+
assert.deepEqual(siteOnly, orgOnly);
|
|
39
|
+
assert.ok(!orgOnly.includes("visitor-mgmt"));
|
|
40
|
+
// CONTROL: the list really does deny something.
|
|
41
|
+
assert.ok(orgOnly.includes("nfc-patrol"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a list that allows no governed module denies nothing", () => {
|
|
45
|
+
// A typo, a renamed key, or only the always-included set: not "hide all".
|
|
46
|
+
assert.deepEqual(deniedModules(["not-a-module"], undefined), []);
|
|
47
|
+
assert.deepEqual(deniedModules(["members", "roles"], undefined), []);
|
|
48
|
+
assert.deepEqual(deniedModules(undefined, ["typo"]), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/* ── what it denies ───────────────────────────────────────────────────── */
|
|
52
|
+
|
|
53
|
+
test("a spelling group is allowed as a whole", () => {
|
|
54
|
+
for (const spelling of WORK_ORDERS) {
|
|
55
|
+
const denied = deniedModules([spelling, "nfc-patrol"], undefined);
|
|
56
|
+
for (const other of WORK_ORDERS) assert.ok(!denied.includes(other), `${spelling} -> ${other}`);
|
|
57
|
+
// CONTROL: something outside the group is still denied.
|
|
58
|
+
assert.ok(denied.includes("visitor-mgmt"));
|
|
59
|
+
}
|
|
60
|
+
assert.deepEqual(expandModules(["workOrder"]).sort(), [...WORK_ORDERS].sort());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a denied group is denied under every spelling", () => {
|
|
64
|
+
const denied = deniedModules(["nfc-patrol"], undefined);
|
|
65
|
+
for (const spelling of WORK_ORDERS) assert.ok(denied.includes(spelling), spelling);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ALWAYS_ALLOWED is never denied", () => {
|
|
69
|
+
const denied = deniedModules(["nfc-patrol"], ["nfc-patrol"]);
|
|
70
|
+
assert.ok(denied.length > 0, "control: the list denies something");
|
|
71
|
+
for (const key of ALWAYS_ALLOWED) assert.ok(!denied.includes(key), key);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an unknown or ungoverned resource is never denied", () => {
|
|
75
|
+
const denied = deniedModules(["nfc-patrol"], undefined);
|
|
76
|
+
for (const key of ["dashboard", "request", "organizations", "not-a-module", "", "*"]) {
|
|
77
|
+
assert.ok(!denied.includes(key), key);
|
|
78
|
+
}
|
|
79
|
+
// Only governed keys ever come back.
|
|
80
|
+
for (const key of denied) assert.ok(GOVERNED_MODULES.includes(key), key);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("both tiers present narrow to the intersection", () => {
|
|
84
|
+
const denied = deniedModules(["visitor-mgmt", "nfc-patrol"], ["nfc-patrol"]);
|
|
85
|
+
assert.ok(denied.includes("visitor-mgmt"));
|
|
86
|
+
assert.ok(!denied.includes("nfc-patrol"));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the intersection meets across spellings", () => {
|
|
90
|
+
const denied = deniedModules(["workOrder", "nfc-patrol"], ["work_orders"]);
|
|
91
|
+
for (const spelling of WORK_ORDERS) assert.ok(!denied.includes(spelling), spelling);
|
|
92
|
+
assert.ok(denied.includes("nfc-patrol"));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("an EMPTY intersection falls back to the organisation's list", () => {
|
|
96
|
+
const denied = deniedModules(["visitor-mgmt", "nfc-patrol"], ["pool-schedule-mgmt"]);
|
|
97
|
+
assert.ok(!denied.includes("visitor-mgmt"));
|
|
98
|
+
assert.ok(!denied.includes("nfc-patrol"));
|
|
99
|
+
assert.ok(denied.includes("pool-schedule-mgmt"));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/* ── the drift pin ────────────────────────────────────────────────────── */
|
|
103
|
+
|
|
104
|
+
test("every spelling-group key is governed or always allowed", () => {
|
|
105
|
+
for (const group of SPELLING_GROUPS) {
|
|
106
|
+
assert.ok(group.length >= 2, `a group of one: ${group}`);
|
|
107
|
+
for (const key of group) {
|
|
108
|
+
assert.ok(
|
|
109
|
+
GOVERNED_MODULES.includes(key) || ALWAYS_ALLOWED.includes(key),
|
|
110
|
+
`${key} is in a spelling group but can never be denied or allowed`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// No key belongs to two groups.
|
|
115
|
+
const all = SPELLING_GROUPS.flat();
|
|
116
|
+
assert.equal(new Set(all).size, all.length);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("the governed set is clean", () => {
|
|
120
|
+
assert.equal(new Set(GOVERNED_MODULES).size, GOVERNED_MODULES.length, "duplicates");
|
|
121
|
+
for (const key of GOVERNED_MODULES) {
|
|
122
|
+
assert.ok(!ALWAYS_ALLOWED.includes(key), `${key} is always allowed`);
|
|
123
|
+
assert.ok(MODULE_KEY.test(key), `${key} could never be saved in a list`);
|
|
124
|
+
}
|
|
125
|
+
// Generated 2026-09-11 from layer-common 5a96f95 (55 keys), minus
|
|
126
|
+
// `notifications`, which must never be hideable. A change in this number
|
|
127
|
+
// means the list was edited: regenerate it, do not hand-edit it.
|
|
128
|
+
assert.equal(GOVERNED_MODULES.length, 54);
|
|
129
|
+
assert.ok(!GOVERNED_MODULES.includes("notifications"), "notifications must never be governed");
|
|
130
|
+
assert.deepEqual(deniedModules(["visitor-mgmt"], undefined).includes("notifications"), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/* ── the impact preview ───────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
const ROLES = {
|
|
136
|
+
star: { _id: "star", name: "Owner", permissions: ["*"] },
|
|
137
|
+
empty: { _id: "empty", name: "Seeded", permissions: [] },
|
|
138
|
+
guard: {
|
|
139
|
+
_id: "guard",
|
|
140
|
+
name: "Guard",
|
|
141
|
+
permissions: ["visitor-mgmt:add-visitor", "workOrder:create-work-order"],
|
|
142
|
+
},
|
|
143
|
+
other: { _id: "other", name: "Recap", permissions: ["request:create-request", "dashboard:view"] },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const MEMBERS = [
|
|
147
|
+
{ _id: "m1", name: "Ann", type: "security_agency", role: "star" },
|
|
148
|
+
{ _id: "m2", name: "Ben", type: "organization", role: "empty" },
|
|
149
|
+
{ _id: "m3", name: "Cat", type: "security_agency", role: "guard" },
|
|
150
|
+
{ _id: "m4", name: "Dan", type: "security_agency", role: "other" },
|
|
151
|
+
{ _id: "m5", name: "Eve", type: "resident", role: "star" },
|
|
152
|
+
{ _id: "m6", name: "Fay", type: "organization", role: "missing" },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
function entry(impact, module) {
|
|
156
|
+
return impact.modules.find((e) => e.module === module);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test("moduleImpact: *, [] and enumerated roles lose; ungoverned strings and residents do not", () => {
|
|
160
|
+
const impact = moduleImpact({
|
|
161
|
+
currentDenied: [],
|
|
162
|
+
proposedDenied: deniedModules(["nfc-patrol"], undefined),
|
|
163
|
+
members: MEMBERS,
|
|
164
|
+
rolesById: ROLES,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const visitors = entry(impact, "visitor-mgmt");
|
|
168
|
+
assert.deepEqual(visitors.members.map((m) => m.name), ["Ann", "Ben", "Cat"]);
|
|
169
|
+
assert.equal(visitors.roleCount, 3);
|
|
170
|
+
|
|
171
|
+
// All four work-order spellings are ONE entry, and `workOrder:` holds it.
|
|
172
|
+
const workOrders = entry(impact, "work_orders");
|
|
173
|
+
assert.deepEqual(workOrders.spellings.sort(), [...WORK_ORDERS].sort());
|
|
174
|
+
assert.deepEqual(workOrders.members.map((m) => m.name), ["Ann", "Ben", "Cat"]);
|
|
175
|
+
|
|
176
|
+
// Only `*` and [] hold a module the enumerated role does not.
|
|
177
|
+
assert.deepEqual(entry(impact, "vehicle-mgmt").members.map((m) => m.name), ["Ann", "Ben"]);
|
|
178
|
+
|
|
179
|
+
// Not lost at all: still allowed.
|
|
180
|
+
assert.equal(entry(impact, "nfc-patrol"), undefined);
|
|
181
|
+
|
|
182
|
+
// Dan (ungoverned strings only), Eve (resident), Fay (no role) lose nothing.
|
|
183
|
+
assert.equal(impact.memberCount, 3);
|
|
184
|
+
for (const e of impact.modules) {
|
|
185
|
+
for (const name of ["Dan", "Eve", "Fay"]) {
|
|
186
|
+
assert.ok(!e.members.some((m) => m.name === name), `${name} in ${e.module}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("moduleImpact counts only what is NEWLY denied", () => {
|
|
192
|
+
const proposed = deniedModules(["nfc-patrol"], undefined);
|
|
193
|
+
const impact = moduleImpact({
|
|
194
|
+
currentDenied: proposed.filter((key) => key !== "visitor-mgmt" && key !== "visitorManagement"),
|
|
195
|
+
proposedDenied: proposed,
|
|
196
|
+
members: MEMBERS,
|
|
197
|
+
rolesById: ROLES,
|
|
198
|
+
});
|
|
199
|
+
assert.deepEqual(impact.modules.map((e) => e.module), ["visitor-mgmt"]);
|
|
200
|
+
assert.equal(impact.memberCount, 3);
|
|
201
|
+
|
|
202
|
+
const none = moduleImpact({
|
|
203
|
+
currentDenied: proposed,
|
|
204
|
+
proposedDenied: proposed,
|
|
205
|
+
members: MEMBERS,
|
|
206
|
+
rolesById: ROLES,
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(none, { memberCount: 0, modules: [] });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("moduleImpact caps names but never counts", () => {
|
|
212
|
+
const many = Array.from({ length: IMPACT_NAME_CAP + 10 }, (_, i) => ({
|
|
213
|
+
_id: `x${i}`,
|
|
214
|
+
name: `Person ${i}`,
|
|
215
|
+
type: "organization",
|
|
216
|
+
role: "star",
|
|
217
|
+
}));
|
|
218
|
+
const impact = moduleImpact({
|
|
219
|
+
currentDenied: [],
|
|
220
|
+
proposedDenied: ["visitor-mgmt"],
|
|
221
|
+
members: many,
|
|
222
|
+
rolesById: ROLES,
|
|
223
|
+
});
|
|
224
|
+
const visitors = entry(impact, "visitor-mgmt");
|
|
225
|
+
assert.equal(visitors.members.length, IMPACT_NAME_CAP);
|
|
226
|
+
assert.equal(visitors.memberCount, IMPACT_NAME_CAP + 10);
|
|
227
|
+
assert.equal(impact.memberCount, IMPACT_NAME_CAP + 10);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("moduleImpact survives junk input", () => {
|
|
231
|
+
for (const members of [undefined, null, [null, {}]]) {
|
|
232
|
+
assert.deepEqual(
|
|
233
|
+
moduleImpact({ currentDenied: null, proposedDenied: null, members, rolesById: null }),
|
|
234
|
+
{ memberCount: 0, modules: [] },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
orgAllowsModule,
|
|
23
23
|
catalogueForOrg,
|
|
24
24
|
MODULE_KEY,
|
|
25
|
+
ALWAYS_ALLOWED,
|
|
26
|
+
SPELLING_GROUPS,
|
|
25
27
|
} from "./.build/utils/org-modules.util.mjs";
|
|
26
28
|
|
|
27
29
|
const CATALOGUE = {
|
|
@@ -179,3 +181,79 @@ test("the change is recorded, with the list readable in the row", () => {
|
|
|
179
181
|
assert.match(body, /modules: modules\.join\(", "\)/);
|
|
180
182
|
assert.match(body, /moduleCount: modules\.length/);
|
|
181
183
|
});
|
|
184
|
+
|
|
185
|
+
/* ── spelling groups and the always-included set: WIDENING only ───────── */
|
|
186
|
+
|
|
187
|
+
// The pre-group rules, verbatim, as the contrast oracle.
|
|
188
|
+
function oldOrgAllowsModule(modules, resource) {
|
|
189
|
+
if (!Array.isArray(modules) || modules.length === 0) return true;
|
|
190
|
+
const key = typeof resource === "string" ? resource.trim() : "";
|
|
191
|
+
if (!key) return true;
|
|
192
|
+
return modules.includes(key);
|
|
193
|
+
}
|
|
194
|
+
function oldCatalogueForOrg(catalogue, modules) {
|
|
195
|
+
if (!Array.isArray(modules) || modules.length === 0) return catalogue;
|
|
196
|
+
const narrowed = {};
|
|
197
|
+
for (const [resource, actions] of Object.entries(catalogue)) {
|
|
198
|
+
if (oldOrgAllowsModule(modules, resource)) narrowed[resource] = actions;
|
|
199
|
+
}
|
|
200
|
+
return Object.keys(narrowed).length ? narrowed : catalogue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const KEYS = [
|
|
204
|
+
...SPELLING_GROUPS.flat(),
|
|
205
|
+
...ALWAYS_ALLOWED,
|
|
206
|
+
"nfc-patrol", "pool-schedule-mgmt", "not-a-module", "has space", " visitor-mgmt ", "",
|
|
207
|
+
];
|
|
208
|
+
const LISTS = [
|
|
209
|
+
undefined, [], ...KEYS.map((key) => [key]),
|
|
210
|
+
["workOrder", "nfc-patrol"], ["work_orders"], ["members"], ["typo", "visitor-mgmt"],
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
test("every spelling of a ticked module is allowed", () => {
|
|
214
|
+
for (const group of SPELLING_GROUPS) {
|
|
215
|
+
for (const ticked of group) {
|
|
216
|
+
for (const other of group) assert.equal(orgAllowsModule([ticked], other), true, `${ticked} -> ${other}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// CONTROL: a different module is still refused.
|
|
220
|
+
assert.equal(orgAllowsModule(["workOrder"], "visitor-mgmt"), false);
|
|
221
|
+
assert.equal(orgAllowsModule(["workOrder"], "nfc-patrol"), false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("the always-included set is allowed by any list", () => {
|
|
225
|
+
for (const key of ALWAYS_ALLOWED) assert.equal(orgAllowsModule(["nfc-patrol"], key), true, key);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("nothing allowed before is refused now", () => {
|
|
229
|
+
for (const list of LISTS) {
|
|
230
|
+
for (const resource of KEYS) {
|
|
231
|
+
if (oldOrgAllowsModule(list, resource)) {
|
|
232
|
+
assert.equal(orgAllowsModule(list, resource), true, `${JSON.stringify(list)} / ${resource}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("no catalogue key offered before is dropped now", () => {
|
|
239
|
+
const catalogue = Object.fromEntries(KEYS.filter(Boolean).map((key) => [key, {}]));
|
|
240
|
+
const property = { workOrder: {}, members: {}, "pool-schedule-mgmt": {} };
|
|
241
|
+
for (const list of LISTS) {
|
|
242
|
+
for (const cat of [catalogue, property, CATALOGUE]) {
|
|
243
|
+
const before = Object.keys(oldCatalogueForOrg(cat, list));
|
|
244
|
+
const after = Object.keys(catalogueForOrg(cat, list));
|
|
245
|
+
for (const key of before) assert.ok(after.includes(key), `${JSON.stringify(list)} dropped ${key}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// The case that needed care: `work_orders` matched nothing EXACTLY in a
|
|
249
|
+
// property catalogue, so it showed everything. It still does.
|
|
250
|
+
assert.equal(catalogueForOrg(property, ["work_orders"]), property);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a real list now offers every spelling and the always-included set", () => {
|
|
254
|
+
const cat = { workOrder: {}, "visitor-mgmt": {}, "facility-mgmt": {}, members: {}, "nfc-patrol": {} };
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
Object.keys(catalogueForOrg(cat, ["work_orders", "visitor-mgmt"])).sort(),
|
|
257
|
+
["members", "visitor-mgmt", "workOrder"],
|
|
258
|
+
);
|
|
259
|
+
});
|
|
@@ -32,7 +32,9 @@ import {
|
|
|
32
32
|
rejectedSiteModulesMessage,
|
|
33
33
|
permissionsOutsideModules,
|
|
34
34
|
requireWithinModules,
|
|
35
|
+
keepStoredSiteModules,
|
|
35
36
|
} from "./.build/utils/site-modules.util.mjs";
|
|
37
|
+
import { ALWAYS_ALLOWED } from "./.build/utils/org-modules.util.mjs";
|
|
36
38
|
|
|
37
39
|
/* ── reading: it must never return "nothing" ───────────────────────────── */
|
|
38
40
|
|
|
@@ -298,3 +300,85 @@ test("the two EDIT paths pass the existing permissions, so only additions are te
|
|
|
298
300
|
|
|
299
301
|
assert.equal(flat.split(CALL).length - 1, 2);
|
|
300
302
|
});
|
|
303
|
+
|
|
304
|
+
/* ── spelling groups and the always-included set: WIDENING only ───────── */
|
|
305
|
+
|
|
306
|
+
test("a site may take any spelling of a module the organisation holds", () => {
|
|
307
|
+
assert.deepEqual(rejectedSiteModules(["workOrder"], ["work_orders", "work-orders", "work_order"]), []);
|
|
308
|
+
assert.deepEqual(rejectedSiteModules(["visitorManagement"], ["visitor-mgmt"]), []);
|
|
309
|
+
assert.deepEqual(rejectedSiteModules(["nfc-patrol"], [...ALWAYS_ALLOWED]), []);
|
|
310
|
+
// CONTROL: a module the organisation lacks is still refused.
|
|
311
|
+
assert.deepEqual(rejectedSiteModules(["workOrder"], ["nfc-patrol"]), ["nfc-patrol"]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a role may be given any spelling of a module the ceiling holds", () => {
|
|
315
|
+
assert.deepEqual(
|
|
316
|
+
permissionsOutsideModules(
|
|
317
|
+
["workOrder"],
|
|
318
|
+
["work_orders:create-work-order", "members:view-members", "roles-and-permissions:add-role"],
|
|
319
|
+
),
|
|
320
|
+
[],
|
|
321
|
+
);
|
|
322
|
+
// CONTROL
|
|
323
|
+
assert.deepEqual(
|
|
324
|
+
permissionsOutsideModules(["workOrder"], ["nfc-patrol:see-all-patrols"]),
|
|
325
|
+
["nfc-patrol:see-all-patrols"],
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("nothing accepted before is refused now", () => {
|
|
330
|
+
// The pre-group rule, verbatim, as the contrast oracle.
|
|
331
|
+
const oldRejected = (org, requested) =>
|
|
332
|
+
Array.isArray(org) && org.length
|
|
333
|
+
? requested.filter((key) => !org.includes(key))
|
|
334
|
+
: [];
|
|
335
|
+
const keys = ["workOrder", "work_orders", "visitor-mgmt", "members", "nfc-patrol", "zzz"];
|
|
336
|
+
for (const org of [[], ...keys.map((k) => [k]), ["workOrder", "nfc-patrol"]]) {
|
|
337
|
+
const now = rejectedSiteModules(org, keys);
|
|
338
|
+
for (const key of now) assert.ok(oldRejected(org, keys).includes(key), `${org} now refuses ${key}`);
|
|
339
|
+
const perms = keys.map((k) => `${k}:do`);
|
|
340
|
+
for (const p of permissionsOutsideModules(org, perms)) {
|
|
341
|
+
assert.ok(oldRejected(org, keys).includes(p.split(":")[0]), `${org} now refuses ${p}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/* ── S6: a generic site save keeps the stored module list ──────────────── */
|
|
347
|
+
|
|
348
|
+
test("the stored list wins over whatever the save sent", () => {
|
|
349
|
+
const stored = { modules: ["visitor-mgmt"], block: 1 };
|
|
350
|
+
assert.deepEqual(
|
|
351
|
+
keepStoredSiteModules({ block: 2, modules: ["nfc-patrol"] }, stored),
|
|
352
|
+
{ block: 2, modules: ["visitor-mgmt"] },
|
|
353
|
+
);
|
|
354
|
+
// A screen read before the list existed sends none: the list is NOT wiped.
|
|
355
|
+
assert.deepEqual(keepStoredSiteModules({ block: 2 }, stored), { block: 2, modules: ["visitor-mgmt"] });
|
|
356
|
+
// An empty stored list is still the stored list.
|
|
357
|
+
assert.deepEqual(keepStoredSiteModules({ modules: ["a"] }, { modules: [] }), { modules: [] });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("with no stored list, a sent list is dropped", () => {
|
|
361
|
+
for (const stored of [undefined, null, {}, { block: 1 }, "junk", []]) {
|
|
362
|
+
assert.deepEqual(keepStoredSiteModules({ block: 2, modules: ["nfc-patrol"] }, stored), { block: 2 });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("everything else in metadata is untouched, and nothing is mutated", () => {
|
|
367
|
+
const sent = { residentAppModules: { billing: false }, hidQrCodePass: { enabled: true }, modules: ["x"] };
|
|
368
|
+
const out = keepStoredSiteModules(sent, { modules: ["y"] });
|
|
369
|
+
assert.deepEqual(out, { residentAppModules: { billing: false }, hidQrCodePass: { enabled: true }, modules: ["y"] });
|
|
370
|
+
assert.deepEqual(sent.modules, ["x"]);
|
|
371
|
+
for (const junk of [undefined, null, "x", [1]]) assert.equal(keepStoredSiteModules(junk, { modules: ["y"] }), junk);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("updateById puts the stored list back before it writes", () => {
|
|
375
|
+
const body = handler("updateById");
|
|
376
|
+
const keep = body.indexOf("keepStoredSiteModules(");
|
|
377
|
+
const write = body.indexOf("await _updateById(");
|
|
378
|
+
assert.notEqual(keep, -1, "updateById does not keep the stored modules");
|
|
379
|
+
assert.ok(keep < write, "the write happens before the stored list is restored");
|
|
380
|
+
assert.match(body, /keepStoredSiteModules\(\s*rest\.metadata,\s*\(existing as any\)\?\.metadata,?\s*\)/);
|
|
381
|
+
// CONTROL: the derived-field strip and the R4 ceiling are still there.
|
|
382
|
+
assert.match(body, /withoutDerivedSiteMetadata\(req\.body\.metadata\)/);
|
|
383
|
+
assert.match(body, /residentAppModulesAboveCeiling\(/);
|
|
384
|
+
});
|