@checkstack/healthcheck-backend 1.20.1 → 1.21.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 CHANGED
@@ -1,5 +1,160 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a74fa01: Relocate health-check assignment management from the catalog-entered,
8
+ system-centric Assignment IDE into the check editor itself, so a check's
9
+ settings AND its assignments (with their per-system settings) are managed in
10
+ one place. Users think in terms of "Health Checks", not the catalog - the
11
+ old flow was discovered through a catalog system row and inverted that mental
12
+ model.
13
+
14
+ - **Check editor Assignment section** (edit mode): lists every assigned
15
+ system as a tree group with the per-assignment panels (General, Thresholds,
16
+ Retention, Execution with satellites + environment fan-out, Notifications)
17
+ plus an "Assign to system..." picker that only offers systems the caller
18
+ can manage. The `AssignmentIDENodeSlot`/`AssignmentIDEPanelSlot` extension
19
+ points keep their names and context shape - extension node ids are
20
+ namespaced per system internally so config-keyed ids (e.g. the anomaly
21
+ panels) no longer collide across systems.
22
+ - **New procedure `getConfigurationAssignments`** (config → systems, the
23
+ inverse of `getSystemAssociations`), handler-authorized fail-closed: global
24
+ configuration read or a team grant on the configuration sees every row;
25
+ otherwise rows filter to systems the caller may read.
26
+ - **`getConfiguration` relaxed** (handler-authorized): a reader of an
27
+ ASSIGNED system may load the (redacted) configuration - the same exposure
28
+ `getSystemConfigurations` already allowed - so system managers can open the
29
+ editor. Unauthorized callers still get the same `undefined` as a missing id.
30
+ - **RLAC**: the edit and config routes now declare
31
+ `manageCapability.parentType: catalog.system`, so a pure system manager
32
+ reaches the editor for its Assignment section; the config side renders
33
+ read-only for them (Save disabled, strategies/collectors/access-control
34
+ gated per-node) while their systems' assignment panels stay writable.
35
+ GitOps-locked systems lock exactly their own assignment nodes.
36
+ - **Catalog wayfinding**: the per-system row button is now a
37
+ "Manage health checks" link opening the Health Checks list pre-filtered to
38
+ that system (`?system=<id>`); the filtered list loads via the
39
+ system-read-gated `getSystemConfigurations`, so it also works for system
40
+ managers without healthcheck grants.
41
+
42
+ BREAKING CHANGES: the standalone system-centric assignment page is removed -
43
+ the `healthcheck` plugin's `assignments` route (`/assignments/:systemId`) no
44
+ longer exists and `healthcheckRoutes.routes.assignments` is gone from
45
+ `@checkstack/healthcheck-common`. Deep links to the old page now 404; use the
46
+ check editor's Assignment section (or the filtered Health Checks list)
47
+ instead.
48
+
49
+ - 4568dcc: Expose health check environment resolution to cross-plugin callers via a new
50
+ `resolveEnqueueEnvironments({ configId, systemId })` procedure. It returns the
51
+ effective environment ids a one-off run should enqueue for (or `[null]` for an
52
+ env-less system) - the same fan-out the `run_now` automation and the recurring
53
+ scheduler use. Gated by any healthcheck read capability (`typeScoped` read),
54
+ consistent with the other utility reads.
55
+
56
+ This lets a cross-plugin health trigger enqueue exactly the environment slices
57
+ the run executor accepts. Previously such a caller could only enqueue an env-less
58
+ run (`environmentId: null`), which the executor drops as stale for a system that
59
+ has effective environments - so the trigger was a silent no-op for env-assigned
60
+ systems. The log-stream fast-path health trigger is the first consumer (covered
61
+ by the existing log streams changeset).
62
+
63
+ - d00e099: Make a catalog System's free-form `metadata` (custom fields) genuinely usable
64
+ end to end, mirroring how Environment custom fields already work. Previously a
65
+ System's `metadata` column was writable but nothing consumed it - it did not
66
+ surface in templating, could not be set via GitOps, and had no UI editor, so
67
+ models (and users) had no way to understand what it was for.
68
+
69
+ Now a system's custom fields are surfaced everywhere an environment's already
70
+ are:
71
+
72
+ - **Config templating**: a system's fields render as
73
+ `{{ system.metadata.<key> }}` in templatable health-check config (e.g. an
74
+ HTTP URL). They are namespaced under `.metadata` so a field named `id`/`name`
75
+ can never shadow the structural `{{ system.id }}` / `{{ system.name }}`.
76
+ - **Satellites**: the fields ride the satellite assignment
77
+ (`SatelliteAssignment.systemMetadata`) so satellite runs template
78
+ `{{ system.metadata.<key> }}` identically to local runs.
79
+ - **UI**: the System editor gains a free-form key/value custom-fields editor
80
+ (extracted into a shared `CustomFieldsEditor` used by both the System and
81
+ Environment editors).
82
+ - **GitOps**: the `System` kind accepts optional `spec.fields`, replaced on
83
+ every reconcile (same shape as the `Environment` kind).
84
+ - **Script collectors**: inline TS collectors read `context.system.metadata`
85
+ (SDK editor types updated), and shell collectors get one
86
+ `CHECKSTACK_SYSTEM_<FIELD>` env var per field, mirroring
87
+ `CHECKSTACK_ENV_<FIELD>`. A field that normalizes to a reserved name
88
+ (`CHECKSTACK_SYSTEM_ID`/`_NAME`) is now skipped with a warning rather than
89
+ clobbering the built-in; the same reserved-name guard was added to the
90
+ environment shell-env builder (previously a custom field named `id`/`name`
91
+ could shadow the structural var).
92
+ - **Editor autocomplete/preview**: the health-check editor offers
93
+ `{{ system.metadata.<key> }}` completions and previews their values when a
94
+ concrete system is in context.
95
+
96
+ The AI assistant is corrected on two fronts:
97
+
98
+ - The catalog create/update-system (and create-environment) tool schemas now
99
+ `.describe()` their `metadata` field, so a model knows it is free-form custom
100
+ fields that surface in templating - not a tagging/labeling mechanism - and
101
+ should only set keys the user explicitly asks for.
102
+ - A new "Acting on requests" chat system-prompt rule tells the assistant to
103
+ perform a requested change via its tool instead of deflecting to a manual
104
+ GitOps/UI how-to, and to name the missing permission when a tool is genuinely
105
+ unavailable. (This entry also covers the regenerated docs index reflecting the
106
+ updated GitOps/templating docs.)
107
+
108
+ State & scale: a system's metadata continues to live solely in the
109
+ `catalog.systems.metadata` Postgres column and is read via the existing
110
+ `getSystem` RPC, so every pod reads the same value. The satellite assignment
111
+ carries a per-dispatch snapshot for the duration of that run (ephemeral,
112
+ re-read on the next dispatch), not a second source of truth. No new table or
113
+ migration.
114
+
115
+ ### Patch Changes
116
+
117
+ - Updated dependencies [4568dcc]
118
+ - Updated dependencies [a74fa01]
119
+ - Updated dependencies [a74fa01]
120
+ - Updated dependencies [d9f2771]
121
+ - Updated dependencies [4568dcc]
122
+ - Updated dependencies [4568dcc]
123
+ - Updated dependencies [4568dcc]
124
+ - Updated dependencies [4568dcc]
125
+ - Updated dependencies [4568dcc]
126
+ - Updated dependencies [4568dcc]
127
+ - Updated dependencies [4568dcc]
128
+ - Updated dependencies [4568dcc]
129
+ - Updated dependencies [a74fa01]
130
+ - Updated dependencies [d00e099]
131
+ - @checkstack/ai-backend@0.11.0
132
+ - @checkstack/healthcheck-common@1.17.0
133
+ - @checkstack/satellite-backend@0.9.0
134
+ - @checkstack/signal-common@0.3.0
135
+ - @checkstack/catalog-common@2.7.3
136
+ - @checkstack/backend-api@0.33.0
137
+ - @checkstack/catalog-backend@1.9.0
138
+ - @checkstack/automation-backend@0.11.4
139
+ - @checkstack/incident-backend@1.13.2
140
+ - @checkstack/sdk@0.130.1
141
+ - @checkstack/ai-common@0.6.6
142
+ - @checkstack/cache-api@0.3.19
143
+ - @checkstack/cache-utils@0.3.0
144
+ - @checkstack/command-backend@0.2.25
145
+ - @checkstack/common@0.22.0
146
+ - @checkstack/gitops-backend@0.5.25
147
+ - @checkstack/gitops-common@0.7.3
148
+ - @checkstack/incident-common@1.10.3
149
+ - @checkstack/maintenance-common@1.10.3
150
+ - @checkstack/notification-common@1.7.1
151
+ - @checkstack/queue-api@0.3.19
152
+ - @checkstack/script-packages-backend@0.4.4
153
+ - @checkstack/secrets-backend@0.3.7
154
+ - @checkstack/secrets-common@0.3.2
155
+ - @checkstack/status-page-backend@0.6.2
156
+ - @checkstack/status-page-common@0.6.3
157
+
3
158
  ## 1.20.1
4
159
 
5
160
  ### 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.0",
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.0",
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.4",
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.0",
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.2",
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.0",
36
+ "@checkstack/script-packages-backend": "0.4.4",
37
+ "@checkstack/sdk": "0.130.1",
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.2",
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
+ });