@7365admin1/core 3.52.14 → 3.52.15
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 +6 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +34 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +34 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/preloved-customer-scope.test.mjs +242 -0
package/package.json
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { entitledSiteScope } from "./.build/utils/camera-view.util.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Two more instances of the `/api/vehicles` defect, plus one of its own.
|
|
10
|
+
*
|
|
11
|
+
* 1. `GET /api/post-preloved` took an OPTIONAL `site` and applied it as a
|
|
12
|
+
* plain filter (`...(site && { site })`), so a request that simply omitted
|
|
13
|
+
* the parameter was answered from the WHOLE marketplace collection - every
|
|
14
|
+
* estate's classifieds, with the seller's identity attached.
|
|
15
|
+
* 2. The same query built `status` twice. `{ status: { $ne: DELETED } }` was
|
|
16
|
+
* the first key and `...(status && { status: { $in: [...] } })` the second,
|
|
17
|
+
* and the second REPLACED the first - so `?status=deleted` returned every
|
|
18
|
+
* soft-deleted post the noticeboard had hidden.
|
|
19
|
+
* 3. `GET /api/customers` declared `org` as `required()` and then trusted it,
|
|
20
|
+
* putting it straight into `{ orgId: org }`. That is a cross-ORGANISATION
|
|
21
|
+
* read, not a site one: any signed-in account could list another client's
|
|
22
|
+
* customers by changing one query parameter.
|
|
23
|
+
*
|
|
24
|
+
* Measured read-only on staging, 2026-09-03: `post-preloved` 24 rows, 0 with no
|
|
25
|
+
* site, 1 distinct site, and all 3 distinct authors hold a site-pinned
|
|
26
|
+
* membership on it - so `$in` drops nothing a legitimate caller sees today.
|
|
27
|
+
* `customers` is EMPTY on staging (0 rows), so no caller can lose a row there.
|
|
28
|
+
*
|
|
29
|
+
* The DECISION is `entitledSiteScope` / `requireOrgReach`, asked here directly.
|
|
30
|
+
* The WIRING needs a live Atlas connection to exercise, so it is asserted on the
|
|
31
|
+
* source exactly as `vehicle-site-scope.test.mjs` and
|
|
32
|
+
* `building-estate-site-scope.test.mjs` do.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const ORG_A = "aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
36
|
+
const ORG_B = "bbbbbbbbbbbbbbbbbbbbbbbb";
|
|
37
|
+
const SITE_A1 = "a1a1a1a1a1a1a1a1a1a1a1a1";
|
|
38
|
+
const SITE_A2 = "a2a2a2a2a2a2a2a2a2a2a2a2";
|
|
39
|
+
const SITE_B1 = "b1b1b1b1b1b1b1b1b1b1b1b1";
|
|
40
|
+
|
|
41
|
+
const sitesByOrg = { [ORG_A]: [SITE_A1, SITE_A2], [ORG_B]: [SITE_B1] };
|
|
42
|
+
|
|
43
|
+
const read = (p) =>
|
|
44
|
+
readFileSync(fileURLToPath(new URL(p, import.meta.url)), "utf8");
|
|
45
|
+
|
|
46
|
+
const prelovedCtrl = read("../src/controllers/post-preloved.controller.ts");
|
|
47
|
+
const prelovedRepo = read("../src/repositories/post-preloved.repo.ts");
|
|
48
|
+
const customerCtrl = read("../src/controllers/customer.controller.ts");
|
|
49
|
+
|
|
50
|
+
/* ---------- the site decision, asked directly ---------- */
|
|
51
|
+
|
|
52
|
+
test("no site + a site-level role sees its own site only", () => {
|
|
53
|
+
const scope = entitledSiteScope({
|
|
54
|
+
memberships: [{ org: ORG_A, siteId: SITE_A1, orgLevelRole: false }],
|
|
55
|
+
sitesByOrg,
|
|
56
|
+
engagedSitesByOrg: {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.deepEqual(scope, [SITE_A1]);
|
|
60
|
+
assert.ok(!scope.includes(SITE_A2), "must not reach a sibling site");
|
|
61
|
+
assert.ok(!scope.includes(SITE_B1), "must never reach another organisation");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("no site + an org-level role sees its whole org and nothing from another", () => {
|
|
65
|
+
const scope = entitledSiteScope({
|
|
66
|
+
memberships: [{ org: ORG_A, siteId: SITE_A1, orgLevelRole: true }],
|
|
67
|
+
sitesByOrg,
|
|
68
|
+
engagedSitesByOrg: {},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.deepEqual([...scope].sort(), [SITE_A1, SITE_A2].sort());
|
|
72
|
+
assert.ok(!scope.includes(SITE_B1), "another organisation is never in scope");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a caller with no live membership gets an empty scope, not the marketplace", () => {
|
|
76
|
+
const scope = entitledSiteScope({
|
|
77
|
+
memberships: [],
|
|
78
|
+
sitesByOrg,
|
|
79
|
+
engagedSitesByOrg: {},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(scope, []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/* ---------- 1. the post-preloved site leak ---------- */
|
|
86
|
+
|
|
87
|
+
test("post-preloved.getAll resolves a scope when no site is named", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
prelovedCtrl.match(
|
|
90
|
+
/const siteScope = site \? null : await entitledSites\(req\);/g,
|
|
91
|
+
)?.length,
|
|
92
|
+
1,
|
|
93
|
+
"exactly one list handler resolves the fallback scope",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// An explicit site is AUTHORISED, not trusted: a site the caller cannot
|
|
97
|
+
// reach answers as a site that does not exist.
|
|
98
|
+
assert.ok(
|
|
99
|
+
/if \(site\) await requireSiteReach\(req, site\);/.test(prelovedCtrl),
|
|
100
|
+
"an explicit site must be authorised",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
assert.ok(
|
|
104
|
+
/^ +siteScope,\s*$/m.test(prelovedCtrl),
|
|
105
|
+
"the scope must reach the repository, not be resolved and dropped",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
assert.ok(
|
|
109
|
+
/import \{ entitledSites, requireSiteReach \} from "\.\.\/utils\/site-reach\.util";/.test(
|
|
110
|
+
prelovedCtrl,
|
|
111
|
+
),
|
|
112
|
+
"the rule is the shared one, not another implementation",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the check runs BEFORE the read, not after it", () => {
|
|
117
|
+
const handler = prelovedCtrl.slice(
|
|
118
|
+
prelovedCtrl.indexOf("async function getAll("),
|
|
119
|
+
prelovedCtrl.indexOf("async function updateById("),
|
|
120
|
+
);
|
|
121
|
+
assert.ok(handler.length > 0, "the handler must still be there");
|
|
122
|
+
assert.ok(
|
|
123
|
+
handler.indexOf("requireSiteReach") < handler.indexOf("_getAll("),
|
|
124
|
+
"an unreachable site must be refused before the query runs",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("post-preloved repository applies an empty scope rather than dropping it", () => {
|
|
129
|
+
assert.equal(
|
|
130
|
+
prelovedRepo.match(/^ +siteScope\?: string\[\] \| null;$/gm)?.length,
|
|
131
|
+
1,
|
|
132
|
+
"exactly one getAll takes the restriction",
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
assert.ok(
|
|
136
|
+
/\.\.\.\(siteScope\s*\?\s*\{/.test(prelovedRepo),
|
|
137
|
+
"the restriction must be applied to the query",
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// `siteScope?.length` here would turn "reaches no site" back into "sees
|
|
141
|
+
// everything", which is the whole defect.
|
|
142
|
+
assert.ok(
|
|
143
|
+
!/siteScope\?\.length|siteScope && siteScope\.length/.test(prelovedRepo),
|
|
144
|
+
"an empty array must not collapse into the no-restriction branch",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// The `$in` and the page count must come from the SAME object, or the list
|
|
148
|
+
// is scoped and the pagination still describes the whole collection.
|
|
149
|
+
assert.equal(
|
|
150
|
+
prelovedRepo.match(/countDocuments\(query, \{ session \}\)/g)?.length,
|
|
151
|
+
2,
|
|
152
|
+
"both list handlers still count the query they matched",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/* ---------- 2. status must never resurrect deleted rows ---------- */
|
|
157
|
+
|
|
158
|
+
test("a caller-supplied status cannot return deleted posts", () => {
|
|
159
|
+
// The defect was two keys named `status` in one object literal. There must
|
|
160
|
+
// now be no spread that rebuilds the key.
|
|
161
|
+
assert.ok(
|
|
162
|
+
!/\.\.\.\(status && \{\s*status: \{ \$in:/.test(prelovedRepo),
|
|
163
|
+
"the second status spread replaced the deleted exclusion and must be gone",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Both query builders - getAll and getByOwnerId - merge instead.
|
|
167
|
+
const merged = prelovedRepo.match(
|
|
168
|
+
/status: \{\s*\$ne: PostStatus\.DELETED,\s*\.\.\.\(status && \{ \$in: Array\.isArray\(status\) \? status : \[status\] \}\),\s*\}/g,
|
|
169
|
+
);
|
|
170
|
+
assert.equal(
|
|
171
|
+
merged?.length,
|
|
172
|
+
2,
|
|
173
|
+
"both list queries keep $ne alongside any caller $in",
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// And the exclusion is still there at all. `>= 2` rather than `=== 2`: a
|
|
177
|
+
// third, unrelated `$ne` guards a single-post read elsewhere in the file and
|
|
178
|
+
// is none of this fix's business.
|
|
179
|
+
assert.ok(
|
|
180
|
+
(prelovedRepo.match(/\$ne: PostStatus\.DELETED/g)?.length ?? 0) >= 2,
|
|
181
|
+
"the deleted exclusion must survive in both list queries",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the merged status object is a real AND, not a last-write-wins", () => {
|
|
186
|
+
// Prove the semantics we rely on: two operators on ONE field are ANDed by
|
|
187
|
+
// Mongo, whereas two keys of the same name in one literal are not. This is
|
|
188
|
+
// the exact expression the repository now builds.
|
|
189
|
+
const build = (status) => ({
|
|
190
|
+
status: {
|
|
191
|
+
$ne: "deleted",
|
|
192
|
+
...(status && { $in: Array.isArray(status) ? status : [status] }),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
assert.deepEqual(build("deleted"), {
|
|
197
|
+
status: { $ne: "deleted", $in: ["deleted"] },
|
|
198
|
+
});
|
|
199
|
+
assert.equal(
|
|
200
|
+
build("deleted").status.$ne,
|
|
201
|
+
"deleted",
|
|
202
|
+
"$ne survives a caller asking for deleted - the $in cannot widen it back",
|
|
203
|
+
);
|
|
204
|
+
assert.deepEqual(build(undefined), { status: { $ne: "deleted" } });
|
|
205
|
+
assert.deepEqual(build(["published", "sold"]), {
|
|
206
|
+
status: { $ne: "deleted", $in: ["published", "sold"] },
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/* ---------- 3. the customers cross-ORG leak ---------- */
|
|
211
|
+
|
|
212
|
+
test("GET /api/customers authorises the org it was handed", () => {
|
|
213
|
+
const handler = customerCtrl.slice(
|
|
214
|
+
customerCtrl.indexOf("async function getAll("),
|
|
215
|
+
customerCtrl.indexOf("async function getCustomerById("),
|
|
216
|
+
);
|
|
217
|
+
assert.ok(handler.length > 0, "the handler must still be there");
|
|
218
|
+
|
|
219
|
+
assert.ok(
|
|
220
|
+
/await requireOrgReach\(req, org\);/.test(handler),
|
|
221
|
+
"the org from the query string must be authorised, not trusted",
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.ok(
|
|
225
|
+
handler.indexOf("requireOrgReach") < handler.indexOf("_getAll("),
|
|
226
|
+
"the check must run BEFORE the read, not after it",
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
assert.ok(
|
|
230
|
+
/import \{ requireOrgReach \} from "\.\.\/utils\/console-authz\.util";/.test(
|
|
231
|
+
customerCtrl,
|
|
232
|
+
),
|
|
233
|
+
"the org rule is the shared one, not a site check standing in for it",
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// This is an ORG check. Site entitlement is a different question and must
|
|
237
|
+
// not be conflated with it here.
|
|
238
|
+
assert.ok(
|
|
239
|
+
!/entitledSites|requireSiteReach/.test(customerCtrl),
|
|
240
|
+
"customers is scoped by organisation, not by site entitlement",
|
|
241
|
+
);
|
|
242
|
+
});
|