@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.
- package/CHANGELOG.md +57 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3992 -3935
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1676 -1619
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/harness.mjs +2 -0
- package/test/e2e/org-default-roles.e2e.test.mjs +320 -0
- package/test/e2e/role-template-restore.e2e.test.mjs +369 -0
- package/test/org-default-roles.test.mjs +218 -0
- package/test/role-scope-separation.test.mjs +149 -0
- package/test/role-scope.test.mjs +15 -3
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.58.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/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);
|
|
@@ -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
|
+
}
|