@7365admin1/core 3.52.18 → 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 +15 -0
- package/dist/index.js +28 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +28 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +220 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|