@7365admin1/core 3.68.3 → 3.69.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 +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +48 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +48 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/client-invite-modules.e2e.test.mjs +164 -0
package/package.json
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Invite Client: Seven365 chooses the new client's modules, and the owner gets
|
|
2
|
+
// the client's OWN owner role (owner, 2026-09-14: "Client Owner Super Admin can
|
|
3
|
+
// only have the full access on what Seven365 Super Admin given to them, same
|
|
4
|
+
// goes to the modules").
|
|
5
|
+
//
|
|
6
|
+
// - `POST /api/auth/invite/user` with no organisation drops the role (it was a
|
|
7
|
+
// shared, org-less template) and, for staff only, stores `modules`.
|
|
8
|
+
// - `POST /api/organizations/onboarding` from that invitation writes the list on
|
|
9
|
+
// the organisation plus the save row that makes the module gate enforce it,
|
|
10
|
+
// and the owner's membership holds the organisation's own seeded owner role.
|
|
11
|
+
//
|
|
12
|
+
// In-process MongoDB, loopback Redis and a loopback mail sink only (harness.mjs).
|
|
13
|
+
//
|
|
14
|
+
// Run with: yarn test:e2e
|
|
15
|
+
|
|
16
|
+
import { after, before, describe, it } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { ObjectId } from "mongodb";
|
|
20
|
+
|
|
21
|
+
import { startHarness } from "./harness.mjs";
|
|
22
|
+
|
|
23
|
+
const PASSWORD = "ClientModules-Passw0rd!";
|
|
24
|
+
const STAFF = "sevenadmin-cim@e2e.example.com";
|
|
25
|
+
const OUTSIDER = "outsider-cim@e2e.example.com";
|
|
26
|
+
const CLIENT = "client-cim@e2e.example.com";
|
|
27
|
+
const OTHER_NATURE = "othernature-cim@e2e.example.com";
|
|
28
|
+
|
|
29
|
+
const MODULES = ["visitor-mgmt", "members", "roles", "invitations", "sites"];
|
|
30
|
+
|
|
31
|
+
// `module-gate.util moduleListHash`: sorted keys, one per line, sha256.
|
|
32
|
+
const listHash = (keys) => createHash("sha256").update([...keys].sort().join("\n")).digest("hex");
|
|
33
|
+
|
|
34
|
+
describe("Invite Client chooses modules and the owner gets the client's own role", { concurrency: 1 }, () => {
|
|
35
|
+
let h;
|
|
36
|
+
const id = {};
|
|
37
|
+
const sid = {};
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
h = await startHarness();
|
|
41
|
+
Object.assign(id, await seed(h));
|
|
42
|
+
for (const email of [STAFF, OUTSIDER, CLIENT, OTHER_NATURE]) 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
|
+
|
|
53
|
+
it("a staff invite for a new client stores the modules and no role", async () => {
|
|
54
|
+
const res = await invite(STAFF, {
|
|
55
|
+
email: CLIENT,
|
|
56
|
+
app: ["security_agency"],
|
|
57
|
+
role: id.templateRole.toString(),
|
|
58
|
+
modules: [...MODULES, "visitor-mgmt"],
|
|
59
|
+
});
|
|
60
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
61
|
+
|
|
62
|
+
const row = await inviteOf(CLIENT);
|
|
63
|
+
assert.ok(!row.metadata.role, "the shared template role is not kept");
|
|
64
|
+
assert.deepEqual(row.metadata.modules, MODULES, "stored once each, in order");
|
|
65
|
+
assert.equal(String(row.metadata.invitedBy), id.staffUser.toString());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("only Seven365 staff may choose modules", async () => {
|
|
69
|
+
const res = await invite(OUTSIDER, { email: "x-cim@e2e.example.com", app: ["security_agency"], modules: MODULES });
|
|
70
|
+
assert.ok([401, 403].includes(res.status), `expected a refusal, got ${res.status}`);
|
|
71
|
+
assert.equal(await inviteOf("x-cim@e2e.example.com"), null, "nothing was written");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("modules are refused for more than one application or an existing organisation", async () => {
|
|
75
|
+
const two = await invite(STAFF, { email: "two-cim@e2e.example.com", app: ["security_agency", "cleaning_services"], modules: MODULES });
|
|
76
|
+
assert.equal(two.status, 400, JSON.stringify(two.body));
|
|
77
|
+
|
|
78
|
+
const existing = await invite(STAFF, {
|
|
79
|
+
email: "existing-cim@e2e.example.com",
|
|
80
|
+
app: ["organization"],
|
|
81
|
+
org: id.org.toString(),
|
|
82
|
+
modules: MODULES,
|
|
83
|
+
});
|
|
84
|
+
assert.equal(existing.status, 400, JSON.stringify(existing.body));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a Seven365 staff invitation keeps its role", async () => {
|
|
88
|
+
const res = await invite(STAFF, { email: "newstaff-cim@e2e.example.com", app: ["admin"], role: id.staffRole.toString() });
|
|
89
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
90
|
+
assert.equal(String((await inviteOf("newstaff-cim@e2e.example.com")).metadata.role), id.staffRole.toString());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("onboarding from the invitation enforces the list and gives the owner the client's own owner role", async () => {
|
|
94
|
+
const row = await inviteOf(CLIENT);
|
|
95
|
+
const res = await h.api("/organizations/onboarding", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
sid: sid[CLIENT],
|
|
98
|
+
body: { name: "CIM Security Client", type: "org", nature: "security_agency", email: CLIENT, inviteId: row._id.toString() },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
101
|
+
|
|
102
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIM Security Client" });
|
|
103
|
+
assert.deepEqual(org.modules, MODULES);
|
|
104
|
+
assert.ok(!org.invitedRole, "no template recorded on the organisation");
|
|
105
|
+
|
|
106
|
+
const save = await h.db
|
|
107
|
+
.collection("console-audit")
|
|
108
|
+
.find({ action: "client.modules-changed", target: org._id.toString().toLowerCase() })
|
|
109
|
+
.toArray();
|
|
110
|
+
assert.equal(save.length, 1, "one save row");
|
|
111
|
+
assert.equal(save[0].after.gateListHash, listHash(MODULES), "the gate's enforcement marker");
|
|
112
|
+
assert.equal(String(save[0].actor), id.staffUser.toString(), "the staff member who chose the list");
|
|
113
|
+
|
|
114
|
+
const member = await h.db.collection("members").findOne({ org: org._id, type: "organization" });
|
|
115
|
+
const role = await h.db.collection("roles").findOne({ _id: member.role });
|
|
116
|
+
assert.equal(String(role.org), org._id.toString(), "the organisation's own role, not a shared template");
|
|
117
|
+
assert.equal(role.name, "owner");
|
|
118
|
+
assert.deepEqual(role.permissions, ["*"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("an organisation of a different nature gets no list", async () => {
|
|
122
|
+
await invite(STAFF, { email: OTHER_NATURE, app: ["security_agency"], modules: MODULES });
|
|
123
|
+
const row = await inviteOf(OTHER_NATURE);
|
|
124
|
+
|
|
125
|
+
const res = await h.api("/organizations/onboarding", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
sid: sid[OTHER_NATURE],
|
|
128
|
+
body: { name: "CIM Cleaning Client", type: "org", nature: "cleaning_services", email: OTHER_NATURE, inviteId: row._id.toString() },
|
|
129
|
+
});
|
|
130
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
131
|
+
|
|
132
|
+
const org = await h.db.collection("organizations").findOne({ name: "CIM Cleaning Client" });
|
|
133
|
+
assert.ok(!org.modules?.length, "a Security list would hide every Cleaning module");
|
|
134
|
+
assert.equal(
|
|
135
|
+
await h.db.collection("console-audit").countDocuments({ target: org._id.toString().toLowerCase() }),
|
|
136
|
+
0,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function seed(h) {
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
const org = new ObjectId();
|
|
144
|
+
const staffRole = new ObjectId();
|
|
145
|
+
const templateRole = new ObjectId();
|
|
146
|
+
|
|
147
|
+
await h.db.collection("organizations").insertOne({
|
|
148
|
+
_id: org, name: "CIM Existing Org", email: "existing-org-cim@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now,
|
|
149
|
+
});
|
|
150
|
+
await h.db.collection("roles").insertMany([
|
|
151
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
152
|
+
{ _id: templateRole, name: "Org Owner", type: "organization", permissions: ["*"], status: "active" },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const users = await h.db.collection("users").insertMany(
|
|
156
|
+
[STAFF, OUTSIDER, CLIENT, OTHER_NATURE].map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
157
|
+
);
|
|
158
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
159
|
+
await h.db.collection("users").updateMany({}, { $set: { password: hashed } });
|
|
160
|
+
|
|
161
|
+
await h.db.collection("members").insertOne({ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" });
|
|
162
|
+
|
|
163
|
+
return { org, staffRole, templateRole, staffUser: users.insertedIds[0] };
|
|
164
|
+
}
|