@7365admin1/core 3.52.18 → 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 +35 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2734 -2529
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2776 -2571
- 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/onboarding-org-save-diagnosis.e2e.test.mjs +220 -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,220 @@
|
|
|
1
|
+
// Why onboarding step 1 answers `{"status":"error","message":"Failed to update
|
|
2
|
+
// organization."}` on production, and what the person is supposed to do about it.
|
|
3
|
+
//
|
|
4
|
+
// Reported live from `org.app.iservice365.org/onboarding/getting-started`: the
|
|
5
|
+
// "Organization Details" modal is filled in, Submit is pressed, and the single
|
|
6
|
+
// `PUT /api/organizations/:id` behind it comes back 500 with that exact body.
|
|
7
|
+
// The web app maps any 5xx to "The server could not save your details. Please
|
|
8
|
+
// try again in a moment." — so nobody, on either side of the wire, learns
|
|
9
|
+
// anything at all about what went wrong.
|
|
10
|
+
//
|
|
11
|
+
// The handler is `organization.repo.ts update()`. Inside its `try` there are
|
|
12
|
+
// exactly two awaited calls: `collection.updateOne` and `delNamespace()`.
|
|
13
|
+
// `delNamespace` swallows every error of its own (`node-server-utils@1.6.0`
|
|
14
|
+
// `useCache`), and `new ObjectId(id)` cannot throw for the 24-hex id in the
|
|
15
|
+
// URL. So the 500 is a MongoDB WRITE ERROR on `organizations` — and the catch
|
|
16
|
+
// throws it away, keeping only `error.message` in a log line with no error
|
|
17
|
+
// code, no key and no organisation id.
|
|
18
|
+
//
|
|
19
|
+
// The write error this collection actually produces is a duplicate key.
|
|
20
|
+
// `organizations` has carried a UNIQUE index since the first version of this
|
|
21
|
+
// repository — `createUniqueIndex()` was `createIndex({ name: 1 }, { unique:
|
|
22
|
+
// true })` (36150ea0), later re-pointed at `email`, and `06ee8915` ("remove
|
|
23
|
+
// unique email index and support multiple orgs per email") stopped CREATING a
|
|
24
|
+
// unique one. Nothing has ever DROPPED the one already built, and `setup.ts`
|
|
25
|
+
// only logs when the re-create conflicts. So the index outlives the code that
|
|
26
|
+
// wanted it.
|
|
27
|
+
//
|
|
28
|
+
// `add()` already knows this — it maps a duplicate to "Organization already
|
|
29
|
+
// exist." `update()` does not, and that asymmetry is the whole defect: create
|
|
30
|
+
// says what is wrong, save says nothing.
|
|
31
|
+
//
|
|
32
|
+
// Everything below is created and thrown away by the harness: an in-process
|
|
33
|
+
// MongoDB replica set, a loopback Redis, a loopback mail sink. No staging or
|
|
34
|
+
// production database, Redis, mailbox or endpoint is touched.
|
|
35
|
+
//
|
|
36
|
+
// Run with: yarn build && node --test test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs
|
|
37
|
+
|
|
38
|
+
import { after, before, describe, it } from "node:test";
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import { ObjectId } from "mongodb";
|
|
41
|
+
|
|
42
|
+
import { startHarness } from "./harness.mjs";
|
|
43
|
+
|
|
44
|
+
const PASSWORD = "OrgSave-Passw0rd!";
|
|
45
|
+
const OWNER = "os-owner@e2e.example.com";
|
|
46
|
+
const TAKEN_NAME = "Benar Property Org";
|
|
47
|
+
|
|
48
|
+
describe("onboarding step 1 says what is wrong when the save is refused", { concurrency: 1 }, () => {
|
|
49
|
+
let h;
|
|
50
|
+
const id = {};
|
|
51
|
+
const result = [];
|
|
52
|
+
|
|
53
|
+
const record = (name, fn) =>
|
|
54
|
+
it(name, async () => {
|
|
55
|
+
try {
|
|
56
|
+
await fn();
|
|
57
|
+
result.push(["PASS", name]);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
result.push(["FAIL", name]);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
before(async () => {
|
|
65
|
+
h = await startHarness();
|
|
66
|
+
Object.assign(id, await seed(h));
|
|
67
|
+
}, { timeout: 300000 });
|
|
68
|
+
|
|
69
|
+
after(async () => {
|
|
70
|
+
if (h) await h.stop();
|
|
71
|
+
console.log("\n--- onboarding organisation save: per-case result ---");
|
|
72
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let ownerSid;
|
|
76
|
+
|
|
77
|
+
const ownOrg = async () =>
|
|
78
|
+
await h.db.collection("organizations").findOne({ _id: id.ownOrg });
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The body `OrgDetails.vue submit()` sends, verbatim.
|
|
82
|
+
*
|
|
83
|
+
* `utils/onboarding-org-details.js orgUpdatePayload()` deletes every empty
|
|
84
|
+
* value before it leaves the browser, which is why Industry, Registration
|
|
85
|
+
* Number, the alternate contact number, Country and State/Province are ABSENT
|
|
86
|
+
* here rather than sent as "" — the organisation record has no field for the
|
|
87
|
+
* last two at all, and `Joi.string()` rejects "" for the rest.
|
|
88
|
+
*/
|
|
89
|
+
const modalBody = (name) => ({
|
|
90
|
+
name,
|
|
91
|
+
type: "business",
|
|
92
|
+
email: "os-benar@e2e.example.com",
|
|
93
|
+
contact: "+63 9569346664",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
record("0. baseline — the owner signs in and reaches their own organisation", async () => {
|
|
97
|
+
ownerSid = await h.login(OWNER, PASSWORD);
|
|
98
|
+
|
|
99
|
+
assert.equal(
|
|
100
|
+
await h.db.collection("members").countDocuments({ user: id.owner }),
|
|
101
|
+
0,
|
|
102
|
+
"the owner must hold no membership — reach is by `createdBy` (DV-0248)",
|
|
103
|
+
);
|
|
104
|
+
assert.equal((await ownOrg()).name, "Ben Org");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
record("1. renaming onto a name another organisation already holds", async () => {
|
|
108
|
+
const res = await h.api(`/organizations/${id.ownOrg}`, {
|
|
109
|
+
method: "PUT",
|
|
110
|
+
sid: ownerSid,
|
|
111
|
+
body: modalBody(TAKEN_NAME),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Before the fix this is the production failure exactly:
|
|
115
|
+
// 500 {"status":"error","message":"Failed to update organization."}
|
|
116
|
+
assert.equal(
|
|
117
|
+
res.status,
|
|
118
|
+
400,
|
|
119
|
+
`expected an actionable 400, got ${res.status} ${JSON.stringify(res.body)}`,
|
|
120
|
+
);
|
|
121
|
+
assert.match(
|
|
122
|
+
String(res.body.message),
|
|
123
|
+
/already/i,
|
|
124
|
+
`the person must be told what to change: ${JSON.stringify(res.body)}`,
|
|
125
|
+
);
|
|
126
|
+
assert.match(
|
|
127
|
+
String(res.body.message),
|
|
128
|
+
/name/i,
|
|
129
|
+
`and WHICH field: ${JSON.stringify(res.body)}`,
|
|
130
|
+
);
|
|
131
|
+
assert.equal((await ownOrg()).name, "Ben Org", "nothing may be written");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
record("2. the message never quotes the other client's record", async () => {
|
|
135
|
+
const res = await h.api(`/organizations/${id.ownOrg}`, {
|
|
136
|
+
method: "PUT",
|
|
137
|
+
sid: ownerSid,
|
|
138
|
+
body: modalBody(TAKEN_NAME),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const text = JSON.stringify(res.body);
|
|
142
|
+
assert.ok(!text.includes(String(id.otherOrg)), `leaks an id: ${text}`);
|
|
143
|
+
assert.ok(!/index|E11000|keyPattern|dup key/i.test(text), `leaks driver internals: ${text}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---- the control: the same request with a free name MUST go through -------
|
|
147
|
+
|
|
148
|
+
record("3. control — a free name saves, so case 1 is the name and not the route", async () => {
|
|
149
|
+
const res = await h.api(`/organizations/${id.ownOrg}`, {
|
|
150
|
+
method: "PUT",
|
|
151
|
+
sid: ownerSid,
|
|
152
|
+
body: modalBody("Ben Org Renamed"),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
156
|
+
|
|
157
|
+
const saved = await ownOrg();
|
|
158
|
+
assert.equal(saved.name, "Ben Org Renamed");
|
|
159
|
+
assert.equal(saved.type, "business");
|
|
160
|
+
assert.equal(saved.contact, "+63 9569346664");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
record("4. and the guard is unchanged — no session is still refused", async () => {
|
|
164
|
+
const res = await h.api(`/organizations/${id.ownOrg}`, {
|
|
165
|
+
method: "PUT",
|
|
166
|
+
body: modalBody("Ben Org Again"),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
170
|
+
assert.equal((await ownOrg()).name, "Ben Org Renamed", "must be untouched");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
async function seed(h) {
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
const otherOrg = new ObjectId();
|
|
177
|
+
const ownOrg = new ObjectId();
|
|
178
|
+
|
|
179
|
+
const orgs = h.db.collection("organizations");
|
|
180
|
+
|
|
181
|
+
// The index the first version of this repository created at every boot
|
|
182
|
+
// (`setup.ts` -> `createUniqueIndex()`), and which nothing has ever dropped.
|
|
183
|
+
await orgs.createIndex({ name: 1 }, { unique: true });
|
|
184
|
+
|
|
185
|
+
await orgs.insertMany([
|
|
186
|
+
{
|
|
187
|
+
_id: otherOrg,
|
|
188
|
+
name: TAKEN_NAME,
|
|
189
|
+
email: "os-other@e2e.example.com",
|
|
190
|
+
type: "business",
|
|
191
|
+
nature: "property_management_agency",
|
|
192
|
+
status: "active",
|
|
193
|
+
createdAt: now,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
_id: ownOrg,
|
|
197
|
+
name: "Ben Org",
|
|
198
|
+
email: "os-benorg@e2e.example.com",
|
|
199
|
+
type: "business",
|
|
200
|
+
nature: "property_management_agency",
|
|
201
|
+
status: "active",
|
|
202
|
+
contact: "+65 00000000",
|
|
203
|
+
createdAt: now,
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const users = await h.db.collection("users").insertMany([
|
|
208
|
+
{ email: OWNER, name: "os-owner", status: "active", createdAt: now },
|
|
209
|
+
]);
|
|
210
|
+
const owner = users.insertedIds[0];
|
|
211
|
+
|
|
212
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
213
|
+
await h.db.collection("users").updateOne({ _id: owner }, { $set: { password: hashed } });
|
|
214
|
+
|
|
215
|
+
// DV-0248: the creator road is the only relationship a self-signup owner
|
|
216
|
+
// holds at step 1.
|
|
217
|
+
await orgs.updateOne({ _id: ownOrg }, { $set: { createdBy: owner } });
|
|
218
|
+
|
|
219
|
+
return { owner, ownOrg, otherOrg };
|
|
220
|
+
}
|
|
@@ -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
|
+
});
|