@7365admin1/core 3.73.1 → 3.75.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 +91 -50
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +91 -50
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/backfill-org-owner-roles.test.mjs +6 -1
- package/test/e2e/client-invite-service365.e2e.test.mjs +168 -0
- package/test/org-default-roles.test.mjs +11 -0
package/package.json
CHANGED
|
@@ -63,6 +63,11 @@ test("a role-less organisation gets exactly the roles defaultOwnerRoles writes",
|
|
|
63
63
|
for (const nature of APP_SERVICE_TYPES) {
|
|
64
64
|
const { payloads } = planForOrg(org({ nature }), 0);
|
|
65
65
|
const expected = defaultOwnerRoles(ORG, nature);
|
|
66
|
+
// The tool posts the nature as the role type; POST /api/roles stores a
|
|
67
|
+
// developer's as property_management_agency (`serviceAppType`), which is
|
|
68
|
+
// what defaultOwnerRoles writes directly.
|
|
69
|
+
const appType = nature === "real_estate_developer" ? "property_management_agency" : nature;
|
|
70
|
+
for (const p of payloads) if (p.type === nature) p.type = appType;
|
|
66
71
|
|
|
67
72
|
// Four now, not two. The tool duplicates the permission list so it runs
|
|
68
73
|
// with no build step, and THIS is the assertion that stops the copy
|
|
@@ -72,7 +77,7 @@ test("a role-less organisation gets exactly the roles defaultOwnerRoles writes",
|
|
|
72
77
|
assert.deepEqual(payloads.map(apiShape), expected.map(apiShape), nature);
|
|
73
78
|
assert.deepEqual(
|
|
74
79
|
payloads.map((p) => p.type),
|
|
75
|
-
["organization", "organization",
|
|
80
|
+
["organization", "organization", appType, appType],
|
|
76
81
|
);
|
|
77
82
|
assert.deepEqual(payloads.map((p) => p.name), [
|
|
78
83
|
"owner", "Administrator", "owner", "Administrator",
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
const RED_OWNER = ["visitor-mgmt:view-visitors", "members:view-members"];
|
|
28
|
+
|
|
29
|
+
// Every switch explicit, the way the form sends them.
|
|
30
|
+
const RESIDENT_APP = {
|
|
31
|
+
feedback: true, bulletin: true, bulletinVideos: false, inviteVisitors: true, myVisitors: true,
|
|
32
|
+
billingAndSoa: false, access: true, facilityBooking: true, onlineForm: true, emergencyContact: true,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("Invite Client gives a Property Management client its Service365 modules", { concurrency: 1 }, () => {
|
|
36
|
+
let h;
|
|
37
|
+
const sid = {};
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
h = await startHarness();
|
|
41
|
+
await seed(h);
|
|
42
|
+
for (const email of [STAFF, OUTSIDER, PM_CLIENT, RED_CLIENT]) sid[email] = await h.login(email, PASSWORD);
|
|
43
|
+
}, { timeout: 300000 });
|
|
44
|
+
|
|
45
|
+
after(async () => {
|
|
46
|
+
if (h) await h.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const invite = (who, body) => h.api("/auth/invite/user", { method: "POST", sid: sid[who], body });
|
|
50
|
+
const inviteOf = (email) =>
|
|
51
|
+
h.db.collection("verifications").findOne({ type: "user-invite", email }, { sort: { createdAt: -1 } });
|
|
52
|
+
const onboard = (who, name, nature) =>
|
|
53
|
+
inviteOf(who).then((row) =>
|
|
54
|
+
h.api("/organizations/onboarding", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
sid: sid[who],
|
|
57
|
+
body: { name, type: "org", nature, email: who, inviteId: row._id.toString() },
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it("a staff invite for a Property Management client stores the resident app switches", async () => {
|
|
62
|
+
const res = await invite(STAFF, { email: PM_CLIENT, app: ["property_management_agency"], modules: MODULES, residentAppModules: RESIDENT_APP });
|
|
63
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
64
|
+
assert.deepEqual((await inviteOf(PM_CLIENT)).metadata.residentAppModules, RESIDENT_APP);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("a re-invite replaces them on the pending invitation", async () => {
|
|
68
|
+
const changed = { ...RESIDENT_APP, facilityBooking: false };
|
|
69
|
+
const res = await invite(STAFF, { email: PM_CLIENT, app: ["property_management_agency"], modules: MODULES, residentAppModules: changed });
|
|
70
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
71
|
+
const rows = await h.db.collection("verifications").find({ type: "user-invite", email: PM_CLIENT }).toArray();
|
|
72
|
+
assert.equal(rows.length, 1, "one pending invitation");
|
|
73
|
+
assert.deepEqual(rows[0].metadata.residentAppModules, changed);
|
|
74
|
+
// Back to the original for the onboarding case below.
|
|
75
|
+
await invite(STAFF, { email: PM_CLIENT, app: ["property_management_agency"], modules: MODULES, residentAppModules: RESIDENT_APP });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("refused for another nature, without modules, with an unknown switch, or from a non-staff caller", async () => {
|
|
79
|
+
const cases = [
|
|
80
|
+
[STAFF, { email: "sec-cis@e2e.example.com", app: ["security_agency"], modules: MODULES, residentAppModules: RESIDENT_APP }, [400]],
|
|
81
|
+
[STAFF, { email: "nomod-cis@e2e.example.com", app: ["property_management_agency"], residentAppModules: RESIDENT_APP }, [400]],
|
|
82
|
+
[STAFF, { email: "badkey-cis@e2e.example.com", app: ["property_management_agency"], modules: MODULES, residentAppModules: { payroll: true } }, [400]],
|
|
83
|
+
[OUTSIDER, { email: "out-cis@e2e.example.com", app: ["property_management_agency"], modules: MODULES, residentAppModules: RESIDENT_APP }, [401, 403]],
|
|
84
|
+
];
|
|
85
|
+
for (const [who, body, statuses] of cases) {
|
|
86
|
+
const res = await invite(who, body);
|
|
87
|
+
assert.ok(statuses.includes(res.status), `${body.email}: got ${res.status} ${JSON.stringify(res.body)}`);
|
|
88
|
+
assert.equal(await inviteOf(body.email), null, "nothing was written for " + body.email);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("onboarding writes the modules AND the resident app switches on the organisation", async () => {
|
|
93
|
+
const res = await onboard(PM_CLIENT, "CIS PM Client", "property_management_agency");
|
|
94
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
95
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIS PM Client" });
|
|
96
|
+
assert.deepEqual(org.modules, MODULES);
|
|
97
|
+
assert.deepEqual(org.residentAppModules, RESIDENT_APP);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("a real estate developer gets the same Property Management set", async () => {
|
|
101
|
+
// Owner, 2026-09-15: Invite Client now offers Real Estate Developer, with the
|
|
102
|
+
// owner role chosen from the Property Management catalogue like a PM client.
|
|
103
|
+
const res = await invite(STAFF, { email: RED_CLIENT, app: ["real_estate_developer"], modules: MODULES, permissions: RED_OWNER, residentAppModules: RESIDENT_APP });
|
|
104
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
105
|
+
const made = await onboard(RED_CLIENT, "CIS Developer Client", "real_estate_developer");
|
|
106
|
+
assert.equal(made.status, 201, JSON.stringify(made.body));
|
|
107
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIS Developer Client" });
|
|
108
|
+
assert.deepEqual(org.residentAppModules, RESIDENT_APP);
|
|
109
|
+
assert.deepEqual(org.modules, MODULES);
|
|
110
|
+
const owner = await h.db.collection("roles").findOne({ _id: org.invitedRole });
|
|
111
|
+
assert.ok(owner, "the chosen owner role is recorded on the organisation");
|
|
112
|
+
// The Property Management app reads property_management_agency roles and
|
|
113
|
+
// memberships; a developer-typed one was a role no application could open.
|
|
114
|
+
assert.equal(owner.type, "property_management_agency");
|
|
115
|
+
assert.deepEqual([...owner.permissions].sort(), [...RED_OWNER].sort());
|
|
116
|
+
assert.equal(org.nature, "real_estate_developer", "the organisation keeps its nature");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("a real estate developer's owner opens the Property Management app, capped like a PM client", async () => {
|
|
120
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIS Developer Client" });
|
|
121
|
+
const owner = await h.db.collection("users").findOne({ email: RED_CLIENT });
|
|
122
|
+
|
|
123
|
+
// Onboarding's finish sends the invitation's app, which for a developer is its nature.
|
|
124
|
+
const member = await h.api("/members/direct", {
|
|
125
|
+
method: "POST",
|
|
126
|
+
sid: sid[RED_CLIENT],
|
|
127
|
+
body: { userId: owner._id.toString(), orgId: org._id.toString(), roleId: org.invitedRole.toString(), app: "real_estate_developer", siteId: new ObjectId().toString(), siteName: "Show flat" },
|
|
128
|
+
});
|
|
129
|
+
assert.equal(member.status, 201, JSON.stringify(member.body));
|
|
130
|
+
const rows = await h.db.collection("members").find({ org: org._id, user: owner._id, type: { $ne: "organization" } }).toArray();
|
|
131
|
+
assert.deepEqual(rows.map((r) => r.type), ["property_management_agency"]);
|
|
132
|
+
|
|
133
|
+
// Asked either way, the lookup answers with that one row.
|
|
134
|
+
for (const type of ["property_management_agency", "real_estate_developer"]) {
|
|
135
|
+
const res = await h.api(`/members/user/${owner._id}/app/${type}/org?org=${org._id}`, { sid: sid[RED_CLIENT] });
|
|
136
|
+
assert.equal(res.status, 200, `${type}: ${JSON.stringify(res.body)}`);
|
|
137
|
+
assert.equal(String(res.body._id), String(rows[0]._id));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The grant caps the developer's roles, whichever type the request names.
|
|
141
|
+
for (const type of ["property_management_agency", "real_estate_developer"]) {
|
|
142
|
+
const wider = await h.api("/roles", {
|
|
143
|
+
method: "POST", sid: sid[RED_CLIENT],
|
|
144
|
+
body: { name: `Too wide ${type}`, type, org: org._id.toString(), permissions: ["visitor-mgmt:delete-visitor"] },
|
|
145
|
+
});
|
|
146
|
+
assert.ok([401, 403].includes(wider.status), `${type}: got ${wider.status} ${JSON.stringify(wider.body)}`);
|
|
147
|
+
}
|
|
148
|
+
const lead = await h.api("/roles", {
|
|
149
|
+
method: "POST", sid: sid[RED_CLIENT],
|
|
150
|
+
body: { name: "Estate lead", type: "real_estate_developer", org: org._id.toString(), permissions: ["visitor-mgmt:view-visitors"] },
|
|
151
|
+
});
|
|
152
|
+
assert.equal(lead.status, 201, JSON.stringify(lead.body));
|
|
153
|
+
const stored = await h.db.collection("roles").findOne({ org: org._id, name: "Estate lead" });
|
|
154
|
+
assert.equal(stored.type, "property_management_agency");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
async function seed(h) {
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const staffRole = new ObjectId();
|
|
161
|
+
await h.db.collection("roles").insertOne({ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" });
|
|
162
|
+
const users = await h.db.collection("users").insertMany(
|
|
163
|
+
[STAFF, OUTSIDER, PM_CLIENT, RED_CLIENT].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
164
|
+
);
|
|
165
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
166
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
167
|
+
await h.db.collection("members").insertOne({ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" });
|
|
168
|
+
}
|
|
@@ -281,3 +281,14 @@ test("the owner-permissions kill switch", async () => {
|
|
|
281
281
|
assert.equal(clientInviteOwnerPermissionsOn({}), true);
|
|
282
282
|
assert.equal(clientInviteOwnerPermissionsOn({ CLIENT_INVITE_OWNER_PERMISSIONS: "off" }), false);
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
test("a real estate developer's application roles are Property Management roles", () => {
|
|
286
|
+
// Owner, 2026-09-12: a developer is the Property Management set. The PM app
|
|
287
|
+
// asks for property_management_agency roles; a developer-typed one was
|
|
288
|
+
// invisible to every application.
|
|
289
|
+
const roles = defaultOwnerRoles(ORG, "real_estate_developer", ["members:view-members"]);
|
|
290
|
+
const types = roles.map((r) => r.type).sort();
|
|
291
|
+
assert.deepEqual(types, ["organization", "organization", "property_management_agency", "property_management_agency"]);
|
|
292
|
+
const owner = roles.find((r) => r.type === "property_management_agency" && r.name === "owner");
|
|
293
|
+
assert.deepEqual(owner.permissions, ["members:view-members"]);
|
|
294
|
+
});
|