@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.
- package/CHANGELOG.md +171 -0
- package/package.json +29 -29
- package/src/aggregation-utils.test.ts +132 -0
- package/src/aggregation-utils.ts +70 -6
- package/src/ai/healthcheck-propose.ts +9 -0
- package/src/ai/healthcheck-update.ts +5 -1
- package/src/collector-assertions.test.ts +97 -1
- package/src/collector-assertions.ts +141 -34
- package/src/config-secrets-backfill.test.ts +142 -0
- package/src/config-secrets-backfill.ts +129 -0
- package/src/config-secrets.test.ts +771 -0
- package/src/config-secrets.ts +361 -0
- package/src/healthcheck-gitops-kinds.ts +42 -31
- package/src/index.ts +47 -1
- package/src/queue-executor.test.ts +182 -0
- package/src/queue-executor.ts +64 -6
- package/src/realtime-aggregation.test.ts +136 -0
- package/src/realtime-aggregation.ts +39 -2
- package/src/retention-job.ts +64 -1
- package/src/retention-rollup.test.ts +69 -0
- package/src/router-config-secrets.test.ts +165 -0
- package/src/router-create-and-assign.test.ts +5 -1
- package/src/router.ts +39 -7
- package/src/service-config-secrets.test.ts +680 -0
- package/src/service-ingest-assertions.test.ts +213 -0
- package/src/service.ts +529 -56
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configSecret } from "@checkstack/backend-api";
|
|
4
|
+
import { SECRET_CLEAR_SENTINEL } from "@checkstack/common";
|
|
5
|
+
import {
|
|
6
|
+
createMaskingContext,
|
|
7
|
+
type InternalSecretsService,
|
|
8
|
+
type SecretResolverService,
|
|
9
|
+
} from "@checkstack/secrets-backend";
|
|
10
|
+
import { internalSecretName } from "@checkstack/secrets-common";
|
|
11
|
+
import {
|
|
12
|
+
extractConfigurationSecrets,
|
|
13
|
+
inflateConfigSecrets,
|
|
14
|
+
redactSecretFields,
|
|
15
|
+
mergeSecretFields,
|
|
16
|
+
mergeConfigurationSecrets,
|
|
17
|
+
deleteConfigurationSecrets,
|
|
18
|
+
pruneOrphanedConfigurationSecrets,
|
|
19
|
+
listPopulatedSecretKeys,
|
|
20
|
+
healthcheckSecretParts,
|
|
21
|
+
healthcheckSecretMarker,
|
|
22
|
+
isHealthcheckSecretMarker,
|
|
23
|
+
} from "./config-secrets";
|
|
24
|
+
|
|
25
|
+
/** Map-backed fake of the internal secrets store. */
|
|
26
|
+
function fakeInternalSecrets(): InternalSecretsService & {
|
|
27
|
+
store: Map<string, string>;
|
|
28
|
+
} {
|
|
29
|
+
const store = new Map<string, string>();
|
|
30
|
+
return {
|
|
31
|
+
store,
|
|
32
|
+
async set({ parts, value }) {
|
|
33
|
+
store.set(internalSecretName(...parts), value);
|
|
34
|
+
},
|
|
35
|
+
async get({ parts }) {
|
|
36
|
+
return store.get(internalSecretName(...parts));
|
|
37
|
+
},
|
|
38
|
+
async delete({ parts }) {
|
|
39
|
+
store.delete(internalSecretName(...parts));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolver fake: `${{ secrets.NAME }}` resolves to `resolved:NAME`. */
|
|
45
|
+
const fakeResolver: Pick<SecretResolverService, "resolveForRun"> = {
|
|
46
|
+
async resolveForRun({ secretEnv }) {
|
|
47
|
+
const env: Record<string, string> = {};
|
|
48
|
+
for (const [key, template] of Object.entries(secretEnv)) {
|
|
49
|
+
const name = /\$\{\{\s*secrets\.([A-Za-z0-9_-]+)\s*\}\}/.exec(
|
|
50
|
+
template,
|
|
51
|
+
)?.[1];
|
|
52
|
+
if (!name) throw new Error(`Unresolvable template: ${template}`);
|
|
53
|
+
env[key] = `resolved:${name}`;
|
|
54
|
+
}
|
|
55
|
+
return { env, masking: createMaskingContext({ values: [] }) };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** HTTP-auth-like strategy schema: two secrets, one plain field. */
|
|
60
|
+
const strategySchema = z.object({
|
|
61
|
+
timeout: z.number().optional(),
|
|
62
|
+
authUsername: z.string().optional(),
|
|
63
|
+
authPassword: configSecret({ id: "authPassword" }).optional(),
|
|
64
|
+
authToken: configSecret({ id: "authToken" }).optional(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const collectorSchema = z.object({
|
|
68
|
+
url: z.string(),
|
|
69
|
+
apiKey: configSecret({ id: "apiKey" }).optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const CONFIG_ID = "cfg-1";
|
|
73
|
+
|
|
74
|
+
describe("extractConfigurationSecrets", () => {
|
|
75
|
+
it("moves inline secrets to internal secrets and stores markers", async () => {
|
|
76
|
+
const internalSecrets = fakeInternalSecrets();
|
|
77
|
+
const result = await extractConfigurationSecrets({
|
|
78
|
+
configurationId: CONFIG_ID,
|
|
79
|
+
strategySchema,
|
|
80
|
+
config: { timeout: 5000, authUsername: "alice", authPassword: "s3cret" },
|
|
81
|
+
collectors: [
|
|
82
|
+
{
|
|
83
|
+
id: "entry-1",
|
|
84
|
+
collectorId: "http.request",
|
|
85
|
+
config: { url: "https://x", apiKey: "collector-key" },
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
getCollectorSchema: () => collectorSchema,
|
|
89
|
+
internalSecrets,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Strategy: password extracted, username untouched.
|
|
93
|
+
expect(result.config.authPassword).toBe(
|
|
94
|
+
healthcheckSecretMarker("authPassword"),
|
|
95
|
+
);
|
|
96
|
+
expect(result.config.authUsername).toBe("alice");
|
|
97
|
+
expect(result.extracted).toBe(2);
|
|
98
|
+
|
|
99
|
+
// Collector: apiKey extracted.
|
|
100
|
+
expect(result.collectors?.[0].config.apiKey).toBe(
|
|
101
|
+
healthcheckSecretMarker("apiKey"),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Values live ONLY in the internal store.
|
|
105
|
+
expect([...internalSecrets.store.values()]).toEqual(
|
|
106
|
+
expect.arrayContaining(["s3cret", "collector-key"]),
|
|
107
|
+
);
|
|
108
|
+
expect(JSON.stringify(result)).not.toContain("s3cret");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is idempotent: markers and references extract nothing", async () => {
|
|
112
|
+
const internalSecrets = fakeInternalSecrets();
|
|
113
|
+
const config = {
|
|
114
|
+
authPassword: healthcheckSecretMarker("authPassword"),
|
|
115
|
+
authToken: "${{ secrets.MY_TOKEN }}",
|
|
116
|
+
};
|
|
117
|
+
const result = await extractConfigurationSecrets({
|
|
118
|
+
configurationId: CONFIG_ID,
|
|
119
|
+
strategySchema,
|
|
120
|
+
config,
|
|
121
|
+
collectors: undefined,
|
|
122
|
+
getCollectorSchema: () => undefined,
|
|
123
|
+
internalSecrets,
|
|
124
|
+
});
|
|
125
|
+
expect(result.extracted).toBe(0);
|
|
126
|
+
expect(result.config).toEqual(config);
|
|
127
|
+
expect(internalSecrets.store.size).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("leaves empty strings alone (nothing to protect)", async () => {
|
|
131
|
+
const internalSecrets = fakeInternalSecrets();
|
|
132
|
+
const result = await extractConfigurationSecrets({
|
|
133
|
+
configurationId: CONFIG_ID,
|
|
134
|
+
strategySchema,
|
|
135
|
+
config: { authPassword: "" },
|
|
136
|
+
collectors: undefined,
|
|
137
|
+
getCollectorSchema: () => undefined,
|
|
138
|
+
internalSecrets,
|
|
139
|
+
});
|
|
140
|
+
expect(result.extracted).toBe(0);
|
|
141
|
+
expect(result.config.authPassword).toBe("");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("neutralizes a FORGED marker pointing at another field's secret", async () => {
|
|
145
|
+
const internalSecrets = fakeInternalSecrets();
|
|
146
|
+
// A privileged operator set authPassword; its secret lives in the store.
|
|
147
|
+
await internalSecrets.set({
|
|
148
|
+
parts: ["healthcheck", CONFIG_ID, "strategy", "authPassword"],
|
|
149
|
+
value: "admin-password",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// A lower-privileged editor types the literal marker for authPassword into
|
|
153
|
+
// the authToken field, hoping it inflates to the admin's password.
|
|
154
|
+
const forged = healthcheckSecretMarker("authPassword");
|
|
155
|
+
const result = await extractConfigurationSecrets({
|
|
156
|
+
configurationId: CONFIG_ID,
|
|
157
|
+
strategySchema,
|
|
158
|
+
config: {
|
|
159
|
+
authPassword: healthcheckSecretMarker("authPassword"), // own marker: kept
|
|
160
|
+
authToken: forged, // forged marker in a DIFFERENT field: must be extracted
|
|
161
|
+
},
|
|
162
|
+
collectors: undefined,
|
|
163
|
+
getCollectorSchema: () => undefined,
|
|
164
|
+
internalSecrets,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The forged value is extracted as a LITERAL into authToken's own slot,
|
|
168
|
+
// and the field becomes authToken's own marker - not a pass-through.
|
|
169
|
+
expect(result.config.authToken).toBe(healthcheckSecretMarker("authToken"));
|
|
170
|
+
expect(
|
|
171
|
+
internalSecrets.store.get(
|
|
172
|
+
internalSecretName("healthcheck", CONFIG_ID, "strategy", "authToken"),
|
|
173
|
+
),
|
|
174
|
+
).toBe(forged);
|
|
175
|
+
// authPassword's own marker passed through untouched; its secret intact.
|
|
176
|
+
expect(result.config.authPassword).toBe(
|
|
177
|
+
healthcheckSecretMarker("authPassword"),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// At inflate, authToken resolves to its OWN slot (the literal marker
|
|
181
|
+
// string), NEVER the admin password.
|
|
182
|
+
const { config } = await inflateConfigSecrets({
|
|
183
|
+
configurationId: CONFIG_ID,
|
|
184
|
+
scope: { kind: "strategy" },
|
|
185
|
+
schema: strategySchema,
|
|
186
|
+
config: result.config,
|
|
187
|
+
deps: { internalSecrets, secretResolver: fakeResolver },
|
|
188
|
+
});
|
|
189
|
+
expect(config.authToken).toBe(forged);
|
|
190
|
+
expect(config.authToken).not.toBe("admin-password");
|
|
191
|
+
expect(config.authPassword).toBe("admin-password");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("a stored forged marker still cannot read another field's slot (inflate keys by own path)", async () => {
|
|
195
|
+
const internalSecrets = fakeInternalSecrets();
|
|
196
|
+
await internalSecrets.set({
|
|
197
|
+
parts: ["healthcheck", CONFIG_ID, "strategy", "authPassword"],
|
|
198
|
+
value: "admin-password",
|
|
199
|
+
});
|
|
200
|
+
// Simulate a forged marker that somehow reached storage in authToken.
|
|
201
|
+
await expect(
|
|
202
|
+
inflateConfigSecrets({
|
|
203
|
+
configurationId: CONFIG_ID,
|
|
204
|
+
scope: { kind: "strategy" },
|
|
205
|
+
schema: strategySchema,
|
|
206
|
+
config: { authToken: healthcheckSecretMarker("authPassword") },
|
|
207
|
+
deps: { internalSecrets, secretResolver: fakeResolver },
|
|
208
|
+
}),
|
|
209
|
+
// authToken has no own-slot secret, so it fails closed rather than
|
|
210
|
+
// leaking authPassword.
|
|
211
|
+
).rejects.toThrow(/Internal secret for "authToken" not found/);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("inflateConfigSecrets", () => {
|
|
216
|
+
it("resolves markers from the internal store and references via the resolver", async () => {
|
|
217
|
+
const internalSecrets = fakeInternalSecrets();
|
|
218
|
+
await internalSecrets.set({
|
|
219
|
+
parts: ["healthcheck", CONFIG_ID, "strategy", "authPassword"],
|
|
220
|
+
value: "s3cret",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const { config, values } = await inflateConfigSecrets({
|
|
224
|
+
configurationId: CONFIG_ID,
|
|
225
|
+
scope: { kind: "strategy" },
|
|
226
|
+
schema: strategySchema,
|
|
227
|
+
config: {
|
|
228
|
+
authPassword: healthcheckSecretMarker("authPassword"),
|
|
229
|
+
authToken: "${{ secrets.MY_TOKEN }}",
|
|
230
|
+
},
|
|
231
|
+
deps: { internalSecrets, secretResolver: fakeResolver },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(config.authPassword).toBe("s3cret");
|
|
235
|
+
expect(config.authToken).toBe("resolved:MY_TOKEN");
|
|
236
|
+
expect(values).toEqual(expect.arrayContaining(["s3cret", "resolved:MY_TOKEN"]));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("passes a legacy bare literal through unchanged", async () => {
|
|
240
|
+
const { config } = await inflateConfigSecrets({
|
|
241
|
+
configurationId: CONFIG_ID,
|
|
242
|
+
scope: { kind: "strategy" },
|
|
243
|
+
schema: strategySchema,
|
|
244
|
+
config: { authPassword: "legacy-plaintext" },
|
|
245
|
+
deps: { internalSecrets: fakeInternalSecrets(), secretResolver: fakeResolver },
|
|
246
|
+
});
|
|
247
|
+
expect(config.authPassword).toBe("legacy-plaintext");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("fails closed on a marker whose internal secret is missing", async () => {
|
|
251
|
+
await expect(
|
|
252
|
+
inflateConfigSecrets({
|
|
253
|
+
configurationId: CONFIG_ID,
|
|
254
|
+
scope: { kind: "strategy" },
|
|
255
|
+
schema: strategySchema,
|
|
256
|
+
config: { authPassword: healthcheckSecretMarker("authPassword") },
|
|
257
|
+
deps: { internalSecrets: fakeInternalSecrets(), secretResolver: fakeResolver },
|
|
258
|
+
}),
|
|
259
|
+
).rejects.toThrow(/Internal secret .* not found/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("scopes collector secrets by entry id", async () => {
|
|
263
|
+
const internalSecrets = fakeInternalSecrets();
|
|
264
|
+
await internalSecrets.set({
|
|
265
|
+
parts: ["healthcheck", CONFIG_ID, "collector", "entry-1", "apiKey"],
|
|
266
|
+
value: "collector-key",
|
|
267
|
+
});
|
|
268
|
+
const { config } = await inflateConfigSecrets({
|
|
269
|
+
configurationId: CONFIG_ID,
|
|
270
|
+
scope: { kind: "collector", entryId: "entry-1" },
|
|
271
|
+
schema: collectorSchema,
|
|
272
|
+
config: { url: "https://x", apiKey: healthcheckSecretMarker("apiKey") },
|
|
273
|
+
deps: { internalSecrets, secretResolver: fakeResolver },
|
|
274
|
+
});
|
|
275
|
+
expect(config.apiKey).toBe("collector-key");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("redactSecretFields", () => {
|
|
280
|
+
it("strips inline secret markers but KEEPS references verbatim (UX-8)", () => {
|
|
281
|
+
const redacted = redactSecretFields({
|
|
282
|
+
schema: strategySchema,
|
|
283
|
+
config: {
|
|
284
|
+
timeout: 5000,
|
|
285
|
+
authUsername: "alice",
|
|
286
|
+
authPassword: healthcheckSecretMarker("authPassword"),
|
|
287
|
+
authToken: "${{ secrets.MY_TOKEN }}",
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
// The extracted-inline marker is stripped; the `${{ secrets.* }}` reference
|
|
291
|
+
// is a pointer (not a value) and stays so the editor shows the wiring.
|
|
292
|
+
expect(redacted).toEqual({
|
|
293
|
+
timeout: 5000,
|
|
294
|
+
authUsername: "alice",
|
|
295
|
+
authToken: "${{ secrets.MY_TOKEN }}",
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("recurses into arrays of objects", () => {
|
|
300
|
+
const schema = z.object({
|
|
301
|
+
targets: z.array(
|
|
302
|
+
z.object({
|
|
303
|
+
host: z.string(),
|
|
304
|
+
password: configSecret({ id: "password" }),
|
|
305
|
+
}),
|
|
306
|
+
),
|
|
307
|
+
});
|
|
308
|
+
const redacted = redactSecretFields({
|
|
309
|
+
schema,
|
|
310
|
+
config: {
|
|
311
|
+
targets: [
|
|
312
|
+
{ host: "a", password: "p1" },
|
|
313
|
+
{ host: "b", password: "p2" },
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
expect(redacted).toEqual({ targets: [{ host: "a" }, { host: "b" }] });
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("mergeSecretFields", () => {
|
|
322
|
+
const stored = {
|
|
323
|
+
authUsername: "alice",
|
|
324
|
+
authPassword: healthcheckSecretMarker("authPassword"),
|
|
325
|
+
authToken: "${{ secrets.MY_TOKEN }}",
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
it("restores stored secrets when incoming is blank or absent", () => {
|
|
329
|
+
const merged = mergeSecretFields({
|
|
330
|
+
schema: strategySchema,
|
|
331
|
+
incoming: { authUsername: "alice", authPassword: "" },
|
|
332
|
+
stored,
|
|
333
|
+
});
|
|
334
|
+
// Blank -> restored marker; absent -> restored reference.
|
|
335
|
+
expect(merged.authPassword).toBe(healthcheckSecretMarker("authPassword"));
|
|
336
|
+
expect(merged.authToken).toBe("${{ secrets.MY_TOKEN }}");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("lets a newly typed secret win over the stored one", () => {
|
|
340
|
+
const merged = mergeSecretFields({
|
|
341
|
+
schema: strategySchema,
|
|
342
|
+
incoming: { authPassword: "brand-new" },
|
|
343
|
+
stored,
|
|
344
|
+
});
|
|
345
|
+
expect(merged.authPassword).toBe("brand-new");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("keeps incoming untouched when nothing is stored (create-like)", () => {
|
|
349
|
+
const merged = mergeSecretFields({
|
|
350
|
+
schema: strategySchema,
|
|
351
|
+
incoming: { authUsername: "alice", authPassword: "" },
|
|
352
|
+
stored: undefined,
|
|
353
|
+
});
|
|
354
|
+
expect(merged.authPassword).toBe("");
|
|
355
|
+
expect(merged.authToken).toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("removes the field on the CLEAR sentinel instead of keeping stored (UX-6)", () => {
|
|
359
|
+
const merged = mergeSecretFields({
|
|
360
|
+
schema: strategySchema,
|
|
361
|
+
incoming: { authUsername: "alice", authPassword: SECRET_CLEAR_SENTINEL },
|
|
362
|
+
stored,
|
|
363
|
+
});
|
|
364
|
+
// Explicit clear wins over keep-existing: the field resolves to undefined,
|
|
365
|
+
// so nothing is persisted (JSONB drops it) and no marker survives for the
|
|
366
|
+
// run path to inflate.
|
|
367
|
+
expect(merged.authPassword).toBeUndefined();
|
|
368
|
+
// Untouched stored secrets are still kept.
|
|
369
|
+
expect(merged.authToken).toBe("${{ secrets.MY_TOKEN }}");
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("mergeConfigurationSecrets", () => {
|
|
374
|
+
it("pairs collector entries by id and skips brand-new entries", () => {
|
|
375
|
+
const { collectors } = mergeConfigurationSecrets({
|
|
376
|
+
strategySchema,
|
|
377
|
+
incomingConfig: {},
|
|
378
|
+
storedConfig: {},
|
|
379
|
+
incomingCollectors: [
|
|
380
|
+
{
|
|
381
|
+
id: "entry-1",
|
|
382
|
+
collectorId: "http.request",
|
|
383
|
+
config: { url: "https://x", apiKey: "" },
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
id: "entry-2",
|
|
387
|
+
collectorId: "http.request",
|
|
388
|
+
config: { url: "https://y", apiKey: "" },
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
storedCollectors: [
|
|
392
|
+
{
|
|
393
|
+
id: "entry-1",
|
|
394
|
+
collectorId: "http.request",
|
|
395
|
+
config: { url: "https://x", apiKey: healthcheckSecretMarker("apiKey") },
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
getCollectorSchema: () => collectorSchema,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(collectors?.[0].config.apiKey).toBe(
|
|
402
|
+
healthcheckSecretMarker("apiKey"),
|
|
403
|
+
);
|
|
404
|
+
// entry-2 is new: nothing stored to restore.
|
|
405
|
+
expect(collectors?.[1].config.apiKey).toBe("");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe("deleteConfigurationSecrets", () => {
|
|
410
|
+
it("deletes exactly the internal secrets the stored markers point at", async () => {
|
|
411
|
+
const internalSecrets = fakeInternalSecrets();
|
|
412
|
+
await internalSecrets.set({
|
|
413
|
+
parts: ["healthcheck", CONFIG_ID, "strategy", "authPassword"],
|
|
414
|
+
value: "s3cret",
|
|
415
|
+
});
|
|
416
|
+
await internalSecrets.set({
|
|
417
|
+
parts: ["healthcheck", CONFIG_ID, "collector", "entry-1", "apiKey"],
|
|
418
|
+
value: "collector-key",
|
|
419
|
+
});
|
|
420
|
+
await internalSecrets.set({
|
|
421
|
+
parts: ["healthcheck", "other-config", "strategy", "authPassword"],
|
|
422
|
+
value: "unrelated",
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await deleteConfigurationSecrets({
|
|
426
|
+
configurationId: CONFIG_ID,
|
|
427
|
+
config: { authPassword: healthcheckSecretMarker("authPassword") },
|
|
428
|
+
collectors: [
|
|
429
|
+
{
|
|
430
|
+
id: "entry-1",
|
|
431
|
+
collectorId: "http.request",
|
|
432
|
+
config: { apiKey: healthcheckSecretMarker("apiKey"), url: "https://x" },
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
internalSecrets,
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(internalSecrets.store.size).toBe(1);
|
|
439
|
+
expect([...internalSecrets.store.values()]).toEqual(["unrelated"]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("deletes secrets even when the strategy AND collector plugins are UNINSTALLED", async () => {
|
|
443
|
+
// Schema-free enumeration: a deleted check must not orphan secrets just
|
|
444
|
+
// because its plugins are no longer loaded.
|
|
445
|
+
const internalSecrets = fakeInternalSecrets();
|
|
446
|
+
await internalSecrets.set({
|
|
447
|
+
parts: ["healthcheck", CONFIG_ID, "strategy", "authPassword"],
|
|
448
|
+
value: "s3cret",
|
|
449
|
+
});
|
|
450
|
+
await internalSecrets.set({
|
|
451
|
+
parts: ["healthcheck", CONFIG_ID, "collector", "entry-1", "apiKey"],
|
|
452
|
+
value: "collector-key",
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await deleteConfigurationSecrets({
|
|
456
|
+
configurationId: CONFIG_ID,
|
|
457
|
+
config: { authPassword: healthcheckSecretMarker("authPassword") },
|
|
458
|
+
collectors: [
|
|
459
|
+
{
|
|
460
|
+
id: "entry-1",
|
|
461
|
+
collectorId: "gone.collector",
|
|
462
|
+
config: { apiKey: healthcheckSecretMarker("apiKey") },
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
internalSecrets,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
expect(internalSecrets.store.size).toBe(0);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("pruneOrphanedConfigurationSecrets", () => {
|
|
473
|
+
/** Seed the internal store with the strategy secrets used below. */
|
|
474
|
+
function seed() {
|
|
475
|
+
const internalSecrets = fakeInternalSecrets();
|
|
476
|
+
return internalSecrets;
|
|
477
|
+
}
|
|
478
|
+
const passwordParts = healthcheckSecretParts({
|
|
479
|
+
configurationId: CONFIG_ID,
|
|
480
|
+
scope: { kind: "strategy" },
|
|
481
|
+
secretId: "authPassword",
|
|
482
|
+
});
|
|
483
|
+
const tokenParts = healthcheckSecretParts({
|
|
484
|
+
configurationId: CONFIG_ID,
|
|
485
|
+
scope: { kind: "strategy" },
|
|
486
|
+
secretId: "authToken",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("deletes a secret orphaned by a CLEARED/removed field (UX-6 / GitOps)", async () => {
|
|
490
|
+
const internalSecrets = seed();
|
|
491
|
+
await internalSecrets.set({ parts: passwordParts, value: "old-pw" });
|
|
492
|
+
await internalSecrets.set({ parts: tokenParts, value: "keep-token" });
|
|
493
|
+
|
|
494
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
495
|
+
configurationId: CONFIG_ID,
|
|
496
|
+
// authPassword marker dropped; authToken marker retained.
|
|
497
|
+
oldConfig: {
|
|
498
|
+
authPassword: healthcheckSecretMarker("authPassword"),
|
|
499
|
+
authToken: healthcheckSecretMarker("authToken"),
|
|
500
|
+
},
|
|
501
|
+
newConfig: { authToken: healthcheckSecretMarker("authToken") },
|
|
502
|
+
oldCollectors: undefined,
|
|
503
|
+
newCollectors: undefined,
|
|
504
|
+
internalSecrets,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(deleted).toBe(1);
|
|
508
|
+
expect(await internalSecrets.get({ parts: passwordParts })).toBeUndefined();
|
|
509
|
+
expect(await internalSecrets.get({ parts: tokenParts })).toBe("keep-token");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("deletes the old inline secret when a field is swapped to a reference", async () => {
|
|
513
|
+
const internalSecrets = seed();
|
|
514
|
+
await internalSecrets.set({ parts: tokenParts, value: "old-inline" });
|
|
515
|
+
|
|
516
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
517
|
+
configurationId: CONFIG_ID,
|
|
518
|
+
oldConfig: { authToken: healthcheckSecretMarker("authToken") },
|
|
519
|
+
newConfig: { authToken: "${{ secrets.MY_TOKEN }}" },
|
|
520
|
+
oldCollectors: undefined,
|
|
521
|
+
newCollectors: undefined,
|
|
522
|
+
internalSecrets,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(deleted).toBe(1);
|
|
526
|
+
expect(await internalSecrets.get({ parts: tokenParts })).toBeUndefined();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("keeps a secret that is still referenced (kept / re-typed)", async () => {
|
|
530
|
+
const internalSecrets = seed();
|
|
531
|
+
await internalSecrets.set({ parts: passwordParts, value: "still-here" });
|
|
532
|
+
|
|
533
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
534
|
+
configurationId: CONFIG_ID,
|
|
535
|
+
oldConfig: { authPassword: healthcheckSecretMarker("authPassword") },
|
|
536
|
+
newConfig: { authPassword: healthcheckSecretMarker("authPassword") },
|
|
537
|
+
oldCollectors: undefined,
|
|
538
|
+
newCollectors: undefined,
|
|
539
|
+
internalSecrets,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(deleted).toBe(0);
|
|
543
|
+
expect(await internalSecrets.get({ parts: passwordParts })).toBe("still-here");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("deletes secrets of a REMOVED collector entry", async () => {
|
|
547
|
+
const internalSecrets = seed();
|
|
548
|
+
const entryParts = healthcheckSecretParts({
|
|
549
|
+
configurationId: CONFIG_ID,
|
|
550
|
+
scope: { kind: "collector", entryId: "entry-1" },
|
|
551
|
+
secretId: "apiKey",
|
|
552
|
+
});
|
|
553
|
+
await internalSecrets.set({ parts: entryParts, value: "collector-secret" });
|
|
554
|
+
|
|
555
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
556
|
+
configurationId: CONFIG_ID,
|
|
557
|
+
oldConfig: {},
|
|
558
|
+
newConfig: {},
|
|
559
|
+
oldCollectors: [
|
|
560
|
+
{
|
|
561
|
+
id: "entry-1",
|
|
562
|
+
collectorId: "http.request",
|
|
563
|
+
config: { apiKey: healthcheckSecretMarker("apiKey"), url: "https://x" },
|
|
564
|
+
},
|
|
565
|
+
],
|
|
566
|
+
newCollectors: [],
|
|
567
|
+
internalSecrets,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
expect(deleted).toBe(1);
|
|
571
|
+
expect(await internalSecrets.get({ parts: entryParts })).toBeUndefined();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("KEEPS a strategy marker preserved verbatim when the new strategy is unregistered", async () => {
|
|
575
|
+
// Round-2 finding #3: switching to an unregistered strategy preserves the
|
|
576
|
+
// old markers in the row (no schema to enumerate them on the new side). The
|
|
577
|
+
// literal-presence check must recognize the retained marker and NOT delete
|
|
578
|
+
// its still-referenced secret.
|
|
579
|
+
const internalSecrets = seed();
|
|
580
|
+
await internalSecrets.set({ parts: passwordParts, value: "live" });
|
|
581
|
+
const marker = healthcheckSecretMarker("authPassword");
|
|
582
|
+
|
|
583
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
584
|
+
configurationId: CONFIG_ID,
|
|
585
|
+
oldConfig: { authPassword: marker },
|
|
586
|
+
newConfig: { authPassword: marker }, // preserved verbatim (new strategy has no schema)
|
|
587
|
+
oldCollectors: undefined,
|
|
588
|
+
newCollectors: undefined,
|
|
589
|
+
internalSecrets,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(deleted).toBe(0);
|
|
593
|
+
expect(await internalSecrets.get({ parts: passwordParts })).toBe("live");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("KEEPS a preserved collector marker when the collector becomes unregistered", async () => {
|
|
597
|
+
const internalSecrets = seed();
|
|
598
|
+
const entryParts = healthcheckSecretParts({
|
|
599
|
+
configurationId: CONFIG_ID,
|
|
600
|
+
scope: { kind: "collector", entryId: "c1" },
|
|
601
|
+
secretId: "apiKey",
|
|
602
|
+
});
|
|
603
|
+
await internalSecrets.set({ parts: entryParts, value: "live" });
|
|
604
|
+
const marker = healthcheckSecretMarker("apiKey");
|
|
605
|
+
|
|
606
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
607
|
+
configurationId: CONFIG_ID,
|
|
608
|
+
oldConfig: {},
|
|
609
|
+
newConfig: {},
|
|
610
|
+
// Old entry registered (schema enumerates the marker); new entry has the
|
|
611
|
+
// SAME preserved config but an unregistered collectorId.
|
|
612
|
+
oldCollectors: [
|
|
613
|
+
{ id: "c1", collectorId: "http.request", config: { apiKey: marker, url: "https://x" } },
|
|
614
|
+
],
|
|
615
|
+
newCollectors: [
|
|
616
|
+
{ id: "c1", collectorId: "gone.collector", config: { apiKey: marker, url: "https://x" } },
|
|
617
|
+
],
|
|
618
|
+
internalSecrets,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
expect(deleted).toBe(0);
|
|
622
|
+
expect(await internalSecrets.get({ parts: entryParts })).toBe("live");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("prunes a cleared field whose marker string is a PREFIX of a kept sibling's marker", async () => {
|
|
626
|
+
// Round-3 re-review finding: a serialized-JSON substring presence check
|
|
627
|
+
// (`newText.includes(marker.value)`) false-positives when one x-secret
|
|
628
|
+
// field's walk-path is a prefix of a sibling's (e.g. `token`/`tokenSecret`),
|
|
629
|
+
// because `__hcsecret__:token` is a substring of `__hcsecret__:tokenSecret`.
|
|
630
|
+
// Clearing the shorter field must still delete its now-orphaned secret.
|
|
631
|
+
const internalSecrets = seed();
|
|
632
|
+
const tokenP = healthcheckSecretParts({
|
|
633
|
+
configurationId: CONFIG_ID,
|
|
634
|
+
scope: { kind: "strategy" },
|
|
635
|
+
secretId: "token",
|
|
636
|
+
});
|
|
637
|
+
const tokenSecretP = healthcheckSecretParts({
|
|
638
|
+
configurationId: CONFIG_ID,
|
|
639
|
+
scope: { kind: "strategy" },
|
|
640
|
+
secretId: "tokenSecret",
|
|
641
|
+
});
|
|
642
|
+
await internalSecrets.set({ parts: tokenP, value: "cleared-one" });
|
|
643
|
+
await internalSecrets.set({ parts: tokenSecretP, value: "kept-one" });
|
|
644
|
+
|
|
645
|
+
const deleted = await pruneOrphanedConfigurationSecrets({
|
|
646
|
+
configurationId: CONFIG_ID,
|
|
647
|
+
oldConfig: {
|
|
648
|
+
token: healthcheckSecretMarker("token"),
|
|
649
|
+
tokenSecret: healthcheckSecretMarker("tokenSecret"),
|
|
650
|
+
},
|
|
651
|
+
// `token` cleared; `tokenSecret` kept. `tokenSecret`'s marker string
|
|
652
|
+
// literally contains `token`'s marker string.
|
|
653
|
+
newConfig: { tokenSecret: healthcheckSecretMarker("tokenSecret") },
|
|
654
|
+
oldCollectors: undefined,
|
|
655
|
+
newCollectors: undefined,
|
|
656
|
+
internalSecrets,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
expect(deleted).toBe(1);
|
|
660
|
+
expect(await internalSecrets.get({ parts: tokenP })).toBeUndefined();
|
|
661
|
+
expect(await internalSecrets.get({ parts: tokenSecretP })).toBe("kept-one");
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe("listPopulatedSecretKeys", () => {
|
|
666
|
+
it("lists only secret keys that actually hold a stored value", () => {
|
|
667
|
+
const keys = listPopulatedSecretKeys({
|
|
668
|
+
schema: strategySchema,
|
|
669
|
+
config: {
|
|
670
|
+
authUsername: "alice",
|
|
671
|
+
authPassword: healthcheckSecretMarker("authPassword"), // inline stored
|
|
672
|
+
// authToken absent -> never set
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
expect(keys).toEqual(["authPassword"]);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("counts a reference as populated but a blank/absent secret as not", () => {
|
|
679
|
+
expect(
|
|
680
|
+
listPopulatedSecretKeys({
|
|
681
|
+
schema: strategySchema,
|
|
682
|
+
config: { authToken: "${{ secrets.T }}" },
|
|
683
|
+
}),
|
|
684
|
+
).toEqual(["authToken"]);
|
|
685
|
+
expect(
|
|
686
|
+
listPopulatedSecretKeys({
|
|
687
|
+
schema: strategySchema,
|
|
688
|
+
config: { authPassword: "" },
|
|
689
|
+
}),
|
|
690
|
+
).toEqual([]);
|
|
691
|
+
expect(
|
|
692
|
+
listPopulatedSecretKeys({ schema: strategySchema, config: {} }),
|
|
693
|
+
).toEqual([]);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("ignores non-secret fields", () => {
|
|
697
|
+
expect(
|
|
698
|
+
listPopulatedSecretKeys({
|
|
699
|
+
schema: strategySchema,
|
|
700
|
+
config: { authUsername: "alice", timeout: 5 },
|
|
701
|
+
}),
|
|
702
|
+
).toEqual([]);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe("union-typed secret fields (redact + merge descend unions)", () => {
|
|
707
|
+
// An x-secret field nested inside a discriminated union - extract stores a
|
|
708
|
+
// marker here, so redact MUST strip it and merge MUST restore keep-existing.
|
|
709
|
+
const unionSchema = z.object({
|
|
710
|
+
auth: z.discriminatedUnion("type", [
|
|
711
|
+
z.object({ type: z.literal("none") }),
|
|
712
|
+
z.object({
|
|
713
|
+
type: z.literal("basic"),
|
|
714
|
+
password: configSecret({ id: "password" }),
|
|
715
|
+
}),
|
|
716
|
+
]),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("redactSecretFields strips a secret inside a discriminated union", () => {
|
|
720
|
+
const redacted = redactSecretFields({
|
|
721
|
+
schema: unionSchema,
|
|
722
|
+
config: { auth: { type: "basic", password: healthcheckSecretMarker("auth.password") } },
|
|
723
|
+
});
|
|
724
|
+
expect(redacted).toEqual({ auth: { type: "basic" } });
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("redactSecretFields keeps a reference inside a union", () => {
|
|
728
|
+
const redacted = redactSecretFields({
|
|
729
|
+
schema: unionSchema,
|
|
730
|
+
config: { auth: { type: "basic", password: "${{ secrets.PW }}" } },
|
|
731
|
+
});
|
|
732
|
+
expect(redacted).toEqual({
|
|
733
|
+
auth: { type: "basic", password: "${{ secrets.PW }}" },
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("mergeSecretFields restores a blank secret inside a union (keep-existing)", () => {
|
|
738
|
+
const merged = mergeSecretFields({
|
|
739
|
+
schema: unionSchema,
|
|
740
|
+
incoming: { auth: { type: "basic", password: "" } },
|
|
741
|
+
stored: { auth: { type: "basic", password: healthcheckSecretMarker("auth.password") } },
|
|
742
|
+
});
|
|
743
|
+
expect((merged.auth as Record<string, unknown>).password).toBe(
|
|
744
|
+
healthcheckSecretMarker("auth.password"),
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("extractConfigurationSecrets extracts a secret inside a union", async () => {
|
|
749
|
+
const internalSecrets = fakeInternalSecrets();
|
|
750
|
+
const result = await extractConfigurationSecrets({
|
|
751
|
+
configurationId: CONFIG_ID,
|
|
752
|
+
strategySchema: unionSchema,
|
|
753
|
+
config: { auth: { type: "basic", password: "plain-pw" } },
|
|
754
|
+
collectors: undefined,
|
|
755
|
+
getCollectorSchema: () => undefined,
|
|
756
|
+
internalSecrets,
|
|
757
|
+
});
|
|
758
|
+
const auth = result.config.auth as Record<string, unknown>;
|
|
759
|
+
expect(isHealthcheckSecretMarker(auth.password as string)).toBe(true);
|
|
760
|
+
expect([...internalSecrets.store.values()]).toContain("plain-pw");
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe("marker format", () => {
|
|
765
|
+
it("round-trips and detects markers", () => {
|
|
766
|
+
const marker = healthcheckSecretMarker("authPassword");
|
|
767
|
+
expect(isHealthcheckSecretMarker(marker)).toBe(true);
|
|
768
|
+
expect(isHealthcheckSecretMarker("plain-value")).toBe(false);
|
|
769
|
+
expect(isHealthcheckSecretMarker("${{ secrets.X }}")).toBe(false);
|
|
770
|
+
});
|
|
771
|
+
});
|