@7365admin1/core 3.53.8 → 3.54.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 +47 -0
- package/dist/index.d.ts +229 -61
- package/dist/index.js +260 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +254 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/camera-view.util.test.mjs +4 -158
- package/test/e2e/harness.mjs +9 -0
- package/test/e2e/personal-emergency-chain-scope.e2e.test.mjs +373 -0
- package/test/notification-category.util.test.mjs +29 -11
- package/test/personal-emergency-chain.test.mjs +261 -0
- package/test/picker-site-scope.test.mjs +181 -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.54.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 --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/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
|
},
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
hasAnyPermission,
|
|
14
14
|
isCameraEntitled,
|
|
15
15
|
cameraGrant,
|
|
16
|
-
orgSiteScope,
|
|
17
16
|
parseDeviceTime,
|
|
18
17
|
parseSoftwareVersion,
|
|
19
18
|
ptzEndpoint,
|
|
@@ -938,93 +937,10 @@ test("a write is scoped to the caller's own sites, not just to any signed-in use
|
|
|
938
937
|
});
|
|
939
938
|
|
|
940
939
|
/*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
* at organisation level and then returned the organisation's whole estate, so a
|
|
945
|
-
* user invited to a single site saw every site in the client in the switcher.
|
|
940
|
+
* The OFFER half of `cameraGrant` is `entitledSiteScope`, and it now answers
|
|
941
|
+
* `GET /api/customer-sites?org=` directly (`entitledSites`). Its own tests are
|
|
942
|
+
* `picker-site-scope.test.mjs` and `building-estate-site-scope.test.mjs`.
|
|
946
943
|
*/
|
|
947
|
-
const SCOPE_ORG = "aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
948
|
-
const SCOPE_OTHER_ORG = "bbbbbbbbbbbbbbbbbbbbbbbb";
|
|
949
|
-
const SCOPE_SITE_A = "cccccccccccccccccccccccc";
|
|
950
|
-
const SCOPE_SITE_B = "dddddddddddddddddddddddd";
|
|
951
|
-
|
|
952
|
-
test("orgSiteScope: an org-wide membership keeps the whole list", () => {
|
|
953
|
-
// The half a careless narrowing breaks. An org owner/admin holds a membership
|
|
954
|
-
// with no site, and must still see every site in their organisation.
|
|
955
|
-
assert.equal(
|
|
956
|
-
orgSiteScope({ memberships: [{ org: SCOPE_ORG, siteId: "" }], org: SCOPE_ORG }),
|
|
957
|
-
null,
|
|
958
|
-
);
|
|
959
|
-
assert.equal(
|
|
960
|
-
orgSiteScope({ memberships: [{ org: SCOPE_ORG }], org: SCOPE_ORG }),
|
|
961
|
-
null,
|
|
962
|
-
);
|
|
963
|
-
// One org-wide membership wins even alongside pinned ones.
|
|
964
|
-
assert.equal(
|
|
965
|
-
orgSiteScope({
|
|
966
|
-
memberships: [
|
|
967
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
968
|
-
{ org: SCOPE_ORG, siteId: "" },
|
|
969
|
-
],
|
|
970
|
-
org: SCOPE_ORG,
|
|
971
|
-
}),
|
|
972
|
-
null,
|
|
973
|
-
);
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
test("orgSiteScope: a site-pinned membership is offered that site alone", () => {
|
|
977
|
-
assert.deepEqual(
|
|
978
|
-
orgSiteScope({ memberships: [{ org: SCOPE_ORG, siteId: SCOPE_SITE_A }], org: SCOPE_ORG }),
|
|
979
|
-
[SCOPE_SITE_A],
|
|
980
|
-
);
|
|
981
|
-
// Two pins in the same organisation are both offered, de-duplicated.
|
|
982
|
-
assert.deepEqual(
|
|
983
|
-
orgSiteScope({
|
|
984
|
-
memberships: [
|
|
985
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
986
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_B },
|
|
987
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
988
|
-
],
|
|
989
|
-
org: SCOPE_ORG,
|
|
990
|
-
}),
|
|
991
|
-
[SCOPE_SITE_A, SCOPE_SITE_B],
|
|
992
|
-
);
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
test("orgSiteScope: another organisation's memberships do not widen this one", () => {
|
|
996
|
-
// The reported defect in one line: pinned to one site here, org-wide
|
|
997
|
-
// somewhere else. The org-wide row must not unlock this organisation.
|
|
998
|
-
assert.deepEqual(
|
|
999
|
-
orgSiteScope({
|
|
1000
|
-
memberships: [
|
|
1001
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
1002
|
-
{ org: SCOPE_OTHER_ORG, siteId: "" },
|
|
1003
|
-
],
|
|
1004
|
-
org: SCOPE_ORG,
|
|
1005
|
-
}),
|
|
1006
|
-
[SCOPE_SITE_A],
|
|
1007
|
-
);
|
|
1008
|
-
// No membership in this organisation at all: fail closed, not "show all".
|
|
1009
|
-
assert.deepEqual(
|
|
1010
|
-
orgSiteScope({ memberships: [{ org: SCOPE_OTHER_ORG, siteId: "" }], org: SCOPE_ORG }),
|
|
1011
|
-
[],
|
|
1012
|
-
);
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
test("orgSiteScope: ObjectId and hex-string ids are the same id", () => {
|
|
1016
|
-
assert.deepEqual(
|
|
1017
|
-
orgSiteScope({
|
|
1018
|
-
memberships: [{ org: { toString: () => SCOPE_ORG }, siteId: { toString: () => SCOPE_SITE_A } }],
|
|
1019
|
-
org: SCOPE_ORG,
|
|
1020
|
-
}),
|
|
1021
|
-
[SCOPE_SITE_A],
|
|
1022
|
-
);
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
test("orgSiteScope: no organisation asked about means no restriction", () => {
|
|
1026
|
-
assert.equal(orgSiteScope({ memberships: [{ org: SCOPE_ORG, siteId: SCOPE_SITE_A }] }), null);
|
|
1027
|
-
});
|
|
1028
944
|
|
|
1029
945
|
/**
|
|
1030
946
|
* The owner's rule of 2026-09-02: "Org-level roles see all sites."
|
|
@@ -1034,80 +950,10 @@ test("orgSiteScope: no organisation asked about means no restriction", () => {
|
|
|
1034
950
|
* (`Org Owner` in most cases) but whose membership row also happens to name a
|
|
1035
951
|
* site. These three tests are the three shapes the rule has to get right.
|
|
1036
952
|
*/
|
|
1037
|
-
test("orgSiteScope: a site-LEVEL role pinned to a site sees only that site", () => {
|
|
1038
|
-
assert.deepEqual(
|
|
1039
|
-
orgSiteScope({
|
|
1040
|
-
memberships: [
|
|
1041
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: false },
|
|
1042
|
-
],
|
|
1043
|
-
org: SCOPE_ORG,
|
|
1044
|
-
}),
|
|
1045
|
-
[SCOPE_SITE_A],
|
|
1046
|
-
);
|
|
1047
|
-
});
|
|
1048
|
-
|
|
1049
|
-
test("orgSiteScope: an ORG-level role carrying a siteId still sees every site", () => {
|
|
1050
|
-
assert.equal(
|
|
1051
|
-
orgSiteScope({
|
|
1052
|
-
memberships: [
|
|
1053
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: true },
|
|
1054
|
-
],
|
|
1055
|
-
org: SCOPE_ORG,
|
|
1056
|
-
}),
|
|
1057
|
-
null,
|
|
1058
|
-
);
|
|
1059
|
-
|
|
1060
|
-
// and it widens the whole organisation, not just its own row
|
|
1061
|
-
assert.equal(
|
|
1062
|
-
orgSiteScope({
|
|
1063
|
-
memberships: [
|
|
1064
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: false },
|
|
1065
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_B, orgLevelRole: true },
|
|
1066
|
-
],
|
|
1067
|
-
org: SCOPE_ORG,
|
|
1068
|
-
}),
|
|
1069
|
-
null,
|
|
1070
|
-
);
|
|
1071
|
-
});
|
|
1072
|
-
|
|
1073
|
-
test("orgSiteScope: an ORG-level role with no siteId sees every site", () => {
|
|
1074
|
-
assert.equal(
|
|
1075
|
-
orgSiteScope({
|
|
1076
|
-
memberships: [{ org: SCOPE_ORG, siteId: "", orgLevelRole: true }],
|
|
1077
|
-
org: SCOPE_ORG,
|
|
1078
|
-
}),
|
|
1079
|
-
null,
|
|
1080
|
-
);
|
|
1081
|
-
});
|
|
1082
|
-
|
|
1083
|
-
test("orgSiteScope: an org-level role in ANOTHER organisation does not widen this one", () => {
|
|
1084
|
-
assert.deepEqual(
|
|
1085
|
-
orgSiteScope({
|
|
1086
|
-
memberships: [
|
|
1087
|
-
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: false },
|
|
1088
|
-
{ org: SCOPE_OTHER_ORG, siteId: "", orgLevelRole: true },
|
|
1089
|
-
],
|
|
1090
|
-
org: SCOPE_ORG,
|
|
1091
|
-
}),
|
|
1092
|
-
[SCOPE_SITE_A],
|
|
1093
|
-
);
|
|
1094
|
-
});
|
|
1095
|
-
|
|
1096
|
-
test("orgSiteScope: an unresolved role fails closed — it does not widen", () => {
|
|
1097
|
-
// `orgLevelRole` absent is how a missing or soft-deleted role arrives.
|
|
1098
|
-
assert.deepEqual(
|
|
1099
|
-
orgSiteScope({
|
|
1100
|
-
memberships: [{ org: SCOPE_ORG, siteId: SCOPE_SITE_A }],
|
|
1101
|
-
org: SCOPE_ORG,
|
|
1102
|
-
}),
|
|
1103
|
-
[SCOPE_SITE_A],
|
|
1104
|
-
);
|
|
1105
|
-
});
|
|
1106
|
-
|
|
1107
953
|
/**
|
|
1108
954
|
* The camera side of the site-scope rule, reconciled with the LIST side.
|
|
1109
955
|
*
|
|
1110
|
-
*
|
|
956
|
+
* The site LIST was changed on 2026-09-02 to the owner's rule: an
|
|
1111
957
|
* ORGANISATION-LEVEL role sees every site of its organisation even when its
|
|
1112
958
|
* membership row also names a `siteId`. `cameraGrant` kept the older, stricter
|
|
1113
959
|
* reading — `!siteId` only — so the switcher offered those people sites the
|
package/test/e2e/harness.mjs
CHANGED
|
@@ -706,6 +706,14 @@ function buildApp(nsu, core) {
|
|
|
706
706
|
notificationPreferences.put("/", requireAuth, update);
|
|
707
707
|
}
|
|
708
708
|
|
|
709
|
+
// mirrors iservice365-API-core/src/routes/personal-emergency-chain.route.ts
|
|
710
|
+
const personalEmergencyContacts = express.Router();
|
|
711
|
+
{
|
|
712
|
+
const { get, update } = core.usePersonalEmergencyChainController();
|
|
713
|
+
personalEmergencyContacts.get("/:userId", requireAuth, get);
|
|
714
|
+
personalEmergencyContacts.put("/:userId", requireAuth, update);
|
|
715
|
+
}
|
|
716
|
+
|
|
709
717
|
|
|
710
718
|
// mirrors iservice365-API-core/src/routes/vehicle.route.ts
|
|
711
719
|
const vehicles = express.Router();
|
|
@@ -1527,6 +1535,7 @@ function buildApp(nsu, core) {
|
|
|
1527
1535
|
app.use("/api/bulletin-videos", bulletinVideos);
|
|
1528
1536
|
app.use("/api/notifications", notifications);
|
|
1529
1537
|
app.use("/api/notification-preferences", notificationPreferences);
|
|
1538
|
+
app.use("/api/personal-emergency-contacts", personalEmergencyContacts);
|
|
1530
1539
|
app.use("/api/subscriptions", subscriptions);
|
|
1531
1540
|
app.use("/api/invoices", invoices);
|
|
1532
1541
|
app.use("/api/post-preloved", postPreloved);
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// End-to-end proof, over real HTTP, that a person's OWN emergency contacts are
|
|
2
|
+
// theirs — readable and writable by them and by Seven365 staff, and by nobody
|
|
3
|
+
// else — and that the ordering of the chain cannot be corrupted.
|
|
4
|
+
//
|
|
5
|
+
// This is a NEW collection (`personal-emergency-contacts`). It does not touch
|
|
6
|
+
// the site's published directory (`emergency-contacts`, in
|
|
7
|
+
// `iservice365-API-core`), which is a different list with a different owner and
|
|
8
|
+
// a different gate. The cases below prove that separation is real: a site's own
|
|
9
|
+
// administrator, with a wildcard role over that site, still cannot read a
|
|
10
|
+
// resident's family numbers.
|
|
11
|
+
//
|
|
12
|
+
// The two rules under test:
|
|
13
|
+
//
|
|
14
|
+
// 1. WHO. `requireSelfOrPlatformStaff` — the same helper `/api/users` and
|
|
15
|
+
// `/api/users/v2` already use. The owner's decision: the person, plus
|
|
16
|
+
// Seven365 platform staff. NOT the site admin, NOT the org admin.
|
|
17
|
+
// 2. ORDER. Two contacts cannot hold the same position, and a reorder either
|
|
18
|
+
// applies completely or leaves the stored chain exactly as it was. There
|
|
19
|
+
// is no half-applied order for a later read to find.
|
|
20
|
+
//
|
|
21
|
+
// Every case has its positive control beside its negative one: a refusal that
|
|
22
|
+
// is never contrasted with a success would also pass against an endpoint that
|
|
23
|
+
// refuses everybody, and would prove nothing.
|
|
24
|
+
//
|
|
25
|
+
// Everything it talks to is created and thrown away by the harness: an
|
|
26
|
+
// in-process MongoDB replica set, a loopback Redis, a loopback mail sink. No
|
|
27
|
+
// staging or production database, Redis, device or endpoint is touched.
|
|
28
|
+
//
|
|
29
|
+
// Run with: yarn test:e2e
|
|
30
|
+
|
|
31
|
+
import { after, before, describe, it } from "node:test";
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { ObjectId } from "mongodb";
|
|
34
|
+
|
|
35
|
+
import { startHarness } from "./harness.mjs";
|
|
36
|
+
|
|
37
|
+
const PASSWORD = "PersonalChain-Passw0rd!";
|
|
38
|
+
const OWNER = "pec-owner@e2e.example.com"; // the resident whose chain it is
|
|
39
|
+
const STRANGER = "pec-stranger@e2e.example.com"; // another org entirely
|
|
40
|
+
const SITE_ADMIN = "pec-siteadmin@e2e.example.com"; // wildcard role AT the owner's org
|
|
41
|
+
const STAFF = "pec-sevenadmin@e2e.example.com"; // Seven365 platform staff
|
|
42
|
+
|
|
43
|
+
const CHAIN = "/personal-emergency-contacts";
|
|
44
|
+
|
|
45
|
+
const wife = { name: "Wife", phone: "+6591234567", relationship: "Spouse", order: 1, active: true };
|
|
46
|
+
const son = { name: "Son", phone: "+6598765432", relationship: "Son", order: 2, active: true };
|
|
47
|
+
const doctor = { name: "Dr Tan", phone: "+6567001122", relationship: "Doctor", order: 3, active: true };
|
|
48
|
+
|
|
49
|
+
describe("a person's own emergency chain", { concurrency: 1 }, () => {
|
|
50
|
+
let h;
|
|
51
|
+
const id = {};
|
|
52
|
+
const result = [];
|
|
53
|
+
|
|
54
|
+
const record = (name, fn) =>
|
|
55
|
+
it(name, async () => {
|
|
56
|
+
try {
|
|
57
|
+
await fn();
|
|
58
|
+
result.push(["PASS", name]);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
result.push(["FAIL", name]);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
before(async () => {
|
|
66
|
+
h = await startHarness();
|
|
67
|
+
Object.assign(id, await seed(h));
|
|
68
|
+
}, { timeout: 300000 });
|
|
69
|
+
|
|
70
|
+
after(async () => {
|
|
71
|
+
if (h) await h.stop();
|
|
72
|
+
console.log("\n--- personal emergency chain: per-case result ---");
|
|
73
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let ownerSid;
|
|
77
|
+
let strangerSid;
|
|
78
|
+
let siteAdminSid;
|
|
79
|
+
let staffSid;
|
|
80
|
+
|
|
81
|
+
const stored = async (user) =>
|
|
82
|
+
await h.db.collection("personal-emergency-contacts").findOne({ user });
|
|
83
|
+
|
|
84
|
+
record("0. baseline — four sessions, and no chain stored for anybody", async () => {
|
|
85
|
+
ownerSid = await h.login(OWNER, PASSWORD);
|
|
86
|
+
strangerSid = await h.login(STRANGER, PASSWORD);
|
|
87
|
+
siteAdminSid = await h.login(SITE_ADMIN, PASSWORD);
|
|
88
|
+
staffSid = await h.login(STAFF, PASSWORD);
|
|
89
|
+
|
|
90
|
+
assert.equal(await stored(id.owner), null);
|
|
91
|
+
assert.equal(
|
|
92
|
+
await h.db.collection("personal-emergency-contacts").countDocuments({}),
|
|
93
|
+
0,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- reading and writing your own ---------------------------------------
|
|
98
|
+
|
|
99
|
+
record("1. an empty chain reads as empty, not as an error, before anything is saved", async () => {
|
|
100
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, { sid: ownerSid });
|
|
101
|
+
|
|
102
|
+
assert.equal(res.status, 200);
|
|
103
|
+
assert.deepEqual(res.body?.data?.contacts, []);
|
|
104
|
+
assert.equal(res.body?.data?.ringSeconds, 20);
|
|
105
|
+
assert.equal(res.body?.data?.siteDirectoryFallback, true);
|
|
106
|
+
// Reading must not have written a row.
|
|
107
|
+
assert.equal(await stored(id.owner), null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
record("2. he saves his own chain — the positive control the refusals are measured against", async () => {
|
|
111
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
112
|
+
method: "PUT",
|
|
113
|
+
sid: ownerSid,
|
|
114
|
+
body: { contacts: [wife, son, doctor], ringSeconds: 25, siteDirectoryFallback: false },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assert.equal(res.status, 200);
|
|
118
|
+
assert.equal(res.body?.data?.contacts?.length, 3);
|
|
119
|
+
|
|
120
|
+
const row = await stored(id.owner);
|
|
121
|
+
assert.notEqual(row, null, "nothing was written");
|
|
122
|
+
assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
|
|
123
|
+
assert.equal(row.ringSeconds, 25);
|
|
124
|
+
assert.equal(row.siteDirectoryFallback, false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
record("3. he reads back exactly what he saved", async () => {
|
|
128
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, { sid: ownerSid });
|
|
129
|
+
|
|
130
|
+
assert.equal(res.status, 200);
|
|
131
|
+
assert.deepEqual(res.body.data.contacts.map((c) => c.phone), [
|
|
132
|
+
wife.phone,
|
|
133
|
+
son.phone,
|
|
134
|
+
doctor.phone,
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---- somebody else's ----------------------------------------------------
|
|
139
|
+
|
|
140
|
+
record("4. a stranger cannot READ his chain", async () => {
|
|
141
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, { sid: strangerSid });
|
|
142
|
+
|
|
143
|
+
assert.equal(res.status, 401);
|
|
144
|
+
assert.equal(res.body?.contacts, undefined);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
record("5. a stranger cannot WRITE his chain", async () => {
|
|
148
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
149
|
+
method: "PUT",
|
|
150
|
+
sid: strangerSid,
|
|
151
|
+
body: { contacts: [{ ...wife, name: "Planted", phone: "+6500000000" }] },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.equal(res.status, 401);
|
|
155
|
+
const row = await stored(id.owner);
|
|
156
|
+
assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
record("6. the SITE ADMIN of his own organisation cannot read it either", async () => {
|
|
160
|
+
// This is the case that separates a personal chain from the site's
|
|
161
|
+
// published directory. The same person holds a wildcard role over this
|
|
162
|
+
// organisation and can administer the site's own emergency numbers.
|
|
163
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, { sid: siteAdminSid });
|
|
164
|
+
|
|
165
|
+
assert.equal(res.status, 401);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
record("7. the SITE ADMIN cannot write it either", async () => {
|
|
169
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
170
|
+
method: "PUT",
|
|
171
|
+
sid: siteAdminSid,
|
|
172
|
+
body: { contacts: [{ ...wife, name: "Planted by admin" }] },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
assert.equal(res.status, 401);
|
|
176
|
+
const row = await stored(id.owner);
|
|
177
|
+
assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
record("8. the site admin CAN still keep his own chain — he is not locked out of the feature", async () => {
|
|
181
|
+
const res = await h.api(`${CHAIN}/${id.siteAdmin}`, {
|
|
182
|
+
method: "PUT",
|
|
183
|
+
sid: siteAdminSid,
|
|
184
|
+
body: { contacts: [{ ...wife, name: "Admin's wife" }] },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.equal(res.status, 200);
|
|
188
|
+
assert.equal((await stored(id.siteAdmin))?.contacts?.length, 1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---- Seven365 staff -----------------------------------------------------
|
|
192
|
+
|
|
193
|
+
record("9. Seven365 staff CAN read it — the precedent a user record already sets", async () => {
|
|
194
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, { sid: staffSid });
|
|
195
|
+
|
|
196
|
+
assert.equal(res.status, 200);
|
|
197
|
+
assert.equal(res.body?.data?.contacts?.length, 3);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
record("10. Seven365 staff CAN write it", async () => {
|
|
201
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
202
|
+
method: "PUT",
|
|
203
|
+
sid: staffSid,
|
|
204
|
+
body: { contacts: [wife, son, doctor], ringSeconds: 30, siteDirectoryFallback: true },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assert.equal(res.status, 200);
|
|
208
|
+
assert.equal((await stored(id.owner))?.ringSeconds, 30);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---- ordering integrity -------------------------------------------------
|
|
212
|
+
|
|
213
|
+
record("11. two contacts cannot hold the same position", async () => {
|
|
214
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
215
|
+
method: "PUT",
|
|
216
|
+
sid: ownerSid,
|
|
217
|
+
body: { contacts: [{ ...wife, order: 1 }, { ...son, order: 1 }] },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
assert.equal(res.status, 400);
|
|
221
|
+
assert.match(String(res.body?.message ?? res.body), /same position/i);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
record("12. the refused reorder wrote NOTHING — the stored chain is untouched", async () => {
|
|
225
|
+
const row = await stored(id.owner);
|
|
226
|
+
|
|
227
|
+
assert.equal(row.contacts.length, 3, "a rejected write changed the chain");
|
|
228
|
+
assert.deepEqual(row.contacts.map((c) => c.order), [1, 2, 3]);
|
|
229
|
+
assert.deepEqual(row.contacts.map((c) => c.name), ["Wife", "Son", "Dr Tan"]);
|
|
230
|
+
assert.equal(row.ringSeconds, 30, "settings moved on a rejected write");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
record("13. a valid reorder applies completely, in one write", async () => {
|
|
234
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
235
|
+
method: "PUT",
|
|
236
|
+
sid: ownerSid,
|
|
237
|
+
body: {
|
|
238
|
+
contacts: [
|
|
239
|
+
{ ...doctor, order: 1 },
|
|
240
|
+
{ ...wife, order: 2 },
|
|
241
|
+
{ ...son, order: 3 },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
assert.equal(res.status, 200);
|
|
247
|
+
const row = await stored(id.owner);
|
|
248
|
+
assert.deepEqual(row.contacts.map((c) => c.name), ["Dr Tan", "Wife", "Son"]);
|
|
249
|
+
assert.deepEqual(row.contacts.map((c) => c.order), [1, 2, 3]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
record("14. concurrent reorders leave ONE chain in ONE of the two orders, never a mixture", async () => {
|
|
253
|
+
const a = [
|
|
254
|
+
{ ...wife, order: 1 },
|
|
255
|
+
{ ...son, order: 2 },
|
|
256
|
+
{ ...doctor, order: 3 },
|
|
257
|
+
];
|
|
258
|
+
const b = [
|
|
259
|
+
{ ...doctor, order: 1 },
|
|
260
|
+
{ ...son, order: 2 },
|
|
261
|
+
{ ...wife, order: 3 },
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
await Promise.all([
|
|
265
|
+
h.api(`${CHAIN}/${id.owner}`, { method: "PUT", sid: ownerSid, body: { contacts: a } }),
|
|
266
|
+
h.api(`${CHAIN}/${id.owner}`, { method: "PUT", sid: ownerSid, body: { contacts: b } }),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
assert.equal(
|
|
270
|
+
await h.db.collection("personal-emergency-contacts").countDocuments({ user: id.owner }),
|
|
271
|
+
1,
|
|
272
|
+
"a second row was created for the same person",
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const names = (await stored(id.owner)).contacts.map((c) => c.name);
|
|
276
|
+
const options = [a, b].map((set) => set.map((c) => c.name));
|
|
277
|
+
assert.ok(
|
|
278
|
+
options.some((option) => option.join() === names.join()),
|
|
279
|
+
`chain is a mixture of both writes: ${names.join()}`,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ---- validation at the trust boundary -----------------------------------
|
|
284
|
+
|
|
285
|
+
record("15. a number that is not dialable internationally is refused", async () => {
|
|
286
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
287
|
+
method: "PUT",
|
|
288
|
+
sid: ownerSid,
|
|
289
|
+
body: { contacts: [{ ...wife, phone: "91234567" }] },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
assert.equal(res.status, 400);
|
|
293
|
+
assert.match(String(res.body?.message ?? res.body), /international format/i);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
record("16. a user id in the BODY cannot redirect the write", async () => {
|
|
297
|
+
const res = await h.api(`${CHAIN}/${id.owner}`, {
|
|
298
|
+
method: "PUT",
|
|
299
|
+
sid: ownerSid,
|
|
300
|
+
body: { contacts: [wife], user: id.stranger.toString() },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// The schema does not accept it at all, so there is nothing to strip and
|
|
304
|
+
// nothing to get wrong later.
|
|
305
|
+
assert.equal(res.status, 400);
|
|
306
|
+
assert.equal(await stored(id.stranger), null);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
record("17. an unauthenticated caller reaches nothing", async () => {
|
|
310
|
+
assert.equal((await h.api(`${CHAIN}/${id.owner}`)).status, 401);
|
|
311
|
+
assert.equal(
|
|
312
|
+
(await h.api(`${CHAIN}/${id.owner}`, { method: "PUT", body: { contacts: [] } })).status,
|
|
313
|
+
401,
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---- the site's own directory is a different list ------------------------
|
|
318
|
+
|
|
319
|
+
record("18. nothing here touched the site's published emergency directory", async () => {
|
|
320
|
+
assert.equal(
|
|
321
|
+
await h.db.collection("emergency-contacts").countDocuments({}),
|
|
322
|
+
0,
|
|
323
|
+
"the site directory collection was written to",
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
async function seed(h) {
|
|
329
|
+
const now = new Date().toISOString();
|
|
330
|
+
const orgA = new ObjectId();
|
|
331
|
+
const orgB = new ObjectId();
|
|
332
|
+
const siteA = new ObjectId();
|
|
333
|
+
const staffRole = new ObjectId();
|
|
334
|
+
const roleA = new ObjectId();
|
|
335
|
+
const roleB = new ObjectId();
|
|
336
|
+
|
|
337
|
+
await h.db.collection("organizations").insertMany([
|
|
338
|
+
{ _id: orgA, name: "PEC Org A", email: "pec-a@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
339
|
+
{ _id: orgB, name: "PEC Org B", email: "pec-b@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
await h.db.collection("sites").insertOne({
|
|
343
|
+
_id: siteA, name: "PEC Site A", orgId: orgA, status: "active", createdAt: now,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await h.db.collection("roles").insertMany([
|
|
347
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, permissions: [], status: "active" },
|
|
348
|
+
// A wildcard role at the OWNER's own organisation: this is the strongest
|
|
349
|
+
// tenant role there is, and case 6 proves even it does not reach a
|
|
350
|
+
// resident's personal chain.
|
|
351
|
+
{ _id: roleA, name: "Site Admin A", org: orgA, type: "organization", permissions: ["*"], status: "active" },
|
|
352
|
+
{ _id: roleB, name: "Admin B", org: orgB, type: "organization", permissions: ["*"], status: "active" },
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
const emails = [OWNER, STRANGER, SITE_ADMIN, STAFF];
|
|
356
|
+
const users = await h.db.collection("users").insertMany(
|
|
357
|
+
emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
361
|
+
await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
|
|
362
|
+
|
|
363
|
+
const [owner, stranger, siteAdmin, staff] = [0, 1, 2, 3].map((i) => users.insertedIds[i]);
|
|
364
|
+
|
|
365
|
+
await h.db.collection("members").insertMany([
|
|
366
|
+
{ user: owner, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
|
|
367
|
+
{ user: stranger, org: orgB, type: "organization", role: roleB, status: "active" },
|
|
368
|
+
{ user: siteAdmin, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
|
|
369
|
+
{ user: staff, type: "admin", role: staffRole, status: "active" },
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
return { orgA, orgB, siteA, owner, stranger, siteAdmin, staff };
|
|
373
|
+
}
|
|
@@ -180,7 +180,7 @@ describe("notification categories", () => {
|
|
|
180
180
|
// -------------------------------------------------------------------------
|
|
181
181
|
// What the September 2026 audit fixed. Both of these FAIL against the
|
|
182
182
|
// catalogue as it stood before it: `serviceProviderInvite` declared
|
|
183
|
-
// `email: true
|
|
183
|
+
// `email: true`, and `emergencyContact` was offered with no sender at all.
|
|
184
184
|
// -------------------------------------------------------------------------
|
|
185
185
|
|
|
186
186
|
it("no category offers a switchable Email row, because no sender reads one", () => {
|
|
@@ -199,22 +199,37 @@ describe("notification categories", () => {
|
|
|
199
199
|
}
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
-
it("emergency contacts
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
202
|
+
it("emergency contacts ARE offered now that a caller exists", () => {
|
|
203
|
+
// `iservice365-API-core`'s `emergency.service.ts` calls
|
|
204
|
+
// `NotificationService.emergencyContactChanged` on each of its three
|
|
205
|
+
// writes, and reads this flag before it sends. So the switch governs a
|
|
206
|
+
// message that really arrives — which is what this catalogue exists to
|
|
207
|
+
// guarantee, and the reason the flag was false until the caller was written.
|
|
207
208
|
const category = notificationCategory("emergencyContact");
|
|
208
|
-
assert.equal(category.rendered,
|
|
209
|
+
assert.equal(category.rendered, true);
|
|
209
210
|
|
|
210
|
-
|
|
211
|
+
// The gate is the `emergency-contact` RESOURCE, so any action on it counts.
|
|
212
|
+
for (const permissions of [
|
|
213
|
+
["emergency-contact:update-emergency-contact"],
|
|
214
|
+
["emergency-contact:see-all"],
|
|
215
|
+
["*"],
|
|
216
|
+
]) {
|
|
211
217
|
assert.ok(
|
|
212
|
-
|
|
218
|
+
keys(categoriesForPermissions({ permissions, isResident: true })).includes(
|
|
213
219
|
"emergencyContact",
|
|
214
220
|
),
|
|
215
|
-
`offered to ${permissions.join()}`,
|
|
221
|
+
`not offered to ${permissions.join()}`,
|
|
216
222
|
);
|
|
217
223
|
}
|
|
224
|
+
|
|
225
|
+
// Negative control: holding an unrelated resource must NOT offer it, or the
|
|
226
|
+
// assertion above would pass for everybody and prove nothing.
|
|
227
|
+
assert.ok(
|
|
228
|
+
!keys(
|
|
229
|
+
categoriesForPermissions({ permissions: ["bulletin-board:see-all"] }),
|
|
230
|
+
).includes("emergencyContact"),
|
|
231
|
+
"offered to a role that holds no emergency-contact permission",
|
|
232
|
+
);
|
|
218
233
|
});
|
|
219
234
|
|
|
220
235
|
it("category keys are unique", () => {
|
|
@@ -371,6 +386,9 @@ const BEFORE = {
|
|
|
371
386
|
"prelovedMarketplace",
|
|
372
387
|
"serviceProviderInvite",
|
|
373
388
|
"incidentReport",
|
|
389
|
+
// Added by the emergency-contact re-land: the sender in API-core exists
|
|
390
|
+
// again, so the switch is rendered again. See `rendered` on the category.
|
|
391
|
+
"emergencyContact",
|
|
374
392
|
"virtualPatrol",
|
|
375
393
|
"cameraFault",
|
|
376
394
|
],
|
|
@@ -378,7 +396,7 @@ const BEFORE = {
|
|
|
378
396
|
"bulletin-board": ["bulletinBoard"],
|
|
379
397
|
"bulletin-board-mgmt": ["bulletinBoard"],
|
|
380
398
|
"bulletin-videos-mgmt": ["bulletinBoard"],
|
|
381
|
-
"emergency-contact": [],
|
|
399
|
+
"emergency-contact": ["emergencyContact"],
|
|
382
400
|
"event-mgmt": ["event"],
|
|
383
401
|
"facility-booking-mgmt": ["facilityBooking"],
|
|
384
402
|
"facility-mgmt": ["facilityBooking"],
|