@checkstack/healthcheck-backend 1.14.0 → 1.16.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.
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "bun:test";
2
+ import { computeAssertionKey } from "@checkstack/healthcheck-common";
2
3
  import { getTableConfig } from "drizzle-orm/pg-core";
3
4
  import { healthCheckAggregates } from "./schema";
4
5
  import {
@@ -116,3 +117,71 @@ describe("DAILY_AGGREGATE_CONFLICT_TARGET", () => {
116
117
  expect(targetCols).toEqual(constraintCols);
117
118
  });
118
119
  });
120
+
121
+ describe("buildDailyAggregates - assertion stats", () => {
122
+ const KEY = computeAssertionKey({
123
+ assertion: { field: "statusCode", operator: "equals", value: 200 },
124
+ });
125
+
126
+ it("sums per-assertion counts across the day's hourly buckets", () => {
127
+ const daily = buildDailyAggregates([
128
+ hourly({
129
+ bucketStart: new Date("2026-01-01T03:00:00.000Z"),
130
+ aggregatedResult: {
131
+ collectors: {},
132
+ assertions: { "uuid-1": { [KEY]: { passCount: 50, failCount: 2 } } },
133
+ },
134
+ }),
135
+ hourly({
136
+ bucketStart: new Date("2026-01-01T04:00:00.000Z"),
137
+ aggregatedResult: {
138
+ assertions: { "uuid-1": { [KEY]: { passCount: 60, failCount: 0 } } },
139
+ },
140
+ }),
141
+ // Pre-feature hourly bucket without stats is tolerated.
142
+ hourly({
143
+ bucketStart: new Date("2026-01-01T05:00:00.000Z"),
144
+ aggregatedResult: null,
145
+ }),
146
+ ]);
147
+
148
+ expect(daily.length).toBe(1);
149
+ expect(daily[0].assertionStats).toEqual({
150
+ "uuid-1": { [KEY]: { passCount: 110, failCount: 2 } },
151
+ });
152
+ });
153
+
154
+ it("keeps assertion stats scoped to their (env, source) series", () => {
155
+ const daily = buildDailyAggregates([
156
+ hourly({
157
+ environmentId: "prod",
158
+ aggregatedResult: {
159
+ assertions: { "uuid-1": { [KEY]: { passCount: 1, failCount: 0 } } },
160
+ },
161
+ }),
162
+ hourly({
163
+ environmentId: "staging",
164
+ aggregatedResult: {
165
+ assertions: { "uuid-1": { [KEY]: { passCount: 0, failCount: 1 } } },
166
+ },
167
+ }),
168
+ ]);
169
+
170
+ expect(daily.length).toBe(2);
171
+ const prod = daily.find((d) => d.environmentId === "prod");
172
+ const staging = daily.find((d) => d.environmentId === "staging");
173
+ expect(prod?.assertionStats?.["uuid-1"][KEY]).toEqual({
174
+ passCount: 1,
175
+ failCount: 0,
176
+ });
177
+ expect(staging?.assertionStats?.["uuid-1"][KEY]).toEqual({
178
+ passCount: 0,
179
+ failCount: 1,
180
+ });
181
+ });
182
+
183
+ it("buckets without stats yield undefined assertionStats", () => {
184
+ const daily = buildDailyAggregates([hourly({})]);
185
+ expect(daily[0].assertionStats).toBeUndefined();
186
+ });
187
+ });
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { z } from "zod";
3
+ import { call } from "@orpc/server";
4
+ import {
5
+ createMockRpcContext,
6
+ configSecret,
7
+ Versioned,
8
+ } from "@checkstack/backend-api";
9
+ import { internalSecretName } from "@checkstack/secrets-common";
10
+ import type {
11
+ InternalSecretsService,
12
+ SecretResolverService,
13
+ } from "@checkstack/secrets-backend";
14
+ import { createHealthCheckRouter } from "./router";
15
+ import {
16
+ healthcheckSecretMarker,
17
+ isHealthcheckSecretMarker,
18
+ } from "./config-secrets";
19
+ import type { HealthCheckCache } from "./cache";
20
+
21
+ /**
22
+ * Guards the SEC-1 fix: `createAndAssign` (the first-check wizard / AI propose
23
+ * creation path) MUST extract inline `x-secret` values into the internal store
24
+ * and return a REDACTED config - never persist or echo a plaintext credential.
25
+ */
26
+
27
+ const passthroughCache: HealthCheckCache = {
28
+ wrapSystemHealthStatus: (_systemId, loader) => loader(),
29
+ invalidateSystem: async () => {},
30
+ invalidateAllSystems: async () => 0,
31
+ scope: {} as HealthCheckCache["scope"],
32
+ };
33
+
34
+ const mockUser = {
35
+ type: "user" as const,
36
+ id: "test-user",
37
+ accessRules: ["*"],
38
+ roles: ["admin"],
39
+ };
40
+
41
+ // A strategy config with one plain field and one x-secret field.
42
+ const strategySchema = z.object({
43
+ url: z.string(),
44
+ password: configSecret({ id: "password" }).optional(),
45
+ });
46
+
47
+ function fakeInternalSecrets(): InternalSecretsService & {
48
+ store: Map<string, string>;
49
+ } {
50
+ const store = new Map<string, string>();
51
+ return {
52
+ store,
53
+ async set({ parts, value }) {
54
+ store.set(internalSecretName(...parts), value);
55
+ },
56
+ async get({ parts }) {
57
+ return store.get(internalSecretName(...parts));
58
+ },
59
+ async delete({ parts }) {
60
+ store.delete(internalSecretName(...parts));
61
+ },
62
+ };
63
+ }
64
+
65
+ const fakeResolver = {
66
+ resolveForRun: mock(async () => ({ env: {}, masking: undefined })),
67
+ } as unknown as SecretResolverService;
68
+
69
+ interface CapturedInsert {
70
+ values: Record<string, unknown>;
71
+ }
72
+
73
+ function createCapturingDb(captured: CapturedInsert[]) {
74
+ const insert = () => ({
75
+ values: (values: Record<string, unknown>) => {
76
+ captured.push({ values });
77
+ return Object.assign(Promise.resolve(undefined), {
78
+ returning: () =>
79
+ Promise.resolve([
80
+ {
81
+ paused: false,
82
+ createdAt: new Date(),
83
+ updatedAt: new Date(),
84
+ collectors: null,
85
+ ...values,
86
+ },
87
+ ]),
88
+ });
89
+ },
90
+ });
91
+ const emptyWhere = Object.assign(Promise.resolve([]), {
92
+ where: () => Promise.resolve([]),
93
+ });
94
+ return {
95
+ insert,
96
+ select: () => ({ from: () => emptyWhere }),
97
+ transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
98
+ fn({ insert }),
99
+ };
100
+ }
101
+
102
+ function buildRouter(captured: CapturedInsert[]) {
103
+ const internalSecrets = fakeInternalSecrets();
104
+ const strategy = { config: new Versioned({ version: 1, schema: strategySchema }) };
105
+ const router = createHealthCheckRouter({
106
+ database: createCapturingDb(captured) as never,
107
+ registry: { getStrategy: mock(() => strategy) } as never,
108
+ collectorRegistry: { getCollector: mock(() => undefined) } as never,
109
+ gitOpsClient: { getProvenance: mock(() => Promise.resolve(null)) } as never,
110
+ getEmitHook: () => undefined,
111
+ cache: passthroughCache,
112
+ configService: { get: mock(async () => undefined), set: mock(async () => {}) } as never,
113
+ catalogClient: { getSystem: mock(async () => null) } as never,
114
+ maintenanceClient: {
115
+ hasActiveMaintenance: mock(async () => ({ active: false })),
116
+ } as never,
117
+ logger: {
118
+ debug: mock(() => {}),
119
+ info: mock(() => {}),
120
+ warn: mock(() => {}),
121
+ error: mock(() => {}),
122
+ } as never,
123
+ configSecrets: { internalSecrets, secretResolver: fakeResolver },
124
+ });
125
+ return { router, internalSecrets };
126
+ }
127
+
128
+ describe("createAndAssign secret extraction (SEC-1 regression)", () => {
129
+ it("extracts an inline secret into the internal store and never persists or returns it", async () => {
130
+ const captured: CapturedInsert[] = [];
131
+ const { router, internalSecrets } = buildRouter(captured);
132
+ const context = createMockRpcContext({ user: mockUser });
133
+
134
+ const result = await call(
135
+ router.createAndAssign,
136
+ {
137
+ systemId: "sys-1",
138
+ configuration: {
139
+ name: "DB check",
140
+ strategyId: "healthcheck-http.http",
141
+ config: { url: "https://x", password: "super-secret" },
142
+ intervalSeconds: 60,
143
+ },
144
+ enabled: false,
145
+ includeLocal: true,
146
+ environmentIds: null,
147
+ },
148
+ { context },
149
+ );
150
+
151
+ // The persisted config row holds a MARKER, never the plaintext.
152
+ const configInsert = captured.find((c) => c.values.name === "DB check");
153
+ expect(configInsert).toBeDefined();
154
+ const storedConfig = configInsert?.values.config as Record<string, unknown>;
155
+ expect(isHealthcheckSecretMarker(storedConfig.password as string)).toBe(true);
156
+ expect(JSON.stringify(captured)).not.toContain("super-secret");
157
+
158
+ // The plaintext lives ONLY in the internal (encrypted) store.
159
+ expect([...internalSecrets.store.values()]).toContain("super-secret");
160
+
161
+ // The response is redacted - the secret field is absent.
162
+ expect(result.config.password).toBeUndefined();
163
+ expect(result.config.url).toBe("https://x");
164
+ });
165
+ });
@@ -184,7 +184,11 @@ describe("createAndAssign router handler", () => {
184
184
  expect(assignmentInsert?.values.environmentIds).toBeNull();
185
185
  // Returns the created configuration.
186
186
  expect(result.name).toBe("Payments API root");
187
- expect(result.id).toBe("cfg-new");
187
+ // The id is generated up front (SEC-1: needed to key extracted secrets
188
+ // before insert), persisted on the config row, and used to link the
189
+ // assignment - so all three must agree.
190
+ expect(configInsert?.values.id).toBe(result.id);
191
+ expect(assignmentInsert?.values.configurationId).toBe(result.id);
188
192
  });
189
193
 
190
194
  it("broadcasts healthcheck.config.changed so open clients refresh", async () => {
package/src/router.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  resolveScriptPackagesDir,
21
21
  } from "@checkstack/script-packages-backend";
22
22
  import { HealthCheckService } from "./service";
23
+ import type { HealthCheckSecretsDeps } from "./config-secrets";
23
24
  import {
24
25
  canReadRunScope,
25
26
  hasGlobalHistoryAccess,
@@ -74,6 +75,12 @@ export const createHealthCheckRouter = (opts: {
74
75
  * run / the SLO self-heal converge the rollup lazily.
75
76
  */
76
77
  recomputeSystemRollupHealth?: (systemId: string) => Promise<void>;
78
+ /**
79
+ * Secrets channel for config credentials (extract-on-write, redact-on-read,
80
+ * blank-keeps-existing on update). Optional only for tests; the real
81
+ * router MUST receive it or writes would store inline secrets verbatim.
82
+ */
83
+ configSecrets?: HealthCheckSecretsDeps;
77
84
  }) => {
78
85
  const {
79
86
  database,
@@ -95,6 +102,7 @@ export const createHealthCheckRouter = (opts: {
95
102
  collectorRegistry,
96
103
  configService,
97
104
  catalogClient,
105
+ opts.configSecrets,
98
106
  );
99
107
 
100
108
  // Create contract implementer with context type AND auto auth middleware
@@ -252,12 +260,15 @@ export const createHealthCheckRouter = (opts: {
252
260
  return runCollectorScriptTest({ input, deps: { resolutionRoot } });
253
261
  }),
254
262
 
263
+ // UI/AI reads are ALWAYS redacted: `x-secret` fields (values, references,
264
+ // internal markers alike) are stripped server-side. The editor renders a
265
+ // blank secret input and blank-on-save means "keep existing".
255
266
  getConfigurations: os.getConfigurations.handler(async () => {
256
- return { configurations: await service.getConfigurations() };
267
+ return { configurations: await service.getConfigurationsRedacted() };
257
268
  }),
258
269
 
259
270
  getConfiguration: os.getConfiguration.handler(async ({ input }) => {
260
- return service.getConfiguration(input.id);
271
+ return service.getConfigurationRedacted(input.id);
261
272
  }),
262
273
 
263
274
  createConfiguration: os.createConfiguration.handler(async ({ input }) => {
@@ -271,7 +282,8 @@ export const createHealthCheckRouter = (opts: {
271
282
  action: "created",
272
283
  configurationId: created.id,
273
284
  });
274
- return created;
285
+ // The response goes back to the editor: keep it redacted like reads.
286
+ return service.redactConfiguration(created);
275
287
  }),
276
288
 
277
289
  validateConfiguration: os.validateConfiguration.handler(
@@ -283,8 +295,24 @@ export const createHealthCheckRouter = (opts: {
283
295
  // `z.record(z.unknown())` on the input) is validated against each
284
296
  // registered schema, surfacing wrong types, missing required fields,
285
297
  // and unknown keys - not just missing-field presence.
298
+ //
299
+ // For an UPDATE (existingConfigurationId set), restore the stored
300
+ // config's secrets into the proposed body first: reads are redacted,
301
+ // so a kept secret arrives blank/absent and would otherwise fail a
302
+ // required-secret check even though the apply path would preserve it.
303
+ // The restored values are used ONLY to validate and never returned.
304
+ let toValidate = input;
305
+ if (input.existingConfigurationId) {
306
+ const restored = await service.restoreSecretsForValidation({
307
+ existingConfigurationId: input.existingConfigurationId,
308
+ strategyId: input.strategyId,
309
+ config: input.config,
310
+ collectors: input.collectors,
311
+ });
312
+ toValidate = { ...input, ...restored };
313
+ }
286
314
  const errors = await collectConfigurationIssues({
287
- input,
315
+ input: toValidate,
288
316
  registry: context.healthCheckRegistry,
289
317
  collectorRegistry: context.collectorRegistry,
290
318
  });
@@ -307,7 +335,8 @@ export const createHealthCheckRouter = (opts: {
307
335
  action: "updated",
308
336
  configurationId: config.id,
309
337
  });
310
- return config;
338
+ // The response goes back to the editor: keep it redacted like reads.
339
+ return service.redactConfiguration(config);
311
340
  }),
312
341
 
313
342
  deleteConfiguration: os.deleteConfiguration.handler(async ({ input }) => {
@@ -381,7 +410,8 @@ export const createHealthCheckRouter = (opts: {
381
410
 
382
411
  getSystemConfigurations: os.getSystemConfigurations.handler(
383
412
  async ({ input }) => {
384
- return service.getSystemConfigurations(input.systemId);
413
+ // Redacted like every other UI config read - x-secret fields stripped.
414
+ return service.getSystemConfigurationsRedacted(input.systemId);
385
415
  },
386
416
  ),
387
417
 
@@ -431,7 +461,9 @@ export const createHealthCheckRouter = (opts: {
431
461
  enabled: input.enabled,
432
462
  queueManager: context.queueManager,
433
463
  });
434
- return configuration;
464
+ // The response goes back to the caller (wizard / AI tool): redact it
465
+ // like every other config read/write response.
466
+ return service.redactConfiguration(configuration);
435
467
  }),
436
468
 
437
469
  disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {