@7365admin1/core 3.73.0 → 3.74.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 +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +65 -40
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +65 -40
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/client-invite-service365.e2e.test.mjs +119 -0
- package/test/e2e/person-write-scope.e2e.test.mjs +57 -0
- package/test/people-create-gate.test.mjs +16 -13
package/package.json
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Invite Client for a Property Management client carries the Service365
|
|
2
|
+
// resident app modules too (owner, 2026-09-12: "for the property management
|
|
3
|
+
// they must have their modules and the Service365 modules"; 2026-09-15: "once I
|
|
4
|
+
// choose the Property Management during the invitation of Client the Roles and
|
|
5
|
+
// Modules for Service365 Mobile app is not included").
|
|
6
|
+
//
|
|
7
|
+
// - `POST /api/auth/invite/user` stores `residentAppModules` for staff, only with
|
|
8
|
+
// modules, only for a Property Management / real estate developer client.
|
|
9
|
+
// - `POST /api/organizations/onboarding` writes them on the organisation, where
|
|
10
|
+
// the Client details card saves them (`organizations.residentAppModules`).
|
|
11
|
+
//
|
|
12
|
+
// In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
|
|
13
|
+
|
|
14
|
+
import { after, before, describe, it } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { ObjectId } from "mongodb";
|
|
17
|
+
|
|
18
|
+
import { startHarness } from "./harness.mjs";
|
|
19
|
+
|
|
20
|
+
const PASSWORD = "ClientS365-Passw0rd!";
|
|
21
|
+
const STAFF = "sevenadmin-cis@e2e.example.com";
|
|
22
|
+
const OUTSIDER = "outsider-cis@e2e.example.com";
|
|
23
|
+
const PM_CLIENT = "pm-cis@e2e.example.com";
|
|
24
|
+
const RED_CLIENT = "red-cis@e2e.example.com";
|
|
25
|
+
|
|
26
|
+
const MODULES = ["visitor-mgmt", "members", "roles", "invitations", "sites"];
|
|
27
|
+
|
|
28
|
+
// Every switch explicit, the way the form sends them.
|
|
29
|
+
const RESIDENT_APP = {
|
|
30
|
+
feedback: true, bulletin: true, bulletinVideos: false, inviteVisitors: true, myVisitors: true,
|
|
31
|
+
billingAndSoa: false, access: true, facilityBooking: true, onlineForm: true, emergencyContact: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("Invite Client gives a Property Management client its Service365 modules", { concurrency: 1 }, () => {
|
|
35
|
+
let h;
|
|
36
|
+
const sid = {};
|
|
37
|
+
|
|
38
|
+
before(async () => {
|
|
39
|
+
h = await startHarness();
|
|
40
|
+
await seed(h);
|
|
41
|
+
for (const email of [STAFF, OUTSIDER, PM_CLIENT, RED_CLIENT]) sid[email] = await h.login(email, PASSWORD);
|
|
42
|
+
}, { timeout: 300000 });
|
|
43
|
+
|
|
44
|
+
after(async () => {
|
|
45
|
+
if (h) await h.stop();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const invite = (who, body) => h.api("/auth/invite/user", { method: "POST", sid: sid[who], body });
|
|
49
|
+
const inviteOf = (email) =>
|
|
50
|
+
h.db.collection("verifications").findOne({ type: "user-invite", email }, { sort: { createdAt: -1 } });
|
|
51
|
+
const onboard = (who, name, nature) =>
|
|
52
|
+
inviteOf(who).then((row) =>
|
|
53
|
+
h.api("/organizations/onboarding", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
sid: sid[who],
|
|
56
|
+
body: { name, type: "org", nature, email: who, inviteId: row._id.toString() },
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it("a staff invite for a Property Management client stores the resident app switches", async () => {
|
|
61
|
+
const res = await invite(STAFF, { email: PM_CLIENT, app: ["property_management_agency"], modules: MODULES, residentAppModules: RESIDENT_APP });
|
|
62
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
63
|
+
assert.deepEqual((await inviteOf(PM_CLIENT)).metadata.residentAppModules, RESIDENT_APP);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("a re-invite replaces them on the pending invitation", async () => {
|
|
67
|
+
const changed = { ...RESIDENT_APP, facilityBooking: false };
|
|
68
|
+
const res = await invite(STAFF, { email: PM_CLIENT, app: ["property_management_agency"], modules: MODULES, residentAppModules: changed });
|
|
69
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
70
|
+
const rows = await h.db.collection("verifications").find({ type: "user-invite", email: PM_CLIENT }).toArray();
|
|
71
|
+
assert.equal(rows.length, 1, "one pending invitation");
|
|
72
|
+
assert.deepEqual(rows[0].metadata.residentAppModules, changed);
|
|
73
|
+
// Back to the original for the onboarding case below.
|
|
74
|
+
await invite(STAFF, { email: PM_CLIENT, app: ["property_management_agency"], modules: MODULES, residentAppModules: RESIDENT_APP });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("refused for another nature, without modules, with an unknown switch, or from a non-staff caller", async () => {
|
|
78
|
+
const cases = [
|
|
79
|
+
[STAFF, { email: "sec-cis@e2e.example.com", app: ["security_agency"], modules: MODULES, residentAppModules: RESIDENT_APP }, [400]],
|
|
80
|
+
[STAFF, { email: "nomod-cis@e2e.example.com", app: ["property_management_agency"], residentAppModules: RESIDENT_APP }, [400]],
|
|
81
|
+
[STAFF, { email: "badkey-cis@e2e.example.com", app: ["property_management_agency"], modules: MODULES, residentAppModules: { payroll: true } }, [400]],
|
|
82
|
+
[OUTSIDER, { email: "out-cis@e2e.example.com", app: ["property_management_agency"], modules: MODULES, residentAppModules: RESIDENT_APP }, [401, 403]],
|
|
83
|
+
];
|
|
84
|
+
for (const [who, body, statuses] of cases) {
|
|
85
|
+
const res = await invite(who, body);
|
|
86
|
+
assert.ok(statuses.includes(res.status), `${body.email}: got ${res.status} ${JSON.stringify(res.body)}`);
|
|
87
|
+
assert.equal(await inviteOf(body.email), null, "nothing was written for " + body.email);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("onboarding writes the modules AND the resident app switches on the organisation", async () => {
|
|
92
|
+
const res = await onboard(PM_CLIENT, "CIS PM Client", "property_management_agency");
|
|
93
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
94
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIS PM Client" });
|
|
95
|
+
assert.deepEqual(org.modules, MODULES);
|
|
96
|
+
assert.deepEqual(org.residentAppModules, RESIDENT_APP);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("a real estate developer gets the same Property Management set", async () => {
|
|
100
|
+
const res = await invite(STAFF, { email: RED_CLIENT, app: ["real_estate_developer"], modules: MODULES, residentAppModules: RESIDENT_APP });
|
|
101
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
102
|
+
const made = await onboard(RED_CLIENT, "CIS Developer Client", "real_estate_developer");
|
|
103
|
+
assert.equal(made.status, 201, JSON.stringify(made.body));
|
|
104
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIS Developer Client" });
|
|
105
|
+
assert.deepEqual(org.residentAppModules, RESIDENT_APP);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
async function seed(h) {
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
const staffRole = new ObjectId();
|
|
112
|
+
await h.db.collection("roles").insertOne({ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" });
|
|
113
|
+
const users = await h.db.collection("users").insertMany(
|
|
114
|
+
[STAFF, OUTSIDER, PM_CLIENT, RED_CLIENT].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
115
|
+
);
|
|
116
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
117
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
118
|
+
await h.db.collection("members").insertOne({ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" });
|
|
119
|
+
}
|
|
@@ -260,6 +260,63 @@ describe("people writes are scoped to a site the caller can reach", { concurrenc
|
|
|
260
260
|
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
261
261
|
assert.equal((await load(id.residentPerson)).name, "Resubmitted By Themselves");
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
// ---- creating a person: POST /api/people -------------------------------
|
|
265
|
+
// The signed-in branch of `add` used to check only that somebody was signed
|
|
266
|
+
// in, while `site`/`org` came from the body. The reach gate is enforced by
|
|
267
|
+
// default now; these cases pin both halves of that.
|
|
268
|
+
|
|
269
|
+
const newPerson = (tag) => ({
|
|
270
|
+
name: `Created ${tag}`,
|
|
271
|
+
contact: "90000001",
|
|
272
|
+
type: "guest",
|
|
273
|
+
isOwner: false,
|
|
274
|
+
org: String(id.orgB),
|
|
275
|
+
site: String(id.siteB),
|
|
276
|
+
});
|
|
277
|
+
const countCreated = (tag) =>
|
|
278
|
+
h.db.collection("site.people").countDocuments({ name: `Created ${tag}` });
|
|
279
|
+
|
|
280
|
+
record("13. an outsider cannot create a person at site B", async () => {
|
|
281
|
+
const res = await h.api("/people", { method: "POST", body: newPerson("outsider"), sid: outsiderSid });
|
|
282
|
+
|
|
283
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
284
|
+
assert.equal(await countCreated("outsider"), 0, "nothing may be written");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
record("14. nor by naming only site B's organisation", async () => {
|
|
288
|
+
const { site, ...orgOnly } = newPerson("outsider-org");
|
|
289
|
+
const res = await h.api("/people", { method: "POST", body: orgOnly, sid: outsiderSid });
|
|
290
|
+
|
|
291
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
292
|
+
assert.equal(await countCreated("outsider-org"), 0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
record("15. the site's own manager CAN create one", async () => {
|
|
296
|
+
// `web-app-property-management components/PeopleFormMgmt.vue` -> `usePeople.create`
|
|
297
|
+
const res = await h.api("/people", { method: "POST", body: newPerson("manager"), sid: managerSid });
|
|
298
|
+
|
|
299
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
300
|
+
assert.equal(await countCreated("manager"), 1);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
record("16. and so can the agency guard posted at site B", async () => {
|
|
304
|
+
// the field guard app registers visitors at the site it is posted to
|
|
305
|
+
const res = await h.api("/people", { method: "POST", body: newPerson("guard"), sid: guardSid });
|
|
306
|
+
|
|
307
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
308
|
+
assert.equal(await countCreated("guard"), 1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
record("17. log mode is a real kill switch: the outsider is let through", async () => {
|
|
312
|
+
process.env.PEOPLE_CREATE_REACH_MODE = "log";
|
|
313
|
+
try {
|
|
314
|
+
const res = await h.api("/people", { method: "POST", body: newPerson("logmode"), sid: outsiderSid });
|
|
315
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
316
|
+
} finally {
|
|
317
|
+
delete process.env.PEOPLE_CREATE_REACH_MODE;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
263
320
|
});
|
|
264
321
|
|
|
265
322
|
async function seed(h) {
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
* `site.people` is the collection every site-reach decision reads its verdict
|
|
8
8
|
* from, so a row written there manufactures reach into another client's estate.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* default, so it cannot become enforcing by accident.
|
|
10
|
+
* The gate shipped in `log` first and is ENFORCED by default since 2026-09-15,
|
|
11
|
+
* after a staging measurement showed nobody legitimate is refused. These tests
|
|
12
|
+
* pin that default; `log` stays available as the kill switch.
|
|
14
13
|
*/
|
|
15
14
|
import test from "node:test";
|
|
16
15
|
import assert from "node:assert/strict";
|
|
@@ -21,11 +20,12 @@ import {
|
|
|
21
20
|
peopleCreateVerdict,
|
|
22
21
|
} from "./.build/utils/people-create-gate.util.mjs";
|
|
23
22
|
|
|
24
|
-
test("the code default is
|
|
25
|
-
assert.equal(PEOPLE_CREATE_REACH_MODE_DEFAULT, "
|
|
26
|
-
assert.equal(peopleCreateReachMode({}), "
|
|
27
|
-
// An unrecognised value falls back to the
|
|
28
|
-
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "nonsense" }), "
|
|
23
|
+
test("the code default is ENFORCE, with log kept as the kill switch", () => {
|
|
24
|
+
assert.equal(PEOPLE_CREATE_REACH_MODE_DEFAULT, "enforce");
|
|
25
|
+
assert.equal(peopleCreateReachMode({}), "enforce");
|
|
26
|
+
// An unrecognised value falls back to the default, so a typo cannot reopen the hole.
|
|
27
|
+
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "nonsense" }), "enforce");
|
|
28
|
+
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "log" }), "log");
|
|
29
29
|
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "ENFORCE" }), "enforce");
|
|
30
30
|
assert.equal(peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "off" }), "off");
|
|
31
31
|
});
|
|
@@ -63,9 +63,12 @@ test("OFF does not even look", () => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("the flag is the only thing between counting and refusing", () => {
|
|
66
|
-
//
|
|
67
|
-
//
|
|
66
|
+
// The default refuses an unreachable estate; `log` is the kill switch that
|
|
67
|
+
// counts instead. If either half fails, the hole is open or the switch is gone.
|
|
68
68
|
const unreachable = { reachable: false };
|
|
69
|
-
assert.equal(peopleCreateVerdict({ mode: peopleCreateReachMode({}), ...unreachable }).refuse,
|
|
70
|
-
assert.equal(
|
|
69
|
+
assert.equal(peopleCreateVerdict({ mode: peopleCreateReachMode({}), ...unreachable }).refuse, true);
|
|
70
|
+
assert.equal(
|
|
71
|
+
peopleCreateVerdict({ mode: peopleCreateReachMode({ PEOPLE_CREATE_REACH_MODE: "log" }), ...unreachable }).refuse,
|
|
72
|
+
false,
|
|
73
|
+
);
|
|
71
74
|
});
|