@7365admin1/core 3.64.0 → 3.64.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 +35 -0
- package/dist/index.js +18 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +18 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/customer-sites-one-row-per-site.test.mjs +107 -0
- package/test/role-scope.test.mjs +26 -0
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ONE ROW PER SITE, NAMED BY THE SITE.
|
|
3
|
+
*
|
|
4
|
+
* Owner report, 2026-09-10: a provider's site list showed FOUR entries for THREE
|
|
5
|
+
* sites, and the site switcher ticked two at once. "NCC" and "Seventh
|
|
6
|
+
* Condominium" were the same site.
|
|
7
|
+
*
|
|
8
|
+
* Two faults, compounding:
|
|
9
|
+
*
|
|
10
|
+
* 1. `getAll` grouped by `{ name, site }`. A `customer-sites` row is an
|
|
11
|
+
* ENGAGEMENT and one site can carry more than one, so two rows for the same
|
|
12
|
+
* site with different names both survived as "distinct".
|
|
13
|
+
* 2. `name` is a SNAPSHOT from when the engagement was created. Site
|
|
14
|
+
* `6923d666…` is named "Seventh Condominium"; its NEWER engagement row still
|
|
15
|
+
* says "NCC". Recency is no guide — the newer row held the staler name — so
|
|
16
|
+
* only the site record is reliable.
|
|
17
|
+
*
|
|
18
|
+
* These assertions read the shipped pipeline rather than a copy, because the
|
|
19
|
+
* defect was in the pipeline's shape, not in a helper.
|
|
20
|
+
*/
|
|
21
|
+
import test from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
|
|
25
|
+
const REPO = readFileSync(
|
|
26
|
+
new URL("../src/repositories/customer-site.repo.ts", import.meta.url),
|
|
27
|
+
"utf8",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function getAllBody() {
|
|
31
|
+
const start = REPO.indexOf("async function getAll(");
|
|
32
|
+
assert.notEqual(start, -1, "getAll is gone");
|
|
33
|
+
const next = REPO.indexOf("\n async function ", start + 1);
|
|
34
|
+
return REPO.slice(start, next === -1 ? undefined : next);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("distinct means one row per SITE, not per name-and-site", () => {
|
|
38
|
+
const body = getAllBody();
|
|
39
|
+
assert.match(body, /\$group: \{ _id: "\$site", doc: \{ \$first: "\$\$ROOT" \} \}/);
|
|
40
|
+
assert.ok(
|
|
41
|
+
!/_id: \{ name: "\$name", site: "\$site" \}/.test(body),
|
|
42
|
+
"still grouping by name AND site — a stale name makes a phantom site",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the name comes from the SITE, with the snapshot as a fallback", () => {
|
|
47
|
+
const body = getAllBody();
|
|
48
|
+
assert.match(body, /from: "sites"/);
|
|
49
|
+
assert.match(body, /localField: "site"/);
|
|
50
|
+
assert.match(body, /\$set: \{ name: \{ \$ifNull: \["\$_liveSite\.name", "\$name"\] \} \}/);
|
|
51
|
+
|
|
52
|
+
// A site that cannot be read must degrade to the stored name, never to blank.
|
|
53
|
+
assert.match(body, /preserveNullAndEmptyArrays: true/);
|
|
54
|
+
// And the joined document must not leak into the response.
|
|
55
|
+
assert.match(body, /\$unset: "_liveSite"/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the search runs AFTER the rename, so a site is found by its real name", () => {
|
|
59
|
+
/*
|
|
60
|
+
* Matching the snapshot would hide a site under the name it actually has —
|
|
61
|
+
* searching "Seventh Condominium" would miss a row whose snapshot says "NCC".
|
|
62
|
+
*/
|
|
63
|
+
const body = getAllBody();
|
|
64
|
+
const rename = body.indexOf("$ifNull");
|
|
65
|
+
const searchMatch = body.indexOf("$regex: search");
|
|
66
|
+
assert.notEqual(searchMatch, -1, "the search stage is gone");
|
|
67
|
+
assert.ok(rename < searchMatch, "the search still runs before the rename");
|
|
68
|
+
|
|
69
|
+
// It must no longer be part of the pre-aggregation query.
|
|
70
|
+
assert.ok(
|
|
71
|
+
!/query\.\$or = \[\{ name: \{ \$regex: search/.test(body),
|
|
72
|
+
"the search is back on the stored snapshot",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the final sort runs after the rename too", () => {
|
|
77
|
+
const body = getAllBody();
|
|
78
|
+
const rename = body.indexOf("$ifNull");
|
|
79
|
+
const lastSort = body.lastIndexOf("$sort: sort");
|
|
80
|
+
assert.ok(lastSort > rename, "rows are ordered by the stale name");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the count is derived from the SAME pipeline", () => {
|
|
84
|
+
/*
|
|
85
|
+
* Counting differently from the way rows are produced is how a list says
|
|
86
|
+
* "1-4 of 4" while showing three — which is exactly what the owner saw.
|
|
87
|
+
*/
|
|
88
|
+
const body = getAllBody();
|
|
89
|
+
assert.match(body, /aggregate\(\[\.\.\.distinctPipeline, \{ \$count: "total" \}\]/);
|
|
90
|
+
assert.ok(
|
|
91
|
+
!/\{ \$group: \{ _id: \{ name: "\$name", site: "\$site" \} \} \},\s*\{ \$count/.test(body),
|
|
92
|
+
"the count still groups by name and site",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("CONTROL: the org, status and site-scope match still runs FIRST", () => {
|
|
97
|
+
/*
|
|
98
|
+
* The scoping is the security boundary — a site-scoped caller must never see
|
|
99
|
+
* the whole estate. It has to stay ahead of everything else in the pipeline.
|
|
100
|
+
*/
|
|
101
|
+
const body = getAllBody();
|
|
102
|
+
const firstMatch = body.indexOf("{ $match: query }");
|
|
103
|
+
const group = body.indexOf('$group: { _id: "$site"');
|
|
104
|
+
assert.notEqual(firstMatch, -1, "the scoping match is gone");
|
|
105
|
+
assert.ok(firstMatch < group, "scoping no longer runs before grouping");
|
|
106
|
+
assert.match(body, /query\.site = \{ \$in: scoped\.map/);
|
|
107
|
+
});
|
package/test/role-scope.test.mjs
CHANGED
|
@@ -112,6 +112,32 @@ test("/api/roles/v2 is gated too — the same hole with /v2 on the URL", () => {
|
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test("/api/roles/v2 cannot mint a role holding the CONSOLE's own resources", () => {
|
|
116
|
+
/*
|
|
117
|
+
* `role.controller createRole` has carried `requireNoPlatformGrant` for a
|
|
118
|
+
* while; this mount did not. It is a separate controller behind a separate
|
|
119
|
+
* route and takes any string at all, so the hole was reachable by adding
|
|
120
|
+
* `/v2` to the URL - the same shape as the two gates above it.
|
|
121
|
+
*
|
|
122
|
+
* MEASURED BEFORE ADDING, on production, read-only: NO site-scoped role holds
|
|
123
|
+
* a platform-only string (6 site-scoped roles read; the 11 roles that do hold
|
|
124
|
+
* one are org-scoped and already covered by the v1 gate). This mount REQUIRES
|
|
125
|
+
* a `site`, so the check refuses nothing that exists today.
|
|
126
|
+
*/
|
|
127
|
+
const body = fn(controllerV2, "createRole");
|
|
128
|
+
assert.match(body, /requireNoPlatformGrant\(/);
|
|
129
|
+
|
|
130
|
+
// It must run BEFORE the write, or it decides nothing.
|
|
131
|
+
const guard = body.indexOf("requireNoPlatformGrant(");
|
|
132
|
+
const write = body.indexOf("_createRole(");
|
|
133
|
+
assert.ok(guard !== -1 && write !== -1);
|
|
134
|
+
assert.ok(guard < write, "the role is created before the platform check");
|
|
135
|
+
|
|
136
|
+
// CONTROL: v1 still has it, so the assertion above is a real comparison and
|
|
137
|
+
// not a rule that only exists on one side.
|
|
138
|
+
assert.match(fn(controller, "createRole"), /requireNoPlatformGrant\(/);
|
|
139
|
+
});
|
|
140
|
+
|
|
115
141
|
/**
|
|
116
142
|
* The four true WRITES now go through `requireRoleWrite`, which is
|
|
117
143
|
* `requireRoleOrg` plus one refusal: a role with no `org` and a non-admin type
|