@7365admin1/core 3.52.19 → 3.53.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 +20 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2706 -2527
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2748 -2569
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/scripts/organizations-name-index.mjs +251 -0
- package/test/e2e/guard-invite-scope.e2e.test.mjs +368 -0
- package/test/e2e/harness.mjs +18 -0
- package/test/e2e/organizations-name-index.e2e.test.mjs +122 -0
- package/test/guard-invite-schema.test.mjs +173 -0
- package/test/guard-invite-wiring.test.mjs +161 -0
|
@@ -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,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
|
+
});
|