@7365admin1/core 3.42.0 → 3.42.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 +87 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3687 -3402
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3715 -3430
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -2
- package/test/camera-alert.util.test.mjs +109 -0
- package/test/dahua-protocol.util.test.mjs +217 -0
- package/test/e2e/harness.mjs +426 -0
- package/test/e2e/service-provider-invite.e2e.test.mjs +471 -0
- package/test/service-provider-invite.test.mjs +198 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// End-to-end proof that a property manager can invite a service provider that
|
|
2
|
+
// already has an account, over real HTTP, against a throwaway database.
|
|
3
|
+
//
|
|
4
|
+
// This is the case reported from production: the provider already worked on
|
|
5
|
+
// another site, the browser guessed "new company", the API trusted that guess,
|
|
6
|
+
// and the invitation dead-ended on "User already exists".
|
|
7
|
+
//
|
|
8
|
+
// Every request below goes through the real Express stack: the real
|
|
9
|
+
// `requireAuth` middleware, the real controllers, the real services and
|
|
10
|
+
// repositories, against an in-process MongoDB replica set. Sessions are
|
|
11
|
+
// obtained by logging in through POST /auth like any client. No credential
|
|
12
|
+
// belonging to a real person is used, and the mailer is pointed at a loopback
|
|
13
|
+
// sink, so no mail can leave the machine.
|
|
14
|
+
//
|
|
15
|
+
// Run with: yarn test:e2e
|
|
16
|
+
|
|
17
|
+
import { after, before, describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PM_PASSWORD = "Pm-Passw0rd!";
|
|
24
|
+
const SP_PASSWORD = "Sp-Passw0rd!";
|
|
25
|
+
const OUTSIDER_PASSWORD = "Out-Passw0rd!";
|
|
26
|
+
const NEWCO_PASSWORD = "New-Passw0rd!";
|
|
27
|
+
|
|
28
|
+
const PROVIDER_EMAIL = "provider@e2e.example.com";
|
|
29
|
+
const NEWCO_EMAIL = "newco@e2e.example.com";
|
|
30
|
+
|
|
31
|
+
describe("service-provider invite, end to end", { concurrency: 1 }, () => {
|
|
32
|
+
let h;
|
|
33
|
+
const id = {};
|
|
34
|
+
const said = {}; // the exact wording the API gave back, reported at the end
|
|
35
|
+
|
|
36
|
+
before(async () => {
|
|
37
|
+
h = await startHarness();
|
|
38
|
+
Object.assign(id, await seed(h));
|
|
39
|
+
}, { timeout: 300000 });
|
|
40
|
+
|
|
41
|
+
after(async () => {
|
|
42
|
+
if (h) await h.stop();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let pmSid;
|
|
46
|
+
let providerSid;
|
|
47
|
+
let inviteId; // the invitation for the new site
|
|
48
|
+
|
|
49
|
+
it("a. inviting an existing provider takes the existing-account path, not sign-up", async () => {
|
|
50
|
+
pmSid = await h.login("pm@e2e.example.com", PM_PASSWORD);
|
|
51
|
+
|
|
52
|
+
const before = h.mails.length;
|
|
53
|
+
|
|
54
|
+
const res = await h.api("/service-providers/invite", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
sid: pmSid,
|
|
57
|
+
body: {
|
|
58
|
+
email: PROVIDER_EMAIL,
|
|
59
|
+
orgId: id.pmOrg.toString(),
|
|
60
|
+
siteId: id.siteA.toString(),
|
|
61
|
+
siteName: "Aurora Residences",
|
|
62
|
+
// The browser's guess, and it is wrong: this provider does have an
|
|
63
|
+
// account. The server must ignore it and decide on the facts.
|
|
64
|
+
inviteType: "organization-invite",
|
|
65
|
+
app: "security",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
70
|
+
assert.match(JSON.stringify(res.body), /Successfully invited service provider/);
|
|
71
|
+
assert.doesNotMatch(JSON.stringify(res.body), /User already exists/i);
|
|
72
|
+
|
|
73
|
+
const invite = await h.db
|
|
74
|
+
.collection("verifications")
|
|
75
|
+
.findOne({ email: PROVIDER_EMAIL, "metadata.siteId": id.siteA });
|
|
76
|
+
|
|
77
|
+
assert.ok(invite, "an invitation was written");
|
|
78
|
+
assert.equal(
|
|
79
|
+
invite.type,
|
|
80
|
+
"service-provider-create-org",
|
|
81
|
+
"existing provider must get the sign-in invitation type, not the sign-up one",
|
|
82
|
+
);
|
|
83
|
+
inviteId = invite._id.toString();
|
|
84
|
+
|
|
85
|
+
// and the email that would have gone out
|
|
86
|
+
const mail = await h.mailAt(before);
|
|
87
|
+
assert.ok(mail, "an invitation email was produced");
|
|
88
|
+
assert.match(mail.to, new RegExp(PROVIDER_EMAIL));
|
|
89
|
+
assert.equal(mail.subject, "Service Provider Organization Invite");
|
|
90
|
+
assert.match(mail.body, new RegExp(`/verify/service-provider-invite/${inviteId}`));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("b. the provider signs in and accepts the invitation", async () => {
|
|
94
|
+
providerSid = await h.login(PROVIDER_EMAIL, SP_PASSWORD);
|
|
95
|
+
|
|
96
|
+
const res = await h.api(`/customer-sites/invite/${inviteId}`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
sid: providerSid,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
102
|
+
said.accepted = res.body;
|
|
103
|
+
assert.match(String(res.body), /successfully added site/i);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("c. exactly one engagement row exists, and accepting again does not add a second", async () => {
|
|
107
|
+
const query = {
|
|
108
|
+
org: id.providerOrg,
|
|
109
|
+
siteOrg: id.pmOrg,
|
|
110
|
+
site: id.siteA,
|
|
111
|
+
status: "active",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
assert.equal(await h.db.collection("customer.sites").countDocuments(query), 1);
|
|
115
|
+
|
|
116
|
+
const again = await h.api(`/customer-sites/invite/${inviteId}`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
sid: providerSid,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
said.acceptedTwice = again.body;
|
|
122
|
+
assert.equal(await h.db.collection("customer.sites").countDocuments(query), 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("d. the invitation is marked complete", async () => {
|
|
126
|
+
const invite = await h.db
|
|
127
|
+
.collection("verifications")
|
|
128
|
+
.findOne({ _id: new ObjectId(inviteId) });
|
|
129
|
+
|
|
130
|
+
assert.equal(invite.status, "complete");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("e. the new site appears in the provider's own site list", async () => {
|
|
134
|
+
const res = await h.api(
|
|
135
|
+
`/customer-sites?org=${id.providerOrg.toString()}&status=active&page=1&limit=20`,
|
|
136
|
+
{ sid: providerSid },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
140
|
+
|
|
141
|
+
const names = JSON.stringify(res.body);
|
|
142
|
+
assert.match(names, /Aurora Residences/, "the newly accepted site is listed");
|
|
143
|
+
assert.match(names, /Beacon Court/, "the site they already worked on is still listed");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("f. inviting the same provider to the same site again is refused, in plain words", async () => {
|
|
147
|
+
const res = await h.api("/service-providers/invite", {
|
|
148
|
+
method: "POST",
|
|
149
|
+
sid: pmSid,
|
|
150
|
+
body: {
|
|
151
|
+
email: PROVIDER_EMAIL,
|
|
152
|
+
orgId: id.pmOrg.toString(),
|
|
153
|
+
siteId: id.siteA.toString(),
|
|
154
|
+
siteName: "Aurora Residences",
|
|
155
|
+
inviteType: "create-org",
|
|
156
|
+
app: "security",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
assert.equal(res.status, 400);
|
|
161
|
+
said.alreadyOnSite = res.body.message;
|
|
162
|
+
assert.doesNotMatch(said.alreadyOnSite, /User already exists/i);
|
|
163
|
+
assert.equal(
|
|
164
|
+
said.alreadyOnSite,
|
|
165
|
+
"This service provider is already working on this site. There is nothing to accept — you can find them under Service Providers.",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
assert.equal(
|
|
169
|
+
await h.db.collection("customer.sites").countDocuments({
|
|
170
|
+
org: id.providerOrg,
|
|
171
|
+
site: id.siteA,
|
|
172
|
+
status: "active",
|
|
173
|
+
}),
|
|
174
|
+
1,
|
|
175
|
+
"a refused invitation writes no engagement",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("g. inviting while an invitation is already pending is refused, in plain words", async () => {
|
|
180
|
+
const first = await h.api("/service-providers/invite", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
sid: pmSid,
|
|
183
|
+
body: {
|
|
184
|
+
email: PROVIDER_EMAIL,
|
|
185
|
+
orgId: id.pmOrg.toString(),
|
|
186
|
+
siteId: id.siteC.toString(),
|
|
187
|
+
siteName: "Cedar Park",
|
|
188
|
+
inviteType: "organization-invite",
|
|
189
|
+
app: "security",
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
assert.equal(first.status, 200, JSON.stringify(first.body));
|
|
193
|
+
|
|
194
|
+
const second = await h.api("/service-providers/invite", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
sid: pmSid,
|
|
197
|
+
body: {
|
|
198
|
+
email: PROVIDER_EMAIL,
|
|
199
|
+
orgId: id.pmOrg.toString(),
|
|
200
|
+
siteId: id.siteC.toString(),
|
|
201
|
+
siteName: "Cedar Park",
|
|
202
|
+
inviteType: "organization-invite",
|
|
203
|
+
app: "security",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assert.equal(second.status, 400);
|
|
208
|
+
said.alreadyPending = second.body.message;
|
|
209
|
+
assert.doesNotMatch(said.alreadyPending, /User already exists/i);
|
|
210
|
+
assert.equal(
|
|
211
|
+
said.alreadyPending,
|
|
212
|
+
"An invitation to this site is already waiting for this email address. Cancel the pending invitation first if you want to send a new one.",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
assert.equal(
|
|
216
|
+
await h.db.collection("verifications").countDocuments({
|
|
217
|
+
email: PROVIDER_EMAIL,
|
|
218
|
+
"metadata.siteId": id.siteC,
|
|
219
|
+
status: "pending",
|
|
220
|
+
}),
|
|
221
|
+
1,
|
|
222
|
+
"no duplicate pending invitation",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("h. regression: a brand-new email still completes the original sign-up path", async () => {
|
|
227
|
+
const before = h.mails.length;
|
|
228
|
+
|
|
229
|
+
const invite = await h.api("/service-providers/invite", {
|
|
230
|
+
method: "POST",
|
|
231
|
+
sid: pmSid,
|
|
232
|
+
body: {
|
|
233
|
+
email: NEWCO_EMAIL,
|
|
234
|
+
orgId: id.pmOrg.toString(),
|
|
235
|
+
siteId: id.siteA.toString(),
|
|
236
|
+
siteName: "Aurora Residences",
|
|
237
|
+
inviteType: "create-org", // the browser's guess is wrong the other way round
|
|
238
|
+
app: "security",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
assert.equal(invite.status, 200, JSON.stringify(invite.body));
|
|
242
|
+
|
|
243
|
+
const written = await h.db
|
|
244
|
+
.collection("verifications")
|
|
245
|
+
.findOne({ email: NEWCO_EMAIL, "metadata.siteId": id.siteA });
|
|
246
|
+
assert.equal(
|
|
247
|
+
written.type,
|
|
248
|
+
"service-provider-invite",
|
|
249
|
+
"an unknown email must still be sent to sign-up",
|
|
250
|
+
);
|
|
251
|
+
assert.equal((await h.mailAt(before)).subject, "Service Provider Invite");
|
|
252
|
+
|
|
253
|
+
// ... and the sign-up itself still works, OTP and all.
|
|
254
|
+
const signUpMailIndex = h.mails.length;
|
|
255
|
+
const signUp = await h.api("/auth/v2/sign-up", {
|
|
256
|
+
method: "POST",
|
|
257
|
+
body: {
|
|
258
|
+
email: NEWCO_EMAIL,
|
|
259
|
+
password: NEWCO_PASSWORD,
|
|
260
|
+
country: "SGP",
|
|
261
|
+
orgName: "Newco Cleaning",
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
assert.equal(signUp.status, 200, JSON.stringify(signUp.body));
|
|
265
|
+
|
|
266
|
+
const otpMail = await h.mailAt(signUpMailIndex);
|
|
267
|
+
assert.equal(otpMail.subject, "Sign Up Verification");
|
|
268
|
+
// the lookbehind skips six-digit hex colours in the template's styling
|
|
269
|
+
const code = otpMail.body.match(/(?<![#\w])(\d{6})(?!\w)/)?.[1];
|
|
270
|
+
assert.ok(code, "a six-digit code was emailed");
|
|
271
|
+
|
|
272
|
+
const verified = await h.api(`/auth/v2/verify/${code}`);
|
|
273
|
+
assert.equal(verified.status, 200, JSON.stringify(verified.body));
|
|
274
|
+
|
|
275
|
+
const created = await h.api(`/users/v2/invite/${verified.body._id}`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
body: { name: "Newco Cleaning", password: NEWCO_PASSWORD, type: "user-sign-up" },
|
|
278
|
+
});
|
|
279
|
+
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
280
|
+
assert.doesNotMatch(JSON.stringify(created.body), /User already exists/i);
|
|
281
|
+
|
|
282
|
+
// the account is real: it can sign in
|
|
283
|
+
const sid = await h.login(NEWCO_EMAIL, NEWCO_PASSWORD);
|
|
284
|
+
assert.ok(sid);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("i. authorisation: the fix did not widen access", async () => {
|
|
288
|
+
// i.1 — the invite endpoint still requires a session.
|
|
289
|
+
const anonymous = await h.api("/service-providers/invite", {
|
|
290
|
+
method: "POST",
|
|
291
|
+
body: {
|
|
292
|
+
email: PROVIDER_EMAIL,
|
|
293
|
+
orgId: id.pmOrg.toString(),
|
|
294
|
+
siteId: id.siteD.toString(),
|
|
295
|
+
siteName: "Dover Mews",
|
|
296
|
+
inviteType: "organization-invite",
|
|
297
|
+
app: "security",
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
assert.equal(anonymous.status, 401, "no session, no invite");
|
|
301
|
+
|
|
302
|
+
// i.2 — a signed-in user from an unrelated organisation.
|
|
303
|
+
//
|
|
304
|
+
// KNOWN GAP, pre-dating this change and untouched by it: the endpoint
|
|
305
|
+
// authenticates the caller but never checks that the caller belongs to the
|
|
306
|
+
// organisation named in the payload. It is asserted here so the hole is
|
|
307
|
+
// visible in a test rather than only in a note, and so this test fails
|
|
308
|
+
// loudly on the day someone closes it — at which point flip the
|
|
309
|
+
// expectation to 401/403.
|
|
310
|
+
const outsiderSid = await h.login("outsider@e2e.example.com", OUTSIDER_PASSWORD);
|
|
311
|
+
const crossOrg = await h.api("/service-providers/invite", {
|
|
312
|
+
method: "POST",
|
|
313
|
+
sid: outsiderSid,
|
|
314
|
+
body: {
|
|
315
|
+
email: PROVIDER_EMAIL,
|
|
316
|
+
orgId: id.pmOrg.toString(),
|
|
317
|
+
siteId: id.siteD.toString(),
|
|
318
|
+
siteName: "Dover Mews",
|
|
319
|
+
inviteType: "organization-invite",
|
|
320
|
+
app: "security",
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
said.crossOrgInvite = `${crossOrg.status} ${JSON.stringify(crossOrg.body)}`;
|
|
324
|
+
assert.equal(
|
|
325
|
+
crossOrg.status,
|
|
326
|
+
200,
|
|
327
|
+
"unchanged by this fix: the endpoint does not scope the invite to the caller's organisation",
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// i.3 — accepting. POST /customer-sites/invite/:id is unauthenticated as
|
|
331
|
+
// shipped (also pre-existing, also flagged). What matters for "did the fix
|
|
332
|
+
// widen access" is what accepting can reach: the engagement is derived from
|
|
333
|
+
// the invitation, so it lands on the invited provider only. An outsider
|
|
334
|
+
// cannot use somebody else's invitation to put their OWN organisation on
|
|
335
|
+
// the site.
|
|
336
|
+
const pendingC = await h.db.collection("verifications").findOne({
|
|
337
|
+
email: PROVIDER_EMAIL,
|
|
338
|
+
"metadata.siteId": id.siteC,
|
|
339
|
+
status: "pending",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const accepted = await h.api(`/customer-sites/invite/${pendingC._id.toString()}`, {
|
|
343
|
+
method: "POST",
|
|
344
|
+
sid: outsiderSid,
|
|
345
|
+
});
|
|
346
|
+
said.crossOrgAccept = `${accepted.status} ${JSON.stringify(accepted.body)}`;
|
|
347
|
+
assert.equal(accepted.status, 201);
|
|
348
|
+
|
|
349
|
+
assert.equal(
|
|
350
|
+
await h.db.collection("customer.sites").countDocuments({
|
|
351
|
+
org: id.outsiderOrg,
|
|
352
|
+
}),
|
|
353
|
+
0,
|
|
354
|
+
"accepting somebody else's invitation gives the caller's organisation nothing",
|
|
355
|
+
);
|
|
356
|
+
assert.equal(
|
|
357
|
+
await h.db.collection("customer.sites").countDocuments({
|
|
358
|
+
org: id.providerOrg,
|
|
359
|
+
site: id.siteC,
|
|
360
|
+
status: "active",
|
|
361
|
+
}),
|
|
362
|
+
1,
|
|
363
|
+
"the engagement lands on the invited provider, exactly once",
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// and the outsider's own site was never reachable by the provider
|
|
367
|
+
assert.equal(
|
|
368
|
+
await h.db.collection("customer.sites").countDocuments({ site: id.siteE }),
|
|
369
|
+
0,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("reports the exact wording the API returned", () => {
|
|
374
|
+
console.log("\n--- verbatim API messages ---");
|
|
375
|
+
for (const [k, v] of Object.entries(said)) console.log(`${k}: ${v}`);
|
|
376
|
+
console.log("-----------------------------\n");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/* ------------------------------------------------------------------- seed */
|
|
381
|
+
|
|
382
|
+
async function seed(h) {
|
|
383
|
+
const now = new Date().toISOString();
|
|
384
|
+
|
|
385
|
+
const pmOrg = new ObjectId();
|
|
386
|
+
const providerOrg = new ObjectId();
|
|
387
|
+
const outsiderOrg = new ObjectId();
|
|
388
|
+
const siteA = new ObjectId(); // the new site the provider is invited to
|
|
389
|
+
const siteB = new ObjectId(); // the site the provider already works on
|
|
390
|
+
const siteC = new ObjectId(); // used for the "already pending" case
|
|
391
|
+
const siteD = new ObjectId(); // used for the authorisation case
|
|
392
|
+
const siteE = new ObjectId(); // belongs to the unrelated organisation
|
|
393
|
+
|
|
394
|
+
await h.db.collection("organizations").insertMany([
|
|
395
|
+
{
|
|
396
|
+
_id: pmOrg,
|
|
397
|
+
name: "Northwind Property Management",
|
|
398
|
+
email: "pm-org@e2e.example.com",
|
|
399
|
+
type: "property-management",
|
|
400
|
+
status: "active",
|
|
401
|
+
createdAt: now,
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
_id: providerOrg,
|
|
405
|
+
name: "Sentry Security Services",
|
|
406
|
+
email: PROVIDER_EMAIL,
|
|
407
|
+
type: "security_agency",
|
|
408
|
+
status: "active",
|
|
409
|
+
createdAt: now,
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
_id: outsiderOrg,
|
|
413
|
+
name: "Vega Facilities",
|
|
414
|
+
email: "outsider@e2e.example.com",
|
|
415
|
+
type: "property-management",
|
|
416
|
+
status: "active",
|
|
417
|
+
createdAt: now,
|
|
418
|
+
},
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
await h.db.collection("sites").insertMany([
|
|
422
|
+
{ _id: siteA, name: "Aurora Residences", org: pmOrg.toString(), status: "active", createdAt: now },
|
|
423
|
+
{ _id: siteB, name: "Beacon Court", org: pmOrg.toString(), status: "active", createdAt: now },
|
|
424
|
+
{ _id: siteC, name: "Cedar Park", org: pmOrg.toString(), status: "active", createdAt: now },
|
|
425
|
+
{ _id: siteD, name: "Dover Mews", org: pmOrg.toString(), status: "active", createdAt: now },
|
|
426
|
+
{ _id: siteE, name: "Vega Tower", org: outsiderOrg.toString(), status: "active", createdAt: now },
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
await h.db.collection("users").insertMany([
|
|
430
|
+
{
|
|
431
|
+
email: "pm@e2e.example.com",
|
|
432
|
+
password: await h.hashPassword(PM_PASSWORD),
|
|
433
|
+
name: "Pat Manager",
|
|
434
|
+
status: "active",
|
|
435
|
+
defaultOrg: pmOrg.toString(),
|
|
436
|
+
createdAt: now,
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
email: PROVIDER_EMAIL,
|
|
440
|
+
password: await h.hashPassword(SP_PASSWORD),
|
|
441
|
+
name: "Sam Provider",
|
|
442
|
+
status: "active",
|
|
443
|
+
defaultOrg: providerOrg.toString(),
|
|
444
|
+
createdAt: now,
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
email: "outsider@e2e.example.com",
|
|
448
|
+
password: await h.hashPassword(OUTSIDER_PASSWORD),
|
|
449
|
+
name: "Olive Outsider",
|
|
450
|
+
status: "active",
|
|
451
|
+
defaultOrg: outsiderOrg.toString(),
|
|
452
|
+
createdAt: now,
|
|
453
|
+
},
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
// the provider is already engaged — on a DIFFERENT site. This is the case
|
|
457
|
+
// that used to fail.
|
|
458
|
+
await h.db.collection("customer.sites").insertOne({
|
|
459
|
+
name: "Beacon Court",
|
|
460
|
+
site: siteB,
|
|
461
|
+
siteOrg: pmOrg,
|
|
462
|
+
siteOrgName: "Northwind Property Management",
|
|
463
|
+
org: providerOrg,
|
|
464
|
+
status: "active",
|
|
465
|
+
createdAt: new Date(),
|
|
466
|
+
updatedAt: "",
|
|
467
|
+
deletedAt: "",
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return { pmOrg, providerOrg, outsiderOrg, siteA, siteB, siteC, siteD, siteE };
|
|
471
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
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 {
|
|
7
|
+
decideServiceProviderInvite,
|
|
8
|
+
SERVICE_PROVIDER_SIGN_IN_TYPE,
|
|
9
|
+
SERVICE_PROVIDER_SIGN_UP_TYPE,
|
|
10
|
+
} from "./.build/utils/service-provider-invite.util.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Inviting a service provider to a site.
|
|
14
|
+
*
|
|
15
|
+
* The DECISION (sign-up or sign-in, or refuse with something the property
|
|
16
|
+
* manager can act on) is pure and is tested directly. The WIRING — that both
|
|
17
|
+
* invite endpoints ask this decision instead of branching on the inviting
|
|
18
|
+
* organisation, and that accepting an invitation writes the engagement once and
|
|
19
|
+
* closes the invitation — is asserted against the service source, because
|
|
20
|
+
* constructing those services needs a live Atlas connection.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here opens a connection, sends an email, or writes anything.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const src = (p) =>
|
|
26
|
+
readFileSync(
|
|
27
|
+
fileURLToPath(new URL(`../src/${p}`, import.meta.url)),
|
|
28
|
+
"utf8",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const facts = (over = {}) => ({
|
|
32
|
+
hasUser: false,
|
|
33
|
+
hasProviderOrg: false,
|
|
34
|
+
engagedOnSite: false,
|
|
35
|
+
invitePending: false,
|
|
36
|
+
...over,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a brand new company is sent to sign-up, exactly as before", () => {
|
|
40
|
+
const d = decideServiceProviderInvite(facts());
|
|
41
|
+
|
|
42
|
+
assert.equal(d.ok, true);
|
|
43
|
+
assert.equal(d.type, SERVICE_PROVIDER_SIGN_UP_TYPE);
|
|
44
|
+
assert.equal(d.existingProvider, false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the case Harris hit: the email already has an account and an organisation", () => {
|
|
48
|
+
// security@… already exists, so sign-up would die on "User already exists".
|
|
49
|
+
const d = decideServiceProviderInvite(
|
|
50
|
+
facts({ hasUser: true, hasProviderOrg: true }),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
assert.equal(d.ok, true);
|
|
54
|
+
assert.equal(d.type, SERVICE_PROVIDER_SIGN_IN_TYPE);
|
|
55
|
+
assert.equal(d.existingProvider, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a provider engaged on another site is invited to a second site", () => {
|
|
59
|
+
// The common case in this product: same company, different property manager,
|
|
60
|
+
// different site. Still sign-in, still additive.
|
|
61
|
+
const d = decideServiceProviderInvite(
|
|
62
|
+
facts({ hasUser: true, hasProviderOrg: true, engagedOnSite: false }),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(d.ok, true);
|
|
66
|
+
assert.equal(d.type, SERVICE_PROVIDER_SIGN_IN_TYPE);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("already working on THIS site is refused, with a reason", () => {
|
|
70
|
+
const d = decideServiceProviderInvite(
|
|
71
|
+
facts({ hasUser: true, hasProviderOrg: true, engagedOnSite: true }),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
assert.equal(d.ok, false);
|
|
75
|
+
assert.match(d.reason, /already working on this site/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("an invitation already waiting is refused before a second one is sent", () => {
|
|
79
|
+
const d = decideServiceProviderInvite(
|
|
80
|
+
facts({ hasUser: true, hasProviderOrg: true, invitePending: true }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
assert.equal(d.ok, false);
|
|
84
|
+
assert.match(d.reason, /already waiting/i);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("an engagement that already exists outranks a pending invitation", () => {
|
|
88
|
+
const d = decideServiceProviderInvite(
|
|
89
|
+
facts({ hasProviderOrg: true, engagedOnSite: true, invitePending: true }),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
assert.equal(d.ok, false);
|
|
93
|
+
assert.match(d.reason, /already working on this site/i);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("an account with no organisation gets a next step, not a dead end", () => {
|
|
97
|
+
const d = decideServiceProviderInvite(facts({ hasUser: true }));
|
|
98
|
+
|
|
99
|
+
assert.equal(d.ok, false);
|
|
100
|
+
assert.match(d.reason, /no service provider organisation/i);
|
|
101
|
+
assert.match(d.reason, /create their organisation first/i);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a pending invitation blocks a duplicate even for a brand new email", () => {
|
|
105
|
+
const d = decideServiceProviderInvite(facts({ invitePending: true }));
|
|
106
|
+
|
|
107
|
+
assert.equal(d.ok, false);
|
|
108
|
+
assert.match(d.reason, /already waiting/i);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("every refusal says what to do next, and none of them mention a type name", () => {
|
|
112
|
+
const refusals = [
|
|
113
|
+
facts({ hasUser: true }),
|
|
114
|
+
facts({ invitePending: true }),
|
|
115
|
+
facts({ hasProviderOrg: true, engagedOnSite: true }),
|
|
116
|
+
].map((f) => decideServiceProviderInvite(f));
|
|
117
|
+
|
|
118
|
+
for (const r of refusals) {
|
|
119
|
+
assert.equal(r.ok, false);
|
|
120
|
+
assert.ok(r.reason.length > 40, "a refusal has to explain itself");
|
|
121
|
+
assert.doesNotMatch(r.reason, /service-provider-(invite|create-org)/);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------- wiring ----
|
|
126
|
+
|
|
127
|
+
test("both invite endpoints ask for the decision", () => {
|
|
128
|
+
for (const file of [
|
|
129
|
+
"services/verification.service.ts",
|
|
130
|
+
"services/verification-v2.service.ts",
|
|
131
|
+
]) {
|
|
132
|
+
const text = src(file);
|
|
133
|
+
|
|
134
|
+
assert.match(
|
|
135
|
+
text,
|
|
136
|
+
/decideServiceProviderInvite\(/,
|
|
137
|
+
`${file} must decide server-side`,
|
|
138
|
+
);
|
|
139
|
+
assert.match(
|
|
140
|
+
text,
|
|
141
|
+
/if \(!decision\.ok\) \{\s*throw new BadRequestError\(decision\.reason\)/,
|
|
142
|
+
`${file} must refuse with the decision's own reason`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("the browser's inviteType no longer picks the path", () => {
|
|
148
|
+
const text = src("services/verification.service.ts");
|
|
149
|
+
|
|
150
|
+
assert.doesNotMatch(
|
|
151
|
+
text,
|
|
152
|
+
/if \(inviteType === "organization-invite"\)/,
|
|
153
|
+
"the client-supplied inviteType must not steer the invite any more",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("v2 no longer branches on the INVITING organisation", () => {
|
|
158
|
+
const text = src("services/verification-v2.service.ts");
|
|
159
|
+
const body = text.slice(text.indexOf("async function createServiceProviderInvite"));
|
|
160
|
+
|
|
161
|
+
assert.doesNotMatch(
|
|
162
|
+
body.slice(0, 3000),
|
|
163
|
+
/if \(org\) \{\s*value\.type/,
|
|
164
|
+
"the inviting org always exists, so that branch always chose sign-up",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("the invitation link points at a page that exists", () => {
|
|
169
|
+
for (const file of [
|
|
170
|
+
"services/verification.service.ts",
|
|
171
|
+
"services/verification-v2.service.ts",
|
|
172
|
+
]) {
|
|
173
|
+
const text = src(file);
|
|
174
|
+
|
|
175
|
+
assert.match(
|
|
176
|
+
text,
|
|
177
|
+
/verify\/service-provider-invite\/\$\{res\}/,
|
|
178
|
+
`${file} must link to the landing page, not /verify/<type>/<id>`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("accepting an invitation writes the engagement once and closes the invite", () => {
|
|
184
|
+
const text = src("services/customer-site.service.ts");
|
|
185
|
+
const body = text.slice(text.indexOf("async function addViaInvite"));
|
|
186
|
+
|
|
187
|
+
assert.match(body, /_countActiveByOrgAndSite\(/, "must check for a duplicate");
|
|
188
|
+
assert.match(
|
|
189
|
+
body,
|
|
190
|
+
/_updateVerificationStatusById\(\s*invite,\s*"complete"/,
|
|
191
|
+
"must not leave the row Pending after acceptance",
|
|
192
|
+
);
|
|
193
|
+
assert.match(
|
|
194
|
+
body,
|
|
195
|
+
/verification\.status !== "pending"/,
|
|
196
|
+
"must refuse an invitation that is spent, expired or cancelled",
|
|
197
|
+
);
|
|
198
|
+
});
|