@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/CHANGELOG.md +327 -0
- package/package.json +29 -29
- package/src/automations.test.ts +43 -1
- package/src/automations.ts +22 -3
- package/src/history-access.test.ts +283 -0
- package/src/history-access.ts +203 -0
- package/src/hooks.ts +7 -0
- package/src/index.ts +21 -4
- package/src/queue-executor.test.ts +108 -16
- package/src/queue-executor.ts +116 -9
- package/src/router-pause-recompute.test.ts +142 -0
- package/src/router.ts +138 -6
- package/src/service-env-filter.test.ts +299 -0
- package/src/service-paused-filter.test.ts +391 -0
- package/src/service-rollup-worst-wins.test.ts +205 -0
- package/src/service.ts +320 -28
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(
|
|
439
|
-
|
|
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
|
-
|
|
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
|
+
});
|