@7365admin1/core 3.54.0 → 3.56.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 +49 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.js +4434 -4375
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +146 -87
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/notification-matrix.e2e.test.mjs +420 -0
- package/test/e2e/notification-preference.e2e.test.mjs +21 -8
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +76 -3
- package/test/e2e/safety-notification.e2e.test.mjs +1 -1
- package/test/role-scope-separation.test.mjs +195 -0
- package/test/role-scope.test.mjs +55 -3
- package/tools/role-scope-lockout-check/check.js +316 -0
- package/tools/role-scope-lockout-check/platform-only-permissions.json +21 -0
- package/tools/role-scope-lockout-check/srv.js +39 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.56.0",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
|
|
14
14
|
"release": "yarn run build && changeset publish",
|
|
15
15
|
"lint": "tsc",
|
|
16
|
-
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
17
17
|
"test:camera": "yarn test",
|
|
18
18
|
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
19
19
|
},
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// The whole Notification Settings matrix, measured rather than reasoned about.
|
|
2
|
+
//
|
|
3
|
+
// The owner's complaint is not "is the guard called" — it is "does moving a
|
|
4
|
+
// switch change what I actually receive". So every category this package can
|
|
5
|
+
// send is driven through its REAL sender, against a real MongoDB, with the
|
|
6
|
+
// preference written and re-read over REAL HTTP through the real `requireAuth`,
|
|
7
|
+
// and the delivery counted on both sides: a row in the `notifications`
|
|
8
|
+
// collection, and a payload handed to the push sender.
|
|
9
|
+
//
|
|
10
|
+
// Four measurements per category, and the FIRST one is a positive control:
|
|
11
|
+
//
|
|
12
|
+
// ON both channels must deliver — a zero here means the test is broken,
|
|
13
|
+
// not that the feature works
|
|
14
|
+
// inApp off -> nothing in the app, the phone still buzzes
|
|
15
|
+
// push off -> the phone is quiet, the message is still in the app
|
|
16
|
+
// both off -> nothing at all
|
|
17
|
+
//
|
|
18
|
+
// then the switches go back on and delivery must return. Every write is made
|
|
19
|
+
// with `PUT /notification-preferences` and every read with a fresh
|
|
20
|
+
// `GET /notification-preferences`, so "it saved" and "it is obeyed" are two
|
|
21
|
+
// separate proofs rather than one.
|
|
22
|
+
//
|
|
23
|
+
// The six categories whose senders live in `iservice365-API-core` are measured
|
|
24
|
+
// the same way, in that repository's own `test/e2e/notification-matrix`.
|
|
25
|
+
//
|
|
26
|
+
// Run with: yarn test:e2e
|
|
27
|
+
|
|
28
|
+
import { after, before, describe, it } from "node:test";
|
|
29
|
+
import assert from "node:assert/strict";
|
|
30
|
+
import { ObjectId } from "mongodb";
|
|
31
|
+
|
|
32
|
+
import { startHarness } from "./harness.mjs";
|
|
33
|
+
|
|
34
|
+
const PASSWORD = "Matrix-Passw0rd!";
|
|
35
|
+
const STAFF = "matrix-staff@e2e.example.com";
|
|
36
|
+
|
|
37
|
+
const RESIDENT_SLUG = "iservice365-resident-mobile-app";
|
|
38
|
+
const MA_SLUG = "iservice365-ma-mobile-app";
|
|
39
|
+
|
|
40
|
+
/** every core sender, one per category it governs */
|
|
41
|
+
function senders(h, id) {
|
|
42
|
+
const to = () => id.user.toString();
|
|
43
|
+
const site = () => id.site;
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
key: "bulletinBoard",
|
|
48
|
+
label: "Notice Board",
|
|
49
|
+
trigger: () =>
|
|
50
|
+
h.core.NotificationService.bulletinBoardCreated({
|
|
51
|
+
to: to(),
|
|
52
|
+
bulletinId: new ObjectId(),
|
|
53
|
+
subject: "Lift maintenance",
|
|
54
|
+
status: "published",
|
|
55
|
+
siteId: site(),
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: "event",
|
|
60
|
+
label: "Events",
|
|
61
|
+
trigger: () =>
|
|
62
|
+
h.core.NotificationService.eventCreatedForMA({
|
|
63
|
+
to: to(),
|
|
64
|
+
eventId: new ObjectId(),
|
|
65
|
+
status: "published",
|
|
66
|
+
siteId: site(),
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: "visitors",
|
|
71
|
+
label: "Visitors",
|
|
72
|
+
trigger: () =>
|
|
73
|
+
h.core.NotificationService.invitedVisitorCheckOutForMa({
|
|
74
|
+
to: to(),
|
|
75
|
+
name: "Sam Visitor",
|
|
76
|
+
_id: new ObjectId(),
|
|
77
|
+
siteId: site(),
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "onlineForm",
|
|
82
|
+
label: "Online Forms",
|
|
83
|
+
trigger: () =>
|
|
84
|
+
h.core.NotificationService.onlineFormRequestStatusUpdated({
|
|
85
|
+
to: to(),
|
|
86
|
+
onlineFormId: new ObjectId(),
|
|
87
|
+
status: "approved",
|
|
88
|
+
siteId: site(),
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "prelovedMarketplace",
|
|
93
|
+
label: "Marketplace",
|
|
94
|
+
trigger: () =>
|
|
95
|
+
h.core.NotificationService.prelovedMarketplacePostCreation({
|
|
96
|
+
to: to(),
|
|
97
|
+
prelovedId: new ObjectId(),
|
|
98
|
+
subject: "Bookshelf",
|
|
99
|
+
status: "published",
|
|
100
|
+
siteId: site(),
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: "serviceProviderInvite",
|
|
105
|
+
label: "Service Provider Invitations",
|
|
106
|
+
trigger: () =>
|
|
107
|
+
h.core.NotificationService.serviceProviderInvited({
|
|
108
|
+
to: to(),
|
|
109
|
+
invitationId: new ObjectId(),
|
|
110
|
+
title: "You have been invited",
|
|
111
|
+
orgName: "Northwind",
|
|
112
|
+
siteName: "Aurora Residences",
|
|
113
|
+
siteId: site(),
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
key: "incidentReport",
|
|
118
|
+
label: "Incident Reports",
|
|
119
|
+
trigger: () =>
|
|
120
|
+
h.core.NotificationService.incidentReported({
|
|
121
|
+
siteId: site(),
|
|
122
|
+
incidentId: new ObjectId(),
|
|
123
|
+
reportId: "IR-1",
|
|
124
|
+
siteName: "Aurora Residences",
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "emergencyContact",
|
|
129
|
+
label: "Emergency Contacts",
|
|
130
|
+
trigger: () =>
|
|
131
|
+
h.core.NotificationService.emergencyContactChanged({
|
|
132
|
+
siteId: site(),
|
|
133
|
+
contactId: new ObjectId(),
|
|
134
|
+
contactTitle: "Fire station",
|
|
135
|
+
action: "updated",
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: "virtualPatrol",
|
|
140
|
+
label: "Guard Patrols",
|
|
141
|
+
trigger: () =>
|
|
142
|
+
h.core.NotificationService.patrolException({
|
|
143
|
+
siteId: site(),
|
|
144
|
+
patrolLogId: new ObjectId(),
|
|
145
|
+
routeName: "Basement",
|
|
146
|
+
problem: "A patrol finished late",
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "cameraFault",
|
|
151
|
+
label: "Camera Faults",
|
|
152
|
+
trigger: () =>
|
|
153
|
+
h.core.NotificationService.cameraFault({
|
|
154
|
+
siteId: site(),
|
|
155
|
+
cameraId: new ObjectId(),
|
|
156
|
+
message: "Camera 3 is not answering.",
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe("notification settings: the measured matrix", { concurrency: 1 }, () => {
|
|
163
|
+
let h;
|
|
164
|
+
let sid;
|
|
165
|
+
const id = {};
|
|
166
|
+
const matrix = [];
|
|
167
|
+
|
|
168
|
+
before(async () => {
|
|
169
|
+
h = await startHarness();
|
|
170
|
+
Object.assign(id, await seed(h));
|
|
171
|
+
sid = await h.login(STAFF, PASSWORD);
|
|
172
|
+
}, { timeout: 300000 });
|
|
173
|
+
|
|
174
|
+
after(async () => {
|
|
175
|
+
if (h) await h.stop();
|
|
176
|
+
console.log("\n--- observed matrix: core senders ---");
|
|
177
|
+
console.log(
|
|
178
|
+
"category".padEnd(26) +
|
|
179
|
+
"ON inApp/push".padEnd(16) +
|
|
180
|
+
"inApp OFF".padEnd(12) +
|
|
181
|
+
"push OFF".padEnd(12) +
|
|
182
|
+
"both OFF".padEnd(12) +
|
|
183
|
+
"restored".padEnd(12) +
|
|
184
|
+
"email",
|
|
185
|
+
);
|
|
186
|
+
for (const row of matrix) console.log(row);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const getPrefs = () => h.api("/notification-preferences", { sid });
|
|
190
|
+
const putPrefs = (off) =>
|
|
191
|
+
h.api("/notification-preferences", { method: "PUT", sid, body: { off } });
|
|
192
|
+
|
|
193
|
+
/** run one sender and report what actually landed */
|
|
194
|
+
async function measure(trigger) {
|
|
195
|
+
const where = { userId: new ObjectId(id.user.toString()) };
|
|
196
|
+
const before = await h.db.collection("notifications").countDocuments(where);
|
|
197
|
+
const pushBefore = h.pushes.length;
|
|
198
|
+
|
|
199
|
+
await trigger();
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
inApp: (await h.db.collection("notifications").countDocuments(where)) - before,
|
|
203
|
+
push: h.pushes.length - pushBefore,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** what a FRESH request says the screen shows for this category */
|
|
208
|
+
async function screenFor(key) {
|
|
209
|
+
const res = await getPrefs();
|
|
210
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
211
|
+
const category = res.body.data.categories.find((c) => c.key === key);
|
|
212
|
+
assert.ok(category, `${key} is not on this person's screen`);
|
|
213
|
+
return Object.fromEntries(
|
|
214
|
+
category.channels.map((c) => [c.key, { enabled: c.enabled, supported: c.supported }]),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
it("every rendered category is on this one person's screen", async () => {
|
|
219
|
+
const shown = (await getPrefs()).body.data.categories.map((c) => c.key);
|
|
220
|
+
const rendered = h.core.NOTIFICATION_CATEGORIES.filter((c) => c.rendered).map((c) => c.key);
|
|
221
|
+
assert.deepEqual(
|
|
222
|
+
rendered.filter((k) => !shown.includes(k)),
|
|
223
|
+
[],
|
|
224
|
+
"the seeded person must see every switch, or the matrix has holes",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
for (const sender of senders(null, null).map((s) => s.key)) {
|
|
229
|
+
it(`${sender}: a switch turned off actually stops it`, async () => {
|
|
230
|
+
const spec = senders(h, id).find((s) => s.key === sender);
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------- ON
|
|
233
|
+
await putPrefs([]);
|
|
234
|
+
const screenOn = await screenFor(spec.key);
|
|
235
|
+
assert.equal(screenOn.inApp.enabled, true, "in-app reads as on");
|
|
236
|
+
assert.equal(screenOn.push.enabled, true, "phone alerts read as on");
|
|
237
|
+
|
|
238
|
+
const on = await measure(spec.trigger);
|
|
239
|
+
assert.ok(on.inApp > 0, `POSITIVE CONTROL FAILED: ${spec.key} delivered nothing in the app while switched ON — the test is wrong, not the feature`);
|
|
240
|
+
assert.ok(on.push > 0, `POSITIVE CONTROL FAILED: ${spec.key} sent no push while switched ON — the test is wrong, not the feature`);
|
|
241
|
+
|
|
242
|
+
// ------------------------------------------------------ in-app OFF
|
|
243
|
+
await putPrefs([{ category: spec.key, channel: "inApp" }]);
|
|
244
|
+
const savedInApp = await screenFor(spec.key);
|
|
245
|
+
assert.equal(savedInApp.inApp.enabled, false, "the OFF choice survived a fresh read");
|
|
246
|
+
assert.equal(savedInApp.push.enabled, true, "and it did not move the other switch");
|
|
247
|
+
|
|
248
|
+
const inAppOff = await measure(spec.trigger);
|
|
249
|
+
assert.equal(inAppOff.inApp, 0, `${spec.key} still wrote to the app feed with in-app switched off`);
|
|
250
|
+
assert.ok(inAppOff.push > 0, `${spec.key} lost its push when only in-app was switched off`);
|
|
251
|
+
|
|
252
|
+
// -------------------------------------------------------- push OFF
|
|
253
|
+
await putPrefs([{ category: spec.key, channel: "push" }]);
|
|
254
|
+
const savedPush = await screenFor(spec.key);
|
|
255
|
+
assert.equal(savedPush.push.enabled, false);
|
|
256
|
+
assert.equal(savedPush.inApp.enabled, true);
|
|
257
|
+
|
|
258
|
+
const pushOff = await measure(spec.trigger);
|
|
259
|
+
assert.equal(pushOff.push, 0, `${spec.key} still sent a push with phone alerts switched off`);
|
|
260
|
+
assert.ok(pushOff.inApp > 0, `${spec.key} vanished from the app feed when only the phone was switched off`);
|
|
261
|
+
|
|
262
|
+
// -------------------------------------------------------- both OFF
|
|
263
|
+
await putPrefs([
|
|
264
|
+
{ category: spec.key, channel: "inApp" },
|
|
265
|
+
{ category: spec.key, channel: "push" },
|
|
266
|
+
]);
|
|
267
|
+
const bothOff = await measure(spec.trigger);
|
|
268
|
+
assert.deepEqual(bothOff, { inApp: 0, push: 0 }, `${spec.key} still delivered with everything switched off`);
|
|
269
|
+
|
|
270
|
+
// -------------------------------------------------------- restored
|
|
271
|
+
await putPrefs([]);
|
|
272
|
+
const restoredScreen = await screenFor(spec.key);
|
|
273
|
+
assert.equal(restoredScreen.inApp.enabled, true);
|
|
274
|
+
assert.equal(restoredScreen.push.enabled, true);
|
|
275
|
+
const restored = await measure(spec.trigger);
|
|
276
|
+
assert.ok(restored.inApp > 0 && restored.push > 0, `${spec.key} did not come back when switched on again`);
|
|
277
|
+
|
|
278
|
+
// ----------------------------------------------------------- email
|
|
279
|
+
// An absent key IS the answer now: the server sends only the channels a
|
|
280
|
+
// category can really deliver on, so email is missing rather than
|
|
281
|
+
// present-and-dimmed.
|
|
282
|
+
const email = restoredScreen.email;
|
|
283
|
+
assert.equal(email, undefined, `${spec.key} still offers an email switch nothing reads`);
|
|
284
|
+
|
|
285
|
+
matrix.push(
|
|
286
|
+
spec.key.padEnd(26) +
|
|
287
|
+
`${on.inApp}/${on.push}`.padEnd(16) +
|
|
288
|
+
`${inAppOff.inApp}/${inAppOff.push}`.padEnd(12) +
|
|
289
|
+
`${pushOff.inApp}/${pushOff.push}`.padEnd(12) +
|
|
290
|
+
`${bothOff.inApp}/${bothOff.push}`.padEnd(12) +
|
|
291
|
+
`${restored.inApp}/${restored.push}`.padEnd(12) +
|
|
292
|
+
(email ? "OFFERED (bug)" : "not offered"),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it("no category in the catalogue can deliver email", async () => {
|
|
298
|
+
const withEmail = h.core.NOTIFICATION_CATEGORIES.filter((c) => c.channels.email).map((c) => c.key);
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
withEmail,
|
|
301
|
+
[],
|
|
302
|
+
"a category claims an email channel — then a sender must call filterRecipients with \"email\", and none does",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("a category that cannot use a channel keeps nobody on it", async () => {
|
|
307
|
+
const { filterRecipients } = h.core.useNotificationPreferenceRepo();
|
|
308
|
+
assert.deepEqual(
|
|
309
|
+
await filterRecipients([id.user.toString()], "bulletinBoard", "email"),
|
|
310
|
+
[],
|
|
311
|
+
"an email send would reach nobody, which is why no switch is offered",
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
async function seed(h) {
|
|
317
|
+
const now = new Date().toISOString();
|
|
318
|
+
|
|
319
|
+
const org = new ObjectId();
|
|
320
|
+
const site = new ObjectId();
|
|
321
|
+
const role = new ObjectId();
|
|
322
|
+
|
|
323
|
+
await h.db.collection("organizations").insertOne({
|
|
324
|
+
_id: org,
|
|
325
|
+
name: "Matrix Property Management",
|
|
326
|
+
email: "matrix-org@e2e.example.com",
|
|
327
|
+
type: "property-management",
|
|
328
|
+
status: "active",
|
|
329
|
+
// so the vendor-only switch is on this one screen too
|
|
330
|
+
isMarketplaceVendor: true,
|
|
331
|
+
createdAt: now,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await h.db.collection("sites").insertOne({
|
|
335
|
+
_id: site,
|
|
336
|
+
name: "Aurora Residences",
|
|
337
|
+
org: org.toString(),
|
|
338
|
+
status: "active",
|
|
339
|
+
createdAt: now,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// One role holding every module behind a rendered category, so a single
|
|
343
|
+
// person is offered the whole screen and no category is measured against a
|
|
344
|
+
// different account.
|
|
345
|
+
await h.db.collection("roles").insertOne({
|
|
346
|
+
_id: role,
|
|
347
|
+
name: "Everything",
|
|
348
|
+
org,
|
|
349
|
+
status: "active",
|
|
350
|
+
permissions: [
|
|
351
|
+
"feedbacks:see-all-feedback",
|
|
352
|
+
"workOrder:see-all-work-orders",
|
|
353
|
+
"bulletin-board:see-all",
|
|
354
|
+
"event-mgmt:see-all",
|
|
355
|
+
"facility-booking-mgmt:see-all",
|
|
356
|
+
"visitorManagement:see-all",
|
|
357
|
+
"online-form-configuration:see-all",
|
|
358
|
+
"service-provider-mgmt:see-all",
|
|
359
|
+
"incident-reports:see-all",
|
|
360
|
+
"emergency-contact:see-all",
|
|
361
|
+
"virtual-patrol:see-all",
|
|
362
|
+
"site-settings:see-all",
|
|
363
|
+
],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const user = (
|
|
367
|
+
await h.db.collection("users").insertOne({
|
|
368
|
+
email: STAFF,
|
|
369
|
+
password: await h.hashPassword(PASSWORD),
|
|
370
|
+
name: "Max Matrix",
|
|
371
|
+
status: "active",
|
|
372
|
+
defaultOrg: org.toString(),
|
|
373
|
+
createdAt: now,
|
|
374
|
+
})
|
|
375
|
+
).insertedId;
|
|
376
|
+
|
|
377
|
+
// `type: "resident"` as well as the role, so the resident-default categories
|
|
378
|
+
// are on the same screen. The safety senders resolve their own recipients
|
|
379
|
+
// from `siteId` + the role's permissions, so the membership must name the
|
|
380
|
+
// site or four categories would measure zero for the wrong reason.
|
|
381
|
+
await h.db.collection("members").insertOne({
|
|
382
|
+
user,
|
|
383
|
+
org,
|
|
384
|
+
siteId: site,
|
|
385
|
+
role,
|
|
386
|
+
type: "resident",
|
|
387
|
+
status: "active",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// A device per app slug the senders address, plus one with no slug at all
|
|
391
|
+
// (the service-provider invite sends without one). A missing token would read
|
|
392
|
+
// exactly like a suppressed push.
|
|
393
|
+
await h.db.collection("push_tokens").insertMany([
|
|
394
|
+
{
|
|
395
|
+
userId: user.toString(),
|
|
396
|
+
token: "ExponentPushToken[matrix-resident]",
|
|
397
|
+
platform: "ios",
|
|
398
|
+
appSlug: RESIDENT_SLUG,
|
|
399
|
+
createdAt: new Date(),
|
|
400
|
+
updatedAt: new Date(),
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
userId: user.toString(),
|
|
404
|
+
token: "ExponentPushToken[matrix-ma]",
|
|
405
|
+
platform: "android",
|
|
406
|
+
appSlug: MA_SLUG,
|
|
407
|
+
createdAt: new Date(),
|
|
408
|
+
updatedAt: new Date(),
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
userId: user.toString(),
|
|
412
|
+
token: "ExponentPushToken[matrix-noslug]",
|
|
413
|
+
platform: "ios",
|
|
414
|
+
createdAt: new Date(),
|
|
415
|
+
updatedAt: new Date(),
|
|
416
|
+
},
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
return { org, site, role, user };
|
|
420
|
+
}
|
|
@@ -297,18 +297,31 @@ describe("notification preferences, end to end", { concurrency: 1 }, () => {
|
|
|
297
297
|
);
|
|
298
298
|
|
|
299
299
|
record(
|
|
300
|
-
"12. a channel that cannot deliver is
|
|
300
|
+
"12. a channel that cannot deliver is not offered at all",
|
|
301
301
|
async () => {
|
|
302
|
+
// It used to be drawn, dimmed, and apologised for. A control that cannot
|
|
303
|
+
// move is not a setting: it tells a person they have been asked
|
|
304
|
+
// something they have not, and it made the summary line claim "on
|
|
305
|
+
// everywhere" for a category with no email at all.
|
|
302
306
|
const res = await getPrefs(managerSid);
|
|
303
307
|
const workOrder = res.body.data.categories.find(
|
|
304
308
|
(c) => c.key === "workOrder",
|
|
305
309
|
);
|
|
306
|
-
const email = workOrder.channels.find((c) => c.key === "email");
|
|
307
310
|
|
|
308
|
-
assert.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
assert.deepEqual(
|
|
312
|
+
workOrder.channels.map((c) => c.key),
|
|
313
|
+
["inApp", "push"],
|
|
314
|
+
"only the channels this category can really deliver on are sent",
|
|
315
|
+
);
|
|
316
|
+
assert.ok(
|
|
317
|
+
workOrder.channels.every((c) => c.supported),
|
|
318
|
+
"everything sent is switchable",
|
|
319
|
+
);
|
|
320
|
+
assert.equal(
|
|
321
|
+
res.body.data.channels.find((c) => c.key === "email"),
|
|
322
|
+
undefined,
|
|
323
|
+
"and the channel dictionary does not advertise one either",
|
|
324
|
+
);
|
|
312
325
|
},
|
|
313
326
|
);
|
|
314
327
|
|
|
@@ -502,8 +515,8 @@ describe("notification preferences, end to end", { concurrency: 1 }, () => {
|
|
|
502
515
|
|
|
503
516
|
assert.deepEqual(
|
|
504
517
|
emailCapable,
|
|
505
|
-
[
|
|
506
|
-
"
|
|
518
|
+
[],
|
|
519
|
+
"no category governs email at all — nothing in the estate sends a notification by email",
|
|
507
520
|
);
|
|
508
521
|
|
|
509
522
|
// and the catalogue holds no category for a one-time code, a password
|
|
@@ -97,9 +97,11 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
|
|
|
97
97
|
ownerSid = await h.login(OWNER, PASSWORD);
|
|
98
98
|
|
|
99
99
|
assert.equal(
|
|
100
|
-
await h.db
|
|
100
|
+
await h.db
|
|
101
|
+
.collection("members")
|
|
102
|
+
.countDocuments({ user: id.owner, org: id.ownOrg }),
|
|
101
103
|
0,
|
|
102
|
-
"the owner must hold no membership — reach is by `createdBy` (DV-0248)",
|
|
104
|
+
"the owner must hold no membership of THIS organisation — reach is by `createdBy` (DV-0248)",
|
|
103
105
|
);
|
|
104
106
|
assert.equal((await ownOrg()).name, "Ben Org");
|
|
105
107
|
});
|
|
@@ -160,6 +162,49 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
|
|
|
160
162
|
assert.equal(saved.contact, "+63 9569346664");
|
|
161
163
|
});
|
|
162
164
|
|
|
165
|
+
// ---- the second control: the legacy document shape is NOT the cause -------
|
|
166
|
+
|
|
167
|
+
record("5. control — the legacy production document shape saves unchanged", async () => {
|
|
168
|
+
// The 16 rows a lead read out of the production `organizations` collection
|
|
169
|
+
// carry the LEGACY `organization-api` field set — `contactNumber`, `sites`,
|
|
170
|
+
// `logo`, `createdBySpId` — and `createdAt`, `updatedAt` and `createdBy`
|
|
171
|
+
// are EMPTY STRINGS, not dates and not ObjectIds. The obvious suspicion is
|
|
172
|
+
// that saving over a row like that throws on a type: `createdAt: ""` where
|
|
173
|
+
// a Date is expected, `createdBy: ""` where an ObjectId is.
|
|
174
|
+
//
|
|
175
|
+
// It cannot. This repository talks to the raw `mongodb` driver — there is
|
|
176
|
+
// no Mongoose, no model layer and no cast step between `update()` and the
|
|
177
|
+
// server. `$set` names `name`, `email`, `contact` and `updatedAt` and
|
|
178
|
+
// nothing else, and MongoDB replaces a field wholesale whatever BSON type
|
|
179
|
+
// it held before. The legacy fields are never read, never re-validated and
|
|
180
|
+
// never rewritten.
|
|
181
|
+
//
|
|
182
|
+
// This case is the proof: same modal body, same route, a row in exactly
|
|
183
|
+
// that shape, no unique index on it — 200, and the legacy fields survive
|
|
184
|
+
// untouched.
|
|
185
|
+
const res = await h.api(`/organizations/${id.legacyOrg}`, {
|
|
186
|
+
method: "PUT",
|
|
187
|
+
sid: ownerSid,
|
|
188
|
+
body: modalBody("Legacy Shaped Org Renamed"),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
192
|
+
|
|
193
|
+
const saved = await h.db
|
|
194
|
+
.collection("organizations")
|
|
195
|
+
.findOne({ _id: id.legacyOrg });
|
|
196
|
+
|
|
197
|
+
assert.equal(saved.name, "Legacy Shaped Org Renamed");
|
|
198
|
+
assert.equal(saved.contact, "+63 9569346664");
|
|
199
|
+
assert.ok(saved.updatedAt instanceof Date, "updatedAt is replaced by a Date");
|
|
200
|
+
// untouched, still exactly as the legacy service left them
|
|
201
|
+
assert.equal(saved.createdAt, "");
|
|
202
|
+
assert.equal(saved.createdBy, "");
|
|
203
|
+
assert.equal(saved.contactNumber, "+65 61234567");
|
|
204
|
+
assert.deepEqual(saved.sites, []);
|
|
205
|
+
assert.equal(saved.createdBySpId, "");
|
|
206
|
+
});
|
|
207
|
+
|
|
163
208
|
record("4. and the guard is unchanged — no session is still refused", async () => {
|
|
164
209
|
const res = await h.api(`/organizations/${id.ownOrg}`, {
|
|
165
210
|
method: "PUT",
|
|
@@ -175,6 +220,7 @@ async function seed(h) {
|
|
|
175
220
|
const now = new Date().toISOString();
|
|
176
221
|
const otherOrg = new ObjectId();
|
|
177
222
|
const ownOrg = new ObjectId();
|
|
223
|
+
const legacyOrg = new ObjectId();
|
|
178
224
|
|
|
179
225
|
const orgs = h.db.collection("organizations");
|
|
180
226
|
|
|
@@ -202,6 +248,24 @@ async function seed(h) {
|
|
|
202
248
|
contact: "+65 00000000",
|
|
203
249
|
createdAt: now,
|
|
204
250
|
},
|
|
251
|
+
// A row in the exact shape a lead read out of production: the legacy
|
|
252
|
+
// `organization-api` field set, with the three timestamps/ids as EMPTY
|
|
253
|
+
// STRINGS. Deliberately NOT covered by the unique index above — its name is
|
|
254
|
+
// its own — so case 5 measures the document shape and nothing else.
|
|
255
|
+
{
|
|
256
|
+
_id: legacyOrg,
|
|
257
|
+
name: "Legacy Shaped Org",
|
|
258
|
+
email: "os-legacy@e2e.example.com",
|
|
259
|
+
contactNumber: "+65 61234567",
|
|
260
|
+
description: "",
|
|
261
|
+
sites: [],
|
|
262
|
+
status: "active",
|
|
263
|
+
logo: [],
|
|
264
|
+
createdBySpId: "",
|
|
265
|
+
createdAt: "",
|
|
266
|
+
updatedAt: "",
|
|
267
|
+
createdBy: "",
|
|
268
|
+
},
|
|
205
269
|
]);
|
|
206
270
|
|
|
207
271
|
const users = await h.db.collection("users").insertMany([
|
|
@@ -216,5 +280,14 @@ async function seed(h) {
|
|
|
216
280
|
// holds at step 1.
|
|
217
281
|
await orgs.updateOne({ _id: ownOrg }, { $set: { createdBy: owner } });
|
|
218
282
|
|
|
219
|
-
|
|
283
|
+
// The legacy row's `createdBy` is "" — that road cannot reach it, so the
|
|
284
|
+
// owner reaches it the ordinary way, by membership.
|
|
285
|
+
await h.db.collection("members").insertOne({
|
|
286
|
+
user: owner,
|
|
287
|
+
org: legacyOrg,
|
|
288
|
+
type: "organization",
|
|
289
|
+
status: "active",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return { owner, ownOrg, otherOrg, legacyOrg };
|
|
220
293
|
}
|
|
@@ -407,7 +407,7 @@ describe("safety alerts, end to end", { concurrency: 1 }, () => {
|
|
|
407
407
|
assert.equal(categorySupportsChannel(category, "email"), false, category);
|
|
408
408
|
assert.ok(
|
|
409
409
|
notificationCategory(category).channelNote?.email,
|
|
410
|
-
`${category} must
|
|
410
|
+
`${category} must record why it offers no email switch`,
|
|
411
411
|
);
|
|
412
412
|
}
|
|
413
413
|
},
|