@checkstack/healthcheck-backend 1.20.1 → 1.21.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,174 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [6540703]
8
+ - Updated dependencies [099045f]
9
+ - @checkstack/ai-backend@0.11.1
10
+ - @checkstack/automation-backend@0.11.5
11
+ - @checkstack/catalog-backend@1.9.1
12
+ - @checkstack/incident-backend@1.13.3
13
+ - @checkstack/secrets-backend@0.3.7
14
+ - @checkstack/satellite-backend@0.9.1
15
+ - @checkstack/status-page-backend@0.6.3
16
+
17
+ ## 1.21.0
18
+
19
+ ### Minor Changes
20
+
21
+ - a74fa01: Relocate health-check assignment management from the catalog-entered,
22
+ system-centric Assignment IDE into the check editor itself, so a check's
23
+ settings AND its assignments (with their per-system settings) are managed in
24
+ one place. Users think in terms of "Health Checks", not the catalog - the
25
+ old flow was discovered through a catalog system row and inverted that mental
26
+ model.
27
+
28
+ - **Check editor Assignment section** (edit mode): lists every assigned
29
+ system as a tree group with the per-assignment panels (General, Thresholds,
30
+ Retention, Execution with satellites + environment fan-out, Notifications)
31
+ plus an "Assign to system..." picker that only offers systems the caller
32
+ can manage. The `AssignmentIDENodeSlot`/`AssignmentIDEPanelSlot` extension
33
+ points keep their names and context shape - extension node ids are
34
+ namespaced per system internally so config-keyed ids (e.g. the anomaly
35
+ panels) no longer collide across systems.
36
+ - **New procedure `getConfigurationAssignments`** (config → systems, the
37
+ inverse of `getSystemAssociations`), handler-authorized fail-closed: global
38
+ configuration read or a team grant on the configuration sees every row;
39
+ otherwise rows filter to systems the caller may read.
40
+ - **`getConfiguration` relaxed** (handler-authorized): a reader of an
41
+ ASSIGNED system may load the (redacted) configuration - the same exposure
42
+ `getSystemConfigurations` already allowed - so system managers can open the
43
+ editor. Unauthorized callers still get the same `undefined` as a missing id.
44
+ - **RLAC**: the edit and config routes now declare
45
+ `manageCapability.parentType: catalog.system`, so a pure system manager
46
+ reaches the editor for its Assignment section; the config side renders
47
+ read-only for them (Save disabled, strategies/collectors/access-control
48
+ gated per-node) while their systems' assignment panels stay writable.
49
+ GitOps-locked systems lock exactly their own assignment nodes.
50
+ - **Catalog wayfinding**: the per-system row button is now a
51
+ "Manage health checks" link opening the Health Checks list pre-filtered to
52
+ that system (`?system=<id>`); the filtered list loads via the
53
+ system-read-gated `getSystemConfigurations`, so it also works for system
54
+ managers without healthcheck grants.
55
+
56
+ BREAKING CHANGES: the standalone system-centric assignment page is removed -
57
+ the `healthcheck` plugin's `assignments` route (`/assignments/:systemId`) no
58
+ longer exists and `healthcheckRoutes.routes.assignments` is gone from
59
+ `@checkstack/healthcheck-common`. Deep links to the old page now 404; use the
60
+ check editor's Assignment section (or the filtered Health Checks list)
61
+ instead.
62
+
63
+ - 4568dcc: Expose health check environment resolution to cross-plugin callers via a new
64
+ `resolveEnqueueEnvironments({ configId, systemId })` procedure. It returns the
65
+ effective environment ids a one-off run should enqueue for (or `[null]` for an
66
+ env-less system) - the same fan-out the `run_now` automation and the recurring
67
+ scheduler use. Gated by any healthcheck read capability (`typeScoped` read),
68
+ consistent with the other utility reads.
69
+
70
+ This lets a cross-plugin health trigger enqueue exactly the environment slices
71
+ the run executor accepts. Previously such a caller could only enqueue an env-less
72
+ run (`environmentId: null`), which the executor drops as stale for a system that
73
+ has effective environments - so the trigger was a silent no-op for env-assigned
74
+ systems. The log-stream fast-path health trigger is the first consumer (covered
75
+ by the existing log streams changeset).
76
+
77
+ - d00e099: Make a catalog System's free-form `metadata` (custom fields) genuinely usable
78
+ end to end, mirroring how Environment custom fields already work. Previously a
79
+ System's `metadata` column was writable but nothing consumed it - it did not
80
+ surface in templating, could not be set via GitOps, and had no UI editor, so
81
+ models (and users) had no way to understand what it was for.
82
+
83
+ Now a system's custom fields are surfaced everywhere an environment's already
84
+ are:
85
+
86
+ - **Config templating**: a system's fields render as
87
+ `{{ system.metadata.<key> }}` in templatable health-check config (e.g. an
88
+ HTTP URL). They are namespaced under `.metadata` so a field named `id`/`name`
89
+ can never shadow the structural `{{ system.id }}` / `{{ system.name }}`.
90
+ - **Satellites**: the fields ride the satellite assignment
91
+ (`SatelliteAssignment.systemMetadata`) so satellite runs template
92
+ `{{ system.metadata.<key> }}` identically to local runs.
93
+ - **UI**: the System editor gains a free-form key/value custom-fields editor
94
+ (extracted into a shared `CustomFieldsEditor` used by both the System and
95
+ Environment editors).
96
+ - **GitOps**: the `System` kind accepts optional `spec.fields`, replaced on
97
+ every reconcile (same shape as the `Environment` kind).
98
+ - **Script collectors**: inline TS collectors read `context.system.metadata`
99
+ (SDK editor types updated), and shell collectors get one
100
+ `CHECKSTACK_SYSTEM_<FIELD>` env var per field, mirroring
101
+ `CHECKSTACK_ENV_<FIELD>`. A field that normalizes to a reserved name
102
+ (`CHECKSTACK_SYSTEM_ID`/`_NAME`) is now skipped with a warning rather than
103
+ clobbering the built-in; the same reserved-name guard was added to the
104
+ environment shell-env builder (previously a custom field named `id`/`name`
105
+ could shadow the structural var).
106
+ - **Editor autocomplete/preview**: the health-check editor offers
107
+ `{{ system.metadata.<key> }}` completions and previews their values when a
108
+ concrete system is in context.
109
+
110
+ The AI assistant is corrected on two fronts:
111
+
112
+ - The catalog create/update-system (and create-environment) tool schemas now
113
+ `.describe()` their `metadata` field, so a model knows it is free-form custom
114
+ fields that surface in templating - not a tagging/labeling mechanism - and
115
+ should only set keys the user explicitly asks for.
116
+ - A new "Acting on requests" chat system-prompt rule tells the assistant to
117
+ perform a requested change via its tool instead of deflecting to a manual
118
+ GitOps/UI how-to, and to name the missing permission when a tool is genuinely
119
+ unavailable. (This entry also covers the regenerated docs index reflecting the
120
+ updated GitOps/templating docs.)
121
+
122
+ State & scale: a system's metadata continues to live solely in the
123
+ `catalog.systems.metadata` Postgres column and is read via the existing
124
+ `getSystem` RPC, so every pod reads the same value. The satellite assignment
125
+ carries a per-dispatch snapshot for the duration of that run (ephemeral,
126
+ re-read on the next dispatch), not a second source of truth. No new table or
127
+ migration.
128
+
129
+ ### Patch Changes
130
+
131
+ - Updated dependencies [4568dcc]
132
+ - Updated dependencies [a74fa01]
133
+ - Updated dependencies [a74fa01]
134
+ - Updated dependencies [d9f2771]
135
+ - Updated dependencies [4568dcc]
136
+ - Updated dependencies [4568dcc]
137
+ - Updated dependencies [4568dcc]
138
+ - Updated dependencies [4568dcc]
139
+ - Updated dependencies [4568dcc]
140
+ - Updated dependencies [4568dcc]
141
+ - Updated dependencies [4568dcc]
142
+ - Updated dependencies [4568dcc]
143
+ - Updated dependencies [a74fa01]
144
+ - Updated dependencies [d00e099]
145
+ - @checkstack/ai-backend@0.11.0
146
+ - @checkstack/healthcheck-common@1.17.0
147
+ - @checkstack/satellite-backend@0.9.0
148
+ - @checkstack/signal-common@0.3.0
149
+ - @checkstack/catalog-common@2.7.3
150
+ - @checkstack/backend-api@0.33.0
151
+ - @checkstack/catalog-backend@1.9.0
152
+ - @checkstack/automation-backend@0.11.4
153
+ - @checkstack/incident-backend@1.13.2
154
+ - @checkstack/sdk@0.130.1
155
+ - @checkstack/ai-common@0.6.6
156
+ - @checkstack/cache-api@0.3.19
157
+ - @checkstack/cache-utils@0.3.0
158
+ - @checkstack/command-backend@0.2.25
159
+ - @checkstack/common@0.22.0
160
+ - @checkstack/gitops-backend@0.5.25
161
+ - @checkstack/gitops-common@0.7.3
162
+ - @checkstack/incident-common@1.10.3
163
+ - @checkstack/maintenance-common@1.10.3
164
+ - @checkstack/notification-common@1.7.1
165
+ - @checkstack/queue-api@0.3.19
166
+ - @checkstack/script-packages-backend@0.4.4
167
+ - @checkstack/secrets-backend@0.3.7
168
+ - @checkstack/secrets-common@0.3.2
169
+ - @checkstack/status-page-backend@0.6.2
170
+ - @checkstack/status-page-common@0.6.3
171
+
3
172
  ## 1.20.1
4
173
 
5
174
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.20.1",
3
+ "version": "1.21.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.12",
17
+ "@checkstack/ai-backend": "0.11.1",
18
18
  "@checkstack/ai-common": "0.6.6",
19
- "@checkstack/automation-backend": "0.11.3",
20
- "@checkstack/backend-api": "0.32.1",
19
+ "@checkstack/automation-backend": "0.11.5",
20
+ "@checkstack/backend-api": "0.33.0",
21
21
  "@checkstack/cache-api": "0.3.19",
22
22
  "@checkstack/cache-utils": "0.3.0",
23
- "@checkstack/catalog-backend": "1.8.1",
24
- "@checkstack/catalog-common": "2.7.2",
25
- "@checkstack/command-backend": "0.2.24",
23
+ "@checkstack/catalog-backend": "1.9.1",
24
+ "@checkstack/catalog-common": "2.7.3",
25
+ "@checkstack/command-backend": "0.2.25",
26
26
  "@checkstack/common": "0.22.0",
27
- "@checkstack/gitops-backend": "0.5.24",
27
+ "@checkstack/gitops-backend": "0.5.25",
28
28
  "@checkstack/gitops-common": "0.7.3",
29
- "@checkstack/healthcheck-common": "1.16.2",
30
- "@checkstack/incident-backend": "1.13.1",
31
- "@checkstack/incident-common": "1.10.2",
32
- "@checkstack/maintenance-common": "1.10.2",
33
- "@checkstack/notification-common": "1.7.0",
29
+ "@checkstack/healthcheck-common": "1.17.0",
30
+ "@checkstack/incident-backend": "1.13.3",
31
+ "@checkstack/incident-common": "1.10.3",
32
+ "@checkstack/maintenance-common": "1.10.3",
33
+ "@checkstack/notification-common": "1.7.1",
34
34
  "@checkstack/queue-api": "0.3.19",
35
- "@checkstack/satellite-backend": "0.8.6",
36
- "@checkstack/script-packages-backend": "0.4.3",
37
- "@checkstack/sdk": "0.129.1",
38
- "@checkstack/secrets-backend": "0.3.6",
35
+ "@checkstack/satellite-backend": "0.9.1",
36
+ "@checkstack/script-packages-backend": "0.4.4",
37
+ "@checkstack/sdk": "0.131.0",
38
+ "@checkstack/secrets-backend": "0.3.7",
39
39
  "@checkstack/secrets-common": "0.3.2",
40
- "@checkstack/signal-common": "0.2.17",
41
- "@checkstack/status-page-backend": "0.6.1",
42
- "@checkstack/status-page-common": "0.6.2",
40
+ "@checkstack/signal-common": "0.3.0",
41
+ "@checkstack/status-page-backend": "0.6.3",
42
+ "@checkstack/status-page-common": "0.6.3",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -52,8 +52,8 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
- "@checkstack/scripts": "0.7.3",
56
- "@checkstack/test-utils-backend": "0.1.58",
55
+ "@checkstack/scripts": "0.7.4",
56
+ "@checkstack/test-utils-backend": "0.1.59",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
59
  "@types/pg": "^8.20.0",
@@ -14,7 +14,8 @@ describe("HEALTHCHECK_SHELL_ENV drift guard", () => {
14
14
  test("the static table describes every reserved var the producer emits", () => {
15
15
  const produced = buildShellRunContextEnv({
16
16
  check: { id: "c1", name: "Check One", intervalSeconds: 60 },
17
- system: { id: "s1", name: "System One" },
17
+ // A custom field exercises the CHECKSTACK_SYSTEM_<FIELD> derivation.
18
+ system: { id: "s1", name: "System One", metadata: { baseUrl: "https://x" } },
18
19
  environment: {
19
20
  id: "e1",
20
21
  name: "prod",
@@ -24,8 +25,9 @@ describe("HEALTHCHECK_SHELL_ENV drift guard", () => {
24
25
  });
25
26
 
26
27
  const tableNames = new Set(HEALTHCHECK_SHELL_ENV.map((v) => v.name));
27
- // The dynamic per-field var is covered by the wildcard descriptor.
28
+ // The dynamic per-field vars are covered by the wildcard descriptors.
28
29
  tableNames.add("CHECKSTACK_ENV_REGION");
30
+ tableNames.add("CHECKSTACK_SYSTEM_BASE_URL");
29
31
 
30
32
  for (const producedName of Object.keys(produced)) {
31
33
  expect(tableNames.has(producedName)).toBe(true);
@@ -0,0 +1,332 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AuthService, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ canReadConfigurationScope,
5
+ hasConfigurationReadGrant,
6
+ hasGlobalConfigurationRead,
7
+ listReadableSystemIds,
8
+ resolveAssignmentRowScope,
9
+ } from "./assignment-access";
10
+
11
+ /**
12
+ * Regression guard for the configuration-centric assignment authorization:
13
+ * global `configuration.read`/`.manage` (or wildcard/service) or a team grant
14
+ * on the CONFIGURATION yields every assignment row, a system-scoped caller is
15
+ * restricted to the systems their teams may read, anyone else is forbidden,
16
+ * and any auth (S2S) failure fails CLOSED.
17
+ */
18
+
19
+ const QUALIFIED_READ = "healthcheck.healthcheck.read";
20
+ const QUALIFIED_MANAGE = "healthcheck.healthcheck.manage";
21
+ const QUALIFIED_SYSTEM_READ = "catalog.system.read";
22
+ const QUALIFIED_SYSTEM_MANAGE = "catalog.system.manage";
23
+
24
+ const realUser = (accessRules: string[]): AuthUser => ({
25
+ type: "user",
26
+ id: "u1",
27
+ accessRules,
28
+ });
29
+
30
+ // Cast is unavoidable for a partial mock: the helpers only touch
31
+ // `listAccessibleObjectIds` / `check`, and implementing the full AuthService
32
+ // here would just be noise.
33
+ const authWith = (impl: Partial<AuthService>): AuthService =>
34
+ impl as unknown as AuthService;
35
+
36
+ describe("hasGlobalConfigurationRead", () => {
37
+ test("true for global read, global manage, wildcard, and services; false otherwise", () => {
38
+ expect(hasGlobalConfigurationRead(realUser([QUALIFIED_READ]))).toBe(true);
39
+ expect(hasGlobalConfigurationRead(realUser([QUALIFIED_MANAGE]))).toBe(true);
40
+ expect(hasGlobalConfigurationRead(realUser(["*"]))).toBe(true);
41
+ expect(
42
+ hasGlobalConfigurationRead({ type: "service", pluginId: "slo" }),
43
+ ).toBe(true);
44
+ expect(
45
+ hasGlobalConfigurationRead(realUser([QUALIFIED_SYSTEM_MANAGE])),
46
+ ).toBe(false);
47
+ expect(hasGlobalConfigurationRead(undefined)).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("resolveAssignmentRowScope", () => {
52
+ test("global read rule yields every row", () => {
53
+ expect(
54
+ resolveAssignmentRowScope({
55
+ user: realUser([QUALIFIED_READ]),
56
+ hasConfigurationGrant: false,
57
+ readableSystemIds: [],
58
+ }),
59
+ ).toEqual({ kind: "all" });
60
+ });
61
+
62
+ test("a configuration team grant yields every row", () => {
63
+ expect(
64
+ resolveAssignmentRowScope({
65
+ user: realUser([]),
66
+ hasConfigurationGrant: true,
67
+ readableSystemIds: [],
68
+ }),
69
+ ).toEqual({ kind: "all" });
70
+ });
71
+
72
+ test("system grants alone scope the rows to those systems", () => {
73
+ expect(
74
+ resolveAssignmentRowScope({
75
+ user: realUser([]),
76
+ hasConfigurationGrant: false,
77
+ readableSystemIds: ["sys-1"],
78
+ }),
79
+ ).toEqual({ kind: "scoped", systemIds: ["sys-1"] });
80
+ });
81
+
82
+ test("no global rule and no grant of either kind is forbidden", () => {
83
+ expect(
84
+ resolveAssignmentRowScope({
85
+ user: realUser([]),
86
+ hasConfigurationGrant: false,
87
+ readableSystemIds: [],
88
+ }),
89
+ ).toEqual({ kind: "forbidden" });
90
+ });
91
+
92
+ test("missing user is forbidden", () => {
93
+ expect(
94
+ resolveAssignmentRowScope({
95
+ user: undefined,
96
+ hasConfigurationGrant: false,
97
+ readableSystemIds: ["sys-1"],
98
+ }),
99
+ ).toEqual({ kind: "forbidden" });
100
+ });
101
+
102
+ test("a read rule from ANOTHER plugin does not unlock the rows", () => {
103
+ expect(
104
+ resolveAssignmentRowScope({
105
+ user: realUser(["incident.incident.read"]),
106
+ hasConfigurationGrant: false,
107
+ readableSystemIds: [],
108
+ }),
109
+ ).toEqual({ kind: "forbidden" });
110
+ });
111
+ });
112
+
113
+ describe("hasConfigurationReadGrant", () => {
114
+ test("resolves a READ grant on the configuration via the auth S2S", async () => {
115
+ const auth = authWith({
116
+ check: async ({ objectType, objectId, action }) => {
117
+ expect(objectType).toBe("healthcheck.healthcheck");
118
+ expect(action).toBe("read");
119
+ return { hasAccess: objectId === "cfg-1" };
120
+ },
121
+ });
122
+
123
+ await expect(
124
+ hasConfigurationReadGrant({
125
+ auth,
126
+ user: realUser([]),
127
+ configurationId: "cfg-1",
128
+ }),
129
+ ).resolves.toBe(true);
130
+ await expect(
131
+ hasConfigurationReadGrant({
132
+ auth,
133
+ user: realUser([]),
134
+ configurationId: "cfg-2",
135
+ }),
136
+ ).resolves.toBe(false);
137
+ });
138
+
139
+ test("fails CLOSED when the auth S2S errors", async () => {
140
+ const auth = authWith({
141
+ check: async () => {
142
+ throw new Error("auth down");
143
+ },
144
+ });
145
+
146
+ await expect(
147
+ hasConfigurationReadGrant({
148
+ auth,
149
+ user: realUser([]),
150
+ configurationId: "cfg-1",
151
+ }),
152
+ ).resolves.toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("listReadableSystemIds", () => {
157
+ test("resolves the team-granted subset keyed on catalog.system read", async () => {
158
+ const auth = authWith({
159
+ listAccessibleObjectIds: async ({ objectType, action, objectIds }) => {
160
+ expect(objectType).toBe("catalog.system");
161
+ expect(action).toBe("read");
162
+ return objectIds.filter((id) => id === "sys-1");
163
+ },
164
+ });
165
+
166
+ await expect(
167
+ listReadableSystemIds({
168
+ auth,
169
+ user: realUser([]),
170
+ allSystemIds: ["sys-1", "sys-2"],
171
+ }),
172
+ ).resolves.toEqual(["sys-1"]);
173
+ });
174
+
175
+ test("the global catalog.system read and manage rules grant every system (parentScope convention)", async () => {
176
+ const auth = authWith({
177
+ listAccessibleObjectIds: async () => {
178
+ throw new Error("must not be called");
179
+ },
180
+ });
181
+
182
+ await expect(
183
+ listReadableSystemIds({
184
+ auth,
185
+ user: realUser([QUALIFIED_SYSTEM_READ]),
186
+ allSystemIds: ["sys-1", "sys-2"],
187
+ }),
188
+ ).resolves.toEqual(["sys-1", "sys-2"]);
189
+ await expect(
190
+ listReadableSystemIds({
191
+ auth,
192
+ user: realUser([QUALIFIED_SYSTEM_MANAGE]),
193
+ allSystemIds: ["sys-1", "sys-2"],
194
+ }),
195
+ ).resolves.toEqual(["sys-1", "sys-2"]);
196
+ });
197
+
198
+ test("fails CLOSED when the auth S2S errors", async () => {
199
+ const auth = authWith({
200
+ listAccessibleObjectIds: async () => {
201
+ throw new Error("auth down");
202
+ },
203
+ });
204
+
205
+ await expect(
206
+ listReadableSystemIds({
207
+ auth,
208
+ user: realUser([]),
209
+ allSystemIds: ["sys-1"],
210
+ }),
211
+ ).resolves.toEqual([]);
212
+ });
213
+ });
214
+
215
+ describe("canReadConfigurationScope", () => {
216
+ const configurationId = "cfg-1";
217
+
218
+ test("global configuration read allows without any S2S call or assignment lookup", async () => {
219
+ const auth = authWith({
220
+ check: async () => {
221
+ throw new Error("must not be called");
222
+ },
223
+ });
224
+
225
+ await expect(
226
+ canReadConfigurationScope({
227
+ auth,
228
+ user: realUser([QUALIFIED_READ]),
229
+ configurationId,
230
+ getAssignedSystemIds: async () => {
231
+ throw new Error("must not be called");
232
+ },
233
+ }),
234
+ ).resolves.toBe(true);
235
+ });
236
+
237
+ test("a team grant on the CONFIGURATION allows without the assignment lookup", async () => {
238
+ const auth = authWith({
239
+ check: async () => ({ hasAccess: true }),
240
+ });
241
+
242
+ await expect(
243
+ canReadConfigurationScope({
244
+ auth,
245
+ user: realUser([]),
246
+ configurationId,
247
+ getAssignedSystemIds: async () => {
248
+ throw new Error("must not be called");
249
+ },
250
+ }),
251
+ ).resolves.toBe(true);
252
+ });
253
+
254
+ test("read access to an ASSIGNED system allows even without a configuration grant", async () => {
255
+ const auth = authWith({
256
+ check: async () => ({ hasAccess: false }),
257
+ listAccessibleObjectIds: async ({ objectIds }) =>
258
+ objectIds.filter((id) => id === "sys-1"),
259
+ });
260
+
261
+ await expect(
262
+ canReadConfigurationScope({
263
+ auth,
264
+ user: realUser([]),
265
+ configurationId,
266
+ getAssignedSystemIds: async () => ["sys-1", "sys-2"],
267
+ }),
268
+ ).resolves.toBe(true);
269
+ });
270
+
271
+ test("a system grant unrelated to the configuration's assignments denies", async () => {
272
+ const auth = authWith({
273
+ check: async () => ({ hasAccess: false }),
274
+ listAccessibleObjectIds: async () => [],
275
+ });
276
+
277
+ await expect(
278
+ canReadConfigurationScope({
279
+ auth,
280
+ user: realUser([]),
281
+ configurationId,
282
+ getAssignedSystemIds: async () => ["sys-2"],
283
+ }),
284
+ ).resolves.toBe(false);
285
+ });
286
+
287
+ test("an unassigned configuration denies a caller with only system grants", async () => {
288
+ const auth = authWith({
289
+ check: async () => ({ hasAccess: false }),
290
+ listAccessibleObjectIds: async () => {
291
+ throw new Error("must not be called for an empty id set");
292
+ },
293
+ });
294
+
295
+ await expect(
296
+ canReadConfigurationScope({
297
+ auth,
298
+ user: realUser([]),
299
+ configurationId,
300
+ getAssignedSystemIds: async () => [],
301
+ }),
302
+ ).resolves.toBe(false);
303
+ });
304
+
305
+ test("missing user denies", async () => {
306
+ await expect(
307
+ canReadConfigurationScope({
308
+ auth: authWith({}),
309
+ user: undefined,
310
+ configurationId,
311
+ getAssignedSystemIds: async () => ["sys-1"],
312
+ }),
313
+ ).resolves.toBe(false);
314
+ });
315
+
316
+ test("fails CLOSED when the assignment lookup errors", async () => {
317
+ const auth = authWith({
318
+ check: async () => ({ hasAccess: false }),
319
+ });
320
+
321
+ await expect(
322
+ canReadConfigurationScope({
323
+ auth,
324
+ user: realUser([]),
325
+ configurationId,
326
+ getAssignedSystemIds: async () => {
327
+ throw new Error("db down");
328
+ },
329
+ }),
330
+ ).resolves.toBe(false);
331
+ });
332
+ });