@7365admin1/core 3.52.19 → 3.53.1

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.
@@ -0,0 +1,122 @@
1
+ // Proves the drop-selection rule in `scripts/organizations-name-index.mjs` against a
2
+ // real MongoDB — an in-process, throwaway one (mongodb-memory-server). Nothing here
3
+ // reaches a shared cluster, and the script's `main()` is never called; only its pure
4
+ // `planIndexDrop()` is, and the drop it selects is applied by hand so the surviving
5
+ // indexes can be checked.
6
+
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { MongoMemoryServer } from "mongodb-memory-server";
10
+ import { MongoClient } from "mongodb";
11
+
12
+ import { planIndexDrop, classifyIndex, redact } from "../../scripts/organizations-name-index.mjs";
13
+
14
+ let mongo;
15
+ let client;
16
+ let db;
17
+
18
+ test.before(async () => {
19
+ mongo = await MongoMemoryServer.create();
20
+ client = new MongoClient(mongo.getUri());
21
+ await client.connect();
22
+ db = client.db("index-selection-test");
23
+ });
24
+
25
+ test.after(async () => {
26
+ await client?.close();
27
+ await mongo?.stop();
28
+ });
29
+
30
+ // Each case gets its own collection, so nothing leaks between them.
31
+ async function seed(name, build) {
32
+ const collection = db.collection(name);
33
+ await collection.insertOne({ name: "seed org" });
34
+ await build(collection);
35
+ return collection;
36
+ }
37
+
38
+ const names = (indexes) => indexes.map((i) => i.name).sort();
39
+
40
+ test("drops a UNIQUE {name:1}, and leaves _id_ and everything else alone", async () => {
41
+ const collection = await seed("case-unique", async (c) => {
42
+ await c.createIndex({ name: 1 }, { unique: true, name: "name_1" });
43
+ await c.createIndex({ email: 1 }, { name: "email_1" });
44
+ });
45
+
46
+ const plan = planIndexDrop(await collection.listIndexes().toArray());
47
+ assert.equal(plan.ok, true);
48
+ assert.deepEqual(
49
+ plan.targets.map((i) => i.name),
50
+ ["name_1"],
51
+ );
52
+
53
+ await collection.dropIndex(plan.targets[0].name);
54
+ assert.deepEqual(names(await collection.listIndexes().toArray()), ["_id_", "email_1"]);
55
+ });
56
+
57
+ test("does NOT drop a NON-unique {name:1}", async () => {
58
+ const collection = await seed("case-plain", async (c) => {
59
+ await c.createIndex({ name: 1 }, { name: "name_1" });
60
+ });
61
+
62
+ const indexes = await collection.listIndexes().toArray();
63
+ const plan = planIndexDrop(indexes);
64
+ assert.equal(plan.ok, false);
65
+ assert.deepEqual(plan.targets, []);
66
+ assert.match(plan.reason, /nothing to drop/i);
67
+ assert.equal(classifyIndex(indexes.find((i) => i.name === "name_1")), "known-name-plain");
68
+ });
69
+
70
+ test("never selects _id_, even when it is the only index there is", async () => {
71
+ const collection = await seed("case-id-only", async () => {});
72
+
73
+ const indexes = await collection.listIndexes().toArray();
74
+ assert.deepEqual(names(indexes), ["_id_"]);
75
+ assert.equal(classifyIndex(indexes[0]), "protected");
76
+ assert.equal(planIndexDrop(indexes).ok, false);
77
+ });
78
+
79
+ test("refuses on an unrecognised compound index involving name", async () => {
80
+ const collection = await seed("case-compound", async (c) => {
81
+ await c.createIndex({ name: 1 }, { unique: true, name: "name_1" });
82
+ await c.createIndex({ name: 1, org: 1 }, { unique: true, name: "name_1_org_1" });
83
+ });
84
+
85
+ const plan = planIndexDrop(await collection.listIndexes().toArray());
86
+ assert.equal(plan.ok, false);
87
+ assert.deepEqual(plan.targets, []);
88
+ assert.deepEqual(
89
+ plan.unrecognised.map((i) => i.name),
90
+ ["name_1_org_1"],
91
+ );
92
+ assert.match(plan.reason, /does not recognise/i);
93
+
94
+ // and refusing means refusing: the collection is untouched.
95
+ assert.deepEqual(names(await collection.listIndexes().toArray()), [
96
+ "_id_",
97
+ "name_1",
98
+ "name_1_org_1",
99
+ ]);
100
+ });
101
+
102
+ test("the repo's own name indexes are recognised, so a real database is not refused", async () => {
103
+ const collection = await seed("case-repo-shape", async (c) => {
104
+ await c.createIndex({ name: 1, description: 1, status: 1, email: 1 });
105
+ await c.createIndex({ name: "text", description: "text" });
106
+ await c.createIndex({ name: 1 }, { unique: true, name: "name_1" });
107
+ });
108
+
109
+ const plan = planIndexDrop(await collection.listIndexes().toArray());
110
+ assert.equal(plan.ok, true);
111
+ assert.deepEqual(
112
+ plan.targets.map((i) => i.name),
113
+ ["name_1"],
114
+ );
115
+ });
116
+
117
+ test("the connection string never survives into an error message", () => {
118
+ const uri = "mongodb+srv://user:secret@cluster0.example.mongodb.net/iservice365";
119
+ const message = redact(`connect ECONNREFUSED for ${uri} after 30000ms`, uri);
120
+ assert.doesNotMatch(message, /secret|cluster0|mongodb\+srv/);
121
+ assert.match(message, /<connection string hidden>/);
122
+ });
@@ -0,0 +1,204 @@
1
+ // End-to-end proof that a role created on one SITE does not appear on another
2
+ // site of the same client.
3
+ //
4
+ // The owner reported it in these words: "the roles and permission per site that
5
+ // create on specific site it should stay on that specific site alone, not
6
+ // connected to other clients or other sites."
7
+ //
8
+ // The client half was already closed -- `GET /api/roles?org=` runs through
9
+ // `requireOrgReach`, and `org-scoping-roles-members.e2e.test.mjs` holds it. The
10
+ // SITE half was not closed at all: every one of the seven site apps renders
11
+ // `RolePermissionMain` with the site in the URL, the role is CREATED carrying
12
+ // that site (`createRole` accepts `site`, `MRole` stores it), and then the list
13
+ // query -- `{ status: "active", org }` -- never mentioned it. So Seventh
14
+ // Condominium's roles-and-permissions screen listed every role of every estate
15
+ // that client owns, with Edit and Delete on each.
16
+ //
17
+ // What must NOT change, and is asserted here as hard as the leak itself: a role
18
+ // with no site is the organisation's own and stays visible on every site. Every
19
+ // role stored before the `site` field existed is in that shape, so a filter
20
+ // that dropped them would have hidden roles that are the only way to edit
21
+ // themselves.
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, device 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 = "SiteScope-Passw0rd!";
36
+ const ADMIN = "site-scope-admin@e2e.example.com"; // an org member, reaches both sites
37
+
38
+ describe("a site's roles list is scoped to that site", { concurrency: 1 }, () => {
39
+ let h;
40
+ const id = {};
41
+ const result = [];
42
+ let sid;
43
+
44
+ const record = (name, fn) =>
45
+ it(name, async () => {
46
+ try {
47
+ await fn();
48
+ result.push(["PASS", name]);
49
+ } catch (error) {
50
+ result.push(["FAIL", name]);
51
+ throw error;
52
+ }
53
+ });
54
+
55
+ before(async () => {
56
+ h = await startHarness();
57
+ Object.assign(id, await seed(h));
58
+ }, { timeout: 300000 });
59
+
60
+ after(async () => {
61
+ if (h) await h.stop();
62
+ console.log("\n--- role site scoping: per-case result ---");
63
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
64
+ });
65
+
66
+ const names = async (query) => {
67
+ const res = await h.api(`/roles?${query}`, { sid });
68
+ assert.equal(res.status, 200, JSON.stringify(res.body));
69
+ return (res.body.items ?? []).map((r) => r.name).sort();
70
+ };
71
+
72
+ record("0. baseline — the admin signs in and sees all four roles", async () => {
73
+ sid = await h.login(ADMIN, PASSWORD);
74
+ assert.ok(sid);
75
+ assert.deepEqual(await names(`org=${id.org}&limit=50`), [
76
+ "Org Wide",
77
+ "Org Wide Legacy",
78
+ "Site A Guard",
79
+ "Site B Guard",
80
+ ]);
81
+ });
82
+
83
+ record("1. site A's screen does not list site B's role", async () => {
84
+ const listed = await names(`org=${id.org}&site=${id.siteA}&limit=50`);
85
+ assert.ok(
86
+ !listed.includes("Site B Guard"),
87
+ `site B's role leaked onto site A: ${JSON.stringify(listed)}`,
88
+ );
89
+ });
90
+
91
+ record("2. and site B's screen does not list site A's role", async () => {
92
+ const listed = await names(`org=${id.org}&site=${id.siteB}&limit=50`);
93
+ assert.ok(
94
+ !listed.includes("Site A Guard"),
95
+ `site A's role leaked onto site B: ${JSON.stringify(listed)}`,
96
+ );
97
+ });
98
+
99
+ record("3. each site still sees its OWN role and the org-wide ones", async () => {
100
+ assert.deepEqual(await names(`org=${id.org}&site=${id.siteA}&limit=50`), [
101
+ "Org Wide",
102
+ "Org Wide Legacy",
103
+ "Site A Guard",
104
+ ]);
105
+ assert.deepEqual(await names(`org=${id.org}&site=${id.siteB}&limit=50`), [
106
+ "Org Wide",
107
+ "Org Wide Legacy",
108
+ "Site B Guard",
109
+ ]);
110
+ });
111
+
112
+ record("4. a second site's answer is not the first one's, out of the cache", async () => {
113
+ // The list is cached in Redis for 15 minutes on a key built from the query.
114
+ // Asking twice in the opposite order is what catches a `site` that filters
115
+ // but is missing from the cache key -- the case where the fix works once and
116
+ // is decorative after that.
117
+ assert.deepEqual(await names(`org=${id.org}&site=${id.siteB}&limit=50`), [
118
+ "Org Wide",
119
+ "Org Wide Legacy",
120
+ "Site B Guard",
121
+ ]);
122
+ assert.deepEqual(await names(`org=${id.org}&site=${id.siteA}&limit=50`), [
123
+ "Org Wide",
124
+ "Org Wide Legacy",
125
+ "Site A Guard",
126
+ ]);
127
+ });
128
+
129
+ record("5. asking without a site is unchanged — the whole client", async () => {
130
+ assert.deepEqual(await names(`org=${id.org}&limit=50`), [
131
+ "Org Wide",
132
+ "Org Wide Legacy",
133
+ "Site A Guard",
134
+ "Site B Guard",
135
+ ]);
136
+ });
137
+
138
+ record("6. a role created through the API carries its site and stays there", async () => {
139
+ const res = await h.api("/roles", {
140
+ method: "POST",
141
+ sid,
142
+ body: {
143
+ name: "Site A Supervisor",
144
+ permissions: ["feedback:see-all-feedbacks"],
145
+ type: "property_management_agency",
146
+ org: id.org.toString(),
147
+ site: id.siteA.toString(),
148
+ platform: "website",
149
+ },
150
+ });
151
+ assert.equal(res.status, 201, JSON.stringify(res.body));
152
+
153
+ assert.ok((await names(`org=${id.org}&site=${id.siteA}&limit=50`)).includes("Site A Supervisor"));
154
+ assert.ok(!(await names(`org=${id.org}&site=${id.siteB}&limit=50`)).includes("Site A Supervisor"));
155
+ });
156
+ });
157
+
158
+ async function seed(h) {
159
+ const now = new Date().toISOString();
160
+ const org = new ObjectId();
161
+ const siteA = new ObjectId();
162
+ const siteB = new ObjectId();
163
+ const adminRole = new ObjectId();
164
+
165
+ await h.db.collection("organizations").insertOne({
166
+ _id: org,
167
+ name: "Site Scope Org",
168
+ email: "site-scope@e2e.example.com",
169
+ type: "org",
170
+ nature: "property_management_agency",
171
+ status: "active",
172
+ createdAt: now,
173
+ });
174
+
175
+ await h.db.collection("sites").insertMany([
176
+ { _id: siteA, name: "Site A", org, orgId: org, status: "active" },
177
+ { _id: siteB, name: "Site B", org, orgId: org, status: "active" },
178
+ ]);
179
+
180
+ await h.db.collection("roles").insertMany([
181
+ // the caller's own role, org-wide
182
+ { _id: adminRole, name: "Org Wide", org, site: "", permissions: ["*"], type: "property_management_agency", status: "active" },
183
+ // the shape every role stored before the `site` field existed has: no key
184
+ { name: "Org Wide Legacy", org, permissions: ["*"], type: "property_management_agency", status: "active" },
185
+ { name: "Site A Guard", org, site: siteA, permissions: [], type: "property_management_agency", status: "active" },
186
+ { name: "Site B Guard", org, site: siteB, permissions: [], type: "property_management_agency", status: "active" },
187
+ ]);
188
+
189
+ const users = await h.db.collection("users").insertMany([
190
+ { email: ADMIN, name: "site-scope-admin", status: "active", createdAt: now },
191
+ ]);
192
+ const hashed = await h.hashPassword(PASSWORD);
193
+ await h.db.collection("users").updateMany({ email: ADMIN }, { $set: { password: hashed } });
194
+
195
+ await h.db.collection("members").insertOne({
196
+ user: users.insertedIds[0],
197
+ org,
198
+ type: "property_management_agency",
199
+ role: adminRole,
200
+ status: "active",
201
+ });
202
+
203
+ return { org, siteA, siteB };
204
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The guard app's invite screens, validated against the schema they will
3
+ * actually be answered by.
4
+ *
5
+ * `POST /api/visitor-transactions/invite/:inviterId` is the RESIDENT app's
6
+ * "invite to my unit" flow. It derives site, org, block, level and unit from
7
+ * the inviter's `site.people` row, and `schemaInviteVisitor` declares no
8
+ * `site`, `org`, `nric` or `members[].visitorPass` — so Joi's default rejects
9
+ * all four and every request the guard app builds answers 400.
10
+ *
11
+ * Deleting the rejected keys would be worse than the 400: a guard holds no
12
+ * `site.people` row, so the invitation would be written with no site and no
13
+ * org — an orphan attributed to nobody.
14
+ *
15
+ * So the guard case gets its OWN schema, and the resident one is left exactly
16
+ * as it is. The bodies below are copied from the two screens on
17
+ * `iservice365-mobile-app-security` PR #203 (`pages/visitors/invite/
18
+ * visitor.vue:394` and `contractor.vue:532`), field for field, because those
19
+ * payloads are what exposed the defect.
20
+ */
21
+ import test from "node:test";
22
+ import assert from "node:assert/strict";
23
+
24
+ import { schemaInviteVisitor } from "./.build/models/visitor-invite.model.mjs";
25
+ import { schemaGuardInviteVisitor } from "./.build/models/guard-invite.model.mjs";
26
+
27
+ const SITE = "6923d6664150ca6a69b9f2b2";
28
+ const ORG = "69bb9dbff572cf9d260d7ce3";
29
+
30
+ /** `pages/visitors/invite/visitor.vue:394`, byte for byte. */
31
+ function guardGuestBody() {
32
+ return {
33
+ type: "guest",
34
+ expectedCheckIn: "2026-09-10T00:00:00.000Z",
35
+ arrivalTime: "14:30",
36
+ duration: "",
37
+ name: "Harris Tan",
38
+ contact: "91234567",
39
+ email: "",
40
+ isOvernightParking: false,
41
+ numberOfPassengers: 0,
42
+ purpose: "Delivery",
43
+ site: SITE,
44
+ org: ORG,
45
+ };
46
+ }
47
+
48
+ /** `pages/visitors/invite/contractor.vue:532`, byte for byte. */
49
+ function guardContractorBody() {
50
+ return {
51
+ type: "contractor",
52
+ expectedCheckIn: "2026-09-10T00:00:00.000Z",
53
+ contractorType: "home-contractor",
54
+ name: "Acme Aircon",
55
+ nric: "S1234567D",
56
+ email: "",
57
+ contact: "98765432",
58
+ company: "Acme Pte Ltd",
59
+ plateNumber: "SGX1234A",
60
+ members: [{ name: "Lee", nric: "S7654321B", contact: "90001111", visitorPass: "" }],
61
+ purpose: "Aircon servicing",
62
+ site: SITE,
63
+ org: ORG,
64
+ };
65
+ }
66
+
67
+ test("the guard GUEST payload is rejected by the resident schema — the reported 400", () => {
68
+ const { error } = schemaInviteVisitor.validate(
69
+ { inviterUserId: "6a7c0a4c850a58d1a7e9ae1b", ...guardGuestBody() },
70
+ { abortEarly: false },
71
+ );
72
+
73
+ assert.ok(error, "schemaInviteVisitor must keep refusing site/org");
74
+ assert.match(error.message, /"site" is not allowed/);
75
+ assert.match(error.message, /"org" is not allowed/);
76
+ });
77
+
78
+ test("the guard CONTRACTOR payload is rejected by the resident schema — the reported 400", () => {
79
+ const { error } = schemaInviteVisitor.validate(
80
+ { inviterUserId: "6a7c0a4c850a58d1a7e9ae1b", ...guardContractorBody() },
81
+ { abortEarly: false },
82
+ );
83
+
84
+ assert.ok(error);
85
+ for (const key of ['"site"', '"org"', '"nric"', '"members[0].visitorPass"']) {
86
+ assert.ok(error.message.includes(`${key} is not allowed`), `expected ${key} to be refused`);
87
+ }
88
+ });
89
+
90
+ test("the guard GUEST payload validates against the guard schema", () => {
91
+ const { error, value } = schemaGuardInviteVisitor.validate(guardGuestBody(), {
92
+ abortEarly: false,
93
+ });
94
+
95
+ assert.equal(error, undefined, error && error.message);
96
+ assert.equal(value.site, SITE);
97
+ assert.equal(value.type, "guest");
98
+ });
99
+
100
+ test("the guard CONTRACTOR payload validates against the guard schema", () => {
101
+ const { error, value } = schemaGuardInviteVisitor.validate(guardContractorBody(), {
102
+ abortEarly: false,
103
+ });
104
+
105
+ assert.equal(error, undefined, error && error.message);
106
+ assert.equal(value.nric, "S1234567D");
107
+ // `emptyMember()` seeds `visitorPass: ""` and never binds it to an input, so
108
+ // the empty string is DROPPED rather than written into a field the rest of
109
+ // the platform reads as an array of key references.
110
+ assert.equal(value.members[0].visitorPass, undefined);
111
+ });
112
+
113
+ test("a real gate pass is accepted, in the shape the platform already stores", () => {
114
+ const body = guardContractorBody();
115
+ body.members[0].visitorPass = [{ keyId: "6923d6664150ca6a69b9f2b4" }];
116
+
117
+ const { error, value } = schemaGuardInviteVisitor.validate(body, { abortEarly: false });
118
+
119
+ assert.equal(error, undefined, error && error.message);
120
+ assert.equal(value.members[0].visitorPass[0].keyId, "6923d6664150ca6a69b9f2b4");
121
+ });
122
+
123
+ test("a pass that is not a key reference is refused, not stored as text", () => {
124
+ const body = guardContractorBody();
125
+ body.members[0].visitorPass = "P-014";
126
+
127
+ const { error } = schemaGuardInviteVisitor.validate(body, { abortEarly: false });
128
+
129
+ assert.ok(error, "free text must not reach an array-of-key-references field");
130
+ });
131
+
132
+ test("the guard schema REQUIRES a site — there is no orphan write to fall into", () => {
133
+ const { site, ...noSite } = guardGuestBody();
134
+ const { error } = schemaGuardInviteVisitor.validate(noSite, { abortEarly: false });
135
+
136
+ assert.ok(error);
137
+ assert.match(error.message, /"site" is required/);
138
+ });
139
+
140
+ test("the guard schema refuses a site that is not an id", () => {
141
+ const body = { ...guardGuestBody(), site: "not-an-id" };
142
+ const { error } = schemaGuardInviteVisitor.validate(body, { abortEarly: false });
143
+
144
+ assert.ok(error);
145
+ assert.match(error.message, /site/);
146
+ });
147
+
148
+ test("the guard schema takes NO inviterUserId — the author comes from the session", () => {
149
+ const body = { ...guardGuestBody(), inviterUserId: "6a7c0a4c850a58d1a7e9ae1b" };
150
+ const { error } = schemaGuardInviteVisitor.validate(body, { abortEarly: false });
151
+
152
+ assert.ok(error, "an inviter id in the body must not be honoured");
153
+ assert.match(error.message, /"inviterUserId" is not allowed/);
154
+ });
155
+
156
+ test("the guard schema declines block/level/unit — a guard invite has no unit", () => {
157
+ for (const key of ["block", "level", "unit"]) {
158
+ const { error } = schemaGuardInviteVisitor.validate(
159
+ { ...guardGuestBody(), [key]: "6923d6664150ca6a69b9f2b3" },
160
+ { abortEarly: false },
161
+ );
162
+ assert.ok(error, `${key} must not be accepted`);
163
+ assert.match(error.message, new RegExp(`"${key}" is not allowed`));
164
+ }
165
+ });
166
+
167
+ test("contractorType stays required for a contractor, as on the resident schema", () => {
168
+ const { contractorType, ...body } = guardContractorBody();
169
+ const { error } = schemaGuardInviteVisitor.validate(body, { abortEarly: false });
170
+
171
+ assert.ok(error);
172
+ assert.match(error.message, /"contractorType" is required/);
173
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The guard invite path, asserted at the seams that would silently rot.
3
+ *
4
+ * `test/e2e/guard-invite-scope.e2e.test.mjs` proves the behaviour over real
5
+ * HTTP against a real database, and asserts the STORED document. What is
6
+ * pinned here is the wiring — the four properties that make the guard path
7
+ * safe, each of which a later edit could remove without any test going red:
8
+ *
9
+ * 1. the site is taken from the REQUEST and checked with `requireSiteReach`
10
+ * before anything is written;
11
+ * 2. the org is read off the RESOLVED site, never off the body — the body's
12
+ * `org` is accepted (so an installed build does not 400) and ignored;
13
+ * 3. the author is the SESSION's caller, not a path segment or a body field;
14
+ * 4. the resident path still derives everything from `_getByUserId`, and
15
+ * `schemaInviteVisitor` is still the schema it validates against.
16
+ *
17
+ * Nothing here opens a socket or a database. Every assertion reads a file.
18
+ */
19
+ import test from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { readFileSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const read = (rel) =>
25
+ readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
26
+
27
+ const controller = read("controllers/visitor-transaction.controller.ts");
28
+ const service = read("services/visitor-transaction.service.ts");
29
+
30
+ /** One `async function name(...)` body out of a source file. */
31
+ function fn(text, name) {
32
+ const start = text.indexOf(`async function ${name}(`);
33
+ assert.notEqual(start, -1, `${name} is no longer defined`);
34
+ const rest = text.slice(start);
35
+ const next = rest.slice(1).search(/\n {0,4}async function [a-zA-Z0-9_]+ ?\(/);
36
+ return next === -1 ? rest : rest.slice(0, next + 1);
37
+ }
38
+
39
+ test("the guard handler checks site reach BEFORE it calls the service", () => {
40
+ const handler = fn(controller, "inviteVisitorAsGuard");
41
+
42
+ const reach = handler.indexOf("requireSiteReach(");
43
+ const write = handler.indexOf("_inviteVisitorAsGuard(");
44
+
45
+ assert.notEqual(reach, -1, "inviteVisitorAsGuard must ask requireSiteReach");
46
+ assert.notEqual(write, -1, "inviteVisitorAsGuard must call the guard service");
47
+ assert.ok(reach < write, "the site check must run before the write");
48
+ });
49
+
50
+ test("the guard handler validates with the GUARD schema, not the resident one", () => {
51
+ const handler = fn(controller, "inviteVisitorAsGuard");
52
+
53
+ assert.ok(handler.includes("schemaGuardInviteVisitor.validate("));
54
+ assert.ok(
55
+ !handler.includes("schemaInviteVisitor.validate("),
56
+ "the guard handler must not reuse the resident schema",
57
+ );
58
+ });
59
+
60
+ test("the guard handler takes its author from the session, not from the URL", () => {
61
+ const handler = fn(controller, "inviteVisitorAsGuard");
62
+
63
+ assert.ok(
64
+ handler.includes("callerId(req)"),
65
+ "the author must be resolved from the session",
66
+ );
67
+ assert.ok(
68
+ !handler.includes("req.params.inviterId"),
69
+ "a client-supplied inviter id must not reach the guard write",
70
+ );
71
+ });
72
+
73
+ test("the guard service resolves the site, and reads org off the RESOLVED site", () => {
74
+ const guard = fn(service, "inviteVisitorAsGuard");
75
+
76
+ assert.ok(guard.includes("_getSiteById("), "the site must be loaded, not trusted");
77
+ assert.ok(
78
+ /site\?\.orgId|site\?\.org/.test(guard),
79
+ "org must come off the resolved site document",
80
+ );
81
+ assert.ok(
82
+ !/value\.org|value\?\.org/.test(guard),
83
+ "org must never be read off the request body",
84
+ );
85
+ });
86
+
87
+ test("a guard invitation carries no block, level or unit", () => {
88
+ const guard = fn(service, "inviteVisitorAsGuard");
89
+
90
+ for (const key of ["block", "level", "unit", "unitName"]) {
91
+ assert.ok(
92
+ guard.includes(`${key}: null`),
93
+ `${key} must be written null on a guard invitation`,
94
+ );
95
+ }
96
+ });
97
+
98
+ test("the guard service never reads a people row — that is the resident path", () => {
99
+ const guard = fn(service, "inviteVisitorAsGuard");
100
+
101
+ assert.ok(
102
+ !guard.includes("_getByUserId("),
103
+ "a guard holds no site.people row; reading one is the orphan-write bug",
104
+ );
105
+ });
106
+
107
+ test("REGRESSION: the resident path still derives its estate from the people row", () => {
108
+ const resident = fn(service, "inviteVisitor");
109
+
110
+ assert.ok(resident.includes("_getByUserId("), "the resident inviter lookup is gone");
111
+ assert.ok(
112
+ !resident.includes("value.site") && !resident.includes("value.org"),
113
+ "the resident path must not start trusting a client-supplied site or org",
114
+ );
115
+ });
116
+
117
+ test("REGRESSION: the resident handler still validates with schemaInviteVisitor", () => {
118
+ const resident = fn(controller, "inviteVisitor");
119
+
120
+ assert.ok(resident.includes("schemaInviteVisitor.validate("));
121
+ assert.ok(
122
+ resident.includes("inviterUserId: req.params.inviterId"),
123
+ "the resident route still takes its inviter from the path segment",
124
+ );
125
+ });
126
+
127
+ test("REGRESSION: schemaInviteVisitor declares no site, org, nric or visitorPass", () => {
128
+ const model = read("models/visitor-invite.model.ts");
129
+ const body = model.slice(model.indexOf("export const schemaInviteVisitor"));
130
+
131
+ // `members[].nric` is legitimately declared on the resident schema and
132
+ // always has been. What must never appear is a TOP-LEVEL site, org or
133
+ // nric, or a member pass number — those are the guard-only fields.
134
+ const topLevel = body
135
+ .split(String.fromCharCode(10))
136
+ .filter((line) => /^ {2}[a-zA-Z]/.test(line))
137
+ .join(String.fromCharCode(10));
138
+
139
+ for (const key of ["site:", "org:", "nric:"]) {
140
+ assert.ok(
141
+ !topLevel.includes(key),
142
+ `${key} appeared on the resident schema — that is the orphan-write trap`,
143
+ );
144
+ }
145
+ assert.ok(
146
+ !body.includes("visitorPass:"),
147
+ "visitorPass appeared on the resident schema",
148
+ );
149
+ assert.ok(
150
+ !body.includes("unknown(true)") && !body.includes("allowUnknown"),
151
+ "the resident schema must keep refusing undeclared keys",
152
+ );
153
+ });
154
+
155
+ test("both invitation paths go through ONE write, so they cannot drift apart", () => {
156
+ const resident = fn(service, "inviteVisitor");
157
+ const guard = fn(service, "inviteVisitorAsGuard");
158
+
159
+ assert.ok(resident.includes("_writeInvitation("));
160
+ assert.ok(guard.includes("_writeInvitation("));
161
+ });