@7365admin1/core 3.64.5 → 3.65.1
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 +52 -0
- package/dist/index.d.ts +1055 -677
- package/dist/index.js +7948 -6996
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6087 -5149
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/console-permission.test.mjs +4 -3
- package/test/e2e/member-direct-role-org.e2e.test.mjs +255 -0
- package/test/e2e/member-direct-self-enrol-role.e2e.test.mjs +204 -0
- package/test/e2e/org-anon-lookup-projection.e2e.test.mjs +11 -1
- package/test/e2e/organization-read-scope.e2e.test.mjs +4 -1
- package/test/e2e/organization-record-reach.e2e.test.mjs +241 -0
- package/test/e2e/user-field-self-allowlist.e2e.test.mjs +172 -0
- package/test/member-direct-role-org.test.mjs +33 -0
- package/test/member-self-enrol-role.test.mjs +43 -0
- package/test/module-access.test.mjs +128 -0
- package/test/module-gate.fixtures.mjs +108 -0
- package/test/module-gate.test.mjs +64 -0
- package/test/modules-endpoint.test.mjs +525 -0
- package/test/notification-access.test.mjs +197 -0
- package/test/org-record-reach.test.mjs +45 -0
- package/test/resident-app-modules.test.mjs +45 -1
- package/test/role-member-org-scope.test.mjs +2 -1
- package/test/site-modules.test.mjs +22 -0
- package/test/staff-console-authz.test.mjs +9 -0
- package/test/user-field-self-allowlist.test.mjs +39 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Q7 (owner, 2026-09-11): staff get no notifications for modules they cannot
|
|
3
|
+
* open. Shipped in LOG-ONLY mode: counted, logged, and still sent to everyone.
|
|
4
|
+
* Only the Super Admin module list can ever drop anybody; the role check is
|
|
5
|
+
* logged only (master plan rule 3). Any error sends to everyone.
|
|
6
|
+
*/
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
filterByAccess,
|
|
13
|
+
notificationAccessReport,
|
|
14
|
+
notifyAccessMode,
|
|
15
|
+
} from "./.build/utils/notification-access.util.mjs";
|
|
16
|
+
import {
|
|
17
|
+
ORG,
|
|
18
|
+
SITE,
|
|
19
|
+
USER,
|
|
20
|
+
USER_2,
|
|
21
|
+
CLIENT_SAVE,
|
|
22
|
+
SITE_SAVE,
|
|
23
|
+
ackRow,
|
|
24
|
+
oldRow,
|
|
25
|
+
member,
|
|
26
|
+
fakeGate,
|
|
27
|
+
withEnv,
|
|
28
|
+
} from "./module-gate.fixtures.mjs";
|
|
29
|
+
|
|
30
|
+
const SITE_DOC = { _id: SITE, orgId: ORG, metadata: {} };
|
|
31
|
+
// The client was given visitors only: work orders, feedback, incidents... are denied.
|
|
32
|
+
const LIST = ["visitor-mgmt"];
|
|
33
|
+
const enforced = (over = {}) =>
|
|
34
|
+
fakeGate({
|
|
35
|
+
orgs: { [ORG]: { _id: ORG, modules: LIST } },
|
|
36
|
+
sites: { [SITE]: SITE_DOC },
|
|
37
|
+
saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(LIST) },
|
|
38
|
+
roles: { r1: { permissions: ["*"] } },
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
const WO = { category: "workOrder", siteId: SITE };
|
|
42
|
+
const on = (fn) => withEnv({ NOTIFY_ACCESS_FILTER: "on", MODULE_LIST_GATE: undefined }, fn);
|
|
43
|
+
|
|
44
|
+
test("the code default is LOG; anything unrecognised is log", () => {
|
|
45
|
+
assert.equal(notifyAccessMode({}), "log");
|
|
46
|
+
assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "garbage" }), "log");
|
|
47
|
+
assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "ON" }), "on");
|
|
48
|
+
assert.equal(notifyAccessMode({ NOTIFY_ACCESS_FILTER: "off" }), "off");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("LOG never drops: the denied recipient is counted and still sent to", async () => {
|
|
52
|
+
const { data } = enforced({ rows: [member()] });
|
|
53
|
+
const report = await notificationAccessReport([USER], WO, data);
|
|
54
|
+
assert.deepEqual(report.dropByModule, [USER], "control: the list does deny work orders");
|
|
55
|
+
const sent = await withEnv({ NOTIFY_ACCESS_FILTER: undefined }, () => filterByAccess([USER], WO, data));
|
|
56
|
+
assert.deepEqual(sent, [USER]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("ON drops only the member whose client list denies the module", async () => {
|
|
60
|
+
const other = member({ user: USER_2, org: "9".repeat(24), siteId: SITE }); // a provider: own org, no list
|
|
61
|
+
const { data } = enforced({ rows: [member(), other] });
|
|
62
|
+
assert.deepEqual(await on(() => filterByAccess([USER, USER_2], WO, data)), [USER_2]);
|
|
63
|
+
// CONTROL: a category the list gives is kept.
|
|
64
|
+
assert.deepEqual(await on(() => filterByAccess([USER], { category: "visitors", siteId: SITE }, data)), [USER]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the kill switch and OFF read nothing and drop nothing", async () => {
|
|
68
|
+
for (const env of [
|
|
69
|
+
{ NOTIFY_ACCESS_FILTER: "off" },
|
|
70
|
+
{ NOTIFY_ACCESS_FILTER: "on", MODULE_LIST_GATE: "off" },
|
|
71
|
+
]) {
|
|
72
|
+
const { data, calls } = enforced({ rows: [member()] });
|
|
73
|
+
assert.deepEqual(await withEnv(env, () => filterByAccess([USER], WO, data)), [USER]);
|
|
74
|
+
assert.deepEqual(calls, {}, JSON.stringify(env));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("an old, never-acknowledged list drops nobody", async () => {
|
|
79
|
+
const { data } = enforced({ rows: [member()], saves: { [`${CLIENT_SAVE}|${ORG}`]: oldRow(LIST) } });
|
|
80
|
+
assert.deepEqual(await on(() => filterByAccess([USER], WO, data)), [USER]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("exempt sends: no site, an unknown category, or one no permission governs", async () => {
|
|
84
|
+
for (const ctx of [
|
|
85
|
+
{ category: "workOrder" },
|
|
86
|
+
{ category: "not-a-category", siteId: SITE },
|
|
87
|
+
{ category: "myVisitors", siteId: SITE },
|
|
88
|
+
{ category: "prelovedMarketplace", siteId: SITE },
|
|
89
|
+
{ category: "marketplaceOrder", siteId: SITE },
|
|
90
|
+
]) {
|
|
91
|
+
const { data, calls } = enforced({ rows: [member()] });
|
|
92
|
+
assert.equal(await notificationAccessReport([USER], ctx, data), null, JSON.stringify(ctx));
|
|
93
|
+
assert.deepEqual(await on(() => filterByAccess([USER], ctx, data)), [USER]);
|
|
94
|
+
assert.deepEqual(calls, {}, "an exempt send reads nothing");
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("exempt recipients: residents, Seven365 staff, and anyone with no membership here", async () => {
|
|
99
|
+
const resident = member({ type: "resident", siteId: SITE });
|
|
100
|
+
const staff = member({ user: USER_2 });
|
|
101
|
+
const staffRow = member({ user: USER_2, type: "admin", org: "" });
|
|
102
|
+
const { data } = enforced({ rows: [resident, staff, staffRow] });
|
|
103
|
+
const stranger = "1".repeat(24); // a push-token user with no member row
|
|
104
|
+
const report = await notificationAccessReport([USER, USER_2, stranger], WO, data);
|
|
105
|
+
assert.deepEqual(report.dropByModule, []);
|
|
106
|
+
assert.deepEqual(report.noMember, [stranger], "counted, never dropped");
|
|
107
|
+
assert.deepEqual(await on(() => filterByAccess([USER, USER_2, stranger], WO, data)), [USER, USER_2, stranger]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("spelling groups: a list giving `workOrder` keeps every work-order notification", async () => {
|
|
111
|
+
const list = ["workOrder"];
|
|
112
|
+
const { data } = enforced({
|
|
113
|
+
rows: [member()],
|
|
114
|
+
orgs: { [ORG]: { _id: ORG, modules: list } },
|
|
115
|
+
saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(list) },
|
|
116
|
+
});
|
|
117
|
+
assert.deepEqual(await on(() => filterByAccess([USER], WO, data)), [USER]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("an ungoverned resource is never denied, and a two-module category needs both denied", async () => {
|
|
121
|
+
const { data } = enforced({ rows: [member()] });
|
|
122
|
+
// cameraFault is governed by `site-settings`, which a list can never take away.
|
|
123
|
+
assert.deepEqual(await on(() => filterByAccess([USER], { category: "cameraFault", siteId: SITE }, data)), [USER]);
|
|
124
|
+
const list = ["facility-mgmt"]; // facilityBooking = facility-booking-mgmt + facility-mgmt
|
|
125
|
+
const one = enforced({
|
|
126
|
+
rows: [member()],
|
|
127
|
+
orgs: { [ORG]: { _id: ORG, modules: list } },
|
|
128
|
+
saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(list) },
|
|
129
|
+
}).data;
|
|
130
|
+
assert.deepEqual(await on(() => filterByAccess([USER], { category: "facilityBooking", siteId: SITE }, one)), [USER]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a person kept by ANY of their memberships at the site is kept", async () => {
|
|
134
|
+
const pinnedElsewhereOrg = member({ _id: "x2", org: "9".repeat(24), siteId: SITE });
|
|
135
|
+
const { data } = enforced({ rows: [member(), pinnedElsewhereOrg] });
|
|
136
|
+
assert.deepEqual(await on(() => filterByAccess([USER], WO, data)), [USER]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("the ROLE check is logged, never enforced: `*` and [] hold everything", async () => {
|
|
140
|
+
// A list that gives work orders, so only the role can matter (and a list
|
|
141
|
+
// must be enforced here for anything to be looked at at all).
|
|
142
|
+
const gives = ["workOrder", "visitor-mgmt"];
|
|
143
|
+
const listed = {
|
|
144
|
+
sites: { [SITE]: SITE_DOC },
|
|
145
|
+
orgs: { [ORG]: { _id: ORG, modules: gives } },
|
|
146
|
+
saves: { [`${CLIENT_SAVE}|${ORG}`]: ackRow(gives) },
|
|
147
|
+
};
|
|
148
|
+
const { data } = fakeGate({
|
|
149
|
+
...listed,
|
|
150
|
+
rows: [member({ role: "star" }), member({ user: USER_2, role: "visitorsOnly" })],
|
|
151
|
+
roles: { star: { permissions: ["*"] }, visitorsOnly: { permissions: ["visitor-mgmt:add-visitor"] } },
|
|
152
|
+
});
|
|
153
|
+
const report = await notificationAccessReport([USER, USER_2], WO, data);
|
|
154
|
+
assert.deepEqual(report.dropByRole, [USER_2]);
|
|
155
|
+
assert.deepEqual(report.dropByModule, []);
|
|
156
|
+
assert.deepEqual(await on(() => filterByAccess([USER, USER_2], WO, data)), [USER, USER_2]);
|
|
157
|
+
|
|
158
|
+
const empty = fakeGate({ ...listed, rows: [member()], roles: { r1: { permissions: [] } } }).data;
|
|
159
|
+
assert.deepEqual((await notificationAccessReport([USER], WO, empty)).dropByRole, []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("no ENFORCED list at the site or its org: returns early, with no members or roles query", async () => {
|
|
163
|
+
for (const setup of [
|
|
164
|
+
{}, // no list anywhere: today's whole estate
|
|
165
|
+
{ orgs: { [ORG]: { _id: ORG, modules: LIST } } }, // stored, never acknowledged
|
|
166
|
+
]) {
|
|
167
|
+
const { data, calls } = fakeGate({ rows: [member()], sites: { [SITE]: SITE_DOC }, ...setup });
|
|
168
|
+
assert.deepEqual(await on(() => filterByAccess([USER], WO, data)), [USER]);
|
|
169
|
+
assert.equal(calls.membershipsOf ?? 0, 0, "no members query");
|
|
170
|
+
assert.equal(calls.roles ?? 0, 0, "no roles query");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// CONTROL: an enforced SITE list alone is enough to look, and to drop.
|
|
174
|
+
const { data, calls } = fakeGate({
|
|
175
|
+
rows: [member()],
|
|
176
|
+
sites: { [SITE]: { ...SITE_DOC, metadata: { modules: LIST } } },
|
|
177
|
+
saves: { [`${SITE_SAVE}|${SITE}`]: ackRow(LIST) },
|
|
178
|
+
});
|
|
179
|
+
assert.deepEqual(await on(() => filterByAccess([USER], WO, data)), []);
|
|
180
|
+
assert.equal(calls.membershipsOf, 1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("any error sends to everyone, even in ON mode", async () => {
|
|
184
|
+
for (const broken of ["membershipsOf", "site", "org", "latestSave", "roles"]) {
|
|
185
|
+
const { data } = enforced({ rows: [member()], throwOn: [broken] });
|
|
186
|
+
assert.deepEqual(await on(() => filterByAccess([USER], WO, data)), [USER], broken);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("core's sender filters BEFORE the preference guard, on both channels", () => {
|
|
191
|
+
const text = readFileSync(new URL("../src/services/notification.service.ts", import.meta.url), "utf8");
|
|
192
|
+
const send = text.slice(text.indexOf("async function send("), text.indexOf("const SAFETY_APP_SLUG"));
|
|
193
|
+
const filter = send.indexOf("userIds = await filterByAccess(userIds, { category: key, siteId: data?.siteId })");
|
|
194
|
+
assert.ok(filter > 0, "send() does not call the filter");
|
|
195
|
+
assert.ok(filter < send.indexOf('_filterRecipients(userIds, key, "inApp")'));
|
|
196
|
+
assert.ok(filter < send.indexOf('_filterRecipients(userIds, key, "push")'));
|
|
197
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* GET /api/organizations/id/:id hands the WHOLE record only to a real
|
|
3
|
+
* relationship; everybody else gets the name-only projection, never a 401.
|
|
4
|
+
* The behaviour is proved end to end in test/e2e/organization-record-reach
|
|
5
|
+
* (needs a local mongod, so CI does not run it); these source checks keep the
|
|
6
|
+
* wiring from quietly regressing in the suite CI does run.
|
|
7
|
+
*/
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
const ORG = readFileSync("src/controllers/organization.controller.ts", "utf8");
|
|
13
|
+
const AUTHZ = readFileSync("src/utils/console-authz.util.ts", "utf8");
|
|
14
|
+
const ACTOR = readFileSync("src/utils/invite-actor.util.ts", "utf8");
|
|
15
|
+
|
|
16
|
+
const body = (src, name, len = 1800) => src.slice(src.indexOf(name), src.indexOf(name) + len);
|
|
17
|
+
|
|
18
|
+
test("getById decides full vs name-only through canReadOrgRecord", () => {
|
|
19
|
+
const getById = body(ORG, "async function getById(");
|
|
20
|
+
assert.match(getById, /\(await canReadOrgRecord\(req, _id\)\)\s*\?\s*await _getById\(_id\)\s*:\s*await _getNameById\(_id\)/);
|
|
21
|
+
// CONTROL: the old "any session gets everything" line is gone.
|
|
22
|
+
assert.doesNotMatch(getById, /callerId\(req\) \? await _getById/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("canReadOrgRecord never throws a refusal - it answers yes or no", () => {
|
|
26
|
+
const fn = body(AUTHZ, "export async function canReadOrgRecord(", 1400);
|
|
27
|
+
assert.match(fn, /gate\.then\(\(\) => true, \(\) => false\)/);
|
|
28
|
+
assert.match(fn, /requireOrgReach\(req, org\)/);
|
|
29
|
+
assert.match(fn, /requireConsolePermission\(req, "organizations", "see-organization-details"\)/);
|
|
30
|
+
assert.match(fn, /hasOrgSiteReach\(id, org, actor\.orgIds\)/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the resident road counts only an active account and an active/approved site.people row; the provider road ignores a dead engagement", () => {
|
|
34
|
+
const fn = body(ACTOR, "export async function hasOrgSiteReach(", 4500);
|
|
35
|
+
// A rejected or resubmit applicant can sign in with the site and unit they
|
|
36
|
+
// applied for still on the account.
|
|
37
|
+
assert.match(fn, /projection: \{ site: 1, unitId: 1, status: 1 \}/);
|
|
38
|
+
assert.match(fn, /if \(!user \|\| !readsAsApprovedAccount\(user\.status\)\) return false;/);
|
|
39
|
+
assert.ok(
|
|
40
|
+
fn.indexOf("readsAsApprovedAccount(user.status)") < fn.indexOf('collection("site.people")'),
|
|
41
|
+
"the account check runs before any resident road",
|
|
42
|
+
);
|
|
43
|
+
assert.match(fn, /status: \{ \$in: \["active", "approved"\] \}/);
|
|
44
|
+
assert.match(fn, /status: "active",\s*deletedAt: \{ \$in: \["", null\] \}/);
|
|
45
|
+
});
|
|
@@ -100,18 +100,55 @@ test("every one of the ten keys is always answered", () => {
|
|
|
100
100
|
|
|
101
101
|
test("turning ON a module Seven365 turned OFF is refused, by name", () => {
|
|
102
102
|
assert.deepEqual(
|
|
103
|
-
residentAppModulesAboveCeiling({ access: false }, { access: true }),
|
|
103
|
+
residentAppModulesAboveCeiling({ access: false }, { access: true }, { access: false }),
|
|
104
104
|
["access"],
|
|
105
105
|
);
|
|
106
106
|
assert.deepEqual(
|
|
107
107
|
residentAppModulesAboveCeiling(
|
|
108
108
|
{ access: false, onlineForm: false },
|
|
109
109
|
{ access: true, onlineForm: true, feedback: true },
|
|
110
|
+
{ access: false, onlineForm: false, feedback: false },
|
|
110
111
|
),
|
|
111
112
|
["access", "onlineForm"],
|
|
112
113
|
);
|
|
113
114
|
});
|
|
114
115
|
|
|
116
|
+
/*
|
|
117
|
+
* L2, 2026-09-11. Every Site Settings save sends the WHOLE metadata back: the
|
|
118
|
+
* PM resident panel fills absent switches in as `true` and sends all ten, and
|
|
119
|
+
* the HID QR / face-reader panels send back whatever they read. Judging the
|
|
120
|
+
* request alone meant ONE unticked module at the client refused every save at
|
|
121
|
+
* every one of its sites - including saves that never touched a resident switch.
|
|
122
|
+
*/
|
|
123
|
+
test("a save that re-sends what the site already has is never refused", () => {
|
|
124
|
+
const ceiling = { access: false, onlineForm: false };
|
|
125
|
+
// nothing stored: the panel sends all ten as true (absent == on)
|
|
126
|
+
assert.deepEqual(residentAppModulesAboveCeiling(ceiling, allOn(), undefined), []);
|
|
127
|
+
assert.deepEqual(residentAppModulesAboveCeiling(ceiling, allOn(), {}), []);
|
|
128
|
+
// stored true (saved before the ceiling existed) is re-sent unchanged
|
|
129
|
+
assert.deepEqual(residentAppModulesAboveCeiling(ceiling, allOn(), allOn()), []);
|
|
130
|
+
// stored false is re-sent unchanged while ANOTHER switch changes
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
residentAppModulesAboveCeiling(
|
|
133
|
+
ceiling,
|
|
134
|
+
{ ...allOn(), access: false, feedback: false },
|
|
135
|
+
{ ...allOn(), access: false },
|
|
136
|
+
),
|
|
137
|
+
[],
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("only the switch actually being flipped from off to on is named", () => {
|
|
142
|
+
assert.deepEqual(
|
|
143
|
+
residentAppModulesAboveCeiling(
|
|
144
|
+
{ access: false, onlineForm: false },
|
|
145
|
+
allOn(),
|
|
146
|
+
{ access: false, onlineForm: true },
|
|
147
|
+
),
|
|
148
|
+
["access"],
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
115
152
|
test("an organisation with no ceiling refuses nothing", () => {
|
|
116
153
|
// Every live organisation is in this state today. If this refused, the Site
|
|
117
154
|
// Settings panel would stop saving for everybody on deploy.
|
|
@@ -184,6 +221,13 @@ test("the site WRITE refuses only when it actually carries the switches", () =>
|
|
|
184
221
|
assert.match(SITE_CONTROLLER, /residentAppModulesAboveCeiling\(/);
|
|
185
222
|
});
|
|
186
223
|
|
|
224
|
+
test("the site WRITE judges the request against what the site already STORES", () => {
|
|
225
|
+
const start = SITE_CONTROLLER.search(/residentAppModulesAboveCeiling\(\s/);
|
|
226
|
+
assert.ok(start >= 0, "the site write calls residentAppModulesAboveCeiling");
|
|
227
|
+
const call = SITE_CONTROLLER.slice(start, start + 300);
|
|
228
|
+
assert.match(call, /\(existing as any\)\?\.metadata\?\.residentAppModules/);
|
|
229
|
+
});
|
|
230
|
+
|
|
187
231
|
test("Seven365 sets the ceiling on the console's own endpoint, and it is optional", () => {
|
|
188
232
|
// Optional so the existing console screen, which sends only `modules`, keeps
|
|
189
233
|
// working and leaves the resident-app ceiling exactly as it was.
|
|
@@ -89,7 +89,8 @@ test("enrolling yourself needs reach; enrolling anybody else needs membership",
|
|
|
89
89
|
const body = fn(memberController, "createMemberDirect");
|
|
90
90
|
assert.match(
|
|
91
91
|
body,
|
|
92
|
-
|
|
92
|
+
// ...and yourself only with a role you may give yourself (requireSelfEnrolRole).
|
|
93
|
+
/if \(userId === callerId\(req\)\)[\s\S]{0,120}await requireOrgReach\(req, orgId\)[\s\S]{0,120}await requireSelfEnrolRole\(req, orgId, roleId\)[\s\S]{0,40}else[\s\S]{0,80}await requireOrgAccess\(req, orgId\)/,
|
|
93
94
|
);
|
|
94
95
|
});
|
|
95
96
|
|
|
@@ -33,7 +33,29 @@ import {
|
|
|
33
33
|
permissionsOutsideModules,
|
|
34
34
|
requireWithinModules,
|
|
35
35
|
keepStoredSiteModules,
|
|
36
|
+
siteModulesForOrg,
|
|
36
37
|
} from "./.build/utils/site-modules.util.mjs";
|
|
38
|
+
|
|
39
|
+
test("a site's list narrows a role only when the site belongs to the role's organisation", () => {
|
|
40
|
+
const site = { orgId: "org-client", metadata: { modules: ["visitor-mgmt"] } };
|
|
41
|
+
assert.deepEqual(siteModulesForOrg(site, "org-client"), ["visitor-mgmt"]);
|
|
42
|
+
// A provider's role at the client's site: the client's site list does not apply.
|
|
43
|
+
assert.equal(siteModulesForOrg(site, "org-provider"), undefined);
|
|
44
|
+
// Older site rows carry `org` instead of `orgId`.
|
|
45
|
+
assert.deepEqual(siteModulesForOrg({ org: "org-client", metadata: { modules: ["a"] } }, "org-client"), ["a"]);
|
|
46
|
+
// A site with no recorded owner does not narrow an org's role; no site, nothing.
|
|
47
|
+
assert.equal(siteModulesForOrg({ metadata: { modules: ["a"] } }, "org-client"), undefined);
|
|
48
|
+
assert.equal(siteModulesForOrg(null, "org-client"), undefined);
|
|
49
|
+
// A role with NO organisation (role-v2 allows it) is still limited by the
|
|
50
|
+
// site's own list, exactly as before - leaving `org` out must not widen.
|
|
51
|
+
assert.deepEqual(siteModulesForOrg(site, ""), ["visitor-mgmt"]);
|
|
52
|
+
assert.deepEqual(siteModulesForOrg(site, undefined), ["visitor-mgmt"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("moduleCeiling passes the site list through the owner check", () => {
|
|
56
|
+
const src = readFileSync("src/utils/module-ceiling.util.ts", "utf8");
|
|
57
|
+
assert.match(src, /effectiveSiteModules\(\s*\(orgDoc as any\)\?\.modules,\s*siteModulesForOrg\(siteDoc, orgId\),\s*\)/);
|
|
58
|
+
});
|
|
37
59
|
import { ALWAYS_ALLOWED } from "./.build/utils/org-modules.util.mjs";
|
|
38
60
|
|
|
39
61
|
/* ── reading: it must never return "nothing" ───────────────────────────── */
|
|
@@ -120,6 +120,15 @@ const GUARDED = [
|
|
|
120
120
|
"requireConsolePermission",
|
|
121
121
|
"_update(",
|
|
122
122
|
],
|
|
123
|
+
// The read-only "who would lose what" preview that must come before a
|
|
124
|
+
// narrowing `updateModules` save: the same gate, since it reads a client's
|
|
125
|
+
// member names and roles.
|
|
126
|
+
[
|
|
127
|
+
"organization.controller.ts",
|
|
128
|
+
"previewOrgModules",
|
|
129
|
+
"requireConsolePermission",
|
|
130
|
+
"orgModulePreview(",
|
|
131
|
+
],
|
|
123
132
|
["organization-v2.controller.ts", "getAll", "requireConsolePermission", "_getAll("],
|
|
124
133
|
[
|
|
125
134
|
"organization-v2.controller.ts",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PATCH /api/users/field/:id (and /v2): self-service is an explicit allow-list
|
|
3
|
+
* of profile fields; `status` and every other field are staff-only, and
|
|
4
|
+
* `defaultOrg` only to an organisation the caller reaches. Proved end to end in
|
|
5
|
+
* test/e2e/user-field-self-allowlist (needs a local mongod, so CI does not run
|
|
6
|
+
* it); these source checks keep the wiring in the suite CI does run.
|
|
7
|
+
*/
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
const AUTHZ = readFileSync("src/utils/console-authz.util.ts", "utf8");
|
|
13
|
+
const V1 = readFileSync("src/controllers/user.controller.ts", "utf8");
|
|
14
|
+
const V2 = readFileSync("src/controllers/user-v2.controller.ts", "utf8");
|
|
15
|
+
|
|
16
|
+
const allow = AUTHZ.slice(AUTHZ.indexOf("export const SELF_EDITABLE_USER_FIELDS"));
|
|
17
|
+
const listed = allow.slice(0, allow.indexOf("];")).match(/"([a-zA-Z]+)"/g).map((s) => s.slice(1, -1));
|
|
18
|
+
|
|
19
|
+
test("the self-service list is exactly the profile fields the apps send", () => {
|
|
20
|
+
assert.deepEqual([...listed].sort(), ["contact", "dateOfBirth", "gender", "name", "nric", "profile"]);
|
|
21
|
+
for (const f of ["status", "email", "plateNumber", "defaultOrg"]) {
|
|
22
|
+
assert.ok(!listed.includes(f), `${f} must not be self-service`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("defaultOrg is self-set only through requireOrgReach, and the fall-through is staff", () => {
|
|
27
|
+
const start = AUTHZ.indexOf("export async function requireUserFieldWrite(");
|
|
28
|
+
const fn = AUTHZ.slice(start, AUTHZ.indexOf("export const SELF_EDITABLE_USER_FIELDS"));
|
|
29
|
+
assert.match(fn, /field === "defaultOrg"/);
|
|
30
|
+
assert.match(fn, /requireOrgReach\(req, org\)/);
|
|
31
|
+
// the LAST statement of the function: anything not allowed above is staff
|
|
32
|
+
assert.match(fn, /return await requirePlatformStaff\(req, grant\);\s*\}\s*(\/\*\*[\s\S]*?\*\/\s*)?$/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("both controllers hand the value to the gate", () => {
|
|
36
|
+
for (const src of [V1, V2]) {
|
|
37
|
+
assert.match(src, /requireUserFieldWrite\(req, _id, payload\.field, \{ resource: "users" \}, payload\.value\)/);
|
|
38
|
+
}
|
|
39
|
+
});
|