@7365admin1/core 3.53.0 → 3.53.2
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 +12 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +67 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +129 -64
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/role-site-scope.e2e.test.mjs +204 -0
- package/test/file-delete-scope.test.mjs +64 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.53.
|
|
4
|
+
"version": "3.53.2",
|
|
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 --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 --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
|
},
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// End-to-end proof that a role created on one SITE does not appear on another
|
|
2
|
+
// site of the same client.
|
|
3
|
+
//
|
|
4
|
+
// The owner reported it in these words: "the roles and permission per site that
|
|
5
|
+
// create on specific site it should stay on that specific site alone, not
|
|
6
|
+
// connected to other clients or other sites."
|
|
7
|
+
//
|
|
8
|
+
// The client half was already closed -- `GET /api/roles?org=` runs through
|
|
9
|
+
// `requireOrgReach`, and `org-scoping-roles-members.e2e.test.mjs` holds it. The
|
|
10
|
+
// SITE half was not closed at all: every one of the seven site apps renders
|
|
11
|
+
// `RolePermissionMain` with the site in the URL, the role is CREATED carrying
|
|
12
|
+
// that site (`createRole` accepts `site`, `MRole` stores it), and then the list
|
|
13
|
+
// query -- `{ status: "active", org }` -- never mentioned it. So Seventh
|
|
14
|
+
// Condominium's roles-and-permissions screen listed every role of every estate
|
|
15
|
+
// that client owns, with Edit and Delete on each.
|
|
16
|
+
//
|
|
17
|
+
// What must NOT change, and is asserted here as hard as the leak itself: a role
|
|
18
|
+
// with no site is the organisation's own and stays visible on every site. Every
|
|
19
|
+
// role stored before the `site` field existed is in that shape, so a filter
|
|
20
|
+
// that dropped them would have hidden roles that are the only way to edit
|
|
21
|
+
// themselves.
|
|
22
|
+
//
|
|
23
|
+
// Everything here is created and thrown away by the harness: an in-process
|
|
24
|
+
// MongoDB replica set, a loopback Redis, a loopback mail sink. No staging or
|
|
25
|
+
// production database, Redis, device or endpoint is touched.
|
|
26
|
+
//
|
|
27
|
+
// Run with: yarn test:e2e
|
|
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 = "SiteScope-Passw0rd!";
|
|
36
|
+
const ADMIN = "site-scope-admin@e2e.example.com"; // an org member, reaches both sites
|
|
37
|
+
|
|
38
|
+
describe("a site's roles list is scoped to that site", { concurrency: 1 }, () => {
|
|
39
|
+
let h;
|
|
40
|
+
const id = {};
|
|
41
|
+
const result = [];
|
|
42
|
+
let sid;
|
|
43
|
+
|
|
44
|
+
const record = (name, fn) =>
|
|
45
|
+
it(name, async () => {
|
|
46
|
+
try {
|
|
47
|
+
await fn();
|
|
48
|
+
result.push(["PASS", name]);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
result.push(["FAIL", name]);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
before(async () => {
|
|
56
|
+
h = await startHarness();
|
|
57
|
+
Object.assign(id, await seed(h));
|
|
58
|
+
}, { timeout: 300000 });
|
|
59
|
+
|
|
60
|
+
after(async () => {
|
|
61
|
+
if (h) await h.stop();
|
|
62
|
+
console.log("\n--- role site scoping: per-case result ---");
|
|
63
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const names = async (query) => {
|
|
67
|
+
const res = await h.api(`/roles?${query}`, { sid });
|
|
68
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
69
|
+
return (res.body.items ?? []).map((r) => r.name).sort();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
record("0. baseline — the admin signs in and sees all four roles", async () => {
|
|
73
|
+
sid = await h.login(ADMIN, PASSWORD);
|
|
74
|
+
assert.ok(sid);
|
|
75
|
+
assert.deepEqual(await names(`org=${id.org}&limit=50`), [
|
|
76
|
+
"Org Wide",
|
|
77
|
+
"Org Wide Legacy",
|
|
78
|
+
"Site A Guard",
|
|
79
|
+
"Site B Guard",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
record("1. site A's screen does not list site B's role", async () => {
|
|
84
|
+
const listed = await names(`org=${id.org}&site=${id.siteA}&limit=50`);
|
|
85
|
+
assert.ok(
|
|
86
|
+
!listed.includes("Site B Guard"),
|
|
87
|
+
`site B's role leaked onto site A: ${JSON.stringify(listed)}`,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
record("2. and site B's screen does not list site A's role", async () => {
|
|
92
|
+
const listed = await names(`org=${id.org}&site=${id.siteB}&limit=50`);
|
|
93
|
+
assert.ok(
|
|
94
|
+
!listed.includes("Site A Guard"),
|
|
95
|
+
`site A's role leaked onto site B: ${JSON.stringify(listed)}`,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
record("3. each site still sees its OWN role and the org-wide ones", async () => {
|
|
100
|
+
assert.deepEqual(await names(`org=${id.org}&site=${id.siteA}&limit=50`), [
|
|
101
|
+
"Org Wide",
|
|
102
|
+
"Org Wide Legacy",
|
|
103
|
+
"Site A Guard",
|
|
104
|
+
]);
|
|
105
|
+
assert.deepEqual(await names(`org=${id.org}&site=${id.siteB}&limit=50`), [
|
|
106
|
+
"Org Wide",
|
|
107
|
+
"Org Wide Legacy",
|
|
108
|
+
"Site B Guard",
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
record("4. a second site's answer is not the first one's, out of the cache", async () => {
|
|
113
|
+
// The list is cached in Redis for 15 minutes on a key built from the query.
|
|
114
|
+
// Asking twice in the opposite order is what catches a `site` that filters
|
|
115
|
+
// but is missing from the cache key -- the case where the fix works once and
|
|
116
|
+
// is decorative after that.
|
|
117
|
+
assert.deepEqual(await names(`org=${id.org}&site=${id.siteB}&limit=50`), [
|
|
118
|
+
"Org Wide",
|
|
119
|
+
"Org Wide Legacy",
|
|
120
|
+
"Site B Guard",
|
|
121
|
+
]);
|
|
122
|
+
assert.deepEqual(await names(`org=${id.org}&site=${id.siteA}&limit=50`), [
|
|
123
|
+
"Org Wide",
|
|
124
|
+
"Org Wide Legacy",
|
|
125
|
+
"Site A Guard",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
record("5. asking without a site is unchanged — the whole client", async () => {
|
|
130
|
+
assert.deepEqual(await names(`org=${id.org}&limit=50`), [
|
|
131
|
+
"Org Wide",
|
|
132
|
+
"Org Wide Legacy",
|
|
133
|
+
"Site A Guard",
|
|
134
|
+
"Site B Guard",
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
record("6. a role created through the API carries its site and stays there", async () => {
|
|
139
|
+
const res = await h.api("/roles", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
sid,
|
|
142
|
+
body: {
|
|
143
|
+
name: "Site A Supervisor",
|
|
144
|
+
permissions: ["feedback:see-all-feedbacks"],
|
|
145
|
+
type: "property_management_agency",
|
|
146
|
+
org: id.org.toString(),
|
|
147
|
+
site: id.siteA.toString(),
|
|
148
|
+
platform: "website",
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
152
|
+
|
|
153
|
+
assert.ok((await names(`org=${id.org}&site=${id.siteA}&limit=50`)).includes("Site A Supervisor"));
|
|
154
|
+
assert.ok(!(await names(`org=${id.org}&site=${id.siteB}&limit=50`)).includes("Site A Supervisor"));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
async function seed(h) {
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const org = new ObjectId();
|
|
161
|
+
const siteA = new ObjectId();
|
|
162
|
+
const siteB = new ObjectId();
|
|
163
|
+
const adminRole = new ObjectId();
|
|
164
|
+
|
|
165
|
+
await h.db.collection("organizations").insertOne({
|
|
166
|
+
_id: org,
|
|
167
|
+
name: "Site Scope Org",
|
|
168
|
+
email: "site-scope@e2e.example.com",
|
|
169
|
+
type: "org",
|
|
170
|
+
nature: "property_management_agency",
|
|
171
|
+
status: "active",
|
|
172
|
+
createdAt: now,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await h.db.collection("sites").insertMany([
|
|
176
|
+
{ _id: siteA, name: "Site A", org, orgId: org, status: "active" },
|
|
177
|
+
{ _id: siteB, name: "Site B", org, orgId: org, status: "active" },
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
await h.db.collection("roles").insertMany([
|
|
181
|
+
// the caller's own role, org-wide
|
|
182
|
+
{ _id: adminRole, name: "Org Wide", org, site: "", permissions: ["*"], type: "property_management_agency", status: "active" },
|
|
183
|
+
// the shape every role stored before the `site` field existed has: no key
|
|
184
|
+
{ name: "Org Wide Legacy", org, permissions: ["*"], type: "property_management_agency", status: "active" },
|
|
185
|
+
{ name: "Site A Guard", org, site: siteA, permissions: [], type: "property_management_agency", status: "active" },
|
|
186
|
+
{ name: "Site B Guard", org, site: siteB, permissions: [], type: "property_management_agency", status: "active" },
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
const users = await h.db.collection("users").insertMany([
|
|
190
|
+
{ email: ADMIN, name: "site-scope-admin", status: "active", createdAt: now },
|
|
191
|
+
]);
|
|
192
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
193
|
+
await h.db.collection("users").updateMany({ email: ADMIN }, { $set: { password: hashed } });
|
|
194
|
+
|
|
195
|
+
await h.db.collection("members").insertOne({
|
|
196
|
+
user: users.insertedIds[0],
|
|
197
|
+
org,
|
|
198
|
+
type: "property_management_agency",
|
|
199
|
+
role: adminRole,
|
|
200
|
+
status: "active",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return { org, siteA, siteB };
|
|
204
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const { fileOwnerId, mayDeleteFile } = await import("./.build/utils/file-owner.util.mjs");
|
|
6
|
+
|
|
7
|
+
const A = "aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
8
|
+
const B = "bbbbbbbbbbbbbbbbbbbbbbbb";
|
|
9
|
+
const ORG_1 = "111111111111111111111111";
|
|
10
|
+
const ORG_2 = "222222222222222222222222";
|
|
11
|
+
|
|
12
|
+
test("a caller deletes their own new file", () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
mayDeleteFile({ ownerId: A, callerId: A, callerOrgIds: [ORG_1], ownerOrgIds: [ORG_1] }),
|
|
15
|
+
true,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a caller deletes another tenant's new file — refused", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
mayDeleteFile({ ownerId: A, callerId: B, callerOrgIds: [ORG_2], ownerOrgIds: [ORG_1] }),
|
|
22
|
+
false,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a legacy row with no ownership behaves unchanged", () => {
|
|
27
|
+
assert.equal(mayDeleteFile({ ownerId: "", callerId: B, callerOrgIds: [ORG_2] }), true);
|
|
28
|
+
assert.equal(fileOwnerId({ name: "old.pdf" }), "");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("same tenant, different person, is still allowed", () => {
|
|
32
|
+
assert.equal(
|
|
33
|
+
mayDeleteFile({ ownerId: A, callerId: B, callerOrgIds: [ORG_2, ORG_1], ownerOrgIds: [ORG_1] }),
|
|
34
|
+
true,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an anonymous caller cannot delete a stamped file", () => {
|
|
39
|
+
assert.equal(mayDeleteFile({ ownerId: A, callerId: "", callerOrgIds: [ORG_1] }), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("super admin keeps its bypass", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
mayDeleteFile({ ownerId: A, callerId: B, callerIsSuperAdmin: true, callerOrgIds: [], ownerOrgIds: [ORG_1] }),
|
|
45
|
+
true,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("fileOwnerId reads an ObjectId-shaped stamp", () => {
|
|
50
|
+
assert.equal(fileOwnerId({ createdBy: { toString: () => A } }), A);
|
|
51
|
+
assert.equal(fileOwnerId(null), "");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the controller wires the gate on delete and stamps on upload", () => {
|
|
55
|
+
const src = readFileSync(new URL("../src/controllers/file.controller.ts", import.meta.url), "utf8");
|
|
56
|
+
assert.match(src, /mayDeleteFile/, "delete must consult the ownership gate");
|
|
57
|
+
assert.match(src, /callerId\(req\)/, "the stamp and the gate come from the session, never the body");
|
|
58
|
+
assert.doesNotMatch(src, /req\.body\.createdBy/, "the owner is never taken from the request body");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the model carries the optional stamp", () => {
|
|
62
|
+
const src = readFileSync(new URL("../src/models/file.model.ts", import.meta.url), "utf8");
|
|
63
|
+
assert.match(src, /createdBy\?:/, "createdBy is optional — no existing row is invalidated");
|
|
64
|
+
});
|