@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/CHANGELOG.md +77 -0
- package/dist/index.js +4467 -4369
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1783 -1685
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/harness.mjs +2 -0
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +76 -3
- package/test/e2e/org-default-roles.e2e.test.mjs +320 -0
- package/test/org-default-roles.test.mjs +218 -0
- package/test/role-scope-separation.test.mjs +195 -0
- package/test/role-scope.test.mjs +55 -3
- package/tools/role-scope-lockout-check/check.js +316 -0
- package/tools/role-scope-lockout-check/platform-only-permissions.json +21 -0
- package/tools/role-scope-lockout-check/srv.js +39 -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.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
|
},
|
package/test/e2e/harness.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
});
|