@7365admin1/core 3.53.1 → 3.53.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.53.1",
4
+ "version": "3.53.3",
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 src/utils/console-permission.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
  },
@@ -298,10 +298,10 @@ test("suspending and reactivating a client both leave a record", () => {
298
298
  // The actor is the session the owner guard resolved, never the request body.
299
299
  assert.match(
300
300
  body,
301
- /const (\w+) = await requirePlatformOwner\(req\)/,
301
+ /const (\w+) = await requirePlatformOwner\(req/,
302
302
  "updateStatus no longer takes the actor from the guard",
303
303
  );
304
- const actorVar = body.match(/const (\w+) = await requirePlatformOwner\(req\)/)[1];
304
+ const actorVar = body.match(/const (\w+) = await requirePlatformOwner\(req/)[1];
305
305
  assert.match(
306
306
  body,
307
307
  new RegExp(`actor: ${actorVar}`),
@@ -335,7 +335,7 @@ test("a lost audit row cannot undo a suspension", () => {
335
335
  // ---------------------------------------------------------------- the read
336
336
 
337
337
  test("the read is staff-gated, paged, and filterable by client and by date", () => {
338
- assert.match(CONTROLLER, /await requirePlatformStaff\(req\)/);
338
+ assert.match(CONTROLLER, /await requireConsolePermission\(req, "activity-history", "see-activity-history"\)/);
339
339
 
340
340
  for (const filter of ["org:", "action:", "from:", "to:", "page:", "limit:"]) {
341
341
  assert.match(CONTROLLER, new RegExp(filter), `no ${filter} filter`);
@@ -343,7 +343,7 @@ test("the read is staff-gated, paged, and filterable by client and by date", ()
343
343
 
344
344
  // The gate runs before the read, not after it.
345
345
  assert.ok(
346
- CONTROLLER.indexOf("await requirePlatformStaff(req)") <
346
+ CONTROLLER.search(/await requireConsolePermission\(req/) <
347
347
  CONTROLLER.indexOf("await _list("),
348
348
  "the audit trail is read before the caller is judged",
349
349
  );
@@ -0,0 +1,273 @@
1
+ /**
2
+ * THE SERVER SIDE OF THE SEVEN365 STAFF CONSOLE GATE.
3
+ *
4
+ * Twelve controllers ask `requirePlatformStaff` before every console write, and
5
+ * until now it read ONE thing: is this session a staff membership. It never read
6
+ * which modules the staff role holds, so a role ticked for Promo Codes alone
7
+ * still reached subscriptions, platform Terms, the client list and every user on
8
+ * the platform. `web-app-org` #186 hides those screens from that role; hiding a
9
+ * screen is drawing, not securing.
10
+ *
11
+ * Two halves are proved here and both matter:
12
+ *
13
+ * - the RULE (`console-permission.util.ts`), executed as the shipped module;
14
+ * - the WIRING -- that all 45 gate call sites across the twelve controllers
15
+ * actually name a module, and that the mapped strings are ones the console's
16
+ * own catalogue can grant. A gate reading a string no role editor can tick is
17
+ * the `visitor:create` defect, and it looks identical to a working gate in a
18
+ * grep.
19
+ *
20
+ * The DECISION itself reads the `members` and `roles` collections and cannot run
21
+ * without a database; it is executed over real HTTP against a throwaway
22
+ * in-memory MongoDB in `test/e2e/console-permission.e2e.test.mjs`.
23
+ *
24
+ * Nothing here opens a socket or a database.
25
+ */
26
+ import test from "node:test";
27
+ import assert from "node:assert/strict";
28
+ import { readFileSync } from "node:fs";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const {
32
+ CONSOLE_PERMISSIONS,
33
+ consoleRoleAllows,
34
+ consoleRoleAllowsAll,
35
+ consoleSpellings,
36
+ } = await import("./.build/utils/console-permission.util.mjs");
37
+
38
+ const read = (rel) =>
39
+ readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
40
+
41
+ // ---- the rule ------------------------------------------------------------
42
+
43
+ test("an EMPTY permission list reaches everything - the zero-lockout rule", () => {
44
+ // `user.service.ts createDefaultUser()` seeds the platform-staff role with
45
+ // `permissions: []`. If this ever returns false, shipping the gate locks the
46
+ // Seven365 owner out of their own console.
47
+ assert.equal(consoleRoleAllowsAll([]), true);
48
+ for (const resource of Object.keys(CONSOLE_PERMISSIONS)) {
49
+ assert.equal(consoleRoleAllows([], resource), true, resource);
50
+ for (const action of CONSOLE_PERMISSIONS[resource]) {
51
+ assert.equal(
52
+ consoleRoleAllows([], resource, action),
53
+ true,
54
+ `${resource}:${action}`,
55
+ );
56
+ }
57
+ }
58
+ });
59
+
60
+ test("a missing or null list is treated as empty, not as a refusal", () => {
61
+ assert.equal(
62
+ consoleRoleAllows(undefined, "promo-codes", "create-promo-code"),
63
+ true,
64
+ );
65
+ assert.equal(consoleRoleAllows(null, "promo-codes", "create-promo-code"), true);
66
+ });
67
+
68
+ test("the wildcard reaches everything - the short-circuit staging relies on", () => {
69
+ // Staging's seeded Super Admin holds `["*"]`, not `[]`. Both must allow all,
70
+ // because production's own owner role cannot be read from here.
71
+ assert.equal(consoleRoleAllowsAll(["*"]), true);
72
+ assert.equal(
73
+ consoleRoleAllows(["*"], "activity-history", "see-activity-history"),
74
+ true,
75
+ );
76
+ assert.equal(consoleRoleAllows(["*"], "subscriptions"), true);
77
+ });
78
+
79
+ test("a one-module role reaches that module and nothing else", () => {
80
+ const held = ["promo-codes:create-promo-code"];
81
+ assert.equal(consoleRoleAllows(held, "promo-codes", "create-promo-code"), true);
82
+ assert.equal(consoleRoleAllows(held, "promo-codes", "delete-promo-code"), false);
83
+ assert.equal(consoleRoleAllows(held, "subscriptions", "manage-subscription"), false);
84
+ assert.equal(
85
+ consoleRoleAllows(held, "activity-history", "see-activity-history"),
86
+ false,
87
+ );
88
+ });
89
+
90
+ test("the resource-only question is ANY action on it, exactly as consoleCanSee", () => {
91
+ // Somebody granted only `delete-promo-code` still has to open Promo Codes.
92
+ const held = ["promo-codes:delete-promo-code"];
93
+ assert.equal(consoleRoleAllows(held, "promo-codes"), true);
94
+ assert.equal(consoleRoleAllows(held, "organizations"), false);
95
+ });
96
+
97
+ test("both shipped spellings of the roles family are accepted - widening, not swapping", () => {
98
+ // `-admin`'s role editor writes `roles-and-permissions:add-role`; eight apps
99
+ // write `roles:add-role`. Picking one would silently un-grant every role
100
+ // holding the other.
101
+ assert.equal(
102
+ consoleRoleAllows(["roles:add-role"], "roles-and-permissions", "add-role"),
103
+ true,
104
+ );
105
+ assert.equal(
106
+ consoleRoleAllows(
107
+ ["roles-and-permissions:add-role"],
108
+ "roles-and-permissions",
109
+ "add-role",
110
+ ),
111
+ true,
112
+ );
113
+ assert.deepEqual([...consoleSpellings("roles-and-permissions", "add-role")].sort(), [
114
+ "roles-and-permissions:add-role",
115
+ "roles:add-role",
116
+ ]);
117
+ });
118
+
119
+ test("CONTROL: an unknown resource is refused a governed role, never waved through", () => {
120
+ // If this passes, a typo in a controller silently disables its own gate.
121
+ assert.equal(consoleRoleAllows(["x:y"], "not-a-console-module", "anything"), false);
122
+ assert.equal(
123
+ consoleRoleAllows(["promo-codes:create-promo-code"], "promo-kodes"),
124
+ false,
125
+ );
126
+ });
127
+
128
+ test("CONTROL: an action outside the resource's catalogue is refused", () => {
129
+ assert.equal(
130
+ consoleRoleAllows(
131
+ ["organizations:delete-organization"],
132
+ "organizations",
133
+ "delete-organization",
134
+ ),
135
+ false,
136
+ );
137
+ });
138
+
139
+ test("CONTROL: holding the string for a DIFFERENT resource does not grant this one", () => {
140
+ assert.equal(consoleRoleAllows(["users:see-all-users"], "members", "view-members"), false);
141
+ });
142
+
143
+ // ---- the catalogue mirrors the console's own ------------------------------
144
+
145
+ test("the catalogue is the eleven console resources, no more and no less", () => {
146
+ assert.deepEqual(Object.keys(CONSOLE_PERMISSIONS).sort(), [
147
+ "activity-history",
148
+ "invitations",
149
+ "marketplace-vendors",
150
+ "members",
151
+ "organizations",
152
+ "platform-terms",
153
+ "promo-codes",
154
+ "roles-and-permissions",
155
+ "sp-approvals",
156
+ "subscriptions",
157
+ "users",
158
+ ]);
159
+ });
160
+
161
+ // ---- the wiring -----------------------------------------------------------
162
+
163
+ const CONTROLLERS = [
164
+ "console-audit",
165
+ "member",
166
+ "organization-v2",
167
+ "organization",
168
+ "platform-terms",
169
+ "promo-code",
170
+ "role-v2",
171
+ "role",
172
+ "subscription-plan",
173
+ "subscription",
174
+ "user-v2",
175
+ "user",
176
+ ];
177
+
178
+ const GATE =
179
+ /require(?:ConsolePermission|PlatformStaff|PlatformOwner|SelfOrPlatformStaff|UserFieldWrite)\(req[^\n]*/g;
180
+
181
+ function gateCalls() {
182
+ const calls = [];
183
+ for (const name of CONTROLLERS) {
184
+ const text = read(`controllers/${name}.controller.ts`);
185
+ for (const [call] of text.matchAll(GATE)) calls.push([name, call]);
186
+ }
187
+ return calls;
188
+ }
189
+
190
+ test("every console gate call site names a module - all 45 of them", () => {
191
+ const calls = gateCalls();
192
+ assert.equal(calls.length, 45, `gate call sites moved: ${calls.length}`);
193
+
194
+ const ungoverned = calls.filter(
195
+ ([, call]) =>
196
+ !/requireConsolePermission\(req, "/.test(call) && !/resource: "/.test(call),
197
+ );
198
+ assert.deepEqual(ungoverned, [], "a console gate still asks identity alone");
199
+ });
200
+
201
+ test("every module string a controller names is one the catalogue can grant", () => {
202
+ // The `visitor:create` defect: a server enforcing a string no role editor can
203
+ // tick is a gate nobody can ever satisfy except through `*` or an empty list.
204
+ const seen = new Set();
205
+ for (const [name, call] of gateCalls()) {
206
+ const direct = call.match(
207
+ /requireConsolePermission\(req, "([^"]+)"(?:, "([^"]+)")?/,
208
+ );
209
+ const object = call.match(/resource: "([^"]+)"(?:, action: "([^"]+)")?/);
210
+ const [, resource, action] = direct ?? object ?? [];
211
+ assert.ok(resource, `${name}: ${call}`);
212
+ assert.ok(CONSOLE_PERMISSIONS[resource], `${name}: unknown resource ${resource}`);
213
+ if (action) {
214
+ assert.ok(
215
+ CONSOLE_PERMISSIONS[resource].includes(action),
216
+ `${name}: ${resource} has no action ${action}`,
217
+ );
218
+ }
219
+ seen.add(resource);
220
+ }
221
+ // Only the resources the twelve controllers actually serve. `sp-approvals`,
222
+ // `marketplace-vendors` and `invitations` live outside this package.
223
+ assert.deepEqual([...seen].sort(), [
224
+ "activity-history",
225
+ "members",
226
+ "organizations",
227
+ "platform-terms",
228
+ "promo-codes",
229
+ "roles-and-permissions",
230
+ "subscriptions",
231
+ "users",
232
+ ]);
233
+ });
234
+
235
+ test("the SELF road is left alone - a person editing their own record needs no module", () => {
236
+ const authz = read("utils/console-authz.util.ts");
237
+ const self = authz.slice(
238
+ authz.indexOf("export async function requireSelfOrPlatformStaff("),
239
+ );
240
+ const selfBody = self.slice(0, self.indexOf("\n}\n"));
241
+ const returnSelf = selfBody.indexOf("return id;");
242
+ assert.ok(returnSelf > 0, "the self short-circuit is gone");
243
+ assert.ok(
244
+ !selfBody.slice(0, returnSelf).includes("consoleRoleAllows"),
245
+ "the self road must not consult the console catalogue",
246
+ );
247
+ });
248
+
249
+ test("the owner tier is still checked BEFORE any module, and cannot be widened by one", () => {
250
+ const authz = read("utils/console-authz.util.ts");
251
+ const owner = authz.slice(
252
+ authz.indexOf("export async function requirePlatformOwner("),
253
+ );
254
+ const body = owner.slice(0, owner.indexOf("\n}\n"));
255
+ assert.ok(
256
+ body.indexOf("isPlatformOwner") < body.indexOf("if (grant)"),
257
+ "owner check must come first",
258
+ );
259
+ assert.ok(body.includes("throw new UnauthorizedError"), "the owner refusal is gone");
260
+ });
261
+
262
+ test("staff identity is still required - the module check is layered, never a swap", () => {
263
+ const authz = read("utils/console-authz.util.ts");
264
+ const staff = authz.slice(
265
+ authz.indexOf("export async function requirePlatformStaff("),
266
+ );
267
+ const body = staff.slice(0, staff.indexOf("\n}\n"));
268
+ assert.ok(body.includes("resolveStaffRole"), "identity is no longer resolved");
269
+ assert.ok(
270
+ body.indexOf("if (!role)") < body.indexOf("consoleRoleAllows"),
271
+ "identity must be decided before the module",
272
+ );
273
+ });
@@ -0,0 +1,286 @@
1
+ // End-to-end proof that a Seven365 staff role's MODULES are enforced by the
2
+ // server, not only drawn by the console.
3
+ //
4
+ // `requirePlatformStaff` answered one question -- is this session a staff
5
+ // membership -- and twelve controllers asked it before every console write. It
6
+ // never read which modules the staff role holds. So a staff account onboarded
7
+ // onto a role ticked for Promo Codes alone could still mint a subscription,
8
+ // publish platform Terms, read the whole client list and enumerate every user
9
+ // on the platform. `web-app-org` #186 hides those screens from that role;
10
+ // hiding a screen is drawing, not securing, and none of these endpoints is
11
+ // reached only through a screen.
12
+ //
13
+ // THE ZERO-LOCKOUT RULE IS THE THING THAT MAKES THIS SHIPPABLE, so it is
14
+ // asserted first and hardest. `user.service.ts createDefaultUser()` seeds the
15
+ // platform-staff role with `permissions: []`, while staging's seeded role holds
16
+ // `["*"]`. Production's own owner role cannot be read from here, so the rule has
17
+ // to be correct for BOTH: an empty list means everything, and `"*"` means
18
+ // everything. Cases 1-4 are the owner walking the console with each of those two
19
+ // lists and losing nothing.
20
+ //
21
+ // Everything runs through the real Express stack against the harness's
22
+ // in-process MongoDB replica set. No staging or production database is touched
23
+ // and every seeded value is obviously fake.
24
+ //
25
+ // Run with: yarn test:e2e
26
+
27
+ import { after, before, describe, it } from "node:test";
28
+ import assert from "node:assert/strict";
29
+ import { ObjectId } from "mongodb";
30
+
31
+ import { startHarness } from "./harness.mjs";
32
+
33
+ const PASSWORD = "Console-Perm-Passw0rd!";
34
+ const UNGOVERNED = "console-perm-empty@e2e.example.com";
35
+ const WILDCARD = "console-perm-star@e2e.example.com";
36
+ const PROMO_ONLY = "console-perm-promo@e2e.example.com";
37
+ const ROLES_LEGACY = "console-perm-roles@e2e.example.com";
38
+ const TENANT = "console-perm-tenant@e2e.example.com";
39
+
40
+ describe("staff console modules are enforced server-side", { concurrency: 1 }, () => {
41
+ let h;
42
+ let id;
43
+ let ungovernedSid;
44
+ let wildcardSid;
45
+ let promoSid;
46
+ let rolesSid;
47
+ let tenantSid;
48
+ const result = [];
49
+
50
+ const record = (name, fn) =>
51
+ it(name, async () => {
52
+ try {
53
+ await fn();
54
+ result.push(["PASS", name]);
55
+ } catch (error) {
56
+ result.push(["FAIL", name]);
57
+ throw error;
58
+ }
59
+ });
60
+
61
+ before(async () => {
62
+ h = await startHarness();
63
+ id = await seed(h);
64
+ ungovernedSid = await h.login(UNGOVERNED, PASSWORD);
65
+ wildcardSid = await h.login(WILDCARD, PASSWORD);
66
+ promoSid = await h.login(PROMO_ONLY, PASSWORD);
67
+ rolesSid = await h.login(ROLES_LEGACY, PASSWORD);
68
+ tenantSid = await h.login(TENANT, PASSWORD);
69
+ }, { timeout: 300000 });
70
+
71
+ after(async () => {
72
+ if (h) await h.stop();
73
+ console.log("\n--- console permission: per-case result ---");
74
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
75
+ });
76
+
77
+ // `status` is required by the handler's Joi schema, which runs BEFORE the
78
+ // gate -- an invalid query answers 400 for everyone and would hide the 401.
79
+ const promoList = (sid) => h.api("/promo-codes/?status=active", { sid });
80
+ const userList = (sid) => h.api("/users/?status=active", { sid });
81
+ const subscriptionList = (sid) => h.api("/subscriptions/?status=active", { sid });
82
+ const orgList = (sid) => h.api("/organizations/v2/?page=1&limit=10", { sid });
83
+
84
+ const mintPromo = (sid, code) =>
85
+ h.api("/promo-codes/", {
86
+ method: "POST",
87
+ sid,
88
+ body: { code, type: "fixed", fixed_rate: 5, description: "e2e" },
89
+ });
90
+
91
+ const createPlatformRole = (sid, name) =>
92
+ h.api("/roles/", {
93
+ method: "POST",
94
+ sid,
95
+ body: { name, type: "admin", permissions: [] },
96
+ });
97
+
98
+ // ---- the zero-lockout rule ---------------------------------------------
99
+
100
+ record("1. EMPTY list: the seeded owner still reaches every console read", async () => {
101
+ // `createDefaultUser()` writes `permissions: []`. If this fails, shipping
102
+ // the gate locks the Seven365 owner out of their own console.
103
+ for (const [label, res] of [
104
+ ["promo codes", await promoList(ungovernedSid)],
105
+ ["users", await userList(ungovernedSid)],
106
+ ["subscriptions", await subscriptionList(ungovernedSid)],
107
+ ["organizations", await orgList(ungovernedSid)],
108
+ ]) {
109
+ assert.equal(res.status, 200, `${label}: ${JSON.stringify(res.body)}`);
110
+ }
111
+ });
112
+
113
+ record("2. EMPTY list: and every console write", async () => {
114
+ const minted = await mintPromo(ungovernedSid, "E2EEMPTY1");
115
+ assert.equal(minted.status, 201, JSON.stringify(minted.body));
116
+
117
+ const role = await createPlatformRole(ungovernedSid, "E2E Empty Staff Role");
118
+ assert.ok(role.status < 400, JSON.stringify(role.body));
119
+ });
120
+
121
+ record('3. "*" reaches everything too -- what staging\'s owner role holds', async () => {
122
+ for (const res of [
123
+ await promoList(wildcardSid),
124
+ await userList(wildcardSid),
125
+ await subscriptionList(wildcardSid),
126
+ ]) {
127
+ assert.equal(res.status, 200, JSON.stringify(res.body));
128
+ }
129
+ const minted = await mintPromo(wildcardSid, "E2ESTAR1");
130
+ assert.equal(minted.status, 201, JSON.stringify(minted.body));
131
+ });
132
+
133
+ // ---- a governed role is actually governed --------------------------------
134
+
135
+ record("4. a Promo-Codes-only staff role still mints a promo code", async () => {
136
+ // Proves the refusals below are the module gate, not a broken route.
137
+ const minted = await mintPromo(promoSid, "E2EPROMO1");
138
+ assert.equal(minted.status, 201, JSON.stringify(minted.body));
139
+ });
140
+
141
+ record("5. ...and is refused the platform-wide user list", async () => {
142
+ const res = await userList(promoSid);
143
+ assert.equal(res.status, 401, JSON.stringify(res.body));
144
+ });
145
+
146
+ record("6. ...the platform-wide subscription list", async () => {
147
+ const res = await subscriptionList(promoSid);
148
+ assert.equal(res.status, 401, JSON.stringify(res.body));
149
+ });
150
+
151
+ record("7. ...and the client list", async () => {
152
+ const res = await orgList(promoSid);
153
+ assert.equal(res.status, 401, JSON.stringify(res.body));
154
+ });
155
+
156
+ record("8. a refusal discloses nothing about the records behind it", async () => {
157
+ const res = await userList(promoSid);
158
+ const text = JSON.stringify(res.body ?? {});
159
+ for (const secret of [TENANT, UNGOVERNED, id.tenantOrg.toString()]) {
160
+ assert.ok(!text.includes(secret), `the refusal leaked ${secret}`);
161
+ }
162
+ });
163
+
164
+ record("9. the module is per-ACTION, not per-screen", async () => {
165
+ // The role holds `create-promo-code` and nothing else on the resource. The
166
+ // LIST is the resource-only question (any action reaches the screen), so it
167
+ // is allowed; DELETE names an action the role does not hold.
168
+ const list = await promoList(promoSid);
169
+ assert.equal(list.status, 200, JSON.stringify(list.body));
170
+
171
+ const removed = await h.api(`/promo-codes/${id.promo.toString()}`, {
172
+ method: "DELETE",
173
+ sid: promoSid,
174
+ });
175
+ assert.equal(removed.status, 401, JSON.stringify(removed.body));
176
+ });
177
+
178
+ record("10. the owner's ungoverned account can still delete it", async () => {
179
+ // Case 9's control: the DELETE route works, so the 401 above is the module.
180
+ const removed = await h.api(`/promo-codes/${id.promo.toString()}`, {
181
+ method: "DELETE",
182
+ sid: ungovernedSid,
183
+ });
184
+ assert.ok(removed.status < 400, JSON.stringify(removed.body));
185
+ });
186
+
187
+ // ---- widening, not swapping ---------------------------------------------
188
+
189
+ record("11. the legacy roles spelling is accepted -- no role migration needed", async () => {
190
+ // This role holds `roles:add-role`. `-admin`'s editor writes
191
+ // `roles-and-permissions:add-role`. Picking one spelling would silently
192
+ // un-grant every role holding the other.
193
+ const role = await createPlatformRole(rolesSid, "E2E Legacy Spelling Role");
194
+ assert.ok(role.status < 400, JSON.stringify(role.body));
195
+ });
196
+
197
+ record("12. ...and that role reaches nothing else on the console", async () => {
198
+ const res = await promoList(rolesSid);
199
+ assert.equal(res.status, 401, JSON.stringify(res.body));
200
+ });
201
+
202
+ // ---- staff identity is unchanged ----------------------------------------
203
+
204
+ record("13. a tenant admin is still refused the console entirely", async () => {
205
+ // The module check is layered ON TOP of the staff identity test, never in
206
+ // place of it. Nobody who was refused before is admitted now.
207
+ for (const res of [
208
+ await promoList(tenantSid),
209
+ await userList(tenantSid),
210
+ await subscriptionList(tenantSid),
211
+ await mintPromo(tenantSid, "E2ETENANT1"),
212
+ ]) {
213
+ assert.equal(res.status, 401, JSON.stringify(res.body));
214
+ }
215
+ });
216
+
217
+ record("14. no session at all is still refused", async () => {
218
+ const res = await promoList();
219
+ assert.equal(res.status, 401, JSON.stringify(res.status));
220
+ });
221
+ });
222
+
223
+ async function seed(h) {
224
+ const now = new Date().toISOString();
225
+ const hashed = await h.hashPassword(PASSWORD);
226
+
227
+ const tenantOrg = new ObjectId();
228
+ const tenantRole = new ObjectId();
229
+ const emptyRole = new ObjectId();
230
+ const starRole = new ObjectId();
231
+ const promoRole = new ObjectId();
232
+ const rolesRole = new ObjectId();
233
+ const promo = new ObjectId();
234
+
235
+ await h.db.collection("organizations").insertOne({
236
+ _id: tenantOrg,
237
+ name: "Console Perm Tenant",
238
+ email: "console-perm-tenant-org@e2e.example.com",
239
+ type: "property-management",
240
+ status: "active",
241
+ createdAt: now,
242
+ });
243
+
244
+ await h.db.collection("roles").insertMany([
245
+ // Staff is a MEMBERSHIP of type "admin" whose role is ALSO type "admin".
246
+ // A role merely NAMED "Super Admin" is not staff -- see `super-admin.util.ts`.
247
+ { _id: emptyRole, name: "Super Admin", type: "admin", default: true, status: "active", permissions: [] },
248
+ { _id: starRole, name: "Staff Wildcard", type: "admin", status: "active", permissions: ["*"] },
249
+ { _id: promoRole, name: "Staff Promo Only", type: "admin", status: "active", permissions: ["promo-codes:create-promo-code"] },
250
+ // The MINORITY spelling. Eight apps write this; `-admin` writes
251
+ // `roles-and-permissions:add-role`. Both must pass.
252
+ { _id: rolesRole, name: "Staff Roles Legacy", type: "admin", status: "active", permissions: ["roles:add-role"] },
253
+ { _id: tenantRole, name: "Organisation Admin", org: tenantOrg, status: "active", permissions: ["*"] },
254
+ ]);
255
+
256
+ const users = await h.db.collection("users").insertMany([
257
+ { email: UNGOVERNED, password: hashed, name: "Ungoverned Staff", status: "active", createdAt: now },
258
+ { email: WILDCARD, password: hashed, name: "Wildcard Staff", status: "active", createdAt: now },
259
+ { email: PROMO_ONLY, password: hashed, name: "Promo Staff", status: "active", createdAt: now },
260
+ { email: ROLES_LEGACY, password: hashed, name: "Roles Staff", status: "active", createdAt: now },
261
+ { email: TENANT, password: hashed, name: "Tenant Admin", status: "active", defaultOrg: tenantOrg.toString(), createdAt: now },
262
+ ]);
263
+
264
+ const [empty, star, promoUser, rolesUser, tenant] = [0, 1, 2, 3, 4].map(
265
+ (i) => users.insertedIds[i],
266
+ );
267
+
268
+ await h.db.collection("members").insertMany([
269
+ { user: empty, type: "admin", role: emptyRole, status: "active" },
270
+ { user: star, type: "admin", role: starRole, status: "active" },
271
+ { user: promoUser, type: "admin", role: promoRole, status: "active" },
272
+ { user: rolesUser, type: "admin", role: rolesRole, status: "active" },
273
+ { user: tenant, org: tenantOrg, role: tenantRole, type: "organization", status: "active" },
274
+ ]);
275
+
276
+ await h.db.collection("promo-codes").insertOne({
277
+ _id: promo,
278
+ code: "E2ESEEDED1",
279
+ type: "fixed",
280
+ fixed_rate: 5,
281
+ status: "active",
282
+ createdAt: now,
283
+ });
284
+
285
+ return { tenantOrg, promo };
286
+ }
@@ -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
+ });