@7365admin1/core 3.53.5 → 3.53.7
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 +16 -0
- package/dist/index.js +1175 -1125
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3186 -3132
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/invite-org-scope.test.mjs +1 -1
- package/test/invite-role-scope.test.mjs +236 -0
- package/test/member-staff-gate.test.mjs +1 -1
- package/test/verification-scope.test.mjs +96 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.53.
|
|
4
|
+
"version": "3.53.7",
|
|
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 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",
|
|
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 src/utils/invite-role-scope.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
|
},
|
|
@@ -62,7 +62,7 @@ test("every invite handler asks requireInviteReach, and asks first", () => {
|
|
|
62
62
|
|
|
63
63
|
assert.match(
|
|
64
64
|
body,
|
|
65
|
-
new RegExp(`await requireInviteReach\\(req, ${org}, app\\)`),
|
|
65
|
+
new RegExp(`await requireInviteReach\\(req, ${org}, app, role\\)`),
|
|
66
66
|
`${handler} must check the organisation it was handed`,
|
|
67
67
|
);
|
|
68
68
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The role an invitation hands out must belong to the organisation it invites
|
|
3
|
+
* into — and must never be a Seven365 console role.
|
|
4
|
+
*
|
|
5
|
+
* Both arms were measured against staging's **942** role-carrying invitations
|
|
6
|
+
* before being written (`_analysis/_invite-role-scope.js`, read-only). The
|
|
7
|
+
* counts in the case names below are that measurement, and they are what makes
|
|
8
|
+
* this shippable: 52 refused, all 52 mis-scoped, 890 untouched.
|
|
9
|
+
*
|
|
10
|
+
* The CONTROL for every refusal case is the pre-fix behaviour: before this
|
|
11
|
+
* change `role` was validated by `Joi.string().hex()` and nothing else, so
|
|
12
|
+
* every input here was accepted. `roleInvitable` not existing at all is what
|
|
13
|
+
* that control looks like, which the wiring assertions at the bottom pin.
|
|
14
|
+
*/
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { roleInvitable } from "./.build/utils/invite-role-scope.util.mjs";
|
|
21
|
+
|
|
22
|
+
const CLIENT_A = "68f063160c11062938dadadd";
|
|
23
|
+
const CLIENT_B = "690053d194b3f2e7d74be49b";
|
|
24
|
+
const SITE_A = "6925212188a3a9a52eb53d37";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// REFUSED — the two shapes that are the defect
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
test("a role owned by another organisation is refused (51 live rows)", () => {
|
|
31
|
+
// "BC Property Group" Org Admin invited into "ABC Property Group".
|
|
32
|
+
const refusal = roleInvitable(
|
|
33
|
+
{ type: "organization", org: CLIENT_A },
|
|
34
|
+
CLIENT_B,
|
|
35
|
+
["organization"],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assert.equal(refusal, "That role belongs to a different organisation.");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a Seven365 console role is refused on a client invitation (1 live row)", () => {
|
|
42
|
+
// CapitaLand Super Admin, type "admin", on a property-management invite.
|
|
43
|
+
for (const org of ["", null, CLIENT_A]) {
|
|
44
|
+
const refusal = roleInvitable({ type: "admin", org }, org, [
|
|
45
|
+
"property_management_agency",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
assert.equal(
|
|
49
|
+
refusal,
|
|
50
|
+
"That role belongs to the Seven365 console and cannot be given to this invitation.",
|
|
51
|
+
`an org-less staff role must not ride an ordinary invitation (org=${org})`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a console role is refused even for the organisation that owns it", () => {
|
|
57
|
+
// The organisation arm would say yes to this; the staff arm is what refuses
|
|
58
|
+
// it, and it is checked FIRST for exactly that reason.
|
|
59
|
+
assert.equal(
|
|
60
|
+
roleInvitable({ type: "admin", org: CLIENT_A }, CLIENT_A, ["organization"]),
|
|
61
|
+
"That role belongs to the Seven365 console and cannot be given to this invitation.",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("an ObjectId-shaped org is compared by value, not by identity", () => {
|
|
66
|
+
const asObjectId = { toString: () => CLIENT_A };
|
|
67
|
+
|
|
68
|
+
assert.equal(
|
|
69
|
+
roleInvitable({ type: "organization", org: asObjectId }, CLIENT_A, []),
|
|
70
|
+
null,
|
|
71
|
+
);
|
|
72
|
+
assert.equal(
|
|
73
|
+
roleInvitable({ type: "organization", org: asObjectId }, CLIENT_B, []),
|
|
74
|
+
"That role belongs to a different organisation.",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// ALLOWED — the zero-lockout proof. Every legitimate shape that reaches the
|
|
80
|
+
// invitation endpoints today, and still must.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const LEGITIMATE = [
|
|
84
|
+
[
|
|
85
|
+
"super-admin invites a client with a TEMPLATE role (org-less, ~340 live rows)",
|
|
86
|
+
{ type: "organization", org: "" },
|
|
87
|
+
CLIENT_A,
|
|
88
|
+
["property_management_agency"],
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
"the same template role when the role org is absent entirely",
|
|
92
|
+
{ type: "organization" },
|
|
93
|
+
CLIENT_A,
|
|
94
|
+
["security_agency"],
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
"org admin invites a member with the organisation OWN role (the majority)",
|
|
98
|
+
{ type: "organization", org: CLIENT_A },
|
|
99
|
+
CLIENT_A,
|
|
100
|
+
["organization"],
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
"a vertical app invites into a site with that org role",
|
|
104
|
+
{ type: "cleaning_services", org: CLIENT_A },
|
|
105
|
+
CLIENT_A,
|
|
106
|
+
["cleaning_services"],
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
"an ORG-WIDE role, site empty, on a site-scoped invitation",
|
|
110
|
+
{ type: "property_management_agency", org: CLIENT_A, site: "" },
|
|
111
|
+
CLIENT_A,
|
|
112
|
+
["property_management_agency"],
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
"a SITE-PINNED role invited into another site of the SAME org (36 live rows)",
|
|
116
|
+
{ type: "property_management_agency", org: CLIENT_A, site: SITE_A },
|
|
117
|
+
CLIENT_A,
|
|
118
|
+
["property_management_agency"],
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
"an invitation that names no organisation at all (2 live rows)",
|
|
122
|
+
{ type: "property_management_agency", org: CLIENT_A },
|
|
123
|
+
"",
|
|
124
|
+
["Organization"],
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
"and one whose org is null",
|
|
128
|
+
{ type: "organization", org: CLIENT_A },
|
|
129
|
+
null,
|
|
130
|
+
["organization"],
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
"a staff invitation carrying a staff role — the console own flow",
|
|
134
|
+
{ type: "admin", org: "" },
|
|
135
|
+
"",
|
|
136
|
+
["admin"],
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
"a multi-app invitation whose app list includes the staff type",
|
|
140
|
+
{ type: "admin", org: "" },
|
|
141
|
+
"",
|
|
142
|
+
["organization", "admin"],
|
|
143
|
+
],
|
|
144
|
+
[
|
|
145
|
+
"a role id that resolves to nothing writes no permissions, so it is left alone",
|
|
146
|
+
null,
|
|
147
|
+
CLIENT_A,
|
|
148
|
+
["organization"],
|
|
149
|
+
],
|
|
150
|
+
[
|
|
151
|
+
"no app named at all, ordinary client role",
|
|
152
|
+
{ type: "organization", org: CLIENT_A },
|
|
153
|
+
CLIENT_A,
|
|
154
|
+
null,
|
|
155
|
+
],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const [name, role, org, apps] of LEGITIMATE) {
|
|
159
|
+
test(`ALLOWED: ${name}`, () => {
|
|
160
|
+
assert.equal(
|
|
161
|
+
roleInvitable(role, org, apps),
|
|
162
|
+
null,
|
|
163
|
+
"this invitation works today and must keep working",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Wiring — the control. If any of this is removed the decision above is dead
|
|
170
|
+
// code and every refusal case silently reverts to the pre-fix behaviour.
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const read = (rel) =>
|
|
174
|
+
readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
|
|
175
|
+
|
|
176
|
+
test("requireInviteReach runs the role check, and runs it last", () => {
|
|
177
|
+
const authz = read("utils/console-authz.util.ts");
|
|
178
|
+
|
|
179
|
+
assert.match(
|
|
180
|
+
authz,
|
|
181
|
+
/const refusal = roleInvitable\(stored, org, apps\);\n\s*if \(refusal\) throw new BadRequestError\(refusal\);/,
|
|
182
|
+
"the refusal must be raised as a 4xx, not swallowed",
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const gate = authz.indexOf("export async function requireInviteReach(");
|
|
186
|
+
const body = authz.slice(gate);
|
|
187
|
+
|
|
188
|
+
assert.match(
|
|
189
|
+
body,
|
|
190
|
+
/await requireRoleInvitable\(role, org, apps\);/,
|
|
191
|
+
"requireInviteReach must run the role check",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
assert.ok(
|
|
195
|
+
body.indexOf("await requireOrgReach(req, org)") <
|
|
196
|
+
body.indexOf("await requireRoleInvitable("),
|
|
197
|
+
"the role read must come after the organisation gate, so no existing caller pays for it",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("the role is only ever read from the STORED role, never from the request", () => {
|
|
202
|
+
const authz = read("utils/console-authz.util.ts");
|
|
203
|
+
const start = authz.indexOf("async function requireRoleInvitable(");
|
|
204
|
+
const body = authz.slice(
|
|
205
|
+
start,
|
|
206
|
+
authz.indexOf("export async function requireInviteReach("),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
assert.match(body, /collection\("roles"\)/);
|
|
210
|
+
assert.match(body, /projection: \{ org: 1, type: 1 \}/);
|
|
211
|
+
assert.doesNotMatch(
|
|
212
|
+
body,
|
|
213
|
+
/req\.body|req\.query/,
|
|
214
|
+
"nothing the caller sends may take part in this decision",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("every invite handler hands its role to the gate", () => {
|
|
219
|
+
const sources = [
|
|
220
|
+
read("controllers/verification.controller.ts"),
|
|
221
|
+
read("controllers/verification-v2.controller.ts"),
|
|
222
|
+
].join("\n");
|
|
223
|
+
|
|
224
|
+
const calls =
|
|
225
|
+
sources.match(/requireInviteReach\(req, [a-zA-Z]+, app[^)]*\)/g) ?? [];
|
|
226
|
+
|
|
227
|
+
assert.equal(calls.length, 6, "there are six invite handlers");
|
|
228
|
+
|
|
229
|
+
for (const call of calls) {
|
|
230
|
+
assert.match(
|
|
231
|
+
call,
|
|
232
|
+
/, role\)$/,
|
|
233
|
+
`${call} must pass the role it is about to write`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
@@ -121,7 +121,7 @@ test("createUserInvite refuses a staff invitation from a non-staff caller", () =
|
|
|
121
121
|
// yourself as staff, then accept your own invitation.
|
|
122
122
|
const body = fn(verificationController, "createUserInvite");
|
|
123
123
|
|
|
124
|
-
assert.match(body, /await requireInviteReach\(req, org, app\)/);
|
|
124
|
+
assert.match(body, /await requireInviteReach\(req, org, app, role\)/);
|
|
125
125
|
|
|
126
126
|
const gate = body.indexOf("requireInviteReach");
|
|
127
127
|
const write = body.indexOf("_createUserInvite(");
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listing and cancelling invitations both ask who is calling.
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/verifications` took `orgId` as an OPTIONAL query filter with
|
|
5
|
+
* `requireAuth` in front and nothing else, so omitting it read every client's
|
|
6
|
+
* pending invitations. `PUT /api/verifications/:id/cancel` took the id off the
|
|
7
|
+
* URL with no authorization identifier in the handler at all, so any signed-in
|
|
8
|
+
* account could cancel any other client's invitations.
|
|
9
|
+
*
|
|
10
|
+
* What is pinned here is the wiring — the part that silently rots when somebody
|
|
11
|
+
* adds a filter, or copies one of these handlers to make a v3 — plus the two
|
|
12
|
+
* rules inside the gates themselves, because the rules are what make the fix
|
|
13
|
+
* lockout-free rather than merely closed.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here opens a socket or a database. Every assertion reads a file.
|
|
16
|
+
*/
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const read = (rel) =>
|
|
23
|
+
readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
|
|
24
|
+
|
|
25
|
+
const v1 = read("controllers/verification.controller.ts");
|
|
26
|
+
const v2 = read("controllers/verification-v2.controller.ts");
|
|
27
|
+
const consoleAuthz = read("utils/console-authz.util.ts");
|
|
28
|
+
|
|
29
|
+
/** One `async function name(...)` body out of a source file. */
|
|
30
|
+
function fn(text, name) {
|
|
31
|
+
const start = text.indexOf(`async function ${name}(`);
|
|
32
|
+
assert.notEqual(start, -1, `${name} is no longer defined`);
|
|
33
|
+
const rest = text.slice(start);
|
|
34
|
+
const next = rest.slice(1).search(/\n *async function [a-zA-Z0-9_]+ ?\(/);
|
|
35
|
+
return next === -1 ? rest : rest.slice(0, next + 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** [source, label, handler, the gate it must call, the call it guards] */
|
|
39
|
+
const GUARDED = [
|
|
40
|
+
[v1, "v1", "getVerifications", "requireVerificationListReach(", "_getVerifications("],
|
|
41
|
+
[v2, "v2", "getVerifications", "requireVerificationListReach(", "_getVerifications("],
|
|
42
|
+
[v1, "v1", "cancelUserInvitation", "requireVerificationReach(", "_cancelUserInvitation("],
|
|
43
|
+
[v2, "v2", "cancelUserInvitation", "requireVerificationReach(", "_cancelUserInvitation("],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
test("both list and both cancel handlers ask, and ask BEFORE the read or write", () => {
|
|
47
|
+
for (const [source, label, handler, gate, guarded] of GUARDED) {
|
|
48
|
+
const body = fn(source, handler);
|
|
49
|
+
|
|
50
|
+
assert.ok(
|
|
51
|
+
body.includes(gate),
|
|
52
|
+
`${label} ${handler} no longer calls ${gate}`
|
|
53
|
+
);
|
|
54
|
+
assert.ok(
|
|
55
|
+
body.indexOf(gate) < body.indexOf(guarded),
|
|
56
|
+
`${label} ${handler} calls ${guarded} before it is authorized`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the cancel gate reads the STORED verification, never the request", () => {
|
|
62
|
+
for (const [source, label] of [[v1, "v1"], [v2, "v2"]]) {
|
|
63
|
+
const body = fn(source, "cancelUserInvitation");
|
|
64
|
+
assert.match(
|
|
65
|
+
body,
|
|
66
|
+
/requireVerificationReach\(\s*req,\s*await _getVerificationById\(otpId\)/,
|
|
67
|
+
`${label} cancel must authorize against the stored record`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the v1 list gate is handed the caller's own orgId, not a literal", () => {
|
|
73
|
+
const body = fn(v1, "getVerifications");
|
|
74
|
+
assert.match(body, /requireVerificationListReach\(req, orgId\)/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("an omitted organisation costs Seven365 staff identity", () => {
|
|
78
|
+
const gate = fn(consoleAuthz, "requireVerificationListReach");
|
|
79
|
+
assert.ok(gate.includes("requireOrgReach(req, orgId)"), "the org branch is gone");
|
|
80
|
+
assert.ok(gate.includes("requirePlatformStaff(req)"), "the estate-wide branch is gone");
|
|
81
|
+
// The org branch must return, or a reaching member would then be asked for
|
|
82
|
+
// staff identity as well and every web app's invitations screen would 401.
|
|
83
|
+
assert.match(gate, /requireOrgReach\(req, orgId\);\s*\n\s*return;/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a verification that names no organisation behaves exactly as before", () => {
|
|
87
|
+
const gate = fn(consoleAuthz, "requireVerificationReach");
|
|
88
|
+
// `createUserInvite` writes rows with an empty organisation and they are
|
|
89
|
+
// cancelled from the staff console. Refusing them would break a working
|
|
90
|
+
// screen; this early return is the zero-lockout half of the fix.
|
|
91
|
+
assert.match(gate, /if \(!org\) return;/);
|
|
92
|
+
assert.ok(
|
|
93
|
+
gate.indexOf("if (!org) return;") < gate.indexOf("requireOrgReach"),
|
|
94
|
+
"the org-less carve-out must come before the refusal"
|
|
95
|
+
);
|
|
96
|
+
});
|