@checkstack/healthcheck-backend 1.11.1 → 1.13.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/src/router.ts CHANGED
@@ -20,6 +20,13 @@ import {
20
20
  resolveScriptPackagesDir,
21
21
  } from "@checkstack/script-packages-backend";
22
22
  import { HealthCheckService } from "./service";
23
+ import {
24
+ canReadRunScope,
25
+ hasGlobalHistoryAccess,
26
+ listManageableSystemIds,
27
+ listTeamManageableConfigurationIds,
28
+ resolveHistoryScope,
29
+ } from "./history-access";
23
30
  import { collectConfigurationIssues } from "./validate-configuration";
24
31
  import { runCollectorScriptTest } from "./collector-script-test";
25
32
  import { healthCheckHooks } from "./hooks";
@@ -57,6 +64,16 @@ export const createHealthCheckRouter = (opts: {
57
64
  * Optional so existing tests can omit it.
58
65
  */
59
66
  signalService?: SignalService;
67
+ /**
68
+ * Recompute and persist the system rollup `health` entity for a single
69
+ * system, used by `pauseConfiguration` to drive the SLO engine when the
70
+ * recomputed aggregate transitions (e.g. degraded → healthy when the
71
+ * sole failing check is paused, which closes the open SLO downtime
72
+ * event via the `HEALTH_ENTITY_KIND` "recovered" transition). Optional so
73
+ * tests can omit it; when absent, pause just flips the flag and the next
74
+ * run / the SLO self-heal converge the rollup lazily.
75
+ */
76
+ recomputeSystemRollupHealth?: (systemId: string) => Promise<void>;
60
77
  }) => {
61
78
  const {
62
79
  database,
@@ -69,6 +86,7 @@ export const createHealthCheckRouter = (opts: {
69
86
  maintenanceClient,
70
87
  logger,
71
88
  signalService,
89
+ recomputeSystemRollupHealth,
72
90
  } = opts;
73
91
  // Create service instance once - shared across all handlers
74
92
  const service = new HealthCheckService(
@@ -312,6 +330,31 @@ export const createHealthCheckRouter = (opts: {
312
330
  action: "updated",
313
331
  configurationId: input.id,
314
332
  });
333
+
334
+ // Recompute the rollup `health` entity for every system this config
335
+ // is assigned to (enabled assignments only). Because
336
+ // `getSystemHealthStatus` now excludes paused configs, the recomputed
337
+ // rollup may transition degraded → healthy, which emits the
338
+ // `HEALTH_ENTITY_KIND` "recovered" edge the SLO engine consumes to
339
+ // close any open downtime event attributed to this check. If the
340
+ // system stays degraded (other failing checks), no edge fires and
341
+ // the open event correctly persists. Best-effort: a recompute
342
+ // failure is logged inside the helper and never breaks the RPC.
343
+ if (recomputeSystemRollupHealth) {
344
+ try {
345
+ const systemIds =
346
+ await service.getSystemIdsForConfiguration(input.id);
347
+ await Promise.all(
348
+ systemIds.map((systemId) =>
349
+ recomputeSystemRollupHealth(systemId),
350
+ ),
351
+ );
352
+ } catch (error) {
353
+ logger.warn(
354
+ `Failed to recompute rollup health after pausing config ${input.id}: ${extractErrorMessage(error, "unknown")}`,
355
+ );
356
+ }
357
+ }
315
358
  }),
316
359
 
317
360
  resumeConfiguration: os.resumeConfiguration.handler(async ({ input }) => {
@@ -323,6 +366,17 @@ export const createHealthCheckRouter = (opts: {
323
366
  action: "updated",
324
367
  configurationId: input.id,
325
368
  });
369
+ // Intentionally NO rollup recompute on resume. The check's last
370
+ // in-window run may have been failing, but we don't know whether the
371
+ // underlying condition is still present — only a fresh run can tell.
372
+ // Recomputing now would open a new SLO downtime event based on stale
373
+ // data and then potentially close it on the next successful run,
374
+ // fabricating a brief false-positive downtime. Instead, let the
375
+ // recurring job's next tick drive any degraded transition: if the
376
+ // check still fails, the new unhealthy run recomputes the rollup and
377
+ // the SLO engine opens a fresh downtime event (the previous event was
378
+ // closed on pause, so the idempotent guard in `handleSystemDown`
379
+ // doesn't suppress it). If the check now passes, no event opens.
326
380
  }),
327
381
 
328
382
  getSystemConfigurations: os.getSystemConfigurations.handler(
@@ -435,12 +489,76 @@ export const createHealthCheckRouter = (opts: {
435
489
  return service.getRunStats(input);
436
490
  }),
437
491
 
438
- getDetailedHistory: os.getDetailedHistory.handler(async ({ input }) => {
439
- return service.getDetailedHistory(input);
440
- }),
492
+ getDetailedHistory: os.getDetailedHistory.handler(
493
+ async ({ input, context }) => {
494
+ // Handler-side authorization (the contract's `access` is deliberately
495
+ // empty - see the contract doc): global `configuration.manage` (or a
496
+ // trusted service) gets the full feed; a team-scoped caller gets the
497
+ // feed filtered to the configurations their teams manage PLUS all
498
+ // runs of the systems they manage (a system's owning team sees every
499
+ // run of that system); everyone else is forbidden. Fail-closed via
500
+ // history-access.ts.
501
+ const user = context.user;
502
+ let accessibleConfigurationIds: string[] = [];
503
+ let accessibleSystemIds: string[] = [];
504
+ if (
505
+ user &&
506
+ (user.type === "user" || user.type === "application") &&
507
+ !hasGlobalHistoryAccess(user)
508
+ ) {
509
+ const configs = await service.getConfigurations();
510
+ [accessibleConfigurationIds, accessibleSystemIds] =
511
+ await Promise.all([
512
+ listTeamManageableConfigurationIds({
513
+ auth: context.auth,
514
+ user,
515
+ allConfigurationIds: configs.map((c) => c.id),
516
+ }),
517
+ listManageableSystemIds({
518
+ auth: context.auth,
519
+ user,
520
+ allSystemIds: await service.getRunSystemIds(),
521
+ }),
522
+ ]);
523
+ }
524
+ const scope = resolveHistoryScope({
525
+ user,
526
+ accessibleConfigurationIds,
527
+ accessibleSystemIds,
528
+ });
529
+ if (scope.kind === "forbidden") {
530
+ throw new ORPCError("FORBIDDEN", {
531
+ message:
532
+ "Run history requires health check manage access (globally, or via a team grant on a configuration or system)",
533
+ });
534
+ }
535
+ if (scope.kind === "all") {
536
+ return service.getDetailedHistory(input);
537
+ }
538
+ return service.getDetailedHistory({
539
+ ...input,
540
+ teamScope: {
541
+ configurationIds: scope.configurationIds,
542
+ systemIds: scope.systemIds,
543
+ },
544
+ });
545
+ },
546
+ ),
441
547
 
442
- getRunById: os.getRunById.handler(async ({ input }) => {
443
- return service.getRunById(input);
548
+ getRunById: os.getRunById.handler(async ({ input, context }) => {
549
+ // Authorized against the FETCHED run's own configuration/system (the
550
+ // anchor cannot be spoofed via input). An unauthorized caller gets the
551
+ // same `undefined` as a missing run, so run ids don't leak existence.
552
+ const run = await service.getRunById({ runId: input.runId });
553
+ if (!run) return;
554
+ const allowed = await canReadRunScope({
555
+ auth: context.auth,
556
+ user: context.user,
557
+ configurationId: run.configurationId,
558
+ systemId: run.systemId,
559
+ });
560
+ if (!allowed) return;
561
+ return run;
444
562
  }),
445
563
 
446
564
  getAggregatedHistory: os.getAggregatedHistory.handler(async ({ input }) => {
@@ -450,7 +568,21 @@ export const createHealthCheckRouter = (opts: {
450
568
  }),
451
569
 
452
570
  getDetailedAggregatedHistory: os.getDetailedAggregatedHistory.handler(
453
- async ({ input }) => {
571
+ async ({ input, context }) => {
572
+ // Authorized on the (configurationId, systemId) input pair - exactly
573
+ // the slice returned. See the contract doc / history-access.ts.
574
+ const allowed = await canReadRunScope({
575
+ auth: context.auth,
576
+ user: context.user,
577
+ configurationId: input.configurationId,
578
+ systemId: input.systemId,
579
+ });
580
+ if (!allowed) {
581
+ throw new ORPCError("FORBIDDEN", {
582
+ message:
583
+ "Detailed run data requires health check manage access (globally, or via a team grant on this configuration or system)",
584
+ });
585
+ }
454
586
  return service.getAggregatedHistory(input, {
455
587
  includeAggregatedResult: true,
456
588
  });
@@ -0,0 +1,299 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { HealthCheckService } from "./service";
3
+
4
+ /**
5
+ * Regression coverage for the server-side `environmentId` predicate added
6
+ * to `getHistory`, `getRunStats`, `getDetailedHistory`, and
7
+ * `getAggregatedHistory` so the per-(check, environment) views in the
8
+ * `HealthCheckDrawer` see only the env the operator clicked — not a
9
+ * mixed-env pool filtered client-side. A client-side filter would double-
10
+ * paginate, miscount totals, and ship rows the backend didn't return; the
11
+ * filter MUST be at the DB so the pagination + total are honest.
12
+ *
13
+ * Verified by capturing the predicate each query method passes to
14
+ * drizzle's `where(...)` and checking the bound value is present in the
15
+ * predicate's SQL-string form (drizzle SQL nodes stringify to SQL
16
+ * fragments that include their bindings — the same convention used by
17
+ * `service-paused-filter.test.ts`).
18
+ */
19
+ describe("HealthCheckService env filter on per-env queries", () => {
20
+ /** Drizzle SQL nodes don't stringify to readable SQL, but they expose a
21
+ * `queryChunks` array (or a `SQL<...>` wrapper) whose leaves carry the
22
+ * bound params and column names. `containsString` recursively walks any
23
+ * captured predicate — `eq(healthCheckRuns.environmentId, "prod")`
24
+ * carries `"prod"` and `"environment_id"` inside its `queryChunks` —
25
+ * and a composition via `and(...)` carries them in the top-level
26
+ * chunks. We piggyback on this internal structure the same way drizzle
27
+ * itself does at query serialization, so the assertion proves the
28
+ * binding made it into the predicate object the service handed the
29
+ * query builder. */
30
+ function containsString(v: unknown, needle: string): boolean {
31
+ return containsStringInner(v, needle, new Set());
32
+ }
33
+ function containsStringInner(
34
+ v: unknown,
35
+ needle: string,
36
+ seen: Set<unknown>,
37
+ ): boolean {
38
+ if (typeof v === "string") return v.includes(needle);
39
+ if (v === null || typeof v !== "object" || seen.has(v)) return false;
40
+ seen.add(v);
41
+ if (Array.isArray(v)) return v.some((x) => containsStringInner(x, needle, seen));
42
+ // Visit every enumerable key + symbol — drizzle keeps bound params
43
+ // and column names on `queryChunks` and nested column descriptors.
44
+ const record = v as Record<PropertyKey, unknown>;
45
+ return Object.getOwnPropertyNames(record)
46
+ .some((key) =>
47
+ containsStringInner(record[key], needle, seen),
48
+ ) || Object.getOwnPropertySymbols(record).some((sym) =>
49
+ containsStringInner(record[sym], needle, seen),
50
+ );
51
+ }
52
+
53
+ /** Extracts every string-leaf reachable from a predicate. Used for
54
+ * focused assertions like "the env column appears as IS NULL, not EQ". */
55
+ function allStrings(v: unknown, seen = new Set<unknown>()): string[] {
56
+ if (typeof v === "string") return [v];
57
+ if (v === null || typeof v !== "object" || seen.has(v)) return [];
58
+ seen.add(v);
59
+ if (Array.isArray(v)) return v.flatMap((x) => allStrings(x, seen));
60
+ const record = v as Record<PropertyKey, unknown>;
61
+ return Object.getOwnPropertyNames(record)
62
+ .flatMap((key) => allStrings(record[key], seen))
63
+ .concat(Object.getOwnPropertySymbols(record).flatMap((sym) => allStrings(record[sym], seen)));
64
+ }
65
+
66
+ /**
67
+ * Build the `db.select(...)` chain. Returns a builder object the service
68
+ * awaits; methods are no-ops that return `self` so the chain
69
+ * terminates. `where(predicate)` records the predicate for inspection.
70
+ * Each captured table is indexed by call order — the service's per-
71
+ * method structure (which selects queries, in which order) is stable so
72
+ * the calling test can read captured predicates by index.
73
+ */
74
+ function makeChainable(returnValue: unknown[] = []) {
75
+ const whereCalls: unknown[] = [];
76
+ // Promise that also exposes the query builder chain. `await` of this
77
+ // object returns the resolved value (an array, iterable); builder
78
+ // methods attached so `.where(...).orderBy(...).limit(...)` chains
79
+ // terminate in the same thenable.
80
+ type Chain = Promise<unknown[]> & {
81
+ where: (predicate: unknown) => Chain;
82
+ orderBy: () => Chain;
83
+ limit: () => Chain;
84
+ offset: () => Chain;
85
+ innerJoin: () => Chain;
86
+ };
87
+ const chain = Object.assign(Promise.resolve(returnValue), {
88
+ where: mock((predicate: unknown) => {
89
+ whereCalls.push(predicate);
90
+ return chain;
91
+ }),
92
+ orderBy: mock(() => chain),
93
+ limit: mock(() => chain),
94
+ offset: mock(() => chain),
95
+ innerJoin: mock(() => chain),
96
+ }) as Chain;
97
+ return { chain, whereCalls };
98
+ }
99
+
100
+ function buildDb(runsReturn: unknown[] = []) {
101
+ const runsChain = makeChainable(runsReturn);
102
+ const aggregatesChain = makeChainable(runsReturn);
103
+ const configChain = makeChainable([{ id: "cfg-1", strategyId: "http" }]);
104
+
105
+ // The service's `select().from(table)` — route every `from()` to one
106
+ // of our chained builders. Order matters per-method; the test reads it
107
+ // back via the returned `captures` lists.
108
+ const selectCallCount = { n: 0 };
109
+ const db = {
110
+ select: mock(() => {
111
+ selectCallCount.n += 1;
112
+ return {
113
+ from: mock((_table?: unknown) => {
114
+ // `healthCheckConfigurations` is the first select in
115
+ // `getAggregatedHistory`. We dispatch on call order: 1 → config,
116
+ // since the others go through table selects below. For simpler
117
+ // methods these are not distinguished, but practice confirms
118
+ // the captured predicate array captures env-id semantics
119
+ // regardless.
120
+ return runsChain.chain;
121
+ }),
122
+ // `getRunStats` uses a column projection: `.select({...})`; the
123
+ // shape returned above already exposes `.from()`.
124
+ };
125
+ }),
126
+ $count: mock(() => Promise.resolve(0)),
127
+ insert: mock(() => ({
128
+ values: mock(() => ({
129
+ onConflictDoUpdate: mock(() => Promise.resolve()),
130
+ onConflictDoNothing: mock(() => Promise.resolve()),
131
+ returning: mock(() => Promise.resolve([])),
132
+ })),
133
+ })),
134
+ update: mock(() => ({
135
+ set: mock(() => ({ where: mock(() => Promise.resolve()) })),
136
+ })),
137
+ delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
138
+ execute: mock(() => Promise.resolve()),
139
+ };
140
+
141
+ return {
142
+ db,
143
+ runsChain,
144
+ aggregatesChain,
145
+ configChain,
146
+ selectCallCount,
147
+ };
148
+ }
149
+
150
+ describe("getHistory", () => {
151
+ it("emits an `environment_id = 'prod'` predicate for environmentId='prod'", async () => {
152
+ const { db, runsChain } = buildDb();
153
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
154
+ await service.getHistory({
155
+ systemId: "sys-1",
156
+ configurationId: "cfg-1",
157
+ environmentId: "prod",
158
+ sortOrder: "desc",
159
+ });
160
+ expect(runsChain.whereCalls.length).toBeGreaterThanOrEqual(1);
161
+ const predicate = runsChain.whereCalls[0];
162
+ expect(containsString(predicate, "prod")).toBe(true);
163
+ expect(containsString(predicate, "environment_id")).toBe(true);
164
+ });
165
+
166
+ it("emits an IS NULL predicate for environmentId=null (env-less slice)", async () => {
167
+ const { db, runsChain } = buildDb();
168
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
169
+ await service.getHistory({
170
+ systemId: "sys-1",
171
+ configurationId: "cfg-1",
172
+ environmentId: null,
173
+ sortOrder: "desc",
174
+ });
175
+ const predicate = runsChain.whereCalls[0];
176
+ expect(containsString(predicate, "environment_id")).toBe(true);
177
+ // drizzle's `isNull` carries an " is null" string chunk in addition
178
+ // to the column reference; distinct from `eq` whose only non-column
179
+ // string is the placeholder "" / the bound `prod` value.
180
+ const leaves = allStrings(predicate);
181
+ const hasIsNull = leaves.some((s) => s.toLowerCase().includes(" is null"));
182
+ expect(hasIsNull).toBe(true);
183
+ });
184
+
185
+ it("omits the env predicate for environmentId=undefined (all envs)", async () => {
186
+ const { db, runsChain } = buildDb();
187
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
188
+ await service.getHistory({
189
+ systemId: "sys-1",
190
+ configurationId: "cfg-1",
191
+ environmentId: undefined,
192
+ sortOrder: "desc",
193
+ });
194
+ const predicate = runsChain.whereCalls[0];
195
+ // The env column NAME may still appear nested inside the shared
196
+ // `healthCheckRuns` schema object attached to every column
197
+ // descriptor. But an `eq(environmentId, X)` binding carries the env
198
+ // VALUE as a string leaf; with `environmentId === undefined` we
199
+ // never build that clause. Assert no env-id VALUE leaf appears —
200
+ // proves the env `eq(...)` (and the `isNull(...)`) was NOT added.
201
+ expect(containsString(predicate, "prod")).toBe(false);
202
+ // No " is null" string-leaf either — that leaf is added exclusively
203
+ // by the env-less branch, not by the systemId / configurationId
204
+ // predicates.
205
+ const leaves = allStrings(predicate);
206
+ expect(leaves.some((s) => s.toLowerCase().includes(" is null"))).toBe(false);
207
+ });
208
+ });
209
+
210
+ describe("getDetailedHistory", () => {
211
+ it("emits the env predicate the same way as getHistory", async () => {
212
+ const { db, runsChain } = buildDb();
213
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
214
+ await service.getDetailedHistory({
215
+ systemId: "sys-1",
216
+ configurationId: "cfg-1",
217
+ environmentId: "staging",
218
+ sortOrder: "desc",
219
+ });
220
+ const predicate = runsChain.whereCalls[0];
221
+ expect(containsString(predicate, "staging")).toBe(true);
222
+ expect(containsString(predicate, "environment_id")).toBe(true);
223
+ });
224
+ });
225
+
226
+ describe("getRunStats", () => {
227
+ it("emits the env predicate (single where() on the runs table)", async () => {
228
+ const { db, runsChain } = buildDb();
229
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
230
+ await service.getRunStats({
231
+ systemId: "sys-1",
232
+ configurationId: "cfg-1",
233
+ startDate: new Date(2025, 0, 1),
234
+ endDate: new Date(2025, 0, 2),
235
+ environmentId: "prod",
236
+ });
237
+ const predicate = runsChain.whereCalls[0];
238
+ expect(containsString(predicate, "prod")).toBe(true);
239
+ expect(containsString(predicate, "environment_id")).toBe(true);
240
+ });
241
+ });
242
+
243
+ describe("getAggregatedHistory", () => {
244
+ /**
245
+ * `getAggregatedHistory` reads `health_check_runs` (raw tier) AND
246
+ * `health_check_aggregates` (hourly + daily tiers) in parallel via
247
+ * `Promise.all`, plus one config-row select. Each tier's
248
+ * `.where(...)` must carry the env predicate so the cross-tier
249
+ * aggregation engine reads ONLY that env's runs and buckets — a
250
+ * mixed-env pool would silently leak the wrong env's sparkline into
251
+ * the drawer's charts. Asserts the env-id binding appears in at least
252
+ * one captured predicate (the raw tier).
253
+ */
254
+ it("emits the env predicate on the runs tier (and aggregates)", async () => {
255
+ const { db, runsChain } = buildDb();
256
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
257
+ await service.getAggregatedHistory(
258
+ {
259
+ systemId: "sys-1",
260
+ configurationId: "cfg-1",
261
+ startDate: new Date(2025, 0, 1),
262
+ endDate: new Date(2025, 0, 2),
263
+ environmentId: "prod",
264
+ },
265
+ { includeAggregatedResult: false },
266
+ );
267
+ expect(runsChain.whereCalls.length).toBeGreaterThanOrEqual(1);
268
+ const anyPredicateContainsProd = runsChain.whereCalls.some(
269
+ (p) => containsString(p, "prod") && containsString(p, "environment_id"),
270
+ );
271
+ expect(anyPredicateContainsProd).toBe(true);
272
+ });
273
+ });
274
+
275
+ describe("getAggregatedHistory (null)", () => {
276
+ it("emits the IS NULL predicate for the env-less slice", async () => {
277
+ const { db, runsChain } = buildDb();
278
+ const service = new HealthCheckService(db as never, {} as never, {} as never);
279
+ await service.getAggregatedHistory(
280
+ {
281
+ systemId: "sys-1",
282
+ configurationId: "cfg-1",
283
+ startDate: new Date(2025, 0, 1),
284
+ endDate: new Date(2025, 0, 2),
285
+ environmentId: null,
286
+ },
287
+ { includeAggregatedResult: false },
288
+ );
289
+ const anyHasEnvColumn = runsChain.whereCalls.some((p) =>
290
+ containsString(p, "environment_id"),
291
+ );
292
+ expect(anyHasEnvColumn).toBe(true);
293
+ const anyHasIsNull = runsChain.whereCalls.some((p) =>
294
+ allStrings(p).some((s) => s.toLowerCase().includes(" is null")),
295
+ );
296
+ expect(anyHasIsNull).toBe(true);
297
+ });
298
+ });
299
+ });