@7365admin1/core 3.63.0 → 3.64.1
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 +47 -0
- package/dist/index.d.ts +134 -119
- package/dist/index.js +5516 -5341
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +580 -406
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/org-modules.test.mjs +22 -3
- package/test/resident-app-modules.test.mjs +197 -0
- package/test/role-scope.test.mjs +26 -0
- package/test/site-modules.test.mjs +151 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.64.1",
|
|
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-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 --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
|
},
|
|
@@ -131,9 +131,28 @@ function handler(name) {
|
|
|
131
131
|
test("setting a client's modules takes the CONSOLE gate", () => {
|
|
132
132
|
const body = handler("updateModules");
|
|
133
133
|
assert.match(body, /await requireConsolePermission\(req, "organizations"\)/);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
/*
|
|
135
|
+
* The write must name ONLY entitlement fields. Anything else and this
|
|
136
|
+
* becomes a second way to edit an organisation, with a different gate from
|
|
137
|
+
* the first.
|
|
138
|
+
*
|
|
139
|
+
* There are two now: `modules` (the catalogue) and `residentAppModules`
|
|
140
|
+
* (R4's resident-app ceiling). Both are the same decision — what did this
|
|
141
|
+
* client buy — taken by the same actor on the same console screen, which is
|
|
142
|
+
* why they share an endpoint rather than getting one each.
|
|
143
|
+
*/
|
|
144
|
+
const write = body.slice(body.indexOf("await _update(orgId, {"));
|
|
145
|
+
assert.match(write, /modules,/);
|
|
146
|
+
assert.match(
|
|
147
|
+
write,
|
|
148
|
+
/\.\.\.\(residentAppModules === undefined \? \{\} : \{ residentAppModules \}\),/,
|
|
149
|
+
);
|
|
150
|
+
// Nothing from the request body reaches the write except those two.
|
|
151
|
+
const spread = write.slice(0, write.indexOf("});"));
|
|
152
|
+
assert.ok(
|
|
153
|
+
!/req\.body\.(?!residentAppModules|modules)/.test(spread),
|
|
154
|
+
"an unexpected request field reaches the organisation write",
|
|
155
|
+
);
|
|
137
156
|
});
|
|
138
157
|
|
|
139
158
|
test("the CLIENT-reachable update still cannot set modules", () => {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* THE RESIDENT-APP MODULE CASCADE — owner requirement R4, 2026-09-10.
|
|
3
|
+
*
|
|
4
|
+
* Seven365 -> organizations.residentAppModules (tier one, this adds it)
|
|
5
|
+
* the org -> sites.metadata.residentAppModules (tier two, already shipped
|
|
6
|
+
* and written by property-management's Site Settings panel)
|
|
7
|
+
*
|
|
8
|
+
* The whole risk is the DEFAULT. No organisation has the new field and no site
|
|
9
|
+
* has to have the old one, so a reading of "absent means nothing" empties the
|
|
10
|
+
* home screen of every resident in the estate the day it deploys. Most of what
|
|
11
|
+
* follows is about the ways this could fail closed when it must fail open.
|
|
12
|
+
*/
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
effectiveResidentAppModules,
|
|
19
|
+
residentAppModulesAboveCeiling,
|
|
20
|
+
residentAppModulesAboveCeilingMessage,
|
|
21
|
+
} from "./.build/utils/resident-app-modules.util.mjs";
|
|
22
|
+
|
|
23
|
+
const KEYS = [
|
|
24
|
+
"feedback",
|
|
25
|
+
"bulletin",
|
|
26
|
+
"bulletinVideos",
|
|
27
|
+
"inviteVisitors",
|
|
28
|
+
"myVisitors",
|
|
29
|
+
"billingAndSoa",
|
|
30
|
+
"access",
|
|
31
|
+
"facilityBooking",
|
|
32
|
+
"onlineForm",
|
|
33
|
+
"emergencyContact",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const allOn = () => Object.fromEntries(KEYS.map((key) => [key, true]));
|
|
37
|
+
|
|
38
|
+
/* ── reading: it must never take a module away by accident ─────────────── */
|
|
39
|
+
|
|
40
|
+
test("absent at BOTH tiers means every module is ON", () => {
|
|
41
|
+
for (const org of [undefined, null, {}, [], "junk", 0, true]) {
|
|
42
|
+
for (const site of [undefined, null, {}, [], "junk", 0, true]) {
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
effectiveResidentAppModules(org, site),
|
|
45
|
+
allOn(),
|
|
46
|
+
`${JSON.stringify(org)} / ${JSON.stringify(site)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("ONLY an explicit false disables — nothing else does", () => {
|
|
53
|
+
for (const value of [undefined, null, 0, "", "false", NaN, true, {}]) {
|
|
54
|
+
const result = effectiveResidentAppModules({ feedback: value }, undefined);
|
|
55
|
+
assert.equal(result.feedback, true, String(value));
|
|
56
|
+
}
|
|
57
|
+
// CONTROL: the one value that does disable it.
|
|
58
|
+
assert.equal(
|
|
59
|
+
effectiveResidentAppModules({ feedback: false }, undefined).feedback,
|
|
60
|
+
false,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a site's false turns its own module off", () => {
|
|
65
|
+
const result = effectiveResidentAppModules(undefined, { bulletin: false });
|
|
66
|
+
assert.equal(result.bulletin, false);
|
|
67
|
+
assert.equal(result.feedback, true, "it must not take anything else with it");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("Seven365's false WINS over a site's true — that is the ceiling", () => {
|
|
71
|
+
/*
|
|
72
|
+
* And it keeps winning if Seven365 turns a module off AFTER a site had
|
|
73
|
+
* already switched it on, which a write-time refusal alone cannot do.
|
|
74
|
+
*/
|
|
75
|
+
const result = effectiveResidentAppModules(
|
|
76
|
+
{ access: false },
|
|
77
|
+
{ access: true },
|
|
78
|
+
);
|
|
79
|
+
assert.equal(result.access, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an unknown key at either tier changes nothing", () => {
|
|
83
|
+
const result = effectiveResidentAppModules(
|
|
84
|
+
{ notAModule: false },
|
|
85
|
+
{ alsoNot: false },
|
|
86
|
+
);
|
|
87
|
+
assert.deepEqual(result, allOn());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("every one of the ten keys is always answered", () => {
|
|
91
|
+
// A missing key reads as `undefined`, and the app's own predicate treats
|
|
92
|
+
// undefined as ON - but a caller comparing keys would see a shorter object.
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
Object.keys(effectiveResidentAppModules({ feedback: false }, undefined)).sort(),
|
|
95
|
+
[...KEYS].sort(),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/* ── writing: a site cannot switch on what the client did not buy ──────── */
|
|
100
|
+
|
|
101
|
+
test("turning ON a module Seven365 turned OFF is refused, by name", () => {
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
residentAppModulesAboveCeiling({ access: false }, { access: true }),
|
|
104
|
+
["access"],
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
residentAppModulesAboveCeiling(
|
|
108
|
+
{ access: false, onlineForm: false },
|
|
109
|
+
{ access: true, onlineForm: true, feedback: true },
|
|
110
|
+
),
|
|
111
|
+
["access", "onlineForm"],
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("an organisation with no ceiling refuses nothing", () => {
|
|
116
|
+
// Every live organisation is in this state today. If this refused, the Site
|
|
117
|
+
// Settings panel would stop saving for everybody on deploy.
|
|
118
|
+
for (const org of [undefined, null, {}, "junk"]) {
|
|
119
|
+
assert.deepEqual(residentAppModulesAboveCeiling(org, allOn()), []);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("turning a module OFF is never refused, whatever the ceiling says", () => {
|
|
124
|
+
// Switching off is how a site opts out, and it can never exceed a ceiling.
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
residentAppModulesAboveCeiling({ access: false }, { access: false }),
|
|
127
|
+
[],
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
residentAppModulesAboveCeiling({ access: false }, {}),
|
|
131
|
+
[],
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a module the ceiling ALLOWS can still be switched on", () => {
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
residentAppModulesAboveCeiling({ access: true }, { access: true }),
|
|
138
|
+
[],
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("the refusal names the switches and says what to do", () => {
|
|
143
|
+
const message = residentAppModulesAboveCeilingMessage(["access", "onlineForm"]);
|
|
144
|
+
assert.match(message, /access/);
|
|
145
|
+
assert.match(message, /onlineForm/);
|
|
146
|
+
assert.match(message, /Ask Seven365/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/* ── the endpoints ─────────────────────────────────────────────────────── */
|
|
150
|
+
|
|
151
|
+
const SITE_CONTROLLER = readFileSync(
|
|
152
|
+
new URL("../src/controllers/site.controller.ts", import.meta.url),
|
|
153
|
+
"utf8",
|
|
154
|
+
);
|
|
155
|
+
const ORG_CONTROLLER = readFileSync(
|
|
156
|
+
new URL("../src/controllers/organization.controller.ts", import.meta.url),
|
|
157
|
+
"utf8",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
test("the site read returns the EFFECTIVE set as a second field", () => {
|
|
161
|
+
/*
|
|
162
|
+
* A second field, not a narrowing of the saved one. The Site Settings panel
|
|
163
|
+
* reads back what it writes: narrowing in place would show a property
|
|
164
|
+
* manager a switch coming back off after they saved it on, which is
|
|
165
|
+
* indistinguishable from a failed save.
|
|
166
|
+
*/
|
|
167
|
+
assert.match(SITE_CONTROLLER, /residentAppModulesEffective: effectiveResidentAppModules\(/);
|
|
168
|
+
assert.match(SITE_CONTROLLER, /res\.json\(await withResidentAppCeiling\(site\)\)/);
|
|
169
|
+
// CONTROL: the saved field is still returned untouched alongside it.
|
|
170
|
+
assert.match(SITE_CONTROLLER, /\.\.\.\(site\.metadata \?\? \{\}\)/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("the site read fails open — an unreadable organisation cannot blank the app", () => {
|
|
174
|
+
const start = SITE_CONTROLLER.indexOf("async function withResidentAppCeiling");
|
|
175
|
+
const body = SITE_CONTROLLER.slice(start, start + 1200);
|
|
176
|
+
assert.match(body, /\.catch\(\(\) => null\)/);
|
|
177
|
+
assert.match(body, /catch \{\s*return site;/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("the site WRITE refuses only when it actually carries the switches", () => {
|
|
181
|
+
// Checking unconditionally would cost every site edit an org lookup, and
|
|
182
|
+
// would refuse a write that never mentioned the resident app.
|
|
183
|
+
assert.match(SITE_CONTROLLER, /if \(rest\?\.metadata\?\.residentAppModules\) \{/);
|
|
184
|
+
assert.match(SITE_CONTROLLER, /residentAppModulesAboveCeiling\(/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("Seven365 sets the ceiling on the console's own endpoint, and it is optional", () => {
|
|
188
|
+
// Optional so the existing console screen, which sends only `modules`, keeps
|
|
189
|
+
// working and leaves the resident-app ceiling exactly as it was.
|
|
190
|
+
assert.match(ORG_CONTROLLER, /residentAppModules: residentAppModulesSchema\.optional\(\)/);
|
|
191
|
+
assert.match(
|
|
192
|
+
ORG_CONTROLLER,
|
|
193
|
+
/\.\.\.\(residentAppModules === undefined \? \{\} : \{ residentAppModules \}\)/,
|
|
194
|
+
);
|
|
195
|
+
// CONTROL: still behind the console permission, not reachable by a client.
|
|
196
|
+
assert.match(ORG_CONTROLLER, /requireConsolePermission\(req, "organizations"\)/);
|
|
197
|
+
});
|
package/test/role-scope.test.mjs
CHANGED
|
@@ -112,6 +112,32 @@ test("/api/roles/v2 is gated too — the same hole with /v2 on the URL", () => {
|
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test("/api/roles/v2 cannot mint a role holding the CONSOLE's own resources", () => {
|
|
116
|
+
/*
|
|
117
|
+
* `role.controller createRole` has carried `requireNoPlatformGrant` for a
|
|
118
|
+
* while; this mount did not. It is a separate controller behind a separate
|
|
119
|
+
* route and takes any string at all, so the hole was reachable by adding
|
|
120
|
+
* `/v2` to the URL - the same shape as the two gates above it.
|
|
121
|
+
*
|
|
122
|
+
* MEASURED BEFORE ADDING, on production, read-only: NO site-scoped role holds
|
|
123
|
+
* a platform-only string (6 site-scoped roles read; the 11 roles that do hold
|
|
124
|
+
* one are org-scoped and already covered by the v1 gate). This mount REQUIRES
|
|
125
|
+
* a `site`, so the check refuses nothing that exists today.
|
|
126
|
+
*/
|
|
127
|
+
const body = fn(controllerV2, "createRole");
|
|
128
|
+
assert.match(body, /requireNoPlatformGrant\(/);
|
|
129
|
+
|
|
130
|
+
// It must run BEFORE the write, or it decides nothing.
|
|
131
|
+
const guard = body.indexOf("requireNoPlatformGrant(");
|
|
132
|
+
const write = body.indexOf("_createRole(");
|
|
133
|
+
assert.ok(guard !== -1 && write !== -1);
|
|
134
|
+
assert.ok(guard < write, "the role is created before the platform check");
|
|
135
|
+
|
|
136
|
+
// CONTROL: v1 still has it, so the assertion above is a real comparison and
|
|
137
|
+
// not a rule that only exists on one side.
|
|
138
|
+
assert.match(fn(controller, "createRole"), /requireNoPlatformGrant\(/);
|
|
139
|
+
});
|
|
140
|
+
|
|
115
141
|
/**
|
|
116
142
|
* The four true WRITES now go through `requireRoleWrite`, which is
|
|
117
143
|
* `requireRoleOrg` plus one refusal: a role with no `org` and a non-admin type
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
effectiveSiteModules,
|
|
31
31
|
rejectedSiteModules,
|
|
32
32
|
rejectedSiteModulesMessage,
|
|
33
|
+
permissionsOutsideModules,
|
|
34
|
+
requireWithinModules,
|
|
33
35
|
} from "./.build/utils/site-modules.util.mjs";
|
|
34
36
|
|
|
35
37
|
/* ── reading: it must never return "nothing" ───────────────────────────── */
|
|
@@ -147,3 +149,152 @@ test("metadata is MERGED, never replaced", () => {
|
|
|
147
149
|
const body = handler("updateSiteModules");
|
|
148
150
|
assert.match(body, /metadata: \{ \.\.\.\(site\.metadata \?\? \{\}\), modules \}/);
|
|
149
151
|
});
|
|
152
|
+
|
|
153
|
+
/* ── TIER THREE: the permissions a role may be GIVEN ───────────────────── */
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* R1's third clause: whatever Seven365 gives an organisation is the only set
|
|
157
|
+
* that organisation can give "to its Site Team member". A permission is
|
|
158
|
+
* `"resource:action"` and the resource IS the module key, so the server refuses
|
|
159
|
+
* exactly what the editor declines to offer.
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
test("no module list means EVERYTHING, so nothing is ever refused", () => {
|
|
163
|
+
// Every one of the 168 live organisations is in this state today.
|
|
164
|
+
for (const ceiling of [undefined, null, [], "junk", 0]) {
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
permissionsOutsideModules(ceiling, ["anything:at-all", "console:whatever"]),
|
|
167
|
+
[],
|
|
168
|
+
String(ceiling),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a grant outside the ceiling is refused, by name", () => {
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
permissionsOutsideModules(
|
|
176
|
+
["visitor-mgmt"],
|
|
177
|
+
["visitor-mgmt:add-visitor", "nfc-patrol:see-all-patrols"],
|
|
178
|
+
),
|
|
179
|
+
["nfc-patrol:see-all-patrols"],
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a grant inside the ceiling is allowed", () => {
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
permissionsOutsideModules(
|
|
186
|
+
["visitor-mgmt", "nfc-patrol"],
|
|
187
|
+
["visitor-mgmt:add-visitor", "nfc-patrol:see-all-patrols"],
|
|
188
|
+
),
|
|
189
|
+
[],
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("ONLY what the write ADDS is tested - a role keeps what it already holds", () => {
|
|
194
|
+
/*
|
|
195
|
+
* This is what makes the guard safe. A role granted `nfc-patrol` before the
|
|
196
|
+
* module list existed can still be renamed, and can still have a DIFFERENT
|
|
197
|
+
* permission removed, without this refusing the write. Refusing those would
|
|
198
|
+
* turn a narrowing into an outage for every role that predates it - the
|
|
199
|
+
* 2026-09-07 shape, designed out.
|
|
200
|
+
*/
|
|
201
|
+
const previous = ["nfc-patrol:see-all-patrols"];
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
permissionsOutsideModules(["visitor-mgmt"], previous, previous),
|
|
204
|
+
[],
|
|
205
|
+
);
|
|
206
|
+
// Removing something it holds is still allowed.
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
permissionsOutsideModules(["visitor-mgmt"], [], previous),
|
|
209
|
+
[],
|
|
210
|
+
);
|
|
211
|
+
// CONTROL: adding a NEW out-of-ceiling string alongside is still refused.
|
|
212
|
+
assert.deepEqual(
|
|
213
|
+
permissionsOutsideModules(
|
|
214
|
+
["visitor-mgmt"],
|
|
215
|
+
[...previous, "pool-mgmt:see-all"],
|
|
216
|
+
previous,
|
|
217
|
+
),
|
|
218
|
+
["pool-mgmt:see-all"],
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("both spellings of EVERYTHING are never refused", () => {
|
|
223
|
+
// An empty list and `["*"]` both mean everything and cannot name a module.
|
|
224
|
+
assert.deepEqual(permissionsOutsideModules(["visitor-mgmt"], []), []);
|
|
225
|
+
assert.deepEqual(permissionsOutsideModules(["visitor-mgmt"], ["*"]), []);
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
permissionsOutsideModules(["visitor-mgmt"], ["*", "pool-mgmt:see-all"]),
|
|
228
|
+
[],
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("a string with no readable resource fails OPEN", () => {
|
|
233
|
+
// Four spellings of "work orders" are live. It is not this guard's job to
|
|
234
|
+
// reject a spelling, and a role write that refuses one is an outage.
|
|
235
|
+
assert.deepEqual(
|
|
236
|
+
permissionsOutsideModules(["visitor-mgmt"], [":no-resource", "", " "]),
|
|
237
|
+
[],
|
|
238
|
+
);
|
|
239
|
+
for (const junk of [null, undefined, 7, {}]) {
|
|
240
|
+
assert.deepEqual(permissionsOutsideModules(["visitor-mgmt"], [junk]), [], String(junk));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("the refusal throws, names the strings, and says what to do", () => {
|
|
245
|
+
assert.doesNotThrow(() =>
|
|
246
|
+
requireWithinModules(["visitor-mgmt"], ["visitor-mgmt:add-visitor"]),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
let thrown;
|
|
250
|
+
try {
|
|
251
|
+
requireWithinModules(["visitor-mgmt"], ["pool-mgmt:see-all"]);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
thrown = error;
|
|
254
|
+
}
|
|
255
|
+
assert.ok(thrown, "an out-of-ceiling grant was allowed through");
|
|
256
|
+
assert.match(thrown.message, /pool-mgmt:see-all/);
|
|
257
|
+
assert.match(thrown.message, /Ask Seven365/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/* ── the four write paths ──────────────────────────────────────────────── */
|
|
261
|
+
|
|
262
|
+
const ROLE_CONTROLLER = readFileSync(
|
|
263
|
+
new URL("../src/controllers/role.controller.ts", import.meta.url),
|
|
264
|
+
"utf8",
|
|
265
|
+
);
|
|
266
|
+
const ROLE_V2_CONTROLLER = readFileSync(
|
|
267
|
+
new URL("../src/controllers/role-v2.controller.ts", import.meta.url),
|
|
268
|
+
"utf8",
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
test("every role write path takes the ceiling, /v2 included", () => {
|
|
272
|
+
/*
|
|
273
|
+
* `/api/roles/v2` is a separate mount onto a separate controller, so gating
|
|
274
|
+
* one and not the other leaves the rule reachable by adding `/v2` to the URL
|
|
275
|
+
* - the reasoning already written down for the platform-grant gate in that
|
|
276
|
+
* same file. And v2 is the path this requirement is really about: it REQUIRES
|
|
277
|
+
* a site, so it is where site roles are created.
|
|
278
|
+
*/
|
|
279
|
+
assert.equal(
|
|
280
|
+
(ROLE_CONTROLLER.match(/requireWithinModules\(/g) ?? []).length,
|
|
281
|
+
3,
|
|
282
|
+
"role.controller has three write paths: create, update, updatePermissionsById",
|
|
283
|
+
);
|
|
284
|
+
assert.equal((ROLE_V2_CONTROLLER.match(/requireWithinModules\(/g) ?? []).length, 1);
|
|
285
|
+
|
|
286
|
+
// CONTROL: the established platform gate is still on all three, so the count
|
|
287
|
+
// above is a real number and not a coincidence.
|
|
288
|
+
assert.equal((ROLE_CONTROLLER.match(/requireNoPlatformGrant\(/g) ?? []).length, 3);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("the two EDIT paths pass the existing permissions, so only additions are tested", () => {
|
|
292
|
+
// Without the third argument every legacy grant would be re-tested on every
|
|
293
|
+
// edit, and a rename would start failing.
|
|
294
|
+
const flat = ROLE_CONTROLLER.replace(/\s+/g, " ");
|
|
295
|
+
const CALL =
|
|
296
|
+
"requireWithinModules( await moduleCeiling((existing as any)?.org, " +
|
|
297
|
+
"(existing as any)?.site), permissions, (existing as any)?.permissions, );";
|
|
298
|
+
|
|
299
|
+
assert.equal(flat.split(CALL).length - 1, 2);
|
|
300
|
+
});
|