@7365admin1/core 3.42.2 → 3.43.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 +323 -0
- package/dist/index.d.ts +997 -1
- package/dist/index.js +8775 -6440
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8519 -6246
- package/dist/index.mjs.map +1 -1
- package/docs/camera-integration-config.md +191 -0
- package/package.json +3 -2
- package/test/camera-capability.util.test.mjs +545 -0
- package/test/camera-device-http.test.mjs +792 -0
- package/test/camera-entitlement-wiring.test.mjs +134 -0
- package/test/camera-view.util.test.mjs +892 -0
- package/test/camera-write-gate.test.mjs +121 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The three entitlement fixes, asserted on the service source.
|
|
8
|
+
*
|
|
9
|
+
* The DECISION is pure and is covered in `camera-view.util.test.mjs`
|
|
10
|
+
* (`cameraGrant` / `isCameraEntitled`). What cannot be covered there is the
|
|
11
|
+
* WIRING: constructing the service needs a live Atlas connection, so the only
|
|
12
|
+
* way to prove the service asks the right questions is to assert the source.
|
|
13
|
+
*
|
|
14
|
+
* Each of these three has already been shipped wrong once, which is why they
|
|
15
|
+
* are pinned:
|
|
16
|
+
*
|
|
17
|
+
* 1. the site's organisation was read from `site.org`, a field on 0 of 200 site
|
|
18
|
+
* documents, so the org-level branch was dead code;
|
|
19
|
+
* 2. engagements (`customer.sites`) were not consulted at all, so a security
|
|
20
|
+
* agency contracted to a site was refused its cameras;
|
|
21
|
+
* 3. permissions were resolved against `memberships[0]`, an arbitrary row in
|
|
22
|
+
* Mongo natural order, rather than the membership that granted access.
|
|
23
|
+
*/
|
|
24
|
+
const source = readFileSync(
|
|
25
|
+
fileURLToPath(new URL("../src/services/camera-view.service.ts", import.meta.url)),
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const permissionSource = readFileSync(
|
|
30
|
+
fileURLToPath(new URL("../src/utils/permission.util.ts", import.meta.url)),
|
|
31
|
+
"utf8",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
test("a site's organisation is read from orgId, the field that exists", () => {
|
|
35
|
+
assert.match(source, /cameraOrg:\s*params\.site\?\.orgId/);
|
|
36
|
+
// Both projections must ask for it, or the field arrives undefined and the
|
|
37
|
+
// org-level branch goes quietly dead again.
|
|
38
|
+
const projections = source.match(/projection:\s*\{[^}]*\}/g) ?? [];
|
|
39
|
+
const siteProjections = projections.filter((p) => /\borg\b|\borgId\b/.test(p));
|
|
40
|
+
assert.ok(siteProjections.length >= 2, "a site projection stopped asking for the org");
|
|
41
|
+
for (const projection of siteProjections) {
|
|
42
|
+
assert.match(projection, /orgId:\s*1/);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("engagements are consulted, scoped to the site and to the caller's own orgs", () => {
|
|
47
|
+
const start = source.indexOf("async function resolveSiteAccess(");
|
|
48
|
+
assert.notEqual(start, -1, "resolveSiteAccess is gone");
|
|
49
|
+
const body = source.slice(start, source.indexOf("\n /**", start + 10));
|
|
50
|
+
|
|
51
|
+
assert.match(body, /collection\("customer\.sites"\)/);
|
|
52
|
+
// All three narrowing clauses matter. Dropping the site turns the query into
|
|
53
|
+
// "every site this org serves"; dropping the org turns it into "every org";
|
|
54
|
+
// dropping the status admits terminated contracts.
|
|
55
|
+
assert.match(body, /org:\s*\{\s*\$in:/);
|
|
56
|
+
assert.match(body, /site:\s*params\.siteObjectId/);
|
|
57
|
+
assert.match(body, /status:\s*"active"/);
|
|
58
|
+
// And the engagement query must not be the thing that authorises: it fills a
|
|
59
|
+
// set that `cameraGrant` then has to match against a real membership.
|
|
60
|
+
assert.match(body, /engagedOrgs/);
|
|
61
|
+
assert.match(body, /cameraGrant\(/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("permissions resolve against the granting organisation, not an arbitrary one", () => {
|
|
65
|
+
assert.match(
|
|
66
|
+
source,
|
|
67
|
+
/org:\s*\(grant\?\.org\s*\?\?\s*memberships\[0\]\?\.org\)/,
|
|
68
|
+
);
|
|
69
|
+
// Nothing else in the file may resolve permissions off memberships[0] alone.
|
|
70
|
+
const calls = source.match(/getUserPermissions\(\{[\s\S]{0,200}?\}\)/g) ?? [];
|
|
71
|
+
assert.equal(calls.length, 1, "getUserPermissions is called from more than one place now");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("permissions resolve against the granting ROLE, not an arbitrary member row", () => {
|
|
75
|
+
// The org alone is not enough: 39 camera authorisations on staging are made
|
|
76
|
+
// by somebody holding more than one membership in the granting organisation,
|
|
77
|
+
// 9 of those across different roles. Passing the org and letting
|
|
78
|
+
// `getUserPermissions` pick a row leaves the answer to Mongo's natural order.
|
|
79
|
+
assert.match(source, /role:\s*grant\?\.role/);
|
|
80
|
+
|
|
81
|
+
// The memberships query must keep asking for the role, or `grant.role` is
|
|
82
|
+
// undefined and this silently falls back to the arbitrary pick.
|
|
83
|
+
const start = source.indexOf("async function resolveSiteAccess(");
|
|
84
|
+
const body = source.slice(start, source.indexOf("\n /**", start + 10));
|
|
85
|
+
assert.match(body, /\.project\(\{[^}]*role:\s*1/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a supplied role skips the arbitrary member pick but still must be active", () => {
|
|
89
|
+
// The role short-circuit is the whole fix, so it must actually short-circuit:
|
|
90
|
+
// the members lookup has to sit behind the "no usable role given" branch.
|
|
91
|
+
const roleGuard = permissionSource.indexOf("if (!roleId || !ObjectId.isValid(roleId))");
|
|
92
|
+
const memberLookup = permissionSource.indexOf('collection("members")');
|
|
93
|
+
const roleLookup = permissionSource.indexOf('collection("roles")');
|
|
94
|
+
assert.ok(roleGuard !== -1, "the supplied-role branch is gone");
|
|
95
|
+
assert.ok(roleGuard < memberLookup, "the member lookup no longer runs only as a fallback");
|
|
96
|
+
assert.ok(memberLookup < roleLookup);
|
|
97
|
+
|
|
98
|
+
// A soft-deleted or inactive role must still resolve to no permissions, or a
|
|
99
|
+
// caller could be authorised by a role that has been switched off.
|
|
100
|
+
assert.match(permissionSource, /_id:\s*new ObjectId\(roleId\),\s*status:\s*"active"/);
|
|
101
|
+
// An unauthenticated / invalid caller is refused before any read, as before.
|
|
102
|
+
const userGuard = permissionSource.indexOf("!ObjectId.isValid(user)");
|
|
103
|
+
assert.ok(userGuard !== -1 && userGuard < memberLookup);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a membership pinned to another site does not grant this one", () => {
|
|
107
|
+
// Defect 1, pinned at the wiring level too: the util enforces it, but the
|
|
108
|
+
// service must keep handing the util the memberships' `siteId`, or the rule
|
|
109
|
+
// has nothing to enforce it on.
|
|
110
|
+
const start = source.indexOf("async function resolveSiteAccess(");
|
|
111
|
+
const body = source.slice(start, source.indexOf("\n /**", start + 10));
|
|
112
|
+
assert.match(body, /\.project\(\{[^}]*siteId:\s*1/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("the entitlement check still runs before the permission check", () => {
|
|
116
|
+
// A permission failure says "you may not"; an entitlement failure says "not
|
|
117
|
+
// found". Reversing them tells an outsider that a site exists.
|
|
118
|
+
const start = source.indexOf("async function authorizeSite(");
|
|
119
|
+
const body = source.slice(start, source.indexOf("\n /**", start + 10));
|
|
120
|
+
const entitle = body.indexOf("entitleSite(");
|
|
121
|
+
const permission = body.indexOf("permissionsForGrant(");
|
|
122
|
+
assert.ok(entitle !== -1 && permission !== -1);
|
|
123
|
+
assert.ok(entitle < permission, "the permission check now runs before entitlement");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a caller with no session is refused before anything is read", () => {
|
|
127
|
+
const start = source.indexOf("async function entitleSite(");
|
|
128
|
+
assert.notEqual(start, -1, "entitleSite is gone");
|
|
129
|
+
const body = source.slice(start, source.indexOf("\n /**", start + 10));
|
|
130
|
+
const unauthorized = body.indexOf("UnauthorizedError");
|
|
131
|
+
const read = body.indexOf("useAtlas.getDb()");
|
|
132
|
+
assert.ok(unauthorized !== -1 && read !== -1);
|
|
133
|
+
assert.ok(unauthorized < read, "the session check moved after the database read");
|
|
134
|
+
});
|