@7365admin1/core 3.52.1-staging.232 → 3.52.1-staging.233
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/package.json
CHANGED
package/test/e2e/harness.mjs
CHANGED
|
@@ -551,6 +551,14 @@ function buildApp(nsu, core) {
|
|
|
551
551
|
invitations.put("/:id/decline", requireAuth, decline);
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
+
// mirrors iservice365-API-core/src/routes/verification.route.ts
|
|
555
|
+
const verifications = express.Router();
|
|
556
|
+
{
|
|
557
|
+
const { getVerifications, cancelUserInvitation } = core.useVerificationController();
|
|
558
|
+
verifications.get("/", requireAuth, getVerifications);
|
|
559
|
+
verifications.put("/:id/cancel", requireAuth, cancelUserInvitation);
|
|
560
|
+
}
|
|
561
|
+
|
|
554
562
|
// mirrors iservice365-API-core/src/routes/customer-site.route.ts
|
|
555
563
|
const customerSites = express.Router();
|
|
556
564
|
{
|
|
@@ -1404,6 +1412,7 @@ function buildApp(nsu, core) {
|
|
|
1404
1412
|
app.use("/api/users", users);
|
|
1405
1413
|
app.use("/api/users/v2", usersV2);
|
|
1406
1414
|
app.use("/api/service-providers", serviceProviders);
|
|
1415
|
+
app.use("/api/verifications", verifications);
|
|
1407
1416
|
app.use("/api/customer-sites", customerSites);
|
|
1408
1417
|
app.use("/api/sites", sites);
|
|
1409
1418
|
app.use("/api/buildings", buildings);
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The whole six-step client onboarding wizard, walked over real HTTP, and
|
|
2
|
+
// judged on WHAT ENDED UP IN THE DATABASE.
|
|
3
|
+
//
|
|
4
|
+
// `organization-update-onboarding.e2e.test.mjs` next door proves step 1 can be
|
|
5
|
+
// submitted at all. This walks the rest, because five of the six steps were
|
|
6
|
+
// never checked against the server and two of them were doing the wrong thing:
|
|
7
|
+
//
|
|
8
|
+
// step 5 "Create Site Admin Role" created an ORGANISATION role - `type:
|
|
9
|
+
// "organization"`, no site - with props byte-identical to step 2's.
|
|
10
|
+
// The wizard's gate for it was "any two organisation roles", which
|
|
11
|
+
// step 2 plus anything already satisfies.
|
|
12
|
+
// step 6 "Invite Site Admin" invited with no site at all, so the site admin
|
|
13
|
+
// was never bound to the site the wizard had just created.
|
|
14
|
+
//
|
|
15
|
+
// The web fix (`web-app-org fix/onboarding-wizard-defects`) sends a site-scoped
|
|
16
|
+
// role and a site-bound invitation. This is the other half of that claim: that
|
|
17
|
+
// the SERVER accepts both, stores them, and hands them back in the two reads
|
|
18
|
+
// the wizard uses to recompute its progress - `GET /roles?org=` (does any role
|
|
19
|
+
// carry a site?) and `GET /verifications?type=member-invite` (does any
|
|
20
|
+
// invitation carry a siteId?). Without those two reads answering, the client's
|
|
21
|
+
// progress model would be honest about nothing.
|
|
22
|
+
//
|
|
23
|
+
// Everything here is created and thrown away by the harness: an in-process
|
|
24
|
+
// MongoDB replica set, a loopback Redis, a loopback mail sink. No staging or
|
|
25
|
+
// production database, Redis, mailbox or endpoint is touched.
|
|
26
|
+
//
|
|
27
|
+
// Run with: yarn test:e2e
|
|
28
|
+
|
|
29
|
+
import { after, before, describe, it } from "node:test";
|
|
30
|
+
import assert from "node:assert/strict";
|
|
31
|
+
import { ObjectId } from "mongodb";
|
|
32
|
+
|
|
33
|
+
import { startHarness } from "./harness.mjs";
|
|
34
|
+
|
|
35
|
+
const PASSWORD = "OnbWalk-Passw0rd!";
|
|
36
|
+
const OWNER = "ow-owner@e2e.example.com"; // invited into the org, no membership yet
|
|
37
|
+
const ORG_ADMIN = "ow-orgadmin@e2e.example.com"; // step 3 invites this address
|
|
38
|
+
const SITE_ADMIN = "ow-siteadmin@e2e.example.com"; // step 6 invites this address
|
|
39
|
+
|
|
40
|
+
const NATURE = "property_management_agency";
|
|
41
|
+
|
|
42
|
+
describe("the six-step onboarding wizard, end to end", { concurrency: 1 }, () => {
|
|
43
|
+
let h;
|
|
44
|
+
const id = {};
|
|
45
|
+
const result = [];
|
|
46
|
+
|
|
47
|
+
const record = (name, fn) =>
|
|
48
|
+
it(name, async () => {
|
|
49
|
+
try {
|
|
50
|
+
await fn();
|
|
51
|
+
result.push(["PASS", name]);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
result.push(["FAIL", name]);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
before(async () => {
|
|
59
|
+
h = await startHarness();
|
|
60
|
+
Object.assign(id, await seed(h));
|
|
61
|
+
}, { timeout: 300000 });
|
|
62
|
+
|
|
63
|
+
after(async () => {
|
|
64
|
+
if (h) await h.stop();
|
|
65
|
+
console.log("\n--- onboarding wizard walk: per-case result ---");
|
|
66
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let sid;
|
|
70
|
+
const created = {};
|
|
71
|
+
|
|
72
|
+
record("0. the owner signs in, holding an invitation and no membership", async () => {
|
|
73
|
+
sid = await h.login(OWNER, PASSWORD);
|
|
74
|
+
|
|
75
|
+
const member = await h.db.collection("members").findOne({ user: id.owner });
|
|
76
|
+
assert.equal(member, null, "the owner has no members row at the start - that is the flow");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---- step 1 : Set Up Organization ---------------------------------------
|
|
80
|
+
|
|
81
|
+
record("1. step 1 saves the organisation details", async () => {
|
|
82
|
+
const res = await h.api(`/organizations/${id.org}`, {
|
|
83
|
+
method: "PUT",
|
|
84
|
+
sid,
|
|
85
|
+
body: {
|
|
86
|
+
name: "OW JLL Singapore",
|
|
87
|
+
type: "org",
|
|
88
|
+
nature: NATURE,
|
|
89
|
+
email: "ow-jll@e2e.example.com",
|
|
90
|
+
contact: "+65 88888888",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
95
|
+
|
|
96
|
+
const org = await h.db.collection("organizations").findOne({ _id: id.org });
|
|
97
|
+
assert.equal(org.contact, "+65 88888888");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
record("1b. and the owner's own membership is written, as the page then does", async () => {
|
|
101
|
+
// `handleStepSuccess` in `getting-started/index.vue` creates this the moment
|
|
102
|
+
// step 1 succeeds. Steps 4 onward depend on it: `POST /customer-sites` is
|
|
103
|
+
// `requireOrgAccess`, i.e. a LIVE MEMBER, not merely an invitation holder.
|
|
104
|
+
const res = await h.api("/members/direct", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
sid,
|
|
107
|
+
body: {
|
|
108
|
+
userId: String(id.owner),
|
|
109
|
+
orgId: String(id.org),
|
|
110
|
+
roleId: String(id.orgRole),
|
|
111
|
+
app: "organization",
|
|
112
|
+
onboardingRequired: true,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.ok(res.status < 400, JSON.stringify(res.body));
|
|
117
|
+
|
|
118
|
+
const member = await h.db.collection("members").findOne({ user: id.owner });
|
|
119
|
+
assert.ok(member, "the owner is now a member of their own organisation");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---- step 2 : Create Organization Admin Role ----------------------------
|
|
123
|
+
|
|
124
|
+
record("2. step 2 creates an ORGANISATION role, with no site", async () => {
|
|
125
|
+
const res = await h.api("/roles", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
sid,
|
|
128
|
+
body: {
|
|
129
|
+
name: "OW Org Admin",
|
|
130
|
+
permissions: ["members:see-all-members"],
|
|
131
|
+
type: "organization",
|
|
132
|
+
org: String(id.org),
|
|
133
|
+
site: "",
|
|
134
|
+
platform: "website",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
139
|
+
// `addRole` returns the inserted id, so `data.role` IS the id.
|
|
140
|
+
created.orgRole = String(res.body.data.role);
|
|
141
|
+
|
|
142
|
+
const role = await h.db.collection("roles").findOne({ _id: new ObjectId(created.orgRole) });
|
|
143
|
+
assert.equal(role.type, "organization");
|
|
144
|
+
assert.ok(!role.site, "an organisation role carries no site");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---- step 3 : Invite Organization Admin ---------------------------------
|
|
148
|
+
|
|
149
|
+
record("3. step 3 records an invitation into the organisation", async () => {
|
|
150
|
+
const res = await h.api("/auth/invite/member", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
sid,
|
|
153
|
+
body: {
|
|
154
|
+
email: ORG_ADMIN,
|
|
155
|
+
app: "organization",
|
|
156
|
+
role: created.orgRole,
|
|
157
|
+
name: "",
|
|
158
|
+
org: String(id.org),
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
163
|
+
|
|
164
|
+
const invite = await h.db
|
|
165
|
+
.collection("verifications")
|
|
166
|
+
.findOne({ email: ORG_ADMIN, type: "member-invite" });
|
|
167
|
+
|
|
168
|
+
assert.ok(invite, "the invitation is stored");
|
|
169
|
+
assert.ok(!invite.metadata?.siteId, "an organisation invitation carries no site");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- step 4 : Create Your First Site ------------------------------------
|
|
173
|
+
|
|
174
|
+
record("4. step 4 creates the site", async () => {
|
|
175
|
+
const res = await h.api("/customer-sites", {
|
|
176
|
+
method: "POST",
|
|
177
|
+
sid,
|
|
178
|
+
body: {
|
|
179
|
+
name: "OW Marina One Residences",
|
|
180
|
+
site: "",
|
|
181
|
+
siteOrg: String(id.org),
|
|
182
|
+
siteOrgName: "OW JLL Singapore",
|
|
183
|
+
org: String(id.org),
|
|
184
|
+
category: "residential",
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
189
|
+
|
|
190
|
+
const row = await h.db
|
|
191
|
+
.collection("customer.sites")
|
|
192
|
+
.findOne({ name: "OW Marina One Residences" });
|
|
193
|
+
|
|
194
|
+
assert.ok(row, "a customer-site row exists");
|
|
195
|
+
assert.ok(row.site, "and it names a site");
|
|
196
|
+
created.siteId = String(row.site);
|
|
197
|
+
created.siteName = row.name;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---- step 5 : Create Site Admin Role. THE DEFECT. -----------------------
|
|
201
|
+
|
|
202
|
+
record("5. step 5 creates a role that is SCOPED TO THE SITE", async () => {
|
|
203
|
+
const res = await h.api("/roles", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
sid,
|
|
206
|
+
body: {
|
|
207
|
+
name: "OW Site Admin",
|
|
208
|
+
permissions: ["members:see-all-members"],
|
|
209
|
+
// what a site-scoped role is everywhere else in the product: the
|
|
210
|
+
// organisation's line of business, plus the site.
|
|
211
|
+
type: NATURE,
|
|
212
|
+
org: String(id.org),
|
|
213
|
+
site: created.siteId,
|
|
214
|
+
platform: "website",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
219
|
+
created.siteRole = String(res.body.data.role);
|
|
220
|
+
|
|
221
|
+
const role = await h.db.collection("roles").findOne({ _id: new ObjectId(created.siteRole) });
|
|
222
|
+
assert.equal(role.type, NATURE, "not `organization` - that is step 2's role");
|
|
223
|
+
assert.equal(String(role.site), created.siteId, "the site is stored on the role");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
record("5b. and it is a DIFFERENT role from step 2's, not a duplicate of it", async () => {
|
|
227
|
+
const orgRole = await h.db.collection("roles").findOne({ _id: new ObjectId(created.orgRole) });
|
|
228
|
+
const siteRole = await h.db.collection("roles").findOne({ _id: new ObjectId(created.siteRole) });
|
|
229
|
+
|
|
230
|
+
assert.notEqual(orgRole.type, siteRole.type);
|
|
231
|
+
assert.equal(Boolean(orgRole.site), false);
|
|
232
|
+
assert.equal(Boolean(siteRole.site), true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ---- step 6 : Invite Site Admin. THE OTHER HALF. ------------------------
|
|
236
|
+
|
|
237
|
+
record("6. step 6 records an invitation BOUND TO THAT SITE", async () => {
|
|
238
|
+
const res = await h.api("/auth/invite/member", {
|
|
239
|
+
method: "POST",
|
|
240
|
+
sid,
|
|
241
|
+
body: {
|
|
242
|
+
email: SITE_ADMIN,
|
|
243
|
+
app: NATURE,
|
|
244
|
+
role: created.siteRole,
|
|
245
|
+
name: "",
|
|
246
|
+
org: String(id.org),
|
|
247
|
+
siteId: created.siteId,
|
|
248
|
+
siteName: created.siteName,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
253
|
+
|
|
254
|
+
const invite = await h.db
|
|
255
|
+
.collection("verifications")
|
|
256
|
+
.findOne({ email: SITE_ADMIN, type: "member-invite" });
|
|
257
|
+
|
|
258
|
+
assert.ok(invite, "the invitation is stored");
|
|
259
|
+
assert.equal(String(invite.metadata.siteId), created.siteId, "and it names the site");
|
|
260
|
+
assert.equal(invite.metadata.siteName, created.siteName);
|
|
261
|
+
assert.equal(invite.metadata.app, NATURE, "into the organisation's own line of business");
|
|
262
|
+
assert.equal(String(invite.metadata.role), created.siteRole, "with step 5's SITE role");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---- the two reads the wizard recomputes its progress from ---------------
|
|
266
|
+
|
|
267
|
+
record("7. `GET /roles?org=` shows which role carries a site", async () => {
|
|
268
|
+
const res = await h.api(`/roles?org=${id.org}&limit=100`, { sid });
|
|
269
|
+
|
|
270
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
271
|
+
|
|
272
|
+
const withSite = res.body.items.filter((r) => !!r.site);
|
|
273
|
+
assert.equal(withSite.length, 1, "exactly the one step 5 created");
|
|
274
|
+
assert.equal(String(withSite[0]._id), created.siteRole);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
record("8. `GET /verifications` shows which invitation carries the site", async () => {
|
|
278
|
+
const res = await h.api(
|
|
279
|
+
`/verifications?type=member-invite&orgId=${id.org}&status=pending&limit=100`,
|
|
280
|
+
{ sid },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
284
|
+
|
|
285
|
+
const bound = res.body.items.filter((v) => !!v.metadata?.siteId);
|
|
286
|
+
assert.equal(bound.length, 1, "exactly the one step 6 created");
|
|
287
|
+
assert.equal(bound[0].email, SITE_ADMIN);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---- and nothing was widened --------------------------------------------
|
|
291
|
+
|
|
292
|
+
record("9. none of these writes is open to a caller with no session", async () => {
|
|
293
|
+
const role = await h.api("/roles", {
|
|
294
|
+
method: "POST",
|
|
295
|
+
body: {
|
|
296
|
+
name: "no session",
|
|
297
|
+
permissions: [],
|
|
298
|
+
type: NATURE,
|
|
299
|
+
org: String(id.org),
|
|
300
|
+
site: created.siteId,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
assert.equal(role.status, 401, JSON.stringify(role.body));
|
|
304
|
+
|
|
305
|
+
const site = await h.api("/customer-sites", {
|
|
306
|
+
method: "POST",
|
|
307
|
+
body: { name: "no session", site: "", siteOrg: String(id.org), org: String(id.org) },
|
|
308
|
+
});
|
|
309
|
+
assert.equal(site.status, 401, JSON.stringify(site.body));
|
|
310
|
+
|
|
311
|
+
assert.equal(
|
|
312
|
+
await h.db.collection("roles").countDocuments({ name: "no session" }),
|
|
313
|
+
0,
|
|
314
|
+
"and nothing was written",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
async function seed(h) {
|
|
320
|
+
const now = new Date().toISOString();
|
|
321
|
+
const org = new ObjectId();
|
|
322
|
+
const orgRole = new ObjectId();
|
|
323
|
+
|
|
324
|
+
await h.db.collection("organizations").insertOne({
|
|
325
|
+
_id: org,
|
|
326
|
+
name: "OW Org",
|
|
327
|
+
email: "ow-org@e2e.example.com",
|
|
328
|
+
type: "org",
|
|
329
|
+
nature: NATURE,
|
|
330
|
+
status: "active",
|
|
331
|
+
contact: "+65 00000000",
|
|
332
|
+
createdAt: now,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// `sites.getByName` runs a `$text` search, which needs the index production
|
|
336
|
+
// creates at boot. A fresh in-memory database has none, and without it the
|
|
337
|
+
// site create answers 500.
|
|
338
|
+
await h.db.collection("sites").createIndex({ name: "text" });
|
|
339
|
+
|
|
340
|
+
await h.db.collection("roles").insertOne({
|
|
341
|
+
_id: orgRole,
|
|
342
|
+
name: "Owner",
|
|
343
|
+
org,
|
|
344
|
+
permissions: ["*"],
|
|
345
|
+
status: "active",
|
|
346
|
+
type: "organization",
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const emails = [OWNER];
|
|
350
|
+
const users = await h.db
|
|
351
|
+
.collection("users")
|
|
352
|
+
.insertMany(emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })));
|
|
353
|
+
|
|
354
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
355
|
+
await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
|
|
356
|
+
|
|
357
|
+
const owner = users.insertedIds[0];
|
|
358
|
+
|
|
359
|
+
// The owner's own sign-up invitation - the only relationship they hold when
|
|
360
|
+
// the wizard opens.
|
|
361
|
+
await h.db.collection("verifications").insertOne({
|
|
362
|
+
type: "user-invite",
|
|
363
|
+
email: OWNER,
|
|
364
|
+
status: "complete",
|
|
365
|
+
metadata: { org, role: orgRole, app: "organization" },
|
|
366
|
+
createdAt: now,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return { org, orgRole, owner };
|
|
370
|
+
}
|