@7365admin1/core 3.53.8 → 3.54.0
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 +47 -0
- package/dist/index.d.ts +229 -61
- package/dist/index.js +260 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +254 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/camera-view.util.test.mjs +4 -158
- package/test/e2e/harness.mjs +9 -0
- package/test/e2e/personal-emergency-chain-scope.e2e.test.mjs +373 -0
- package/test/notification-category.util.test.mjs +29 -11
- package/test/personal-emergency-chain.test.mjs +261 -0
- package/test/picker-site-scope.test.mjs +181 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordering rules of a person's own emergency chain, run as the shipped
|
|
3
|
+
* module -- not a description of it.
|
|
4
|
+
*
|
|
5
|
+
* Two things decide whether "ring my wife, then my son, then my doctor" works
|
|
6
|
+
* at all, and both are enforced here rather than in a screen:
|
|
7
|
+
*
|
|
8
|
+
* 1. no two contacts may claim the same position -- otherwise "who is second"
|
|
9
|
+
* has two answers and the dialer picks one at random;
|
|
10
|
+
* 2. a chain that breaks rule 1 is refused BEFORE anything is written, which is
|
|
11
|
+
* what makes a reorder atomic. There is no partial order to clean up,
|
|
12
|
+
* because a rejected reorder never reaches the database at all.
|
|
13
|
+
*
|
|
14
|
+
* The E.164 rule is checked here too: a number that is not dialable from
|
|
15
|
+
* outside the country it was typed in is not an emergency contact.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here opens a socket or a database.
|
|
18
|
+
*/
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { ObjectId } from "mongodb";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
MAX_PERSONAL_EMERGENCY_CONTACTS,
|
|
27
|
+
DEFAULT_RING_SECONDS,
|
|
28
|
+
MPersonalEmergencyChain,
|
|
29
|
+
emptyPersonalEmergencyChain,
|
|
30
|
+
schemaUpdatePersonalEmergencyChain,
|
|
31
|
+
} from "./.build/models/personal-emergency-chain.model.mjs";
|
|
32
|
+
|
|
33
|
+
const USER = new ObjectId().toString();
|
|
34
|
+
|
|
35
|
+
const contact = (over = {}) => ({
|
|
36
|
+
name: "Wife",
|
|
37
|
+
phone: "+6591234567",
|
|
38
|
+
relationship: "Spouse",
|
|
39
|
+
order: 1,
|
|
40
|
+
active: true,
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const validate = (body) =>
|
|
45
|
+
schemaUpdatePersonalEmergencyChain.validate(body, { convert: true });
|
|
46
|
+
|
|
47
|
+
const chain = (contacts) => ({
|
|
48
|
+
user: USER,
|
|
49
|
+
contacts,
|
|
50
|
+
ringSeconds: DEFAULT_RING_SECONDS,
|
|
51
|
+
siteDirectoryFallback: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- positions
|
|
55
|
+
|
|
56
|
+
test("a chain with distinct positions is accepted -- the positive control", () => {
|
|
57
|
+
const { error, value } = validate({
|
|
58
|
+
contacts: [
|
|
59
|
+
contact({ name: "Wife", order: 1 }),
|
|
60
|
+
contact({ name: "Son", phone: "+6598765432", order: 2 }),
|
|
61
|
+
contact({ name: "Dr Tan", phone: "+6567001122", order: 3 }),
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(error, undefined);
|
|
66
|
+
// A positive control that returned nothing would prove nothing.
|
|
67
|
+
assert.equal(value.contacts.length, 3);
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
value.contacts.map((c) => c.order),
|
|
70
|
+
[1, 2, 3],
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("two contacts cannot share a position", () => {
|
|
75
|
+
const { error } = validate({
|
|
76
|
+
contacts: [contact({ order: 2 }), contact({ name: "Son", order: 2 })],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
assert.notEqual(error, undefined);
|
|
80
|
+
assert.match(error.message, /same position/i);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the model refuses a duplicate position even if validation is bypassed", () => {
|
|
84
|
+
// The repository calls the model, so this is the last gate before the write.
|
|
85
|
+
assert.throws(
|
|
86
|
+
() =>
|
|
87
|
+
MPersonalEmergencyChain(
|
|
88
|
+
chain([contact({ order: 1 }), contact({ name: "Son", order: 1 })]),
|
|
89
|
+
),
|
|
90
|
+
/same position/i,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a stored chain comes back in dialling order, whatever order it was sent in", () => {
|
|
95
|
+
const doc = MPersonalEmergencyChain(
|
|
96
|
+
chain([
|
|
97
|
+
contact({ name: "Dr Tan", phone: "+6567001122", order: 3 }),
|
|
98
|
+
contact({ name: "Wife", order: 1 }),
|
|
99
|
+
contact({ name: "Son", phone: "+6598765432", order: 2 }),
|
|
100
|
+
]),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
doc.contacts.map((c) => c.name),
|
|
105
|
+
["Wife", "Son", "Dr Tan"],
|
|
106
|
+
);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
doc.contacts.map((c) => c.order),
|
|
109
|
+
[1, 2, 3],
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a reorder is a whole new chain, and an invalid one changes nothing", () => {
|
|
114
|
+
const wife = contact({ name: "Wife", order: 1 });
|
|
115
|
+
const son = contact({ name: "Son", phone: "+6598765432", order: 2 });
|
|
116
|
+
|
|
117
|
+
const before = MPersonalEmergencyChain(chain([wife, son]));
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
before.contacts.map((c) => c.name),
|
|
120
|
+
["Wife", "Son"],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Swapping them is valid and lands complete.
|
|
124
|
+
const after = MPersonalEmergencyChain(
|
|
125
|
+
chain([
|
|
126
|
+
contact({ name: "Wife", order: 2 }),
|
|
127
|
+
contact({ name: "Son", phone: "+6598765432", order: 1 }),
|
|
128
|
+
]),
|
|
129
|
+
);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
after.contacts.map((c) => c.name),
|
|
132
|
+
["Son", "Wife"],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// A half-typed reorder -- both at position 1 -- is refused outright, so the
|
|
136
|
+
// caller still holds the chain it had.
|
|
137
|
+
assert.throws(
|
|
138
|
+
() =>
|
|
139
|
+
MPersonalEmergencyChain(
|
|
140
|
+
chain([
|
|
141
|
+
contact({ name: "Wife", order: 1 }),
|
|
142
|
+
contact({ name: "Son", phone: "+6598765432", order: 1 }),
|
|
143
|
+
]),
|
|
144
|
+
),
|
|
145
|
+
/same position/i,
|
|
146
|
+
);
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
before.contacts.map((c) => c.name),
|
|
149
|
+
["Wife", "Son"],
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// -------------------------------------------------------------------- phone
|
|
154
|
+
|
|
155
|
+
test("a phone number must be dialable from anywhere", () => {
|
|
156
|
+
for (const phone of [
|
|
157
|
+
"91234567",
|
|
158
|
+
"+0591234567",
|
|
159
|
+
"6591234567",
|
|
160
|
+
"+65 9123 4567",
|
|
161
|
+
"",
|
|
162
|
+
"+",
|
|
163
|
+
]) {
|
|
164
|
+
const { error } = validate({ contacts: [contact({ phone })] });
|
|
165
|
+
assert.notEqual(error, undefined, JSON.stringify(phone) + " was accepted");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Positive control across three countries, so the rule is not "Singapore only".
|
|
169
|
+
for (const phone of ["+6591234567", "+442071234567", "+14155550123"]) {
|
|
170
|
+
const { error } = validate({ contacts: [contact({ phone })] });
|
|
171
|
+
assert.equal(error, undefined, phone + " was refused");
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ----------------------------------------------------------------- settings
|
|
176
|
+
|
|
177
|
+
test("chain settings have working defaults and real bounds", () => {
|
|
178
|
+
const { error, value } = validate({ contacts: [] });
|
|
179
|
+
|
|
180
|
+
assert.equal(error, undefined);
|
|
181
|
+
assert.equal(value.ringSeconds, DEFAULT_RING_SECONDS);
|
|
182
|
+
assert.equal(value.siteDirectoryFallback, true);
|
|
183
|
+
|
|
184
|
+
assert.notEqual(validate({ contacts: [], ringSeconds: 1 }).error, undefined);
|
|
185
|
+
assert.notEqual(validate({ contacts: [], ringSeconds: 600 }).error, undefined);
|
|
186
|
+
assert.equal(validate({ contacts: [], ringSeconds: 45 }).error, undefined);
|
|
187
|
+
|
|
188
|
+
assert.equal(
|
|
189
|
+
validate({ contacts: [], siteDirectoryFallback: false }).value
|
|
190
|
+
.siteDirectoryFallback,
|
|
191
|
+
false,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("the chain is bounded, so a body cannot be padded into an expensive write", () => {
|
|
196
|
+
const many = Array.from(
|
|
197
|
+
{ length: MAX_PERSONAL_EMERGENCY_CONTACTS + 1 },
|
|
198
|
+
(_, i) => contact({ order: i + 1 }),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
assert.notEqual(validate({ contacts: many }).error, undefined);
|
|
202
|
+
assert.equal(
|
|
203
|
+
validate({ contacts: many.slice(0, MAX_PERSONAL_EMERGENCY_CONTACTS) }).error,
|
|
204
|
+
undefined,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a contact can be kept but taken out of the chain", () => {
|
|
209
|
+
const { error, value } = validate({
|
|
210
|
+
contacts: [contact({ order: 1, active: false })],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.equal(error, undefined);
|
|
214
|
+
assert.equal(value.contacts[0].active, false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("no user id is accepted from the body -- the URL and the gate decide that", () => {
|
|
218
|
+
const { error } = validate({ contacts: [], user: new ObjectId().toString() });
|
|
219
|
+
|
|
220
|
+
assert.notEqual(error, undefined, "a user id in the body was accepted");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ------------------------------------------------------------------- absent
|
|
224
|
+
|
|
225
|
+
test("somebody who has never saved a chain reads an empty one, not an error", () => {
|
|
226
|
+
const empty = emptyPersonalEmergencyChain(USER);
|
|
227
|
+
|
|
228
|
+
assert.deepEqual(empty.contacts, []);
|
|
229
|
+
assert.equal(empty.ringSeconds, DEFAULT_RING_SECONDS);
|
|
230
|
+
assert.equal(empty.siteDirectoryFallback, true);
|
|
231
|
+
assert.equal(empty.user, USER);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("nothing in the model names a telephony vendor", () => {
|
|
235
|
+
// The chain has to outlive whatever places the call. If a provider name ever
|
|
236
|
+
// appears here, the data model has been tied to one dialer.
|
|
237
|
+
const text = readFileSync(
|
|
238
|
+
fileURLToPath(
|
|
239
|
+
new URL(
|
|
240
|
+
"../src/models/personal-emergency-chain.model.ts",
|
|
241
|
+
import.meta.url,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
"utf8",
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
for (const vendor of [
|
|
248
|
+
"twilio",
|
|
249
|
+
"vonage",
|
|
250
|
+
"nexmo",
|
|
251
|
+
"asterisk",
|
|
252
|
+
"plivo",
|
|
253
|
+
"sinch",
|
|
254
|
+
"telnyx",
|
|
255
|
+
]) {
|
|
256
|
+
assert.ok(
|
|
257
|
+
!new RegExp(vendor, "i").test(text),
|
|
258
|
+
vendor + " is named in the model",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
cameraGrant,
|
|
6
|
+
entitledSiteScope,
|
|
7
|
+
} from "./.build/utils/camera-view.util.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The site PICKER must offer exactly the sites the camera check will serve.
|
|
11
|
+
*
|
|
12
|
+
* `GET /api/customer-sites?org=<agency>` fills the site switcher in the guard
|
|
13
|
+
* app, the MA apps and the seven vertical web apps. Every screen then calls with
|
|
14
|
+
* the chosen site id — `getAnprCameras` -> `GET /api/site-cameras` -> core's
|
|
15
|
+
* `entitleSite`. When the two disagree, the switcher is a menu of things that do
|
|
16
|
+
* not work: the user picks a site and reads "Site not found.".
|
|
17
|
+
*
|
|
18
|
+
* Owner's ruling, 2026-09-06: **a guard reaches only the sites they are
|
|
19
|
+
* personally assigned to, not every site their agency is engaged at.** So the
|
|
20
|
+
* PICKER is the half that was wrong, and the entitlement rule is unchanged.
|
|
21
|
+
*
|
|
22
|
+
* The controller therefore now asks the SAME helper the camera check asks:
|
|
23
|
+
* `entitledSites` -> `entitledSiteScope`, which is `cameraGrant`'s three
|
|
24
|
+
* branches expressed as a list. These tests pin that agreement, in both
|
|
25
|
+
* directions, and pin that an ORG-level role is not narrowed with the guards.
|
|
26
|
+
*
|
|
27
|
+
* Measured read-only on staging 2026-09-06: 384 offered pairs -> 342; the 42
|
|
28
|
+
* removed are exactly the 42 `entitleSite` already refuses; 26 of 189 users see
|
|
29
|
+
* a shorter list; ZERO users gain a site.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const AGENCY = "aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
33
|
+
const CLIENT = "bbbbbbbbbbbbbbbbbbbbbbbb";
|
|
34
|
+
const SITE_A = "cccccccccccccccccccccccc";
|
|
35
|
+
const SITE_B = "dddddddddddddddddddddddd";
|
|
36
|
+
|
|
37
|
+
/** The engagement rows `?org=AGENCY` returns: the agency serves both sites. */
|
|
38
|
+
const ENGAGED = { [AGENCY]: [SITE_A, SITE_B] };
|
|
39
|
+
/** Both sites belong to the CLIENT, not to the agency. */
|
|
40
|
+
const OWNED = { [CLIENT]: [SITE_A, SITE_B] };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The rule the controller used BEFORE this change (`orgSiteScope`), transcribed
|
|
44
|
+
* so the defect is reproducible from the test file alone rather than only from
|
|
45
|
+
* the staging measurement. It is the control every test below is measured
|
|
46
|
+
* against: each `notDeepEqual`/`ok` pair here fails if the two rules are made to
|
|
47
|
+
* agree by accident, and each assertion on `entitledSiteScope` is one the
|
|
48
|
+
* pre-fix rule does not satisfy.
|
|
49
|
+
*/
|
|
50
|
+
function preFixOrgSiteScope({ memberships, org }) {
|
|
51
|
+
if (!org) return null;
|
|
52
|
+
const inOrg = memberships.filter((m) => String(m.org ?? "") === org);
|
|
53
|
+
if (inOrg.some((m) => !String(m.siteId ?? "") || m.orgLevelRole === true)) {
|
|
54
|
+
return null; // "no restriction" — the whole engagement list
|
|
55
|
+
}
|
|
56
|
+
return inOrg.map((m) => String(m.siteId ?? "")).filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the picker offers for `?org=AGENCY` under a given scope list. */
|
|
60
|
+
function offered(scope) {
|
|
61
|
+
return scope === null
|
|
62
|
+
? ENGAGED[AGENCY]
|
|
63
|
+
: ENGAGED[AGENCY].filter((site) => scope.includes(site));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Does the camera check serve this site to this caller? */
|
|
67
|
+
function grants(site, memberships) {
|
|
68
|
+
return Boolean(
|
|
69
|
+
cameraGrant({
|
|
70
|
+
cameraSite: site,
|
|
71
|
+
cameraOrg: CLIENT,
|
|
72
|
+
memberships,
|
|
73
|
+
engagedOrgs: new Set(
|
|
74
|
+
Object.entries(ENGAGED)
|
|
75
|
+
.filter(([, sites]) => sites.includes(site))
|
|
76
|
+
.map(([org]) => org),
|
|
77
|
+
),
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("a guard assigned to one site is offered that site, and its cameras load", () => {
|
|
83
|
+
// The shape the owner ruled on: an ORG-LEVEL role at the agency whose
|
|
84
|
+
// membership row names site A. 26 of 189 staging users are this shape.
|
|
85
|
+
const memberships = [
|
|
86
|
+
{ org: AGENCY, siteId: SITE_A, role: "guard-role", orgLevelRole: true },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const scope = entitledSiteScope({
|
|
90
|
+
memberships,
|
|
91
|
+
sitesByOrg: OWNED,
|
|
92
|
+
engagedSitesByOrg: ENGAGED,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(offered(scope), [SITE_A]);
|
|
96
|
+
assert.equal(grants(SITE_A, memberships), true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an agency site the guard is NOT assigned to is neither offered nor reachable", () => {
|
|
100
|
+
const memberships = [
|
|
101
|
+
{ org: AGENCY, siteId: SITE_A, role: "guard-role", orgLevelRole: true },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const scope = entitledSiteScope({
|
|
105
|
+
memberships,
|
|
106
|
+
sitesByOrg: OWNED,
|
|
107
|
+
engagedSitesByOrg: ENGAGED,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.equal(offered(scope).includes(SITE_B), false);
|
|
111
|
+
assert.equal(grants(SITE_B, memberships), false);
|
|
112
|
+
|
|
113
|
+
// CONTROL — this is exactly what the pre-fix rule did, and why site B showed
|
|
114
|
+
// up in the switcher and then answered "Site not found.".
|
|
115
|
+
const before = offered(preFixOrgSiteScope({ memberships, org: AGENCY }));
|
|
116
|
+
assert.equal(before.includes(SITE_B), true);
|
|
117
|
+
assert.equal(grants(SITE_B, memberships), false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the offer and the camera check agree on every site, both ways", () => {
|
|
121
|
+
// The property the whole change exists for: offered == granted, with no
|
|
122
|
+
// site offered that is refused and no site refused that is offered.
|
|
123
|
+
const shapes = [
|
|
124
|
+
// a guard pinned to a site, org-level role — the defect's shape
|
|
125
|
+
[{ org: AGENCY, siteId: SITE_A, role: "r", orgLevelRole: true }],
|
|
126
|
+
// a guard pinned to a site, site-level role
|
|
127
|
+
[{ org: AGENCY, siteId: SITE_A, role: "r" }],
|
|
128
|
+
// agency staff with NO site — org-wide, reaches every engaged site
|
|
129
|
+
[{ org: AGENCY, siteId: "", role: "r" }],
|
|
130
|
+
// a member of nothing relevant
|
|
131
|
+
[{ org: CLIENT, siteId: SITE_A, role: "r" }],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const memberships of shapes) {
|
|
135
|
+
const scope = entitledSiteScope({
|
|
136
|
+
memberships,
|
|
137
|
+
sitesByOrg: OWNED,
|
|
138
|
+
engagedSitesByOrg: ENGAGED,
|
|
139
|
+
});
|
|
140
|
+
for (const site of ENGAGED[AGENCY]) {
|
|
141
|
+
assert.equal(
|
|
142
|
+
offered(scope).includes(site),
|
|
143
|
+
grants(site, memberships),
|
|
144
|
+
`offer and camera check disagree on ${site} for ${JSON.stringify(memberships)}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// CONTROL — the pre-fix rule disagrees on the first shape. If this ever
|
|
150
|
+
// stops failing, the transcription above has drifted and the test is void.
|
|
151
|
+
const [defect] = shapes;
|
|
152
|
+
const before = offered(preFixOrgSiteScope({ memberships: defect, org: AGENCY }));
|
|
153
|
+
assert.ok(
|
|
154
|
+
ENGAGED[AGENCY].some(
|
|
155
|
+
(site) => before.includes(site) !== grants(site, defect),
|
|
156
|
+
),
|
|
157
|
+
"the pre-fix rule must disagree — otherwise there was nothing to fix",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("an ORG-level role at the site's OWNER still reaches its whole estate", () => {
|
|
162
|
+
// Do not narrow admins while narrowing guards. An organisation-level role at
|
|
163
|
+
// the CLIENT keeps every site the client owns, pinned row or not.
|
|
164
|
+
const memberships = [
|
|
165
|
+
{ org: CLIENT, siteId: SITE_A, role: "org-owner", orgLevelRole: true },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const scope = entitledSiteScope({
|
|
169
|
+
memberships,
|
|
170
|
+
sitesByOrg: OWNED,
|
|
171
|
+
engagedSitesByOrg: ENGAGED,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.deepEqual([...scope].sort(), [SITE_A, SITE_B].sort());
|
|
175
|
+
assert.equal(grants(SITE_A, memberships), true);
|
|
176
|
+
assert.equal(grants(SITE_B, memberships), true);
|
|
177
|
+
|
|
178
|
+
// ...and a site-less membership at the owner is the same answer.
|
|
179
|
+
const siteless = [{ org: CLIENT, siteId: "", role: "org-owner" }];
|
|
180
|
+
assert.equal(grants(SITE_B, siteless), true);
|
|
181
|
+
});
|