@7365admin1/core 3.134.0 → 3.136.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 +18 -0
- package/dist/index.d.ts +261 -150
- package/dist/index.js +1336 -870
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1538 -1077
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/harness.mjs +11 -0
- package/test/e2e/member-direct-self-enrol-role.e2e.test.mjs +5 -3
- package/test/e2e/member-site-access.e2e.test.mjs +91 -2
- package/test/e2e/org-scoping-roles-members.e2e.test.mjs +5 -3
- package/test/e2e/service-provider-invite-approval.e2e.test.mjs +16 -1
- package/test/e2e/service-provider-invite-email-new-company.e2e.test.mjs +425 -0
- package/test/role-member-org-scope.test.mjs +3 -1
- package/test/sp-invite-email-status.test.mjs +74 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// End-to-end proof for the owner's report of 2026-09-30 (Latitude / JLL
|
|
2
|
+
// Singapore invited latitude.sec@ as Security):
|
|
3
|
+
//
|
|
4
|
+
// A. "No email was received" - the invitation was still waiting for Seven365.
|
|
5
|
+
// Now: every Seven365 administrator is emailed (not only those who opted
|
|
6
|
+
// in) when an invitation waits; approval sends the provider's email and
|
|
7
|
+
// RECORDS the outcome on the invitation ("email-sent" / "email-failed" +
|
|
8
|
+
// reason); a failed send is shown, not thrown, and Resend (rate-limited)
|
|
9
|
+
// tries again.
|
|
10
|
+
// B. "Signed up as a new company but it shows as Property Management" - the
|
|
11
|
+
// browser enrolled the invitee into the SENDER's organisation. Now: an SP
|
|
12
|
+
// invitation never reaches the sender's organisation, and a new company
|
|
13
|
+
// is created as its OWN organisation typed by the invited service, owner
|
|
14
|
+
// in that service's app, with the site on its list.
|
|
15
|
+
//
|
|
16
|
+
// Real Express stack, in-process MongoDB, loopback mail sink (a "bounce"
|
|
17
|
+
// address is refused by the sink with 550). Nothing leaves the machine.
|
|
18
|
+
//
|
|
19
|
+
// Run with: yarn test:e2e
|
|
20
|
+
|
|
21
|
+
import { after, before, describe, it } from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { ObjectId } from "mongodb";
|
|
24
|
+
|
|
25
|
+
import { startHarness } from "./harness.mjs";
|
|
26
|
+
|
|
27
|
+
const PASSWORD = "Inv-Passw0rd!";
|
|
28
|
+
const PM_EMAIL = "pm-inv@e2e.example.com";
|
|
29
|
+
// staff address on the non-production allow-list (*@seven365.com.sg); the
|
|
30
|
+
// mailer points at the loopback sink, so nothing is really sent
|
|
31
|
+
const ADMIN_EMAIL = "sp-approvals-e2e@seven365.com.sg";
|
|
32
|
+
const EXISTING_EMAIL = "existing-guard@e2e.example.com";
|
|
33
|
+
|
|
34
|
+
const SERVICES = [
|
|
35
|
+
["security_agency", "security_agency"],
|
|
36
|
+
["cleaning_services", "cleaning_services"],
|
|
37
|
+
["mechanical_electrical_services", "mechanical_electrical_services"],
|
|
38
|
+
["pest_control_services", "pest_control_services"],
|
|
39
|
+
["landscaping_services", "landscaping_services"],
|
|
40
|
+
["pool_maintenance_services", "pool_maintenance_services"],
|
|
41
|
+
// an older row carrying the short app name
|
|
42
|
+
["security", "security_agency"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
describe("SP invitation: email status, Super Admin alert, new company typed by service", { concurrency: 1 }, () => {
|
|
46
|
+
let h;
|
|
47
|
+
let id;
|
|
48
|
+
let pmSid;
|
|
49
|
+
let adminSid;
|
|
50
|
+
|
|
51
|
+
before(async () => {
|
|
52
|
+
h = await startHarness();
|
|
53
|
+
id = await seed(h);
|
|
54
|
+
pmSid = await h.login(PM_EMAIL, PASSWORD);
|
|
55
|
+
adminSid = await h.login(ADMIN_EMAIL, PASSWORD);
|
|
56
|
+
}, { timeout: 300000 });
|
|
57
|
+
|
|
58
|
+
after(async () => {
|
|
59
|
+
if (h) await h.stop();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
async function invite(email, app, site = id.site) {
|
|
63
|
+
const res = await h.api("/service-providers/invite", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
sid: pmSid,
|
|
66
|
+
body: {
|
|
67
|
+
email,
|
|
68
|
+
orgId: id.pmOrg.toString(),
|
|
69
|
+
siteId: site.toString(),
|
|
70
|
+
siteName: "Latitude",
|
|
71
|
+
inviteType: "organization-invite",
|
|
72
|
+
app,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
76
|
+
const row = await h.db.collection("verifications").findOne({ email, "metadata.siteId": site });
|
|
77
|
+
assert.equal(row.status, "awaiting-approval");
|
|
78
|
+
return row._id.toString();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const approve = (inviteId) =>
|
|
82
|
+
h.api(`/service-provider-invitations/${inviteId}/approve`, { method: "PUT", sid: adminSid });
|
|
83
|
+
|
|
84
|
+
async function newAccount(email) {
|
|
85
|
+
await h.db.collection("users").insertOne({
|
|
86
|
+
email,
|
|
87
|
+
password: await h.hashPassword(PASSWORD),
|
|
88
|
+
name: "New Co Owner",
|
|
89
|
+
status: "active",
|
|
90
|
+
createdAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
return h.login(email, PASSWORD);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------- A. email
|
|
96
|
+
|
|
97
|
+
it("A1. pending: every Seven365 administrator is emailed, not only those who opted in", async () => {
|
|
98
|
+
const before = h.mails.length;
|
|
99
|
+
await invite("waiting-co@e2e.example.com", "security_agency", id.site2);
|
|
100
|
+
|
|
101
|
+
const mail = await h.mailAt(before);
|
|
102
|
+
// the sink keeps the multipart body quoted-printable: undo soft breaks and =3D
|
|
103
|
+
const body = mail.body.replace(/=\r?\n/g, "").replace(/=3D/g, "=");
|
|
104
|
+
assert.match(mail.to, /sp-approvals-e2e@seven365\.com\.sg/);
|
|
105
|
+
assert.match(mail.subject, /Service provider invitation to approve/);
|
|
106
|
+
assert.match(body, /only after a Seven365 administrator approves/);
|
|
107
|
+
assert.match(body, /notifications\/open\?kind=spApproval/);
|
|
108
|
+
// and the bell
|
|
109
|
+
const bell = await h.db.collection("notifications").findOne({ userId: id.adminUser, title: /to approve/ });
|
|
110
|
+
assert.ok(bell, "the in-app bell row is written too");
|
|
111
|
+
// nothing reached the provider
|
|
112
|
+
assert.ok(!h.mails.some((m) => /waiting-co@/.test(m.to)));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("A2. the inviter sees who must act while it waits", async () => {
|
|
116
|
+
const res = await h.api(`/service-provider-invitations?org=${id.pmOrg}&siteId=${id.site2}`, { sid: pmSid });
|
|
117
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
118
|
+
const row = res.body.items.find((r) => r.email === "waiting-co@e2e.example.com");
|
|
119
|
+
assert.equal(row.statusLabel, "Waiting for Seven365");
|
|
120
|
+
assert.equal(row.emailStatus, null, "no email has been attempted yet");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("A3. approve -> the provider's email is sent and the invitation says so", async () => {
|
|
124
|
+
const inviteId = await invite("sent-co@e2e.example.com", "security_agency", id.site3);
|
|
125
|
+
await new Promise((r) => setTimeout(r, 200)); // let the staff email drain
|
|
126
|
+
const before = h.mails.length;
|
|
127
|
+
|
|
128
|
+
const res = await approve(inviteId);
|
|
129
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
130
|
+
assert.equal(res.body.emailSent, true);
|
|
131
|
+
assert.match(res.body.message, /email has been sent/);
|
|
132
|
+
|
|
133
|
+
const mail = await h.mailAt(before);
|
|
134
|
+
assert.match(mail.to, /sent-co@/);
|
|
135
|
+
assert.match(mail.body, new RegExp(`/verify/service-provider-invite/${inviteId}`));
|
|
136
|
+
|
|
137
|
+
const list = await h.api(`/service-provider-invitations?org=${id.pmOrg}&siteId=${id.site3}`, { sid: pmSid });
|
|
138
|
+
const row = list.body.items.find((r) => r._id === inviteId);
|
|
139
|
+
assert.equal(row.emailStatus.state, "sent");
|
|
140
|
+
assert.ok(row.emailStatus.at);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("A4. a refused send: approved anyway, status 'failed' + reason, no 500, Resend offered", async () => {
|
|
144
|
+
const inviteId = await invite("bounce-co@e2e.example.com", "cleaning_services", id.site4);
|
|
145
|
+
const res = await approve(inviteId);
|
|
146
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
147
|
+
assert.equal(res.body.emailSent, false);
|
|
148
|
+
assert.match(res.body.message, /could not be sent: the email address was refused/);
|
|
149
|
+
assert.match(res.body.message, /Resend/);
|
|
150
|
+
|
|
151
|
+
const row = await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) });
|
|
152
|
+
assert.equal(row.status, "pending", "approval stands");
|
|
153
|
+
const last = row.history.at(-1);
|
|
154
|
+
assert.equal(last.action, "email-failed");
|
|
155
|
+
assert.match(last.reason, /refused/);
|
|
156
|
+
assert.ok(!row.history.some((e) => e.action === "provider-notified"), "not claimed as notified");
|
|
157
|
+
// no secret anywhere in what was stored
|
|
158
|
+
assert.ok(!JSON.stringify(row.history).includes("e2e-mailer-secret"));
|
|
159
|
+
|
|
160
|
+
const list = await h.api(`/service-provider-invitations?org=${id.pmOrg}&siteId=${id.site4}`, { sid: pmSid });
|
|
161
|
+
const view = list.body.items.find((r) => r._id === inviteId);
|
|
162
|
+
assert.equal(view.emailStatus.state, "failed");
|
|
163
|
+
assert.match(view.emailStatus.reason, /refused/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("A5. Resend is rate-limited, then records its own outcome", async () => {
|
|
167
|
+
const row = await h.db.collection("verifications").findOne({ email: "bounce-co@e2e.example.com" });
|
|
168
|
+
const inviteId = row._id.toString();
|
|
169
|
+
|
|
170
|
+
const early = await h.api(`/service-provider-invitations/${inviteId}/resend`, { method: "PUT", sid: pmSid });
|
|
171
|
+
assert.equal(early.status, 400);
|
|
172
|
+
assert.match(early.body.message, /resend it in \d+ minute/);
|
|
173
|
+
|
|
174
|
+
// the clock moves on: age the last attempt past the limit
|
|
175
|
+
await h.db.collection("verifications").updateOne(
|
|
176
|
+
{ _id: row._id },
|
|
177
|
+
{ $set: { "history.$[e].at": new Date(Date.now() - 10 * 60_000).toISOString() } },
|
|
178
|
+
{ arrayFilters: [{ "e.action": "email-failed" }] },
|
|
179
|
+
);
|
|
180
|
+
// fix the address, as the sender would (a new send to a good mailbox)
|
|
181
|
+
await h.db.collection("verifications").updateOne({ _id: row._id }, { $set: { email: "fixed-co@e2e.example.com" } });
|
|
182
|
+
|
|
183
|
+
const before = h.mails.length;
|
|
184
|
+
const again = await h.api(`/service-provider-invitations/${inviteId}/resend`, { method: "PUT", sid: pmSid });
|
|
185
|
+
assert.equal(again.status, 200, JSON.stringify(again.body));
|
|
186
|
+
assert.match((await h.mailAt(before)).to, /fixed-co@/);
|
|
187
|
+
const after = await h.db.collection("verifications").findOne({ _id: row._id });
|
|
188
|
+
assert.equal(after.history.at(-1).action, "email-sent");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("A6. the Super Admin may resend too; an outsider may not", async () => {
|
|
192
|
+
const row = await h.db.collection("verifications").findOne({ email: "sent-co@e2e.example.com" });
|
|
193
|
+
await h.db.collection("verifications").updateOne(
|
|
194
|
+
{ _id: row._id },
|
|
195
|
+
{ $set: { "history.$[e].at": new Date(Date.now() - 10 * 60_000).toISOString() } },
|
|
196
|
+
{ arrayFilters: [{ "e.action": "email-sent" }] },
|
|
197
|
+
);
|
|
198
|
+
const res = await h.api(`/service-provider-invitations/${row._id}/resend`, { method: "PUT", sid: adminSid });
|
|
199
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------- B. new company typed
|
|
203
|
+
|
|
204
|
+
for (const [app, nature] of SERVICES) {
|
|
205
|
+
it(`B. new company invited for ${app} -> its own ${nature} organisation, owner in that app, site linked`, async () => {
|
|
206
|
+
const email = `newco-${app.replace(/_/g, "-")}@e2e.example.com`;
|
|
207
|
+
const site = new ObjectId();
|
|
208
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
|
|
209
|
+
const inviteId = await invite(email, app, site);
|
|
210
|
+
await approve(inviteId);
|
|
211
|
+
|
|
212
|
+
const sid = await newAccount(email);
|
|
213
|
+
const res = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, {
|
|
214
|
+
method: "PUT",
|
|
215
|
+
sid,
|
|
216
|
+
body: { companyName: `New ${app} Pte Ltd`, country: "Singapore" },
|
|
217
|
+
});
|
|
218
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
219
|
+
assert.equal(res.body.nature, nature);
|
|
220
|
+
|
|
221
|
+
const org = await h.db.collection("organizations").findOne({ _id: new ObjectId(res.body.orgId) });
|
|
222
|
+
assert.equal(org.nature, nature, "typed by the invited service");
|
|
223
|
+
assert.equal(org.type, nature);
|
|
224
|
+
assert.equal(org.name, `New ${app} Pte Ltd`);
|
|
225
|
+
assert.notEqual(org._id.toString(), id.pmOrg.toString());
|
|
226
|
+
|
|
227
|
+
const user = await h.db.collection("users").findOne({ email });
|
|
228
|
+
assert.equal(user.defaultOrg?.toString(), org._id.toString());
|
|
229
|
+
|
|
230
|
+
const rows = await h.db.collection("members").find({ user: user._id, status: "active" }).toArray();
|
|
231
|
+
assert.deepEqual(rows.map((m) => m.type).sort(), ["organization", nature].sort());
|
|
232
|
+
assert.ok(rows.every((m) => m.org.toString() === org._id.toString()), "memberships only in its own company");
|
|
233
|
+
assert.equal(
|
|
234
|
+
await h.db.collection("members").countDocuments({ user: user._id, org: { $in: [id.pmOrg, id.pmOrg.toString()] } }),
|
|
235
|
+
0,
|
|
236
|
+
"never a member of the property manager",
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const link = await h.db.collection("customer.sites").findOne({ org: org._id, site });
|
|
240
|
+
assert.ok(link, "the site is on the new company's list as a service provider");
|
|
241
|
+
const after = await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) });
|
|
242
|
+
assert.equal(after.status, "complete");
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it("B2. an EXISTING company is not re-created: refused here, accepts as itself, unchanged", async () => {
|
|
247
|
+
const site = new ObjectId();
|
|
248
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
|
|
249
|
+
const inviteId = await invite(EXISTING_EMAIL, "security_agency", site);
|
|
250
|
+
await approve(inviteId);
|
|
251
|
+
const sid = await h.login(EXISTING_EMAIL, PASSWORD);
|
|
252
|
+
const orgsBefore = await h.db.collection("organizations").countDocuments();
|
|
253
|
+
|
|
254
|
+
const refused = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, {
|
|
255
|
+
method: "PUT",
|
|
256
|
+
sid,
|
|
257
|
+
body: { companyName: "Dup" },
|
|
258
|
+
});
|
|
259
|
+
assert.equal(refused.status, 400);
|
|
260
|
+
assert.match(refused.body.message, /already have a Security company/);
|
|
261
|
+
|
|
262
|
+
const ok = await h.api(`/service-provider-invitations/${inviteId}/accept`, {
|
|
263
|
+
method: "PUT",
|
|
264
|
+
sid,
|
|
265
|
+
body: { org: id.existingOrg.toString() },
|
|
266
|
+
});
|
|
267
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
268
|
+
assert.equal(await h.db.collection("organizations").countDocuments(), orgsBefore);
|
|
269
|
+
const org = await h.db.collection("organizations").findOne({ _id: id.existingOrg });
|
|
270
|
+
assert.equal(org.nature, "security_agency");
|
|
271
|
+
assert.ok(await h.db.collection("customer.sites").findOne({ org: id.existingOrg, site }));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("B3. only the invited account; not while held; not twice", async () => {
|
|
275
|
+
const site = new ObjectId();
|
|
276
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
|
|
277
|
+
const inviteId = await invite("held-co@e2e.example.com", "security_agency", site);
|
|
278
|
+
const sid = await newAccount("held-co@e2e.example.com");
|
|
279
|
+
|
|
280
|
+
const held = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: {} });
|
|
281
|
+
assert.equal(held.status, 404, "not before Seven365 approves");
|
|
282
|
+
|
|
283
|
+
await approve(inviteId);
|
|
284
|
+
const other = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid: pmSid, body: {} });
|
|
285
|
+
assert.equal(other.status, 401);
|
|
286
|
+
|
|
287
|
+
const first = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: { companyName: "Held Co" } });
|
|
288
|
+
assert.equal(first.status, 200, JSON.stringify(first.body));
|
|
289
|
+
const second = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: { companyName: "Held Co 2" } });
|
|
290
|
+
assert.equal(second.status, 400);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("B4. the old browser path is closed: an SP invitee cannot enrol into the SENDER's organisation", async () => {
|
|
294
|
+
const site = new ObjectId();
|
|
295
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
|
|
296
|
+
const email = "legacy-co@e2e.example.com";
|
|
297
|
+
const inviteId = await invite(email, "security_agency", site);
|
|
298
|
+
await approve(inviteId);
|
|
299
|
+
const sid = await newAccount(email);
|
|
300
|
+
const user = await h.db.collection("users").findOne({ email });
|
|
301
|
+
|
|
302
|
+
const role = await h.api("/roles", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
sid,
|
|
305
|
+
body: { name: "Admin Service Provider", permissions: ["*"], type: "security_agency", org: id.pmOrg.toString(), site: site.toString(), platform: "website" },
|
|
306
|
+
});
|
|
307
|
+
assert.equal(role.status, 401, JSON.stringify(role.body));
|
|
308
|
+
|
|
309
|
+
const enrol = await h.api("/members/direct", {
|
|
310
|
+
method: "POST",
|
|
311
|
+
sid,
|
|
312
|
+
body: { userId: user._id.toString(), orgId: id.pmOrg.toString(), roleId: id.pmRole.toString(), app: "organization", siteId: site.toString(), siteName: "Latitude" },
|
|
313
|
+
});
|
|
314
|
+
assert.equal(enrol.status, 401, JSON.stringify(enrol.body));
|
|
315
|
+
assert.equal(await h.db.collection("members").countDocuments({ user: user._id }), 0);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ------------------------------------------- C. repair the live wrong join
|
|
319
|
+
|
|
320
|
+
it("C1. Super Admin repair: preview (read only) -> repair -> undo -> repair; then the right path works", async () => {
|
|
321
|
+
// the production state of latitude.sec@: approved invitation, an account,
|
|
322
|
+
// and two memberships the OLD web flow wrote into the SENDER's organisation
|
|
323
|
+
const site = new ObjectId();
|
|
324
|
+
await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
|
|
325
|
+
const email = "latitude-sec@e2e.example.com";
|
|
326
|
+
const inviteId = await invite(email, "security_agency", site);
|
|
327
|
+
await approve(inviteId);
|
|
328
|
+
const sid = await newAccount(email);
|
|
329
|
+
const user = await h.db.collection("users").findOne({ email });
|
|
330
|
+
const spRole = new ObjectId();
|
|
331
|
+
await h.db.collection("roles").insertOne({ _id: spRole, name: "Admin Service Provider", type: "security_agency", org: id.pmOrg.toString(), site: site.toString(), permissions: ["*"], status: "active" });
|
|
332
|
+
const later = new Date(Date.now() + 1000);
|
|
333
|
+
await h.db.collection("members").insertMany([
|
|
334
|
+
{ user: user._id, org: id.pmOrg, role: spRole, type: "organization", siteId: site, siteName: "Latitude", status: "active", createdAt: later },
|
|
335
|
+
{ user: user._id, org: id.pmOrg, role: spRole, type: "security_agency", siteId: site, siteName: "Latitude", status: "active", createdAt: later },
|
|
336
|
+
]);
|
|
337
|
+
await h.db.collection("users").updateOne({ _id: user._id }, { $set: { defaultOrg: id.pmOrg.toString() } });
|
|
338
|
+
|
|
339
|
+
// not for the property manager, not for the invitee
|
|
340
|
+
for (const who of [pmSid, sid]) {
|
|
341
|
+
const refused = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: who });
|
|
342
|
+
assert.ok([401, 403].includes(refused.status), JSON.stringify(refused.body));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const before = JSON.stringify(await h.db.collection("members").find({ user: user._id }).toArray());
|
|
346
|
+
const preview = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: adminSid });
|
|
347
|
+
assert.equal(preview.status, 200, JSON.stringify(preview.body));
|
|
348
|
+
assert.equal(preview.body.senderOrgName, "JLL Singapore");
|
|
349
|
+
assert.equal(preview.body.memberships.length, 2);
|
|
350
|
+
assert.ok(preview.body.memberships.every((m) => m.fullAccess && m.roleName === "Admin Service Provider"));
|
|
351
|
+
assert.equal(preview.body.defaultOrgIsSender, true);
|
|
352
|
+
assert.equal(preview.body.repaired, null);
|
|
353
|
+
assert.equal(JSON.stringify(await h.db.collection("members").find({ user: user._id }).toArray()), before, "preview writes nothing");
|
|
354
|
+
|
|
355
|
+
const repaired = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
|
|
356
|
+
assert.equal(repaired.status, 200, JSON.stringify(repaired.body));
|
|
357
|
+
assert.equal(repaired.body.changed, 2);
|
|
358
|
+
assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 0);
|
|
359
|
+
|
|
360
|
+
const undone = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/undo`, { method: "PUT", sid: adminSid });
|
|
361
|
+
assert.equal(undone.status, 200, JSON.stringify(undone.body));
|
|
362
|
+
assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 2);
|
|
363
|
+
|
|
364
|
+
await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
|
|
365
|
+
const again = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: adminSid });
|
|
366
|
+
assert.equal(again.body.memberships.length, 0);
|
|
367
|
+
assert.ok(again.body.repaired);
|
|
368
|
+
// the pending invitation is untouched by all of this
|
|
369
|
+
assert.equal((await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) })).status, "pending");
|
|
370
|
+
|
|
371
|
+
// and now the invitee takes the right path: its own Security company
|
|
372
|
+
const res = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, {
|
|
373
|
+
method: "PUT",
|
|
374
|
+
sid,
|
|
375
|
+
body: { companyName: "Latitude Security" },
|
|
376
|
+
});
|
|
377
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
378
|
+
const org = await h.db.collection("organizations").findOne({ _id: new ObjectId(res.body.orgId) });
|
|
379
|
+
assert.equal(org.nature, "security_agency");
|
|
380
|
+
assert.ok(await h.db.collection("customer.sites").findOne({ org: org._id, site }));
|
|
381
|
+
assert.equal(
|
|
382
|
+
(await h.db.collection("users").findOne({ _id: user._id })).defaultOrg?.toString(),
|
|
383
|
+
org._id.toString(),
|
|
384
|
+
"the account now opens its own company",
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
async function seed(h) {
|
|
390
|
+
const now = new Date().toISOString();
|
|
391
|
+
const pmOrg = new ObjectId();
|
|
392
|
+
const existingOrg = new ObjectId();
|
|
393
|
+
const pmUser = new ObjectId();
|
|
394
|
+
const adminUser = new ObjectId();
|
|
395
|
+
const existingUser = new ObjectId();
|
|
396
|
+
const adminRole = new ObjectId();
|
|
397
|
+
const pmRole = new ObjectId();
|
|
398
|
+
const existingRole = new ObjectId();
|
|
399
|
+
const [site, site2, site3, site4] = [0, 1, 2, 3].map(() => new ObjectId());
|
|
400
|
+
|
|
401
|
+
await h.db.collection("organizations").insertMany([
|
|
402
|
+
{ _id: pmOrg, name: "JLL Singapore", email: "pm-org-inv@e2e.example.com", type: "property_management_agency", nature: "property_management_agency", status: "active", createdAt: now },
|
|
403
|
+
{ _id: existingOrg, name: "Existing Guards", email: EXISTING_EMAIL, type: "security_agency", nature: "security_agency", createdBy: existingUser, status: "active", createdAt: now },
|
|
404
|
+
]);
|
|
405
|
+
await h.db.collection("sites").insertMany(
|
|
406
|
+
[site, site2, site3, site4].map((_id) => ({ _id, name: "Latitude", orgId: pmOrg, status: "active", createdAt: now })),
|
|
407
|
+
);
|
|
408
|
+
const pw = await h.hashPassword(PASSWORD);
|
|
409
|
+
await h.db.collection("users").insertMany([
|
|
410
|
+
{ _id: pmUser, email: PM_EMAIL, password: pw, name: "Latitude", status: "active", defaultOrg: pmOrg.toString(), createdAt: now },
|
|
411
|
+
{ _id: adminUser, email: ADMIN_EMAIL, password: pw, name: "Master7365", status: "active", createdAt: now },
|
|
412
|
+
{ _id: existingUser, email: EXISTING_EMAIL, password: pw, name: "Existing Owner", status: "active", defaultOrg: existingOrg.toString(), createdAt: now },
|
|
413
|
+
]);
|
|
414
|
+
await h.db.collection("roles").insertMany([
|
|
415
|
+
{ _id: adminRole, name: "Super Admin", type: "admin", default: true, permissions: ["*"], status: "active" },
|
|
416
|
+
{ _id: pmRole, name: "owner", type: "property_management_agency", org: pmOrg.toString(), permissions: ["*"], status: "active" },
|
|
417
|
+
{ _id: existingRole, name: "owner", type: "organization", org: existingOrg.toString(), permissions: ["*"], status: "active" },
|
|
418
|
+
]);
|
|
419
|
+
await h.db.collection("members").insertMany([
|
|
420
|
+
{ user: adminUser, name: "Master7365", role: adminRole, type: "admin", org: "", status: "active", createdAt: now },
|
|
421
|
+
{ user: pmUser, name: "Latitude", role: pmRole, type: "property_management_agency", org: pmOrg, status: "active", createdAt: now },
|
|
422
|
+
{ user: existingUser, name: "Existing Owner", role: existingRole, type: "organization", org: existingOrg, status: "active", createdAt: now },
|
|
423
|
+
]);
|
|
424
|
+
return { pmOrg, existingOrg, pmUser, adminUser, pmRole, site, site2, site3, site4 };
|
|
425
|
+
}
|
|
@@ -143,8 +143,10 @@ test("only invitation types count, not any verification", () => {
|
|
|
143
143
|
const body = fn(inviteActor, "hasOrgInvitation");
|
|
144
144
|
assert.match(
|
|
145
145
|
body,
|
|
146
|
-
/\$in: \["user-invite", "member-invite"
|
|
146
|
+
/\$in: \["user-invite", "member-invite"\]/,
|
|
147
147
|
);
|
|
148
|
+
// a service-provider invitation names the SENDER: never a key to it (2026-09-30)
|
|
149
|
+
assert.doesNotMatch(body, /"service-provider-invite"/);
|
|
148
150
|
// a forgotten-password or sign-up code must not be a key to an organisation
|
|
149
151
|
assert.doesNotMatch(body, /forget-password|user-sign-up/);
|
|
150
152
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
inviteEmailStatus,
|
|
6
|
+
inviteEmailFailureReason,
|
|
7
|
+
inviteResendWaitMinutes,
|
|
8
|
+
serviceProviderNatureFor,
|
|
9
|
+
} from "./.build/utils/service-provider-invite.util.mjs";
|
|
10
|
+
import { notificationEmail } from "./.build/utils/notification-email.util.mjs";
|
|
11
|
+
|
|
12
|
+
// Owner report 2026-09-30: did the invitation email go out, and why not.
|
|
13
|
+
|
|
14
|
+
test("email status is the LATEST send attempt", () => {
|
|
15
|
+
assert.equal(inviteEmailStatus([{ action: "created" }]), null);
|
|
16
|
+
assert.deepEqual(
|
|
17
|
+
inviteEmailStatus([
|
|
18
|
+
{ action: "email-failed", at: "t1", reason: "could not reach the mail server" },
|
|
19
|
+
{ action: "resent", at: "t2" },
|
|
20
|
+
{ action: "email-sent", at: "t3" },
|
|
21
|
+
]),
|
|
22
|
+
{ state: "sent", at: "t3" },
|
|
23
|
+
);
|
|
24
|
+
assert.equal(inviteEmailStatus([{ action: "email-failed", at: "t" }]).reason, "unknown error");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("failure reasons come from the error CODE only, never its message", () => {
|
|
28
|
+
const leaky = { code: "EAUTH", message: "Invalid login: user@x pass=hunter2" };
|
|
29
|
+
const reason = inviteEmailFailureReason(leaky);
|
|
30
|
+
assert.match(reason, /refused Seven365's login/);
|
|
31
|
+
assert.ok(!reason.includes("hunter2"));
|
|
32
|
+
assert.match(inviteEmailFailureReason({ code: "EENVELOPE" }), /address was refused/);
|
|
33
|
+
assert.match(inviteEmailFailureReason({ responseCode: 550 }), /address was refused/);
|
|
34
|
+
assert.match(inviteEmailFailureReason({ code: "ETIMEDOUT" }), /did not answer/);
|
|
35
|
+
assert.match(inviteEmailFailureReason({ code: "ECONNECTION" }), /could not reach/);
|
|
36
|
+
assert.match(inviteEmailFailureReason({ responseCode: 421 }), /busy/);
|
|
37
|
+
assert.match(inviteEmailFailureReason({ code: "ETEMPLATE" }), /could not be prepared/);
|
|
38
|
+
assert.equal(inviteEmailFailureReason(new Error("x")), "the email could not be sent");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("Resend waits 2 minutes after the last attempt", () => {
|
|
42
|
+
const now = new Date("2026-09-30T10:00:00Z");
|
|
43
|
+
assert.equal(inviteResendWaitMinutes([], now), 0);
|
|
44
|
+
assert.equal(inviteResendWaitMinutes([{ action: "email-sent", at: "2026-09-30T09:59:30Z" }], now), 2);
|
|
45
|
+
assert.equal(inviteResendWaitMinutes([{ action: "email-failed", at: "2026-09-30T09:58:30Z" }], now), 1);
|
|
46
|
+
assert.equal(inviteResendWaitMinutes([{ action: "email-sent", at: "2026-09-30T09:57:00Z" }], now), 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a new company's nature comes from the invited service; never property management", () => {
|
|
50
|
+
assert.equal(serviceProviderNatureFor("security_agency"), "security_agency");
|
|
51
|
+
assert.equal(serviceProviderNatureFor("Security"), "security_agency");
|
|
52
|
+
assert.equal(serviceProviderNatureFor("hygiene"), "cleaning_services");
|
|
53
|
+
assert.equal(serviceProviderNatureFor("pool"), "pool_maintenance_services");
|
|
54
|
+
assert.equal(serviceProviderNatureFor("property_management_agency"), "");
|
|
55
|
+
assert.equal(serviceProviderNatureFor("real_estate_developer"), "");
|
|
56
|
+
assert.equal(serviceProviderNatureFor(""), "");
|
|
57
|
+
assert.equal(serviceProviderNatureFor("rocket_science"), "");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a role-based email has no turn-off link and says why it was sent", () => {
|
|
61
|
+
const m = notificationEmail({
|
|
62
|
+
title: "Service provider invitation to approve",
|
|
63
|
+
body: "x",
|
|
64
|
+
moduleLabel: "Seven365 platform",
|
|
65
|
+
openUrl: "https://a/notifications/open?kind=spApproval",
|
|
66
|
+
settingsUrl: "https://a/notification-settings",
|
|
67
|
+
why: "You are a Seven365 administrator.",
|
|
68
|
+
});
|
|
69
|
+
assert.ok(!/Turn off email/.test(m.html) && !/Turn off email/.test(m.text));
|
|
70
|
+
assert.match(m.text, /You are a Seven365 administrator/);
|
|
71
|
+
// the opt-in email is unchanged
|
|
72
|
+
const optIn = notificationEmail({ title: "t", body: "b", moduleLabel: "M", openUrl: "o", offUrl: "off", settingsUrl: "s" });
|
|
73
|
+
assert.match(optIn.text, /Turn off email for M: off/);
|
|
74
|
+
});
|