@7365admin1/core 3.52.17 → 3.52.19
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 +37 -0
- package/dist/index.js +35 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +35 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/harness.mjs +5 -2
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +220 -0
- package/test/e2e/subscription-read-scope.e2e.test.mjs +182 -0
- package/test/e2e/user-email-lookup-leak.e2e.test.mjs +68 -1
package/package.json
CHANGED
package/test/e2e/harness.mjs
CHANGED
|
@@ -345,8 +345,11 @@ function buildApp(nsu, core) {
|
|
|
345
345
|
// mirrors iservice365-API-core/src/routes/subscription.route.ts
|
|
346
346
|
const subscriptions = express.Router();
|
|
347
347
|
{
|
|
348
|
-
const { updateSubscriptionSeats } =
|
|
349
|
-
|
|
348
|
+
const { updateSubscriptionSeats, getByOrgId, getSubscriptions } =
|
|
349
|
+
core.useSubscriptionController();
|
|
350
|
+
// subscription.route.ts:21-24
|
|
351
|
+
subscriptions.get("/", requireAuth, getSubscriptions);
|
|
352
|
+
subscriptions.get("/org/:id", requireAuth, getByOrgId);
|
|
350
353
|
subscriptions.put("/seats/:id", requireAuth, updateSubscriptionSeats);
|
|
351
354
|
}
|
|
352
355
|
|
|
@@ -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,182 @@
|
|
|
1
|
+
// End-to-end proof that a client's billing record is not readable by every
|
|
2
|
+
// signed-in account on the platform.
|
|
3
|
+
//
|
|
4
|
+
// Two reads on `subscription.route.ts` carried `requireAuth` and nothing else:
|
|
5
|
+
//
|
|
6
|
+
// - `GET /api/subscriptions/org/:id` — ONE organisation's subscription: its
|
|
7
|
+
// plan, seat count, price, currency and renewal date. Any signed-in
|
|
8
|
+
// account — a resident, a guard, a cleaner, another client's admin — could
|
|
9
|
+
// read any organisation's, given only the org id.
|
|
10
|
+
// - `GET /api/subscriptions/` — EVERY subscription on the platform in one
|
|
11
|
+
// searchable, paged list. Not filtered by organisation at all: that is the
|
|
12
|
+
// Seven365 staff console answer, and it was open to anybody signed in.
|
|
13
|
+
//
|
|
14
|
+
// The gates are the two already used elsewhere in the same file:
|
|
15
|
+
// `requireOrgAccess(req, org)` (the pattern `updateSubscriptionSeats` uses on
|
|
16
|
+
// the same records) and `requirePlatformStaff(req)` (the console gate).
|
|
17
|
+
//
|
|
18
|
+
// No live caller loses access. `/org/:id` has exactly one caller — web-app-org's
|
|
19
|
+
// own subscription and manage-seats pages — and both pass `currentOrg`, the
|
|
20
|
+
// caller's OWN organisation. The list has no caller at all: layer-common
|
|
21
|
+
// exports `getSubscriptions()` and no screen anywhere calls it.
|
|
22
|
+
//
|
|
23
|
+
// Everything runs through the real Express stack against the harness's
|
|
24
|
+
// in-process MongoDB replica set. No staging or production database is touched,
|
|
25
|
+
// no payment gateway is contacted, and every seeded value is obviously fake.
|
|
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 = "Sub-Read-Passw0rd!";
|
|
36
|
+
const OWN_ADMIN = "sub-read-own@e2e.example.com";
|
|
37
|
+
const OTHER_ADMIN = "sub-read-other@e2e.example.com";
|
|
38
|
+
const STAFF = "sub-read-staff@e2e.example.com";
|
|
39
|
+
|
|
40
|
+
describe("subscription reads are scoped", { concurrency: 1 }, () => {
|
|
41
|
+
let h;
|
|
42
|
+
let id;
|
|
43
|
+
let ownSid;
|
|
44
|
+
let otherSid;
|
|
45
|
+
let staffSid;
|
|
46
|
+
const result = [];
|
|
47
|
+
|
|
48
|
+
const record = (name, fn) =>
|
|
49
|
+
it(name, async () => {
|
|
50
|
+
try {
|
|
51
|
+
await fn();
|
|
52
|
+
result.push(["PASS", name]);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
result.push(["FAIL", name]);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
before(async () => {
|
|
60
|
+
h = await startHarness();
|
|
61
|
+
id = await seed(h);
|
|
62
|
+
ownSid = await h.login(OWN_ADMIN, PASSWORD);
|
|
63
|
+
otherSid = await h.login(OTHER_ADMIN, PASSWORD);
|
|
64
|
+
staffSid = await h.login(STAFF, PASSWORD);
|
|
65
|
+
}, { timeout: 300000 });
|
|
66
|
+
|
|
67
|
+
after(async () => {
|
|
68
|
+
if (h) await h.stop();
|
|
69
|
+
console.log("\n--- subscription read scope: per-case result ---");
|
|
70
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const byOrg = (org, sid) =>
|
|
74
|
+
h.api(`/subscriptions/org/${org.toString()}`, { sid });
|
|
75
|
+
|
|
76
|
+
const list = (sid) => h.api("/subscriptions/?status=active", { sid });
|
|
77
|
+
|
|
78
|
+
// ---- GET /subscriptions/org/:id ----------------------------------------
|
|
79
|
+
|
|
80
|
+
record("1. same tenant: the org's own admin still reads its subscription", async () => {
|
|
81
|
+
const res = await byOrg(id.ownOrg, ownSid);
|
|
82
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
83
|
+
// The page renders the plan and the seat numbers, so the record itself must
|
|
84
|
+
// still come back, not an empty shell.
|
|
85
|
+
assert.equal(res.body?._id, id.ownSub.toString());
|
|
86
|
+
assert.equal(res.body?.maxSeats, 5);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
record("2. cross tenant: another client's admin is refused", async () => {
|
|
90
|
+
const res = await byOrg(id.ownOrg, otherSid);
|
|
91
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
record("3. and learns nothing about the record from the refusal", async () => {
|
|
95
|
+
const res = await byOrg(id.ownOrg, otherSid);
|
|
96
|
+
const body = JSON.stringify(res.body ?? {});
|
|
97
|
+
for (const key of ["maxSeats", "currency", "nextBillingDate"]) {
|
|
98
|
+
assert.ok(!body.includes(key), `refusal must not disclose ${key}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
record("4. the other admin still reads their OWN organisation", async () => {
|
|
103
|
+
// Proves case 2 refused for the right reason — the gate, not a broken route.
|
|
104
|
+
const res = await byOrg(id.otherOrg, otherSid);
|
|
105
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
106
|
+
assert.equal(res.body?._id, id.otherSub.toString());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
record("5. no session at all is still refused", async () => {
|
|
110
|
+
const res = await byOrg(id.ownOrg);
|
|
111
|
+
assert.equal(res.status, 401, JSON.stringify(res.status));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---- GET /subscriptions/ ------------------------------------------------
|
|
115
|
+
|
|
116
|
+
record("6. the platform-wide list is refused to an ordinary client admin", async () => {
|
|
117
|
+
const res = await list(ownSid);
|
|
118
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
record("7. and does not leak the other client's row in the refusal", async () => {
|
|
122
|
+
const res = await list(ownSid);
|
|
123
|
+
assert.ok(
|
|
124
|
+
!JSON.stringify(res.body ?? {}).includes(id.otherSub.toString()),
|
|
125
|
+
"a refused list must not carry any subscription id",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
record("8. Seven365 staff still get the list", async () => {
|
|
130
|
+
// The console is the legitimate caller. If this fails the gate is too tight.
|
|
131
|
+
const res = await list(staffSid);
|
|
132
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
async function seed(h) {
|
|
137
|
+
const now = new Date().toISOString();
|
|
138
|
+
const nextBillingDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
|
|
139
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
140
|
+
|
|
141
|
+
const ownOrg = new ObjectId();
|
|
142
|
+
const otherOrg = new ObjectId();
|
|
143
|
+
const ownSub = new ObjectId();
|
|
144
|
+
const otherSub = new ObjectId();
|
|
145
|
+
const ownRole = new ObjectId();
|
|
146
|
+
const otherRole = new ObjectId();
|
|
147
|
+
const staffRole = new ObjectId();
|
|
148
|
+
|
|
149
|
+
await h.db.collection("organizations").insertMany([
|
|
150
|
+
{ _id: ownOrg, name: "Sub Read Own", email: "sub-read-own-org@e2e.example.com", type: "property-management", status: "active", createdAt: now },
|
|
151
|
+
{ _id: otherOrg, name: "Sub Read Other", email: "sub-read-other-org@e2e.example.com", type: "property-management", status: "active", createdAt: now },
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
await h.db.collection("roles").insertMany([
|
|
155
|
+
{ _id: ownRole, name: "Organisation Staff", org: ownOrg, status: "active", permissions: [] },
|
|
156
|
+
{ _id: otherRole, name: "Organisation Staff", org: otherOrg, status: "active", permissions: [] },
|
|
157
|
+
// Staff is a MEMBERSHIP of type "admin" whose role is also type "admin" —
|
|
158
|
+
// never a role merely NAMED "Super Admin". See `super-admin.util.ts`.
|
|
159
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, status: "active", permissions: [] },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const users = await h.db.collection("users").insertMany([
|
|
163
|
+
{ email: OWN_ADMIN, password: hashed, name: "Own Admin", status: "active", defaultOrg: ownOrg.toString(), createdAt: now },
|
|
164
|
+
{ email: OTHER_ADMIN, password: hashed, name: "Other Admin", status: "active", defaultOrg: otherOrg.toString(), createdAt: now },
|
|
165
|
+
{ email: STAFF, password: hashed, name: "Platform Staff", status: "active", createdAt: now },
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const [own, other, staff] = [0, 1, 2].map((i) => users.insertedIds[i]);
|
|
169
|
+
|
|
170
|
+
await h.db.collection("members").insertMany([
|
|
171
|
+
{ user: own, org: ownOrg, role: ownRole, type: "organization", status: "active" },
|
|
172
|
+
{ user: other, org: otherOrg, role: otherRole, type: "organization", status: "active" },
|
|
173
|
+
{ user: staff, type: "admin", role: staffRole, status: "active" },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
await h.db.collection("subscriptions").insertMany([
|
|
177
|
+
{ _id: ownSub, org: ownOrg, user: own, type: "organization", status: "active", currency: "SGD", maxSeats: 5, paidSeats: 5, currentSeats: 1, billingCycle: "monthly", nextBillingDate, createdAt: now },
|
|
178
|
+
{ _id: otherSub, org: otherOrg, user: other, type: "organization", status: "active", currency: "SGD", maxSeats: 9, paidSeats: 9, currentSeats: 1, billingCycle: "monthly", nextBillingDate, createdAt: now },
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
return { ownOrg, otherOrg, ownSub, otherSub };
|
|
182
|
+
}
|
|
@@ -37,6 +37,10 @@ const UNKNOWN = "leak-nobody@e2e.example.com";
|
|
|
37
37
|
// `sid` are the two the users collection actually stores today; the rest are
|
|
38
38
|
// listed so that the day one of them is added, this test fails rather than the
|
|
39
39
|
// endpoint quietly starting to leak it.
|
|
40
|
+
// The identity fields the users collection stores. None of them is a credential,
|
|
41
|
+
// which is exactly why the remove-list left every one of them on the wire.
|
|
42
|
+
const PERSONAL_KEYS = ["nric", "contact", "dateOfBirth", "gender", "defaultOrg", "status"];
|
|
43
|
+
|
|
40
44
|
const CREDENTIAL_KEYS = [
|
|
41
45
|
"password",
|
|
42
46
|
"sid",
|
|
@@ -151,6 +155,56 @@ describe("account lookup by e-mail never returns credentials", { concurrency: 1
|
|
|
151
155
|
assert.ok(res.body?._id, "the authenticated reply must still carry _id");
|
|
152
156
|
});
|
|
153
157
|
|
|
158
|
+
record("7a. and gets ONLY the three allow-listed identity fields", async () => {
|
|
159
|
+
// The remove-list left everything that was not a credential on the wire, so
|
|
160
|
+
// knowing an e-mail address bought the whole identity record. This is the
|
|
161
|
+
// case that pins the reply to an allow-list.
|
|
162
|
+
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
Object.keys(res.body ?? {}).sort(),
|
|
165
|
+
["_id", "email", "name"],
|
|
166
|
+
"the authenticated reply must be exactly {_id, name, email}",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
record("7b. specifically, none of the personal-detail fields", async () => {
|
|
171
|
+
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
172
|
+
const leaked = PERSONAL_KEYS.filter((k) => k in (res.body ?? {}));
|
|
173
|
+
assert.deepEqual(leaked, [], `personal detail leaked: ${leaked.join(", ")}`);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
record("7c. the seeded record really does carry those details", async () => {
|
|
177
|
+
// Without this, 7b would pass just as well against an account that never
|
|
178
|
+
// had an NRIC in the first place.
|
|
179
|
+
const stored = await h.db.collection("users").findOne({ email: KNOWN });
|
|
180
|
+
const present = PERSONAL_KEYS.filter((k) => k in (stored ?? {}));
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
present.sort(),
|
|
183
|
+
[...PERSONAL_KEYS].sort(),
|
|
184
|
+
"the seeded account must hold every field the reply is being tested for",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
record("7d. the invite flow still works end to end on the narrowed reply", async () => {
|
|
189
|
+
// What all six live call sites actually do: look the invitee up, and use
|
|
190
|
+
// `_id` (and, in ClientDetailForm, `name`) from the reply. If the allow-list
|
|
191
|
+
// had dropped either, this is the case that breaks.
|
|
192
|
+
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
193
|
+
assert.ok(res.body?._id, "InvitationClientForm / ServiceProviderMain read _id");
|
|
194
|
+
assert.equal(res.body?.name, "Known Account", "ClientDetailForm reads name");
|
|
195
|
+
assert.equal(res.body?.email, KNOWN, "the caller's own input, echoed back");
|
|
196
|
+
|
|
197
|
+
// ...and it is the RIGHT id — the one the forms then pass to the membership
|
|
198
|
+
// lookup — not merely some id-shaped value.
|
|
199
|
+
assert.equal(res.body._id, id.known.toString(), "must identify the seeded account");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
record("7e. an e-mail nobody holds still answers, and answers nothing", async () => {
|
|
203
|
+
const res = await h.api(`/users/email/${UNKNOWN}`, { sid: callerSid });
|
|
204
|
+
assert.equal(res.status, 200, JSON.stringify(res.status));
|
|
205
|
+
assert.ok(!res.body?._id, "an unknown e-mail must not come back with an id");
|
|
206
|
+
});
|
|
207
|
+
|
|
154
208
|
record("8. but that reply is stripped of credentials too", async () => {
|
|
155
209
|
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
156
210
|
const leaked = CREDENTIAL_KEYS.filter((k) => k in (res.body ?? {}));
|
|
@@ -164,6 +218,17 @@ describe("account lookup by e-mail never returns credentials", { concurrency: 1
|
|
|
164
218
|
assert.deepEqual(leaked, [], `credential keys leaked: ${leaked.join(", ")}`);
|
|
165
219
|
});
|
|
166
220
|
|
|
221
|
+
record("9a. the v2 twin is narrowed to the same three fields", async () => {
|
|
222
|
+
// It has no callers anywhere in the organisation, so it can be narrowed
|
|
223
|
+
// without asking anyone — and leaving it wide would just move the leak.
|
|
224
|
+
const res = await h.api(`/users/v2/email/${KNOWN}`, { sid: callerSid });
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
Object.keys(res.body ?? {}).sort(),
|
|
227
|
+
["_id", "email", "name"],
|
|
228
|
+
"the v2 reply must be exactly {_id, name, email}",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
167
232
|
// ---- and the internal path that genuinely needs the hash is untouched ---
|
|
168
233
|
|
|
169
234
|
record("10. signing in still works — the hash path is not projected away", async () => {
|
|
@@ -182,7 +247,9 @@ async function seed(h) {
|
|
|
182
247
|
const users = await h.db.collection("users").insertMany([
|
|
183
248
|
// A live `sid` is seeded deliberately: it is the second credential on this
|
|
184
249
|
// document and leaking it is a ready-made session hijack.
|
|
185
|
-
|
|
250
|
+
// The personal detail is seeded so the allow-list cases test a record that
|
|
251
|
+
// genuinely holds it. Every value is obviously fake and none is printed.
|
|
252
|
+
{ email: KNOWN, name: "Known Account", status: "active", password: hashed, sid: "e2e-not-a-real-session", nric: "S0000000Z", contact: "+65 0000 0000", dateOfBirth: "1970-01-01", gender: "unspecified", defaultOrg: "000000000000000000000000", createdAt: now },
|
|
186
253
|
{ email: CALLER, name: "Caller Account", status: "active", password: hashed, sid: "e2e-not-a-real-session-2", createdAt: now },
|
|
187
254
|
]);
|
|
188
255
|
|