@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.61.0",
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 two roles defaultOwnerRoles writes", () => {
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
- assert.equal(payloads.length, 2, `${nature} must plan two roles`);
67
- assert.equal(expected.length, 2, `${nature}: defaultOwnerRoles must write two`);
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(payloads.map((p) => p.type), ["organization", nature]);
70
- assert.deepEqual(payloads.map((p) => p.permissions), [["*"], ["*"]]);
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 role", () => {
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, 1);
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, 2);
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 === 2));
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-pair leaves ONE role. The skip rule protects it -- it
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 * 2);
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 [call] of text.matchAll(GATE)) calls.push([name, call]);
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 45 of them", () => {
214
+ test("every console gate call site names a module - all 46 of them", () => {
191
215
  const calls = gateCalls();
192
- assert.equal(calls.length, 45, `gate call sites moved: ${calls.length}`);
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
- const object = call.match(/resource: "([^"]+)"(?:, action: "([^"]+)")?/);
210
- const [, resource, action] = direct ?? object ?? [];
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 of an app nature gets the same two roles a paid signup gets", () => {
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, 2, "two roles, exactly as subscription.service.ts writes");
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.name, "owner");
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
- roles.map((r) => r.type).sort(),
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 shape matches what subscription.service.ts already writes", () => {
61
- // The paid path is the only default-role story that has ever run in
62
- // production. If somebody changes it, this fails rather than letting the two
63
- // drift into two different definitions of "a new client's owner role".
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
- const seeding = source.slice(
70
- source.indexOf("const role = await addRole("),
71
- source.indexOf("await addMember("),
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
- assert.ok(seeding.length > 0, "found the seeding block in subscription.service.ts");
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
- (seeding.match(/name: "owner"/g) ?? []).length,
77
- 2,
78
- "the paid path still seeds two roles named owner",
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
- (seeding.match(/permissions: \["\*"\]/g) ?? []).length,
82
- 2,
83
- 'the paid path still seeds ["*"] on both',
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
- (seeding.match(/default: true/g) ?? []).length,
87
- 2,
88
- "the paid path still seeds both as default",
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, 1);
107
- assert.equal(roles[0].type, "organization");
108
- assert.equal(roles[0].org, ORG);
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, 2);
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 both roles through the repository it is handed", async () => {
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, 2);
170
- assert.deepEqual(written.map((r) => r.type).sort(), ["organization", "security_agency"]);
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 two roles a new one gets", async () => {
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, 2, "two roles, exactly as a paid signup writes");
105
- assert.equal(world.written.length, 2);
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
- "one organisation role and one typed by the organisation's nature",
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), 2);
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, 2, "still two roles, not four");
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, 2],
170
+ [0, 4],
166
171
  "exactly one caller seeded; the other did nothing at all",
167
172
  );
168
- assert.equal(world.written.length, 2, "two roles in total, not four");
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, 2);
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 the organisation role only, still org-scoped", async () => {
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), 1);
200
- assert.equal(world.written[0].type, "organization");
201
- assert.equal(world.written[0].org, ORG);
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 () => {