@7365admin1/core 3.56.0 → 3.58.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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * A platform admin can put a shared client template BACK to allow-all, and can
3
+ * still do nothing else to it.
4
+ *
5
+ * ## What this is proving
6
+ *
7
+ * The guard shipped in core 3.56.0 refuses every write to an org-less
8
+ * `type: "organization"` role — the shared CLIENT TEMPLATES that every client
9
+ * invited with them depends on. It stopped the 2026-09-07 outage recurring and
10
+ * it also stopped it being repaired: production "Org Owner"
11
+ * (`69df7c8034293c971075ab97`) still holds the 34 platform permission strings,
12
+ * 119 members are still locked out, and the guard is what refuses the one write
13
+ * that gives them their modules back.
14
+ *
15
+ * The unit half (`test/role-scope-separation.test.mjs`) proves the predicate.
16
+ * This proves it over real HTTP, through the real routes and the real
17
+ * controller, and — the assertion that matters most — that what LANDS IN THE
18
+ * DATABASE after the restore is `["*"]` and not something else.
19
+ *
20
+ * The template here is seeded holding exactly the 34 strings production holds,
21
+ * so case 2 is today's outage attempted against today's code.
22
+ *
23
+ * Everything is created and thrown away by the harness: an in-process MongoDB
24
+ * replica set, a loopback Redis, a loopback mail sink. No staging or production
25
+ * database, Redis, mailbox or endpoint is touched.
26
+ *
27
+ * Run with: yarn test:e2e
28
+ */
29
+
30
+ import { after, before, describe, it } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { ObjectId } from "mongodb";
33
+
34
+ import { startHarness } from "./harness.mjs";
35
+
36
+ const PASSWORD = "RoleTemplateRestore-Passw0rd!";
37
+ const STAFF = "rtr-staff@e2e.example.com"; // Seven365 console
38
+ const CLIENT = "rtr-client@e2e.example.com"; // an ordinary tenant member
39
+
40
+ /** Verbatim `useAdminPermission`: the list the console save wrote. */
41
+ const CONSOLE_SAVE = [
42
+ "organizations:see-all-organizations",
43
+ "organizations:see-organization-details",
44
+ "promo-codes:create-promo-code",
45
+ "promo-codes:see-promo-code-details",
46
+ "promo-codes:edit-promo-code-details",
47
+ "promo-codes:change-promo-code-status",
48
+ "promo-codes:delete-promo-code",
49
+ "users:see-all-users",
50
+ "users:see-user-details",
51
+ "invitations:create-invitation",
52
+ "invitations:view-invitations",
53
+ "invitations:cancel-invitation",
54
+ "subscriptions:see-all-subscriptions",
55
+ "subscriptions:see-subscription-details",
56
+ "subscriptions:manage-subscription",
57
+ "sp-approvals:see-all-sp-approvals",
58
+ "sp-approvals:approve-sp",
59
+ "sp-approvals:reject-sp",
60
+ "sp-approvals:delete-sp-approval",
61
+ "marketplace-vendors:see-all-marketplace-vendors",
62
+ "marketplace-vendors:see-marketplace-vendor-details",
63
+ "platform-terms:see-platform-terms",
64
+ "platform-terms:edit-platform-terms",
65
+ "activity-history:see-activity-history",
66
+ "members:view-members",
67
+ "members:assign-member-role",
68
+ "members:suspend-member",
69
+ "members:activate-member",
70
+ "members:delete-member",
71
+ "roles-and-permissions:add-role",
72
+ "roles-and-permissions:see-all-roles",
73
+ "roles-and-permissions:see-role-details",
74
+ "roles-and-permissions:update-role",
75
+ "roles-and-permissions:delete-role",
76
+ ];
77
+
78
+ const REFUSAL = /shared by every client/;
79
+
80
+ describe("a shared client template can be restored and nothing else", { concurrency: 1 }, () => {
81
+ let h;
82
+ let id;
83
+ let staffSid;
84
+ let clientSid;
85
+ const result = [];
86
+
87
+ const record = (name, fn) =>
88
+ it(name, async () => {
89
+ try {
90
+ await fn();
91
+ result.push(["PASS", name]);
92
+ } catch (error) {
93
+ result.push(["FAIL", name]);
94
+ throw error;
95
+ }
96
+ });
97
+
98
+ before(async () => {
99
+ h = await startHarness();
100
+ id = await seed(h);
101
+ staffSid = await h.login(STAFF, PASSWORD);
102
+ clientSid = await h.login(CLIENT, PASSWORD);
103
+ }, { timeout: 300000 });
104
+
105
+ after(async () => {
106
+ if (h) await h.stop();
107
+ console.log("\n--- role template restore: per-case result ---");
108
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
109
+ });
110
+
111
+ const permissionsOf = async (roleId) =>
112
+ (await h.db.collection("roles").findOne({ _id: roleId }))?.permissions;
113
+
114
+ const patchPermissions = (roleId, permissions, sid) =>
115
+ h.api("/roles/permissions/id/" + roleId.toString(), {
116
+ method: "PATCH",
117
+ sid,
118
+ body: { permissions },
119
+ });
120
+
121
+ // ---- the outage, attempted -----------------------------------------------
122
+
123
+ record("1. the template starts in production's broken state — 34 strings", async () => {
124
+ assert.deepEqual(await permissionsOf(id.template), CONSOLE_SAVE);
125
+ });
126
+
127
+ record("2. THE OUTAGE: a console save of the 34 strings is still refused", async () => {
128
+ const res = await patchPermissions(id.template, CONSOLE_SAVE, staffSid);
129
+
130
+ assert.equal(res.status, 401, JSON.stringify(res.body));
131
+ assert.match(res.body?.message ?? "", REFUSAL);
132
+ assert.deepEqual(
133
+ await permissionsOf(id.template),
134
+ CONSOLE_SAVE,
135
+ "the refused write must not have touched the row",
136
+ );
137
+ });
138
+
139
+ record("3. NARROWING: a strict subset of what it holds is refused", async () => {
140
+ const res = await patchPermissions(id.template, CONSOLE_SAVE.slice(0, 10), staffSid);
141
+
142
+ assert.equal(res.status, 401, JSON.stringify(res.body));
143
+ assert.match(res.body?.message ?? "", REFUSAL);
144
+ assert.deepEqual(await permissionsOf(id.template), CONSOLE_SAVE);
145
+ });
146
+
147
+ record("4. a hand-built superset is refused too — nothing authors a template", async () => {
148
+ const res = await patchPermissions(
149
+ id.template,
150
+ [...CONSOLE_SAVE, "members:view-members-extra"],
151
+ staffSid,
152
+ );
153
+
154
+ assert.equal(res.status, 401, JSON.stringify(res.body));
155
+ assert.deepEqual(await permissionsOf(id.template), CONSOLE_SAVE);
156
+ });
157
+
158
+ // ---- the repair ----------------------------------------------------------
159
+
160
+ record("5. THE REPAIR: a platform admin sets it to allow-all and it STORES it", async () => {
161
+ const res = await patchPermissions(id.template, ["*"], staffSid);
162
+
163
+ assert.equal(res.status, 200, JSON.stringify(res.body));
164
+ assert.deepEqual(
165
+ await permissionsOf(id.template),
166
+ ["*"],
167
+ "the whole point: the row must now read allow-all",
168
+ );
169
+ });
170
+
171
+ record("6. an EMPTY list is not refused by the template guard — a different rule stops it", async () => {
172
+ /*
173
+ * MEASURED, and it is the reason the repair instruction says `["*"]`.
174
+ *
175
+ * `PATCH /roles/permissions/id/:id` refuses an empty list in the repository
176
+ * (`role.repo.ts:481` — "Permissions cannot be empty."), and has since long
177
+ * before any of this. It is a 400, not the 401 the template guard raises,
178
+ * so the guard did let it through; the storage layer is what says no.
179
+ *
180
+ * Deliberately NOT changed. Loosening it would let an empty list — which
181
+ * the permission model reads as EVERYTHING — be saved on any role by any
182
+ * client's own editor. That is a widening with a blast radius far past this
183
+ * repair, and `["*"]` restores the same thing.
184
+ */
185
+ const res = await patchPermissions(id.secondTemplate, [], staffSid);
186
+
187
+ assert.equal(res.status, 400, JSON.stringify(res.body));
188
+ assert.match(res.body?.message ?? "", /Permissions cannot be empty/);
189
+ assert.doesNotMatch(
190
+ res.body?.message ?? "",
191
+ REFUSAL,
192
+ "the template guard must NOT be what refused an empty list",
193
+ );
194
+ assert.deepEqual(await permissionsOf(id.secondTemplate), CONSOLE_SAVE);
195
+ });
196
+
197
+ record("6b. and where an empty list IS accepted, the guard permits it", async () => {
198
+ // `PATCH /roles/id/:id` stores it (`role.repo.ts:427` keeps a `[]`), so the
199
+ // guard's empty-list arm is exercised end to end and not merely asserted.
200
+ const res = await h.api("/roles/id/" + id.secondTemplate.toString(), {
201
+ method: "PATCH",
202
+ sid: staffSid,
203
+ body: { name: "Org Manager", permissions: [] },
204
+ });
205
+
206
+ assert.equal(res.status, 200, JSON.stringify(res.body));
207
+ assert.deepEqual(await permissionsOf(id.secondTemplate), []);
208
+ });
209
+
210
+ record("7. PATCH /id/:id — the other write endpoint behaves identically", async () => {
211
+ const refused = await h.api("/roles/id/" + id.thirdTemplate.toString(), {
212
+ method: "PATCH",
213
+ sid: staffSid,
214
+ body: { name: "Renamed Template", permissions: CONSOLE_SAVE },
215
+ });
216
+ assert.equal(refused.status, 401, JSON.stringify(refused.body));
217
+ assert.match(refused.body?.message ?? "", REFUSAL);
218
+
219
+ const restored = await h.api("/roles/id/" + id.thirdTemplate.toString(), {
220
+ method: "PATCH",
221
+ sid: staffSid,
222
+ body: { name: "Renamed Template", permissions: ["*"] },
223
+ });
224
+ assert.equal(restored.status, 200, JSON.stringify(restored.body));
225
+ assert.deepEqual(await permissionsOf(id.thirdTemplate), ["*"]);
226
+ });
227
+
228
+ // ---- what the exception must NOT have opened -----------------------------
229
+
230
+ record("8. a client member cannot use the restore path on a shared template", async () => {
231
+ // Reaching the restore still means passing the console gate. A tenant
232
+ // member holds no staff membership, so this must not be a way in.
233
+ const res = await patchPermissions(id.fourthTemplate, ["*"], clientSid);
234
+
235
+ assert.notEqual(res.status, 200, JSON.stringify(res.body));
236
+ assert.deepEqual(
237
+ await permissionsOf(id.fourthTemplate),
238
+ ["members:view-members"],
239
+ "a non-staff caller must not have restored anything",
240
+ );
241
+ });
242
+
243
+ record("9. an ORG-SCOPED role is unaffected — it still narrows normally", async () => {
244
+ const res = await patchPermissions(id.clientRole, ["members:view-members"], staffSid);
245
+
246
+ assert.equal(res.status, 200, JSON.stringify(res.body));
247
+ assert.deepEqual(await permissionsOf(id.clientRole), ["members:view-members"]);
248
+ });
249
+
250
+ record("10. a client editor still cannot grant a platform resource", async () => {
251
+ const res = await patchPermissions(
252
+ id.clientRole,
253
+ ["members:view-members", "organizations:see-all-organizations"],
254
+ staffSid,
255
+ );
256
+
257
+ assert.equal(res.status, 401, JSON.stringify(res.body));
258
+ assert.match(res.body?.message ?? "", /Seven365 console permissions/);
259
+ assert.deepEqual(await permissionsOf(id.clientRole), ["members:view-members"]);
260
+ });
261
+
262
+ record("11. POSITIVE CONTROL: the run really did write, and really did refuse", async () => {
263
+ // If every case above 401'd, cases 5-7 and 9 would be vacuous; if every
264
+ // case 200'd, cases 2-4 and 8 would be. Count both.
265
+ const verdicts = result.map(([v]) => v);
266
+ assert.equal(verdicts.includes("FAIL"), false, JSON.stringify(result));
267
+
268
+ for (const roleId of [id.template, id.thirdTemplate]) {
269
+ assert.deepEqual(await permissionsOf(roleId), ["*"], roleId.toString());
270
+ }
271
+ assert.deepEqual(await permissionsOf(id.secondTemplate), []);
272
+ // and one template that nothing was allowed to move
273
+ assert.deepEqual(await permissionsOf(id.fourthTemplate), ["members:view-members"]);
274
+ });
275
+ });
276
+
277
+ async function seed(h) {
278
+ const now = new Date().toISOString();
279
+ const hashed = await h.hashPassword(PASSWORD);
280
+
281
+ const staffRole = new ObjectId();
282
+ const clientOrg = new ObjectId();
283
+ const clientRole = new ObjectId();
284
+ const template = new ObjectId();
285
+ const secondTemplate = new ObjectId();
286
+ const thirdTemplate = new ObjectId();
287
+ const fourthTemplate = new ObjectId();
288
+
289
+ await h.db.collection("organizations").insertOne({
290
+ _id: clientOrg,
291
+ name: "RTR Client",
292
+ email: "rtr-org@e2e.example.com",
293
+ type: "org",
294
+ nature: "organization",
295
+ status: "active",
296
+ createdAt: now,
297
+ });
298
+
299
+ await h.db.collection("roles").insertMany([
300
+ // Seven365 staff: `type: "admin"` with `["*"]`, so the console gate passes
301
+ // and the cases measure the TEMPLATE rule, not authorization.
302
+ { _id: staffRole, name: "Staff Wildcard", type: "admin", status: "active", permissions: ["*"] },
303
+ // The client's own role — org-scoped, so the template rule never applies.
304
+ {
305
+ _id: clientRole,
306
+ name: "RTR Client Admin",
307
+ org: clientOrg,
308
+ type: "organization",
309
+ status: "active",
310
+ permissions: ["*"],
311
+ },
312
+ // Four shared templates: org-less, no `org` field at all. The first three
313
+ // hold production's broken list verbatim.
314
+ {
315
+ _id: template,
316
+ name: "Org Owner",
317
+ type: "organization",
318
+ status: "active",
319
+ default: true,
320
+ permissions: CONSOLE_SAVE,
321
+ },
322
+ {
323
+ _id: secondTemplate,
324
+ name: "Org Manager",
325
+ type: "organization",
326
+ status: "active",
327
+ permissions: CONSOLE_SAVE,
328
+ },
329
+ {
330
+ _id: thirdTemplate,
331
+ name: "Agency Owner",
332
+ type: "security_agency",
333
+ status: "active",
334
+ permissions: CONSOLE_SAVE,
335
+ },
336
+ {
337
+ _id: fourthTemplate,
338
+ name: "Org Supervisor",
339
+ type: "organization",
340
+ status: "active",
341
+ permissions: ["members:view-members"],
342
+ },
343
+ ]);
344
+
345
+ const users = await h.db.collection("users").insertMany([
346
+ { email: STAFF, password: hashed, name: "RTR Staff", status: "active", createdAt: now },
347
+ {
348
+ email: CLIENT,
349
+ password: hashed,
350
+ name: "RTR Client User",
351
+ status: "active",
352
+ defaultOrg: clientOrg.toString(),
353
+ createdAt: now,
354
+ },
355
+ ]);
356
+
357
+ await h.db.collection("members").insertMany([
358
+ { user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" },
359
+ {
360
+ user: users.insertedIds[1],
361
+ org: clientOrg,
362
+ type: "organization",
363
+ role: clientRole,
364
+ status: "active",
365
+ },
366
+ ]);
367
+
368
+ return { clientOrg, clientRole, template, secondTemplate, thirdTemplate, fourthTemplate };
369
+ }
@@ -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
+ });