@7365admin1/core 3.55.0 → 3.57.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.55.0",
4
+ "version": "3.57.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/invite-role-scope.util.ts src/models/personal-emergency-chain.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/invite-role-scope.util.ts src/models/personal-emergency-chain.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
  },
@@ -651,6 +651,7 @@ function buildApp(nsu, core) {
651
651
  {
652
652
  const {
653
653
  getAll,
654
+ add,
654
655
  addOnboardingOrg,
655
656
  getOrgsByUserId,
656
657
  getByName,
@@ -660,6 +661,7 @@ function buildApp(nsu, core) {
660
661
  update,
661
662
  } = core.useOrgController();
662
663
 
664
+ organizations.post("/", requireAuth, add);
663
665
  organizations.get("/", requireAuth, getAll);
664
666
  organizations.post("/onboarding", requireAuth, addOnboardingOrg);
665
667
  organizations.get("/user/:user", requireAuth, getOrgsByUserId);
@@ -97,9 +97,11 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
97
97
  ownerSid = await h.login(OWNER, PASSWORD);
98
98
 
99
99
  assert.equal(
100
- await h.db.collection("members").countDocuments({ user: id.owner }),
100
+ await h.db
101
+ .collection("members")
102
+ .countDocuments({ user: id.owner, org: id.ownOrg }),
101
103
  0,
102
- "the owner must hold no membership — reach is by `createdBy` (DV-0248)",
104
+ "the owner must hold no membership of THIS organisation — reach is by `createdBy` (DV-0248)",
103
105
  );
104
106
  assert.equal((await ownOrg()).name, "Ben Org");
105
107
  });
@@ -160,6 +162,49 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
160
162
  assert.equal(saved.contact, "+63 9569346664");
161
163
  });
162
164
 
165
+ // ---- the second control: the legacy document shape is NOT the cause -------
166
+
167
+ record("5. control — the legacy production document shape saves unchanged", async () => {
168
+ // The 16 rows a lead read out of the production `organizations` collection
169
+ // carry the LEGACY `organization-api` field set — `contactNumber`, `sites`,
170
+ // `logo`, `createdBySpId` — and `createdAt`, `updatedAt` and `createdBy`
171
+ // are EMPTY STRINGS, not dates and not ObjectIds. The obvious suspicion is
172
+ // that saving over a row like that throws on a type: `createdAt: ""` where
173
+ // a Date is expected, `createdBy: ""` where an ObjectId is.
174
+ //
175
+ // It cannot. This repository talks to the raw `mongodb` driver — there is
176
+ // no Mongoose, no model layer and no cast step between `update()` and the
177
+ // server. `$set` names `name`, `email`, `contact` and `updatedAt` and
178
+ // nothing else, and MongoDB replaces a field wholesale whatever BSON type
179
+ // it held before. The legacy fields are never read, never re-validated and
180
+ // never rewritten.
181
+ //
182
+ // This case is the proof: same modal body, same route, a row in exactly
183
+ // that shape, no unique index on it — 200, and the legacy fields survive
184
+ // untouched.
185
+ const res = await h.api(`/organizations/${id.legacyOrg}`, {
186
+ method: "PUT",
187
+ sid: ownerSid,
188
+ body: modalBody("Legacy Shaped Org Renamed"),
189
+ });
190
+
191
+ assert.equal(res.status, 200, JSON.stringify(res.body));
192
+
193
+ const saved = await h.db
194
+ .collection("organizations")
195
+ .findOne({ _id: id.legacyOrg });
196
+
197
+ assert.equal(saved.name, "Legacy Shaped Org Renamed");
198
+ assert.equal(saved.contact, "+63 9569346664");
199
+ assert.ok(saved.updatedAt instanceof Date, "updatedAt is replaced by a Date");
200
+ // untouched, still exactly as the legacy service left them
201
+ assert.equal(saved.createdAt, "");
202
+ assert.equal(saved.createdBy, "");
203
+ assert.equal(saved.contactNumber, "+65 61234567");
204
+ assert.deepEqual(saved.sites, []);
205
+ assert.equal(saved.createdBySpId, "");
206
+ });
207
+
163
208
  record("4. and the guard is unchanged — no session is still refused", async () => {
164
209
  const res = await h.api(`/organizations/${id.ownOrg}`, {
165
210
  method: "PUT",
@@ -175,6 +220,7 @@ async function seed(h) {
175
220
  const now = new Date().toISOString();
176
221
  const otherOrg = new ObjectId();
177
222
  const ownOrg = new ObjectId();
223
+ const legacyOrg = new ObjectId();
178
224
 
179
225
  const orgs = h.db.collection("organizations");
180
226
 
@@ -202,6 +248,24 @@ async function seed(h) {
202
248
  contact: "+65 00000000",
203
249
  createdAt: now,
204
250
  },
251
+ // A row in the exact shape a lead read out of production: the legacy
252
+ // `organization-api` field set, with the three timestamps/ids as EMPTY
253
+ // STRINGS. Deliberately NOT covered by the unique index above — its name is
254
+ // its own — so case 5 measures the document shape and nothing else.
255
+ {
256
+ _id: legacyOrg,
257
+ name: "Legacy Shaped Org",
258
+ email: "os-legacy@e2e.example.com",
259
+ contactNumber: "+65 61234567",
260
+ description: "",
261
+ sites: [],
262
+ status: "active",
263
+ logo: [],
264
+ createdBySpId: "",
265
+ createdAt: "",
266
+ updatedAt: "",
267
+ createdBy: "",
268
+ },
205
269
  ]);
206
270
 
207
271
  const users = await h.db.collection("users").insertMany([
@@ -216,5 +280,14 @@ async function seed(h) {
216
280
  // holds at step 1.
217
281
  await orgs.updateOne({ _id: ownOrg }, { $set: { createdBy: owner } });
218
282
 
219
- return { owner, ownOrg, otherOrg };
283
+ // The legacy row's `createdBy` is "" — that road cannot reach it, so the
284
+ // owner reaches it the ordinary way, by membership.
285
+ await h.db.collection("members").insertOne({
286
+ user: owner,
287
+ org: legacyOrg,
288
+ type: "organization",
289
+ status: "active",
290
+ });
291
+
292
+ return { owner, ownOrg, otherOrg, legacyOrg };
220
293
  }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * A NEW client organisation gets its own owner roles, and NO existing
3
+ * organisation is touched.
4
+ *
5
+ * ## What this is proving
6
+ *
7
+ * Measured on staging 2026-09-07: **74 of 195 live organisations hold not one
8
+ * role**, and not one of those 74 has a subscription. The paid signup path
9
+ * (`subscription.service.ts:145-165`) has always seeded two `owner` roles with
10
+ * `permissions: ["*"]`; the two paths that actually create clients today —
11
+ * `POST /organizations` from the Seven365 console and `POST
12
+ * /organizations/onboarding` from the client wizard — seeded nothing. An
13
+ * organisation with no role of its own has nothing to invite anybody with,
14
+ * which is why the console offers the ORG-LESS templates instead: the shared
15
+ * documents that one console save took every client's modules away with.
16
+ *
17
+ * The unit half (`test/org-default-roles.test.mjs`) proves the shape. This
18
+ * proves the rows land, over real HTTP, through the real controller, and — the
19
+ * assertion that matters most on the day after an outage — that an
20
+ * organisation that already existed comes out of the run byte-identical.
21
+ *
22
+ * Everything here is created and thrown away by the harness: an in-process
23
+ * MongoDB replica set, a loopback Redis, a loopback mail sink. No staging or
24
+ * production database, Redis, mailbox or endpoint is touched.
25
+ *
26
+ * Run with: yarn test:e2e
27
+ */
28
+
29
+ import { after, before, describe, it } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { ObjectId } from "mongodb";
32
+
33
+ import { startHarness } from "./harness.mjs";
34
+
35
+ const PASSWORD = "OrgDefaultRoles-Passw0rd!";
36
+ const STAFF = "odr-staff@e2e.example.com"; // Seven365 console, creates clients
37
+ const WIZARD = "odr-wizard@e2e.example.com"; // a client walking the wizard
38
+
39
+ const NATURE = "security_agency";
40
+
41
+ describe("a new organisation is seeded with its own owner roles", { concurrency: 1 }, () => {
42
+ let h;
43
+ let id;
44
+ let staffSid;
45
+ let wizardSid;
46
+ const result = [];
47
+
48
+ const record = (name, fn) =>
49
+ it(name, async () => {
50
+ try {
51
+ await fn();
52
+ result.push(["PASS", name]);
53
+ } catch (error) {
54
+ result.push(["FAIL", name]);
55
+ throw error;
56
+ }
57
+ });
58
+
59
+ before(async () => {
60
+ h = await startHarness();
61
+ id = await seed(h);
62
+ staffSid = await h.login(STAFF, PASSWORD);
63
+ wizardSid = await h.login(WIZARD, PASSWORD);
64
+ }, { timeout: 300000 });
65
+
66
+ after(async () => {
67
+ if (h) await h.stop();
68
+ console.log("\n--- org default roles: per-case result ---");
69
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
70
+ });
71
+
72
+ const rolesOf = (org) =>
73
+ h.db.collection("roles").find({ org: new ObjectId(org.toString()) }).toArray();
74
+
75
+ // ---- the console path ---------------------------------------------------
76
+
77
+ record("1. POST /organizations still answers 201 — client creation is not broken", async () => {
78
+ const res = await h.api("/organizations/", {
79
+ method: "POST",
80
+ sid: staffSid,
81
+ body: {
82
+ name: "ODR Console Client",
83
+ type: "org",
84
+ nature: NATURE,
85
+ email: "odr-console@e2e.example.com",
86
+ },
87
+ });
88
+
89
+ assert.equal(res.status, 201, JSON.stringify(res.body));
90
+ });
91
+
92
+ record("2. that organisation now holds the two owner roles a paid signup gets", async () => {
93
+ const org = await h.db
94
+ .collection("organizations")
95
+ .findOne({ name: "ODR Console Client" });
96
+ assert.ok(org, "the organisation was created");
97
+
98
+ const roles = await rolesOf(org._id);
99
+
100
+ assert.equal(roles.length, 2, `expected two roles, got ${JSON.stringify(roles)}`);
101
+ assert.deepEqual(
102
+ roles.map((r) => r.type).sort(),
103
+ ["organization", NATURE],
104
+ "one organisation role and one typed by the organisation's nature",
105
+ );
106
+
107
+ for (const role of roles) {
108
+ assert.equal(role.name, "owner");
109
+ assert.deepEqual(role.permissions, ["*"], '["*"] means everything — nobody is locked out');
110
+ assert.equal(role.default, true);
111
+ assert.equal(role.status, "active");
112
+ }
113
+ });
114
+
115
+ record("3. every seeded role carries THIS organisation's id and no other", async () => {
116
+ // The 2026-09-07 outage was an `org`-less role of `type: "organization"` —
117
+ // one document shared by every client invited with it. If seeding could
118
+ // ever emit one, this fails.
119
+ const org = await h.db
120
+ .collection("organizations")
121
+ .findOne({ name: "ODR Console Client" });
122
+
123
+ for (const role of await rolesOf(org._id)) {
124
+ assert.ok(role.org, "the role has an org");
125
+ assert.equal(role.org.toString(), org._id.toString());
126
+ }
127
+
128
+ const orgless = await h.db
129
+ .collection("roles")
130
+ .find({ $or: [{ org: "" }, { org: null }, { org: { $exists: false } }] })
131
+ .toArray();
132
+
133
+ assert.equal(
134
+ orgless.filter((r) => r.name === "owner").length,
135
+ 0,
136
+ "seeding created no org-less shared template",
137
+ );
138
+ });
139
+
140
+ // ---- the wizard path ----------------------------------------------------
141
+
142
+ record("4. POST /organizations/onboarding seeds the same two roles", async () => {
143
+ const res = await h.api("/organizations/onboarding", {
144
+ method: "POST",
145
+ sid: wizardSid,
146
+ body: {
147
+ name: "ODR Wizard Client",
148
+ type: "org",
149
+ nature: "cleaning_services",
150
+ email: "odr-wizard-org@e2e.example.com",
151
+ },
152
+ });
153
+
154
+ assert.equal(res.status, 201, JSON.stringify(res.body));
155
+
156
+ const org = await h.db
157
+ .collection("organizations")
158
+ .findOne({ name: "ODR Wizard Client" });
159
+ assert.ok(org, "the organisation was created");
160
+
161
+ const roles = await rolesOf(org._id);
162
+ assert.deepEqual(
163
+ roles.map((r) => r.type).sort(),
164
+ ["cleaning_services", "organization"],
165
+ );
166
+ for (const role of roles) {
167
+ assert.equal(role.name, "owner");
168
+ assert.deepEqual(role.permissions, ["*"]);
169
+ assert.equal(role.org.toString(), org._id.toString());
170
+ }
171
+ });
172
+
173
+ record("5. the wizard still gets its organisation back in the response body", async () => {
174
+ // The wizard reads `data._id` from this response to drive step 2. Seeding
175
+ // runs between the insert and that read, so a seeding failure must not be
176
+ // able to blank it.
177
+ const res = await h.api("/organizations/onboarding", {
178
+ method: "POST",
179
+ sid: wizardSid,
180
+ body: {
181
+ name: "ODR Wizard Client Two",
182
+ type: "org",
183
+ nature: "landscaping_services",
184
+ email: "odr-wizard-two@e2e.example.com",
185
+ },
186
+ });
187
+
188
+ assert.equal(res.status, 201, JSON.stringify(res.body));
189
+ assert.ok(res.body?.data?._id, `no organisation in the body: ${JSON.stringify(res.body)}`);
190
+ });
191
+
192
+ // ---- the part that matters most ----------------------------------------
193
+
194
+ record("6. the EXISTING organisation and its roles are byte-identical", async () => {
195
+ const org = await h.db.collection("organizations").findOne({ _id: id.existingOrg });
196
+ assert.deepEqual(
197
+ org,
198
+ id.existingOrgBefore,
199
+ "an organisation that already existed was modified",
200
+ );
201
+
202
+ const roles = await h.db
203
+ .collection("roles")
204
+ .find({ org: id.existingOrg })
205
+ .sort({ _id: 1 })
206
+ .toArray();
207
+
208
+ assert.deepEqual(
209
+ roles,
210
+ id.existingRolesBefore,
211
+ "an existing organisation's roles were modified",
212
+ );
213
+ });
214
+
215
+ record("7. no existing MEMBER was repointed and no role was deleted", async () => {
216
+ const members = await h.db
217
+ .collection("members")
218
+ .find({})
219
+ .sort({ _id: 1 })
220
+ .toArray();
221
+
222
+ assert.deepEqual(members, id.membersBefore, "a members row changed");
223
+
224
+ for (const roleId of id.allRoleIdsBefore) {
225
+ const still = await h.db.collection("roles").findOne({ _id: roleId });
226
+ assert.ok(still, `role ${roleId} disappeared`);
227
+ }
228
+ });
229
+
230
+ record("8. positive control: the run really did create roles", async () => {
231
+ // If cases 6 and 7 passed because nothing happened at all, they prove
232
+ // nothing. This is the count that makes them mean something.
233
+ const after = await h.db.collection("roles").countDocuments();
234
+
235
+ assert.ok(
236
+ after > id.roleCountBefore,
237
+ `roles went from ${id.roleCountBefore} to ${after} — seeding wrote nothing`,
238
+ );
239
+ assert.equal(
240
+ after - id.roleCountBefore,
241
+ 6,
242
+ "three organisations were created, two roles each",
243
+ );
244
+ });
245
+ });
246
+
247
+ async function seed(h) {
248
+ const now = new Date().toISOString();
249
+ const hashed = await h.hashPassword(PASSWORD);
250
+
251
+ const staffRole = new ObjectId();
252
+ const existingOrg = new ObjectId();
253
+ const existingRole = new ObjectId();
254
+ const wizardRole = new ObjectId();
255
+
256
+ await h.db.collection("organizations").insertOne({
257
+ _id: existingOrg,
258
+ name: "ODR Existing Client",
259
+ email: "odr-existing@e2e.example.com",
260
+ type: "org",
261
+ nature: NATURE,
262
+ status: "active",
263
+ createdAt: now,
264
+ });
265
+
266
+ await h.db.collection("roles").insertMany([
267
+ // Seven365 staff: a members row of type "admin" whose role is ALSO type
268
+ // "admin". `["*"]` so the console gate passes and case 1 measures seeding,
269
+ // not authorization.
270
+ { _id: staffRole, name: "Staff Wildcard", type: "admin", status: "active", permissions: ["*"] },
271
+ // The existing client's own role, the one case 6 proves untouched.
272
+ {
273
+ _id: existingRole,
274
+ name: "Existing Org Admin",
275
+ org: existingOrg,
276
+ type: "organization",
277
+ status: "active",
278
+ permissions: ["members:view-member"],
279
+ },
280
+ { _id: wizardRole, name: "Wizard Owner", org: existingOrg, type: NATURE, status: "active", permissions: ["*"] },
281
+ ]);
282
+
283
+ const users = await h.db.collection("users").insertMany([
284
+ { email: STAFF, password: hashed, name: "ODR Staff", status: "active", createdAt: now },
285
+ {
286
+ email: WIZARD,
287
+ password: hashed,
288
+ name: "ODR Wizard User",
289
+ status: "active",
290
+ defaultOrg: existingOrg.toString(),
291
+ createdAt: now,
292
+ },
293
+ ]);
294
+
295
+ await h.db.collection("members").insertMany([
296
+ { user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" },
297
+ {
298
+ user: users.insertedIds[1],
299
+ org: existingOrg,
300
+ type: "organization",
301
+ role: existingRole,
302
+ status: "active",
303
+ },
304
+ ]);
305
+
306
+ return {
307
+ existingOrg,
308
+ existingOrgBefore: await h.db.collection("organizations").findOne({ _id: existingOrg }),
309
+ existingRolesBefore: await h.db
310
+ .collection("roles")
311
+ .find({ org: existingOrg })
312
+ .sort({ _id: 1 })
313
+ .toArray(),
314
+ membersBefore: await h.db.collection("members").find({}).sort({ _id: 1 }).toArray(),
315
+ allRoleIdsBefore: (await h.db.collection("roles").find({}, { projection: { _id: 1 } }).toArray()).map(
316
+ (r) => r._id,
317
+ ),
318
+ roleCountBefore: await h.db.collection("roles").countDocuments(),
319
+ };
320
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The default roles a brand-new organisation gets.
3
+ *
4
+ * Three things are asserted, and each one is a way the 2026-09-07 outage could
5
+ * come back:
6
+ *
7
+ * 1. every seeded role carries the NEW organisation's id. An `org`-less role
8
+ * of a non-admin type is a template shared by every client that was ever
9
+ * invited with it — one of those is what took every client's modules away.
10
+ * A helper that can emit one is the same bug waiting.
11
+ * 2. the permission list is `["*"]`, which means EVERYTHING. A new client is
12
+ * never locked out of anything by its own default role, and no enumerated
13
+ * catalogue is mirrored server-side to drift out of date.
14
+ * 3. the shape passes the separation guard shipped in 3.56.0
15
+ * (`role-scope.util.ts`), so seeding cannot be refused by the very rule
16
+ * that was added to stop this class of mistake.
17
+ *
18
+ * Nothing here opens a socket or a database. The end-to-end half — that the
19
+ * rows actually land, that an EXISTING organisation is untouched, and that
20
+ * client creation still answers 201 — is
21
+ * `test/e2e/org-default-roles.e2e.test.mjs`.
22
+ */
23
+
24
+ import { strict as assert } from "node:assert";
25
+ import test from "node:test";
26
+ import { readFileSync } from "node:fs";
27
+
28
+ import {
29
+ defaultOwnerRoles,
30
+ seedDefaultOrgRoles,
31
+ } from "./.build/utils/org-default-roles.util.mjs";
32
+ import {
33
+ roleScope,
34
+ requireNoPlatformGrant,
35
+ refuseSharedClientTemplate,
36
+ PLATFORM_ONLY_PERMISSIONS,
37
+ } from "./.build/utils/role-scope.util.mjs";
38
+
39
+ const ORG = "68b0c1d2e3f4a5b6c7d8e9f0";
40
+
41
+ test("a new organisation of an app nature gets the same two roles a paid signup gets", () => {
42
+ const roles = defaultOwnerRoles(ORG, "security_agency");
43
+
44
+ assert.equal(roles.length, 2, "two roles, exactly as subscription.service.ts writes");
45
+
46
+ for (const role of roles) {
47
+ assert.equal(role.name, "owner");
48
+ assert.deepEqual(role.permissions, ["*"]);
49
+ assert.equal(role.default, true);
50
+ assert.equal(role.org, ORG, "the role is scoped to the NEW organisation");
51
+ }
52
+
53
+ assert.deepEqual(
54
+ roles.map((r) => r.type).sort(),
55
+ ["organization", "security_agency"],
56
+ "one organisation role and one typed by the organisation's nature",
57
+ );
58
+ });
59
+
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".
64
+ const source = readFileSync(
65
+ new URL("../src/services/subscription.service.ts", import.meta.url),
66
+ "utf8",
67
+ );
68
+
69
+ const seeding = source.slice(
70
+ source.indexOf("const role = await addRole("),
71
+ source.indexOf("await addMember("),
72
+ );
73
+
74
+ assert.ok(seeding.length > 0, "found the seeding block in subscription.service.ts");
75
+ assert.equal(
76
+ (seeding.match(/name: "owner"/g) ?? []).length,
77
+ 2,
78
+ "the paid path still seeds two roles named owner",
79
+ );
80
+ assert.equal(
81
+ (seeding.match(/permissions: \["\*"\]/g) ?? []).length,
82
+ 2,
83
+ 'the paid path still seeds ["*"] on both',
84
+ );
85
+ 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",
97
+ );
98
+ });
99
+
100
+ test("a nature that is not an app seeds only the organisation role", () => {
101
+ // `customer.service.ts` writes nature "property_owner", which is on neither
102
+ // `allowedNatures` nor `MEMBER_TYPES`. A role typed with it is a row no app
103
+ // can ever ask for.
104
+ const roles = defaultOwnerRoles(ORG, "property_owner");
105
+
106
+ assert.equal(roles.length, 1);
107
+ assert.equal(roles[0].type, "organization");
108
+ assert.equal(roles[0].org, ORG);
109
+ });
110
+
111
+ test("it can never produce an org-less shared template", () => {
112
+ // The single most important assertion in this file. `org: ""` on a
113
+ // `type: "organization"` role is the exact shape of "Org Owner", the document
114
+ // 119 members across every client shared on 2026-09-07.
115
+ for (const missing of [undefined, null, ""]) {
116
+ assert.deepEqual(
117
+ defaultOwnerRoles(missing, "security_agency"),
118
+ [],
119
+ `no roles for org=${JSON.stringify(missing)}`,
120
+ );
121
+ }
122
+ });
123
+
124
+ test("an ObjectId-like org is accepted and kept", () => {
125
+ const oid = { toString: () => ORG };
126
+ const roles = defaultOwnerRoles(oid, "cleaning_services");
127
+
128
+ assert.equal(roles.length, 2);
129
+ for (const role of roles) assert.equal(role.org, oid);
130
+ });
131
+
132
+ test("every seeded role passes the 3.56.0 separation guard", () => {
133
+ for (const nature of [
134
+ "real_estate_developer",
135
+ "property_management_agency",
136
+ "security_agency",
137
+ "cleaning_services",
138
+ "mechanical_electrical_services",
139
+ "landscaping_services",
140
+ "pest_control_services",
141
+ "pool_maintenance_services",
142
+ "property_owner",
143
+ ]) {
144
+ const roles = defaultOwnerRoles(ORG, nature);
145
+ assert.ok(roles.length >= 1, `${nature} seeds at least one role`);
146
+
147
+ for (const role of roles) {
148
+ assert.equal(
149
+ roleScope(role),
150
+ "client",
151
+ `${nature}/${role.type}: scoped to a client, not a template and not platform`,
152
+ );
153
+
154
+ // The write gate on `role.controller.ts` calls both of these. Neither may
155
+ // refuse a role we seed, or the role becomes unsavable the moment a
156
+ // client opens it in their own editor.
157
+ assert.doesNotThrow(() => refuseSharedClientTemplate(role));
158
+ assert.doesNotThrow(() => requireNoPlatformGrant(role, role.permissions));
159
+ // and re-saving it unchanged, which is what an editor does
160
+ assert.doesNotThrow(() => requireNoPlatformGrant(role, ["*"]));
161
+ }
162
+ }
163
+ });
164
+
165
+ test("seeding writes both roles through the repository it is handed", async () => {
166
+ const written = [];
167
+ await seedDefaultOrgRoles(async (role) => written.push(role), ORG, "security_agency");
168
+
169
+ assert.equal(written.length, 2);
170
+ assert.deepEqual(written.map((r) => r.type).sort(), ["organization", "security_agency"]);
171
+ });
172
+
173
+ test("a seeding failure never fails the request that created the organisation", async () => {
174
+ // The whole reason this is not inside a transaction. If a role insert throws,
175
+ // the organisation is already committed and the caller must still get its
176
+ // 201 — otherwise a seeding bug becomes a client-creation outage.
177
+ let attempts = 0;
178
+
179
+ await assert.doesNotReject(
180
+ seedDefaultOrgRoles(
181
+ async () => {
182
+ attempts += 1;
183
+ throw new Error("simulated insert failure");
184
+ },
185
+ ORG,
186
+ "security_agency",
187
+ ),
188
+ );
189
+
190
+ assert.equal(attempts, 1, "positive control: the failing insert was actually attempted");
191
+ });
192
+
193
+ test("positive control: the guard DOES refuse the shapes this seeding avoids", () => {
194
+ // If this test ever passes because the guard refuses nothing, the assertions
195
+ // above are worthless. So prove the guard still bites.
196
+ assert.ok(
197
+ PLATFORM_ONLY_PERMISSIONS.size > 0,
198
+ "the platform-only set is not empty",
199
+ );
200
+
201
+ const platformString = [...PLATFORM_ONLY_PERMISSIONS][0];
202
+
203
+ assert.throws(
204
+ () => refuseSharedClientTemplate({ type: "organization", org: "" }),
205
+ /shared by every client/,
206
+ "an org-less client template is still refused",
207
+ );
208
+
209
+ assert.throws(
210
+ () =>
211
+ requireNoPlatformGrant(
212
+ { type: "organization", org: ORG, permissions: [] },
213
+ [platformString],
214
+ ),
215
+ /Seven365 console permissions/,
216
+ "a platform grant on a client role is still refused",
217
+ );
218
+ });