@7365admin1/core 3.61.0 → 3.62.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.
- package/CHANGELOG.md +11 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +4573 -4488
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4573 -4488
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/backfill-org-owner-roles.test.mjs +25 -11
- package/test/console-permission.test.mjs +37 -5
- package/test/org-default-roles.test.mjs +82 -39
- package/test/org-modules.test.mjs +162 -0
- package/test/org-role-selfheal.test.mjs +24 -16
- package/test/repair-client-template-permissions.test.mjs +331 -0
- package/test/staff-console-authz.test.mjs +22 -2
- package/tools/backfill-org-owner-roles/backfill.mjs +32 -2
- package/tools/repair-client-template-permissions/devtools-snippet.js +163 -0
- package/tools/repair-client-template-permissions/repair.mjs +329 -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.62.0",
|
|
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/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/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
|
},
|
|
@@ -58,16 +58,29 @@ test("an organisation with >= 1 role is skipped, with no exceptions", () => {
|
|
|
58
58
|
|
|
59
59
|
// ── 3. creates exactly two, and exactly the paid path's shape ───────────────
|
|
60
60
|
|
|
61
|
-
test("a role-less organisation gets exactly the
|
|
61
|
+
test("a role-less organisation gets exactly the roles defaultOwnerRoles writes", () => {
|
|
62
62
|
for (const nature of APP_SERVICE_TYPES) {
|
|
63
63
|
const { payloads } = planForOrg(org({ nature }), 0);
|
|
64
64
|
const expected = defaultOwnerRoles(ORG, nature);
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Four now, not two. The tool duplicates the permission list so it runs
|
|
67
|
+
// with no build step, and THIS is the assertion that stops the copy
|
|
68
|
+
// drifting from `ADMINISTRATOR_PERMISSIONS`: it compares string for string.
|
|
69
|
+
assert.equal(payloads.length, 4, `${nature} must plan four roles`);
|
|
70
|
+
assert.equal(expected.length, 4, `${nature}: defaultOwnerRoles must write four`);
|
|
68
71
|
assert.deepEqual(payloads.map(apiShape), expected.map(apiShape), nature);
|
|
69
|
-
assert.deepEqual(
|
|
70
|
-
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
payloads.map((p) => p.type),
|
|
74
|
+
["organization", "organization", nature, nature],
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(payloads.map((p) => p.name), [
|
|
77
|
+
"owner", "Administrator", "owner", "Administrator",
|
|
78
|
+
]);
|
|
79
|
+
// The owner roles stay `["*"]`. That is what makes an incomplete
|
|
80
|
+
// Administrator enumeration harmless: a role granting everything is still
|
|
81
|
+
// there, so nobody can be locked out of anything by this.
|
|
82
|
+
assert.deepEqual(payloads[0].permissions, ["*"]);
|
|
83
|
+
assert.deepEqual(payloads[2].permissions, ["*"]);
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
// POSITIVE CONTROL: a wrong shape must be caught. `["*"]` is the whole point
|
|
@@ -80,11 +93,12 @@ test("a role-less organisation gets exactly the two roles defaultOwnerRoles writ
|
|
|
80
93
|
);
|
|
81
94
|
});
|
|
82
95
|
|
|
83
|
-
test("a nature no app serves gets only the organization-typed
|
|
96
|
+
test("a nature no app serves gets only the organization-typed roles", () => {
|
|
84
97
|
for (const nature of ["property_owner", "", null, undefined, "SECURITY_AGENCY"]) {
|
|
85
98
|
const { payloads } = planForOrg(org({ nature }), 0);
|
|
86
99
|
assert.deepEqual(payloads.map(apiShape), defaultOwnerRoles(ORG, nature).map(apiShape), String(nature));
|
|
87
|
-
assert.equal(payloads.length,
|
|
100
|
+
assert.equal(payloads.length, 2);
|
|
101
|
+
assert.deepEqual(payloads.map((p) => p.type), ["organization", "organization"]);
|
|
88
102
|
}
|
|
89
103
|
});
|
|
90
104
|
|
|
@@ -123,7 +137,7 @@ test("never emits a role without a 24-hex organisation", () => {
|
|
|
123
137
|
// ever. Prove a GOOD id still plans, and that every payload it plans really
|
|
124
138
|
// does carry the org.
|
|
125
139
|
const { payloads } = planForOrg(org(), 0);
|
|
126
|
-
assert.equal(payloads.length,
|
|
140
|
+
assert.equal(payloads.length, 4);
|
|
127
141
|
assert.ok(payloads.every((p) => p.org === ORG));
|
|
128
142
|
});
|
|
129
143
|
|
|
@@ -135,18 +149,18 @@ test("a second run over the same organisations plans nothing", () => {
|
|
|
135
149
|
// Run 1: nothing exists, so everything is planned.
|
|
136
150
|
const first = orgs.map((o) => planForOrg(o, 0));
|
|
137
151
|
const writtenPerOrg = first.map((p) => p.payloads.length);
|
|
138
|
-
assert.ok(writtenPerOrg.every((n) => n ===
|
|
152
|
+
assert.ok(writtenPerOrg.every((n) => n === 4));
|
|
139
153
|
|
|
140
154
|
// Run 2: each organisation now holds what run 1 wrote.
|
|
141
155
|
const second = orgs.map((o, i) => planForOrg(o, writtenPerOrg[i]));
|
|
142
156
|
assert.ok(second.every((p) => p.payloads === undefined), "run 2 must plan nothing");
|
|
143
157
|
|
|
144
|
-
// A run that died mid-
|
|
158
|
+
// A run that died mid-set leaves fewer than four. The skip rule protects it -- it
|
|
145
159
|
// is not silently completed, it is reported for a human. Assert that, so the
|
|
146
160
|
// behaviour is a decision and not an accident.
|
|
147
161
|
assert.equal(planForOrg(org(), 1).payloads, undefined);
|
|
148
162
|
|
|
149
163
|
// POSITIVE CONTROL: run 2 is only meaningfully empty because run 1 was not.
|
|
150
|
-
assert.equal(first.reduce((n, p) => n + p.payloads.length, 0), APP_SERVICE_TYPES.length *
|
|
164
|
+
assert.equal(first.reduce((n, p) => n + p.payloads.length, 0), APP_SERVICE_TYPES.length * 4);
|
|
151
165
|
assert.equal(second.reduce((n, p) => n + (p.payloads?.length ?? 0), 0), 0);
|
|
152
166
|
});
|
|
@@ -182,14 +182,39 @@ function gateCalls() {
|
|
|
182
182
|
const calls = [];
|
|
183
183
|
for (const name of CONTROLLERS) {
|
|
184
184
|
const text = read(`controllers/${name}.controller.ts`);
|
|
185
|
-
for (const
|
|
185
|
+
for (const match of text.matchAll(GATE)) {
|
|
186
|
+
let call = match[0];
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* `GATE` stops at the newline, which is right for the one-line form and
|
|
190
|
+
* loses everything for the other one. A gate whose grant object is long
|
|
191
|
+
* enough for prettier to break it is written as
|
|
192
|
+
* `requirePlatformOwner(req, {` and then three more lines - and the
|
|
193
|
+
* captured text is just `requirePlatformOwner(req, {`, which names no
|
|
194
|
+
* module. So the check below reported as UNGOVERNED the very call that is
|
|
195
|
+
* governed most tightly.
|
|
196
|
+
*
|
|
197
|
+
* Widening `GATE` itself to run to the first `);` is not the fix: it
|
|
198
|
+
* merges adjacent single-line calls and the site count drops from 45 to
|
|
199
|
+
* 41. Only a call left OPEN at the end of its line is continued, and only
|
|
200
|
+
* as far as the `);` that closes it.
|
|
201
|
+
*/
|
|
202
|
+
if (/[({,]\s*$/.test(call)) {
|
|
203
|
+
const rest = text.slice(match.index + call.length);
|
|
204
|
+
const end = rest.indexOf(");");
|
|
205
|
+
if (end !== -1) call += rest.slice(0, end + 2);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
calls.push([name, call]);
|
|
209
|
+
}
|
|
186
210
|
}
|
|
187
211
|
return calls;
|
|
188
212
|
}
|
|
189
213
|
|
|
190
|
-
test("every console gate call site names a module - all
|
|
214
|
+
test("every console gate call site names a module - all 46 of them", () => {
|
|
191
215
|
const calls = gateCalls();
|
|
192
|
-
|
|
216
|
+
// 46 since `organization.controller.ts updateModules` was added.
|
|
217
|
+
assert.equal(calls.length, 46, `gate call sites moved: ${calls.length}`);
|
|
193
218
|
|
|
194
219
|
const ungoverned = calls.filter(
|
|
195
220
|
([, call]) =>
|
|
@@ -206,8 +231,15 @@ test("every module string a controller names is one the catalogue can grant", ()
|
|
|
206
231
|
const direct = call.match(
|
|
207
232
|
/requireConsolePermission\(req, "([^"]+)"(?:, "([^"]+)")?/,
|
|
208
233
|
);
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
// The object form is written across lines by prettier when the strings are
|
|
235
|
+
// long (`platform-terms` / `edit-platform-terms`), so this must not assume
|
|
236
|
+
// `resource` and `action` sit on one line with a comma between them.
|
|
237
|
+
const object = [
|
|
238
|
+
call.match(/resource: "([^"]+)"/)?.[1],
|
|
239
|
+
call.match(/action: "([^"]+)"/)?.[1],
|
|
240
|
+
];
|
|
241
|
+
const objectMatch = object[0] ? [null, object[0], object[1]] : null;
|
|
242
|
+
const [, resource, action] = direct ?? objectMatch ?? [];
|
|
211
243
|
assert.ok(resource, `${name}: ${call}`);
|
|
212
244
|
assert.ok(CONSOLE_PERMISSIONS[resource], `${name}: unknown resource ${resource}`);
|
|
213
245
|
if (action) {
|
|
@@ -38,62 +38,97 @@ import {
|
|
|
38
38
|
|
|
39
39
|
const ORG = "68b0c1d2e3f4a5b6c7d8e9f0";
|
|
40
40
|
|
|
41
|
-
test("a new organisation
|
|
41
|
+
test("a new organisation gets the paid signup's owner pair AND an Administrator pair", () => {
|
|
42
42
|
const roles = defaultOwnerRoles(ORG, "security_agency");
|
|
43
43
|
|
|
44
|
-
assert.equal(roles.length,
|
|
44
|
+
assert.equal(roles.length, 4, "the two owner roles, plus an Administrator of each type");
|
|
45
45
|
|
|
46
46
|
for (const role of roles) {
|
|
47
|
-
assert.equal(role.
|
|
48
|
-
assert.deepEqual(role.permissions, ["*"]);
|
|
49
|
-
assert.equal(role.default, true);
|
|
47
|
+
assert.equal(role.default, true, "seeded rows must not tick the onboarding checklist");
|
|
50
48
|
assert.equal(role.org, ORG, "the role is scoped to the NEW organisation");
|
|
51
49
|
}
|
|
52
50
|
|
|
51
|
+
const owners = roles.filter((r) => r.name === "owner");
|
|
52
|
+
const admins = roles.filter((r) => r.name === "Administrator");
|
|
53
|
+
|
|
54
|
+
assert.equal(owners.length, 2, "the owner pair subscription.service.ts writes is unchanged");
|
|
55
|
+
assert.equal(admins.length, 2);
|
|
56
|
+
|
|
57
|
+
for (const role of owners) assert.deepEqual(role.permissions, ["*"]);
|
|
58
|
+
|
|
53
59
|
assert.deepEqual(
|
|
54
|
-
|
|
60
|
+
owners.map((r) => r.type).sort(),
|
|
55
61
|
["organization", "security_agency"],
|
|
56
62
|
"one organisation role and one typed by the organisation's nature",
|
|
57
63
|
);
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
admins.map((r) => r.type).sort(),
|
|
66
|
+
["organization", "security_agency"],
|
|
67
|
+
"the Administrator has to exist in BOTH type namespaces or it is invisible " +
|
|
68
|
+
"on the vertical app's member screen, which asks type=<membership type>",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
for (const role of admins) {
|
|
72
|
+
assert.equal(role.permissions.length, 13);
|
|
73
|
+
// The cluster 73-82% of live client roles already carry: run your own
|
|
74
|
+
// people. Canonical spellings, so each renders as a ticked box.
|
|
75
|
+
assert.ok(role.permissions.includes("members:view-members"));
|
|
76
|
+
assert.ok(role.permissions.includes("invitations:create-invitation"));
|
|
77
|
+
assert.ok(role.permissions.includes("roles:see-all-roles"));
|
|
78
|
+
// Never an operational module -- those differ per service and the server
|
|
79
|
+
// has no vertical catalogue to enumerate them from.
|
|
80
|
+
assert.ok(!role.permissions.some((perm) => perm.startsWith("visitor")));
|
|
81
|
+
assert.ok(!role.permissions.includes("*"), "an Administrator is not an owner");
|
|
82
|
+
}
|
|
58
83
|
});
|
|
59
84
|
|
|
60
|
-
test("the
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
test("the paid signup path uses this helper rather than a copy of it", () => {
|
|
86
|
+
/*
|
|
87
|
+
* This test used to compare the helper against a hand-written block inside
|
|
88
|
+
* `createOrgSubscription`, counting `name: "owner"` and `permissions: ["*"]`
|
|
89
|
+
* in the source text. It was there because the paid path was the only
|
|
90
|
+
* default-role story that had ever run in production, and the two had to
|
|
91
|
+
* agree.
|
|
92
|
+
*
|
|
93
|
+
* Text-matching two definitions is the weaker half of that idea, and it broke
|
|
94
|
+
* the moment a THIRD role was added: the copy would have kept seeding two.
|
|
95
|
+
* The paid path now calls `defaultOwnerRoles`, so they cannot disagree at
|
|
96
|
+
* all, and this asserts the thing that actually guarantees it.
|
|
97
|
+
*/
|
|
64
98
|
const source = readFileSync(
|
|
65
99
|
new URL("../src/services/subscription.service.ts", import.meta.url),
|
|
66
100
|
"utf8",
|
|
67
101
|
);
|
|
68
102
|
|
|
69
|
-
|
|
70
|
-
source.
|
|
71
|
-
|
|
103
|
+
assert.ok(
|
|
104
|
+
source.includes('import { defaultOwnerRoles } from "../utils/org-default-roles.util"'),
|
|
105
|
+
"the paid path must import the shared helper",
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
source.includes("defaultOwnerRoles(org, value.organization.nature)"),
|
|
109
|
+
"the paid path must ASK the helper for the roles, not describe its own",
|
|
72
110
|
);
|
|
73
111
|
|
|
74
|
-
|
|
112
|
+
// The membership written straight after is pointed at the FIRST role seeded,
|
|
113
|
+
// so the helper's order is part of the contract, not an incidental detail.
|
|
75
114
|
assert.equal(
|
|
76
|
-
(
|
|
77
|
-
|
|
78
|
-
"the
|
|
115
|
+
defaultOwnerRoles(ORG, "security_agency")[0].type,
|
|
116
|
+
"organization",
|
|
117
|
+
"the first seeded role must be the organisation-typed owner",
|
|
79
118
|
);
|
|
80
|
-
assert.equal(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
119
|
+
assert.equal(defaultOwnerRoles(ORG, "security_agency")[0].name, "owner");
|
|
120
|
+
|
|
121
|
+
// POSITIVE CONTROL: the old inline shape must really be gone, or this test
|
|
122
|
+
// would pass while a second definition quietly survived next to the call.
|
|
123
|
+
const paidBlock = source.slice(
|
|
124
|
+
source.indexOf("const seeded = defaultOwnerRoles("),
|
|
125
|
+
source.indexOf("await addMember("),
|
|
84
126
|
);
|
|
127
|
+
assert.ok(paidBlock.length > 0, "found the seeding block");
|
|
85
128
|
assert.equal(
|
|
86
|
-
(
|
|
87
|
-
|
|
88
|
-
"
|
|
89
|
-
);
|
|
90
|
-
assert.ok(
|
|
91
|
-
seeding.includes('type: "organization"'),
|
|
92
|
-
"the paid path still seeds an organisation-typed role",
|
|
93
|
-
);
|
|
94
|
-
assert.ok(
|
|
95
|
-
seeding.includes("type: value.organization.nature"),
|
|
96
|
-
"the paid path still seeds a nature-typed role",
|
|
129
|
+
(paidBlock.match(/name: "owner"/g) ?? []).length,
|
|
130
|
+
0,
|
|
131
|
+
"no hand-written role literal may remain beside the helper call",
|
|
97
132
|
);
|
|
98
133
|
});
|
|
99
134
|
|
|
@@ -103,9 +138,12 @@ test("a nature that is not an app seeds only the organisation role", () => {
|
|
|
103
138
|
// can ever ask for.
|
|
104
139
|
const roles = defaultOwnerRoles(ORG, "property_owner");
|
|
105
140
|
|
|
106
|
-
assert.equal(roles.length,
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
assert.equal(roles.length, 2, "the organisation-typed owner and Administrator, and no more");
|
|
142
|
+
for (const role of roles) {
|
|
143
|
+
assert.equal(role.type, "organization");
|
|
144
|
+
assert.equal(role.org, ORG);
|
|
145
|
+
}
|
|
146
|
+
assert.deepEqual(roles.map((r) => r.name), ["owner", "Administrator"]);
|
|
109
147
|
});
|
|
110
148
|
|
|
111
149
|
test("it can never produce an org-less shared template", () => {
|
|
@@ -125,7 +163,7 @@ test("an ObjectId-like org is accepted and kept", () => {
|
|
|
125
163
|
const oid = { toString: () => ORG };
|
|
126
164
|
const roles = defaultOwnerRoles(oid, "cleaning_services");
|
|
127
165
|
|
|
128
|
-
assert.equal(roles.length,
|
|
166
|
+
assert.equal(roles.length, 4);
|
|
129
167
|
for (const role of roles) assert.equal(role.org, oid);
|
|
130
168
|
});
|
|
131
169
|
|
|
@@ -162,12 +200,17 @@ test("every seeded role passes the 3.56.0 separation guard", () => {
|
|
|
162
200
|
}
|
|
163
201
|
});
|
|
164
202
|
|
|
165
|
-
test("seeding writes
|
|
203
|
+
test("seeding writes every role through the repository it is handed", async () => {
|
|
166
204
|
const written = [];
|
|
167
205
|
await seedDefaultOrgRoles(async (role) => written.push(role), ORG, "security_agency");
|
|
168
206
|
|
|
169
|
-
assert.equal(written.length,
|
|
170
|
-
assert.deepEqual(written.map((r) => r.type).sort(), [
|
|
207
|
+
assert.equal(written.length, 4);
|
|
208
|
+
assert.deepEqual(written.map((r) => r.type).sort(), [
|
|
209
|
+
"organization", "organization", "security_agency", "security_agency",
|
|
210
|
+
]);
|
|
211
|
+
assert.deepEqual(written.map((r) => r.name), [
|
|
212
|
+
"owner", "Administrator", "owner", "Administrator",
|
|
213
|
+
]);
|
|
171
214
|
});
|
|
172
215
|
|
|
173
216
|
test("a seeding failure never fails the request that created the organisation", async () => {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHICH MODULES SEVEN365 HAS GIVEN A CLIENT.
|
|
3
|
+
*
|
|
4
|
+
* Owner brief 2026-09-09: "Seven365 will give the modules that they only need
|
|
5
|
+
* on the application/services that they want." Nothing expressed that - a plan
|
|
6
|
+
* is a name and a price, and which modules a client reaches is decided by which
|
|
7
|
+
* app somebody logs into. So every client's role editor offers the same
|
|
8
|
+
* 127-entry catalogue whether they bought one service or seven.
|
|
9
|
+
*
|
|
10
|
+
* The whole risk in this feature is the DEFAULT. Every one of the 168 live
|
|
11
|
+
* organisations has no `modules` field, so a reading of "absent means nothing"
|
|
12
|
+
* empties the role catalogue for all of them on deploy. Most of what follows is
|
|
13
|
+
* about absent, empty, and every other way this could fail closed when it must
|
|
14
|
+
* fail open.
|
|
15
|
+
*/
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
normaliseModules,
|
|
22
|
+
orgAllowsModule,
|
|
23
|
+
catalogueForOrg,
|
|
24
|
+
MODULE_KEY,
|
|
25
|
+
} from "./.build/utils/org-modules.util.mjs";
|
|
26
|
+
|
|
27
|
+
const CATALOGUE = {
|
|
28
|
+
"visitor-mgmt": { "see-all-visitor": {}, "add-visitor": {} },
|
|
29
|
+
"facility-mgmt": { "add-facility": {} },
|
|
30
|
+
"nfc-patrol": { "see-all-patrols": {} },
|
|
31
|
+
workOrder: { "create-work-order": {} },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/* ── absent and empty mean EVERYTHING ─────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
test("absent, empty, null and a non-array all mean EVERYTHING", () => {
|
|
37
|
+
// If any one of these meant "nothing", deploying this would empty the role
|
|
38
|
+
// catalogue for all 168 live organisations at once.
|
|
39
|
+
for (const modules of [undefined, null, [], "visitor-mgmt", 0, {}, NaN]) {
|
|
40
|
+
assert.equal(orgAllowsModule(modules, "visitor-mgmt"), true, String(modules));
|
|
41
|
+
assert.equal(
|
|
42
|
+
catalogueForOrg(CATALOGUE, modules),
|
|
43
|
+
CATALOGUE,
|
|
44
|
+
"the catalogue must come back UNCHANGED, not a rebuilt copy",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a list that matches nothing shows the WHOLE catalogue, not an empty one", () => {
|
|
50
|
+
/*
|
|
51
|
+
* A blank role editor is indistinguishable from a broken screen, and the
|
|
52
|
+
* likeliest cause of matching nothing is a console typo or a catalogue whose
|
|
53
|
+
* resource keys were renamed. Neither should take the editor away.
|
|
54
|
+
*/
|
|
55
|
+
const narrowed = catalogueForOrg(CATALOGUE, ["not-a-real-module", "also-not"]);
|
|
56
|
+
assert.deepEqual(Object.keys(narrowed).sort(), Object.keys(CATALOGUE).sort());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an unreadable resource key is shown, never hidden", () => {
|
|
60
|
+
for (const resource of [undefined, null, "", " ", 42]) {
|
|
61
|
+
assert.equal(orgAllowsModule(["visitor-mgmt"], resource), true, String(resource));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/* ── what it actually narrows ─────────────────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
test("a real list narrows the catalogue to exactly those modules", () => {
|
|
68
|
+
const narrowed = catalogueForOrg(CATALOGUE, ["visitor-mgmt", "workOrder"]);
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(Object.keys(narrowed).sort(), ["visitor-mgmt", "workOrder"]);
|
|
71
|
+
// The actions inside a kept module are untouched, not rebuilt.
|
|
72
|
+
assert.equal(narrowed["visitor-mgmt"], CATALOGUE["visitor-mgmt"]);
|
|
73
|
+
// CONTROL: the assertion can fail - the dropped ones really were in there.
|
|
74
|
+
assert.ok(CATALOGUE["facility-mgmt"]);
|
|
75
|
+
assert.equal(narrowed["facility-mgmt"], undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("matching is exact, so a prefix cannot pull in a sibling module", () => {
|
|
79
|
+
// `visitor-mgmt` must not admit `visitor-mgmt-extra`, and `nfc` must not
|
|
80
|
+
// admit `nfc-patrol`. A loose match here quietly widens what Seven365 sold.
|
|
81
|
+
assert.equal(orgAllowsModule(["visitor-mgmt"], "visitor-mgmt-extra"), false);
|
|
82
|
+
assert.equal(orgAllowsModule(["nfc"], "nfc-patrol"), false);
|
|
83
|
+
assert.equal(orgAllowsModule(["nfc-patrol"], "nfc-patrol"), true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/* ── the shape it accepts ─────────────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
test("normalise trims, de-duplicates and keeps order", () => {
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
normaliseModules([" visitor-mgmt ", "workOrder", "visitor-mgmt", "workOrder"]),
|
|
91
|
+
["visitor-mgmt", "workOrder"],
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("normalise drops anything that is not a module key", () => {
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
normaliseModules(["visitor-mgmt", "", " ", null, 7, {}, "has space", "-leading"]),
|
|
98
|
+
["visitor-mgmt"],
|
|
99
|
+
);
|
|
100
|
+
assert.deepEqual(normaliseModules(null), []);
|
|
101
|
+
assert.deepEqual(normaliseModules("visitor-mgmt"), []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the key shape accepts every spelling the real catalogues use", () => {
|
|
105
|
+
// Taken from the shipped catalogues: kebab, camel and snake all occur.
|
|
106
|
+
for (const key of [
|
|
107
|
+
"visitor-mgmt", "workOrder", "work_orders", "access-card-mgmt",
|
|
108
|
+
"nfc-patrol", "blocklist", "SOA-mgmt", "roles", "members",
|
|
109
|
+
]) {
|
|
110
|
+
assert.ok(MODULE_KEY.test(key), key);
|
|
111
|
+
}
|
|
112
|
+
for (const bad of ["", " ", "-starts-with-dash", "has space", "a".repeat(65)]) {
|
|
113
|
+
assert.ok(!MODULE_KEY.test(bad), bad);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/* ── the endpoint, and the one it must NOT be part of ─────────────────── */
|
|
118
|
+
|
|
119
|
+
const CONTROLLER = readFileSync(
|
|
120
|
+
new URL("../src/controllers/organization.controller.ts", import.meta.url),
|
|
121
|
+
"utf8",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
function handler(name) {
|
|
125
|
+
const start = CONTROLLER.indexOf(`async function ${name}(`);
|
|
126
|
+
assert.notEqual(start, -1, `handler ${name} is gone`);
|
|
127
|
+
const next = CONTROLLER.indexOf("\n async function ", start + 1);
|
|
128
|
+
return CONTROLLER.slice(start, next === -1 ? undefined : next);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test("setting a client's modules takes the CONSOLE gate", () => {
|
|
132
|
+
const body = handler("updateModules");
|
|
133
|
+
assert.match(body, /await requireConsolePermission\(req, "organizations"\)/);
|
|
134
|
+
// The write must name only `modules`. Anything else and this becomes a second
|
|
135
|
+
// way to edit an organisation, with a different gate from the first.
|
|
136
|
+
assert.match(body, /_update\(orgId, \{ modules \}\)/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("the CLIENT-reachable update still cannot set modules", () => {
|
|
140
|
+
/*
|
|
141
|
+
* `update` is `requireOrgReach` on purpose - a new owner fills in their own
|
|
142
|
+
* organisation during onboarding before any membership exists. If `modules`
|
|
143
|
+
* were in ITS whitelist, a client could decide its own entitlements by
|
|
144
|
+
* editing its own organisation. This is the assertion that keeps the two
|
|
145
|
+
* endpoints apart.
|
|
146
|
+
*/
|
|
147
|
+
const body = handler("update");
|
|
148
|
+
assert.match(body, /await requireOrgReach\(req, id\)/, "control: still the client path");
|
|
149
|
+
assert.ok(
|
|
150
|
+
!/modules/.test(body),
|
|
151
|
+
"the client-reachable update must not mention modules at all",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("the change is recorded, with the list readable in the row", () => {
|
|
156
|
+
const body = handler("updateModules");
|
|
157
|
+
assert.match(body, /ConsoleAuditAction\.CLIENT_MODULES_CHANGED/);
|
|
158
|
+
// An ARRAY is dropped by `auditValue`, which would leave a row saying a change
|
|
159
|
+
// happened without saying what to. It is joined into a string on purpose.
|
|
160
|
+
assert.match(body, /modules: modules\.join\(", "\)/);
|
|
161
|
+
assert.match(body, /moduleCount: modules\.length/);
|
|
162
|
+
});
|
|
@@ -96,25 +96,30 @@ function fakeWorld({
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
test("an organisation holding zero roles gets the same
|
|
99
|
+
test("an organisation holding zero roles gets the same set a new one gets", async () => {
|
|
100
100
|
const world = fakeWorld();
|
|
101
101
|
|
|
102
102
|
const seeded = await healOrgDefaultRoles(world.deps, ORG);
|
|
103
103
|
|
|
104
|
-
assert.equal(seeded,
|
|
105
|
-
assert.equal(world.written.length,
|
|
104
|
+
assert.equal(seeded, 4, "the paid signup's owner pair, plus an Administrator of each type");
|
|
105
|
+
assert.equal(world.written.length, 4);
|
|
106
|
+
assert.deepEqual(world.written.map((r) => r.name), [
|
|
107
|
+
"owner", "Administrator", "owner", "Administrator",
|
|
108
|
+
]);
|
|
106
109
|
|
|
107
|
-
for (const role of world.written) {
|
|
108
|
-
assert.equal(role.name, "owner");
|
|
110
|
+
for (const role of world.written.filter((r) => r.name === "owner")) {
|
|
109
111
|
assert.deepEqual(role.permissions, ["*"]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const role of world.written) {
|
|
110
115
|
assert.equal(role.default, true, "the repository path writes `default: true`");
|
|
111
116
|
assert.equal(role.org, ORG, "every seeded role carries the organisation");
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
assert.deepEqual(
|
|
115
120
|
world.written.map((r) => r.type).sort(),
|
|
116
|
-
["organization", "security_agency"],
|
|
117
|
-
"
|
|
121
|
+
["organization", "organization", "security_agency", "security_agency"],
|
|
122
|
+
"an owner and an Administrator in each of the two type namespaces",
|
|
118
123
|
);
|
|
119
124
|
});
|
|
120
125
|
|
|
@@ -146,9 +151,9 @@ test("the count is re-read UNDER the claim, so a role created meanwhile still st
|
|
|
146
151
|
test("a second load of the same organisation is a no-op", async () => {
|
|
147
152
|
const world = fakeWorld();
|
|
148
153
|
|
|
149
|
-
assert.equal(await healOrgDefaultRoles(world.deps, ORG),
|
|
154
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 4);
|
|
150
155
|
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 0, "the claim is already held");
|
|
151
|
-
assert.equal(world.written.length,
|
|
156
|
+
assert.equal(world.written.length, 4, "still one set, not two");
|
|
152
157
|
});
|
|
153
158
|
|
|
154
159
|
test("two simultaneous loads of the same organisation do not create four roles", async () => {
|
|
@@ -162,10 +167,10 @@ test("two simultaneous loads of the same organisation do not create four roles",
|
|
|
162
167
|
|
|
163
168
|
assert.deepEqual(
|
|
164
169
|
results.sort(),
|
|
165
|
-
[0,
|
|
170
|
+
[0, 4],
|
|
166
171
|
"exactly one caller seeded; the other did nothing at all",
|
|
167
172
|
);
|
|
168
|
-
assert.equal(world.written.length,
|
|
173
|
+
assert.equal(world.written.length, 4, "one set in total, not two");
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
test("ten simultaneous loads still produce exactly one set", async () => {
|
|
@@ -180,7 +185,7 @@ test("ten simultaneous loads still produce exactly one set", async () => {
|
|
|
180
185
|
1,
|
|
181
186
|
"one winner out of ten",
|
|
182
187
|
);
|
|
183
|
-
assert.equal(world.written.length,
|
|
188
|
+
assert.equal(world.written.length, 4);
|
|
184
189
|
});
|
|
185
190
|
|
|
186
191
|
test("it never emits an org-less role", async () => {
|
|
@@ -193,12 +198,15 @@ test("it never emits an org-less role", async () => {
|
|
|
193
198
|
}
|
|
194
199
|
});
|
|
195
200
|
|
|
196
|
-
test("a nature that is not an app produces
|
|
201
|
+
test("a nature that is not an app produces organisation-typed roles only, still org-scoped", async () => {
|
|
197
202
|
const world = fakeWorld({ nature: "property_owner" });
|
|
198
203
|
|
|
199
|
-
assert.equal(await healOrgDefaultRoles(world.deps, ORG),
|
|
200
|
-
assert.
|
|
201
|
-
|
|
204
|
+
assert.equal(await healOrgDefaultRoles(world.deps, ORG), 2);
|
|
205
|
+
assert.deepEqual(world.written.map((r) => r.name), ["owner", "Administrator"]);
|
|
206
|
+
for (const role of world.written) {
|
|
207
|
+
assert.equal(role.type, "organization");
|
|
208
|
+
assert.equal(role.org, ORG);
|
|
209
|
+
}
|
|
202
210
|
});
|
|
203
211
|
|
|
204
212
|
test("an insert that fails does not break the read, and gives the claim back", async () => {
|