@7365admin1/core 3.64.5 → 3.65.1

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.
@@ -0,0 +1,108 @@
1
+ /*
2
+ * Shared fakes for the module-gate tests: an in-memory `TModuleGateData`, so
3
+ * the SHIPPED service and utils run with no database. Not a test file itself
4
+ * (`node --test test/*.test.mjs` does not pick it up).
5
+ */
6
+ import { moduleListHash } from "./.build/utils/module-gate.util.mjs";
7
+
8
+ export const ORG = "a".repeat(24);
9
+ export const OTHER_ORG = "b".repeat(24);
10
+ export const SITE = "c".repeat(24);
11
+ export const OTHER_SITE = "d".repeat(24); // belongs to OTHER_ORG
12
+ export const USER = "e".repeat(24);
13
+ export const USER_2 = "f".repeat(24);
14
+
15
+ export const CLIENT_SAVE = "client.modules-changed";
16
+ export const SITE_SAVE = "site.modules-changed";
17
+ // In the past: a real save row is never stamped later than now.
18
+ export const SAVED_AT = "2026-01-01T00:00:00.000Z";
19
+
20
+ /** The console-audit row the NEW save path writes for `list`. */
21
+ export const ackRow = (list, createdAt = SAVED_AT) => ({
22
+ after: { modules: list.join(", "), moduleCount: list.length, gateListHash: moduleListHash(list) },
23
+ createdAt,
24
+ });
25
+
26
+ /** A row the OLD save path wrote: no fingerprint. */
27
+ export const oldRow = (list) => ({ after: { modules: list.join(", "), moduleCount: list.length }, createdAt: SAVED_AT });
28
+
29
+ export const member = (over = {}) => ({
30
+ _id: "m".repeat(23) + "1",
31
+ user: USER,
32
+ type: "security_agency",
33
+ org: ORG,
34
+ siteId: "",
35
+ status: "active",
36
+ role: "r1",
37
+ ...over,
38
+ });
39
+
40
+ /**
41
+ * `throwOn` names loaders that reject. `calls` counts every loader call, so a
42
+ * test can prove a path reads NOTHING.
43
+ */
44
+ export function fakeGate({
45
+ rows = [],
46
+ orgs = {},
47
+ sites = {},
48
+ saves = {},
49
+ staff = false,
50
+ members = [],
51
+ roles = {},
52
+ throwOn = [],
53
+ } = {}) {
54
+ const calls = {};
55
+ const hit = (name) => {
56
+ calls[name] = (calls[name] ?? 0) + 1;
57
+ if (throwOn.includes(name)) throw new Error(`${name} is down`);
58
+ };
59
+ const data = {
60
+ async membershipsOf(ids) {
61
+ hit("membershipsOf");
62
+ return rows.filter((r) => ids.includes(String(r.user)));
63
+ },
64
+ async org(id) {
65
+ hit("org");
66
+ return orgs[id] ?? null;
67
+ },
68
+ async site(id) {
69
+ hit("site");
70
+ return sites[id] ?? null;
71
+ },
72
+ async latestSave(action, org, target) {
73
+ hit("latestSave");
74
+ return saves[`${action}|${target}`] ?? null;
75
+ },
76
+ async isPlatformStaff() {
77
+ hit("isPlatformStaff");
78
+ return staff;
79
+ },
80
+ async staffMembers(query) {
81
+ hit("staffMembers");
82
+ calls.staffMembersQuery = query;
83
+ return members;
84
+ },
85
+ async roles(ids) {
86
+ hit("roles");
87
+ return Object.fromEntries(ids.filter((id) => roles[id]).map((id) => [id, roles[id]]));
88
+ },
89
+ };
90
+ return { data, calls };
91
+ }
92
+
93
+ /** Run `fn` with these env values, then put the environment back. */
94
+ export async function withEnv(env, fn) {
95
+ const saved = Object.fromEntries(Object.keys(env).map((k) => [k, process.env[k]]));
96
+ for (const [k, v] of Object.entries(env)) {
97
+ if (v === undefined) delete process.env[k];
98
+ else process.env[k] = v;
99
+ }
100
+ try {
101
+ return await fn();
102
+ } finally {
103
+ for (const [k, v] of Object.entries(saved)) {
104
+ if (v === undefined) delete process.env[k];
105
+ else process.env[k] = v;
106
+ }
107
+ }
108
+ }
@@ -235,3 +235,67 @@ test("moduleImpact survives junk input", () => {
235
235
  );
236
236
  }
237
237
  });
238
+
239
+ /* ── the switch, the acknowledged-save marker, one member's deny list ── */
240
+
241
+ import {
242
+ MODULE_LIST_GATE_DEFAULT,
243
+ moduleListGateOn,
244
+ moduleListHash,
245
+ enforcedModuleList,
246
+ deniedForMembership,
247
+ GATE_LIST_HASH_FIELD,
248
+ } from "./.build/utils/module-gate.util.mjs";
249
+
250
+ test("MODULE_LIST_GATE: on by default; off/false/0/no switch the whole gate off", () => {
251
+ assert.equal(MODULE_LIST_GATE_DEFAULT, "on");
252
+ assert.equal(moduleListGateOn({}), true);
253
+ assert.equal(moduleListGateOn({ MODULE_LIST_GATE: "on" }), true);
254
+ assert.equal(moduleListGateOn({ MODULE_LIST_GATE: "typo" }), true, "a typo keeps the default");
255
+ for (const off of ["off", " OFF ", "false", "0", "no"]) {
256
+ assert.equal(moduleListGateOn({ MODULE_LIST_GATE: off }), false, off);
257
+ }
258
+ });
259
+
260
+ test("the list fingerprint ignores order, duplicates and junk; empty is no fingerprint", () => {
261
+ assert.equal(moduleListHash(["b", "a"]), moduleListHash(["a", "b", "a", 7, " "]));
262
+ assert.notEqual(moduleListHash(["a"]), moduleListHash(["a", "b"]));
263
+ assert.equal(moduleListHash([]), "");
264
+ assert.equal(moduleListHash(undefined), "");
265
+ assert.match(moduleListHash(["a"]), /^[0-9a-f]{64}$/);
266
+ });
267
+
268
+ test("a stored list is enforced only when its latest save carries its fingerprint", () => {
269
+ const list = ["visitor-mgmt"];
270
+ const row = (hash, createdAt = "2026-09-12T00:00:00.000Z") => ({ after: { [GATE_LIST_HASH_FIELD]: hash }, createdAt });
271
+ assert.deepEqual(enforcedModuleList(list, row(moduleListHash(list))), { list, since: "2026-09-12T00:00:00.000Z" });
272
+ for (const save of [undefined, null, {}, { after: null }, row(""), row(moduleListHash(["other"]))]) {
273
+ assert.deepEqual(enforcedModuleList(list, save), { list: [], since: "" }, JSON.stringify(save));
274
+ }
275
+ // An empty stored list is absent whatever the row says.
276
+ assert.deepEqual(enforcedModuleList([], row(moduleListHash([]))), { list: [], since: "" });
277
+ });
278
+
279
+ test("deniedForMembership: residents and org-less rows are exempt; since is the latest save", () => {
280
+ const orgList = ["visitor-mgmt", "nfc-patrol"];
281
+ const siteList = ["visitor-mgmt"];
282
+ const save = (list, at) => ({ after: { [GATE_LIST_HASH_FIELD]: moduleListHash(list) }, createdAt: at });
283
+ const args = {
284
+ membership: { org: "a".repeat(24), type: "security_agency" },
285
+ org: { modules: orgList },
286
+ orgSave: save(orgList, "2026-09-12T00:00:00.000Z"),
287
+ site: { orgId: "a".repeat(24), metadata: { modules: siteList } },
288
+ siteSave: save(siteList, "2026-09-13T00:00:00.000Z"),
289
+ };
290
+ const both = deniedForMembership(args);
291
+ assert.ok(both.denied.includes("nfc-patrol"), "the site tier narrows inside its own org");
292
+ assert.equal(both.since, "2026-09-13T00:00:00.000Z");
293
+
294
+ for (const membership of [{ ...args.membership, type: "resident" }, { type: "admin" }, null]) {
295
+ assert.deepEqual(deniedForMembership({ ...args, membership }), { denied: [], since: "" });
296
+ }
297
+ // A site of ANOTHER organisation never narrows (Q1: providers follow their own company).
298
+ const elsewhere = deniedForMembership({ ...args, site: { ...args.site, orgId: "b".repeat(24) } });
299
+ assert.ok(!elsewhere.denied.includes("nfc-patrol"));
300
+ assert.equal(elsewhere.since, "2026-09-12T00:00:00.000Z");
301
+ });