@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,680 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configSecret, Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import type {
|
|
5
|
+
InternalSecretsService,
|
|
6
|
+
SecretResolverService,
|
|
7
|
+
} from "@checkstack/secrets-backend";
|
|
8
|
+
import { internalSecretName } from "@checkstack/secrets-common";
|
|
9
|
+
import { SECRET_CLEAR_SENTINEL } from "@checkstack/common";
|
|
10
|
+
import { HealthCheckService } from "./service";
|
|
11
|
+
import {
|
|
12
|
+
healthcheckSecretMarker,
|
|
13
|
+
healthcheckSecretParts,
|
|
14
|
+
healthcheckConfigLockKey,
|
|
15
|
+
isHealthcheckSecretMarker,
|
|
16
|
+
} from "./config-secrets";
|
|
17
|
+
import type { HealthCheckConfiguration } from "@checkstack/healthcheck-common";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Guards the REG-5 fix: an UNREGISTERED strategy/collector plugin reads back
|
|
21
|
+
* REDACTED to `{}` (fail-closed - no schema to know its secret fields). If the
|
|
22
|
+
* editor round-trips that empty object into `updateConfiguration`, the service
|
|
23
|
+
* MUST preserve the STORED config verbatim instead of wiping it to `{}`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const strategySchema = z.object({
|
|
27
|
+
url: z.string(),
|
|
28
|
+
password: configSecret({ id: "password" }).optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function fakeInternalSecrets(): InternalSecretsService & {
|
|
32
|
+
store: Map<string, string>;
|
|
33
|
+
} {
|
|
34
|
+
const store = new Map<string, string>();
|
|
35
|
+
return {
|
|
36
|
+
store,
|
|
37
|
+
async set({ parts, value }) {
|
|
38
|
+
store.set(internalSecretName(...parts), value);
|
|
39
|
+
},
|
|
40
|
+
async get({ parts }) {
|
|
41
|
+
return store.get(internalSecretName(...parts));
|
|
42
|
+
},
|
|
43
|
+
async delete({ parts }) {
|
|
44
|
+
store.delete(internalSecretName(...parts));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const fakeResolver = {
|
|
50
|
+
resolveForRun: mock(async () => ({ env: {}, masking: undefined })),
|
|
51
|
+
} as unknown as SecretResolverService;
|
|
52
|
+
|
|
53
|
+
interface CapturedUpdate {
|
|
54
|
+
set: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** DB that serves ONE stored config row and captures the update `.set(...)`. */
|
|
58
|
+
function createDb({
|
|
59
|
+
stored,
|
|
60
|
+
updates,
|
|
61
|
+
}: {
|
|
62
|
+
stored: Record<string, unknown>;
|
|
63
|
+
updates: CapturedUpdate[];
|
|
64
|
+
}) {
|
|
65
|
+
return {
|
|
66
|
+
select: () => ({
|
|
67
|
+
from: () => ({
|
|
68
|
+
where: () => Promise.resolve([stored]),
|
|
69
|
+
}),
|
|
70
|
+
}),
|
|
71
|
+
update: () => ({
|
|
72
|
+
set: (set: Record<string, unknown>) => {
|
|
73
|
+
updates.push({ set });
|
|
74
|
+
return {
|
|
75
|
+
where: () => ({
|
|
76
|
+
returning: () => Promise.resolve([{ ...stored, ...set }]),
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
delete: () => ({ where: () => Promise.resolve(undefined) }),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildService({
|
|
86
|
+
stored,
|
|
87
|
+
updates,
|
|
88
|
+
strategyRegistered,
|
|
89
|
+
registeredCollectorIds,
|
|
90
|
+
internalSecrets = fakeInternalSecrets(),
|
|
91
|
+
advisoryLock,
|
|
92
|
+
db,
|
|
93
|
+
}: {
|
|
94
|
+
stored: Record<string, unknown>;
|
|
95
|
+
updates: CapturedUpdate[];
|
|
96
|
+
strategyRegistered: boolean;
|
|
97
|
+
registeredCollectorIds: string[];
|
|
98
|
+
internalSecrets?: ReturnType<typeof fakeInternalSecrets>;
|
|
99
|
+
advisoryLock?: { withXactLock: (args: { key: string; fn: () => Promise<unknown> }) => Promise<unknown> };
|
|
100
|
+
db?: unknown;
|
|
101
|
+
}) {
|
|
102
|
+
const strategy = {
|
|
103
|
+
config: new Versioned({ version: 1, schema: strategySchema }),
|
|
104
|
+
};
|
|
105
|
+
const registry = {
|
|
106
|
+
getStrategy: mock(() => (strategyRegistered ? strategy : undefined)),
|
|
107
|
+
};
|
|
108
|
+
const collectorRegistry = {
|
|
109
|
+
getCollector: mock((collectorId: string) =>
|
|
110
|
+
registeredCollectorIds.includes(collectorId)
|
|
111
|
+
? { collector: { config: new Versioned({ version: 1, schema: strategySchema }) } }
|
|
112
|
+
: undefined,
|
|
113
|
+
),
|
|
114
|
+
};
|
|
115
|
+
const service = new HealthCheckService(
|
|
116
|
+
(db ?? createDb({ stored, updates })) as never,
|
|
117
|
+
registry as never,
|
|
118
|
+
collectorRegistry as never,
|
|
119
|
+
undefined,
|
|
120
|
+
undefined,
|
|
121
|
+
{ internalSecrets, secretResolver: fakeResolver, advisoryLock: advisoryLock as never },
|
|
122
|
+
);
|
|
123
|
+
return { service, internalSecrets };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("updateConfiguration unregistered-plugin guard (REG-5)", () => {
|
|
127
|
+
it("preserves the stored strategy config when the strategy plugin is unregistered", async () => {
|
|
128
|
+
const storedConfig = {
|
|
129
|
+
url: "https://x",
|
|
130
|
+
password: healthcheckSecretMarker("password"),
|
|
131
|
+
};
|
|
132
|
+
const stored = {
|
|
133
|
+
id: "cfg-1",
|
|
134
|
+
name: "old",
|
|
135
|
+
strategyId: "gone.strategy",
|
|
136
|
+
config: storedConfig,
|
|
137
|
+
collectors: null,
|
|
138
|
+
intervalSeconds: 60,
|
|
139
|
+
isTemplate: false,
|
|
140
|
+
paused: false,
|
|
141
|
+
createdAt: new Date(),
|
|
142
|
+
updatedAt: new Date(),
|
|
143
|
+
};
|
|
144
|
+
const updates: CapturedUpdate[] = [];
|
|
145
|
+
const { service } = buildService({
|
|
146
|
+
stored,
|
|
147
|
+
updates,
|
|
148
|
+
strategyRegistered: false,
|
|
149
|
+
registeredCollectorIds: [],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// The editor round-trips the redacted `{}` config.
|
|
153
|
+
await service.updateConfiguration("cfg-1", { config: {} });
|
|
154
|
+
|
|
155
|
+
const persisted = updates.at(-1)?.set.config;
|
|
156
|
+
// The stored config (with its secret marker) is preserved, NOT wiped to {}.
|
|
157
|
+
expect(persisted).toEqual(storedConfig);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("preserves an unregistered collector entry's stored config", async () => {
|
|
161
|
+
const goneConfig = { secret: healthcheckSecretMarker("secret") };
|
|
162
|
+
const stored = {
|
|
163
|
+
id: "cfg-2",
|
|
164
|
+
name: "old",
|
|
165
|
+
strategyId: "reg.strategy",
|
|
166
|
+
config: { url: "https://x" },
|
|
167
|
+
collectors: [{ id: "c2", collectorId: "gone.collector", config: goneConfig }],
|
|
168
|
+
intervalSeconds: 60,
|
|
169
|
+
isTemplate: false,
|
|
170
|
+
paused: false,
|
|
171
|
+
createdAt: new Date(),
|
|
172
|
+
updatedAt: new Date(),
|
|
173
|
+
};
|
|
174
|
+
const updates: CapturedUpdate[] = [];
|
|
175
|
+
const { service } = buildService({
|
|
176
|
+
stored,
|
|
177
|
+
updates,
|
|
178
|
+
strategyRegistered: true,
|
|
179
|
+
registeredCollectorIds: [],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Editor round-trips the unregistered collector redacted to `{}`.
|
|
183
|
+
await service.updateConfiguration("cfg-2", {
|
|
184
|
+
collectors: [{ id: "c2", collectorId: "gone.collector", config: {} }],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const persisted = updates.at(-1)?.set.collectors as
|
|
188
|
+
| Array<{ id: string; config: Record<string, unknown> }>
|
|
189
|
+
| undefined;
|
|
190
|
+
expect(persisted?.[0]?.config).toEqual(goneConfig);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("updateConfiguration mergeSecrets flag (GITOPS-9)", () => {
|
|
195
|
+
it("keeps a stored secret when a blank field is round-tripped (UI, mergeSecrets=true)", async () => {
|
|
196
|
+
const stored = {
|
|
197
|
+
id: "cfg-3",
|
|
198
|
+
name: "old",
|
|
199
|
+
strategyId: "reg.strategy",
|
|
200
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
201
|
+
collectors: null,
|
|
202
|
+
intervalSeconds: 60,
|
|
203
|
+
isTemplate: false,
|
|
204
|
+
paused: false,
|
|
205
|
+
createdAt: new Date(),
|
|
206
|
+
updatedAt: new Date(),
|
|
207
|
+
};
|
|
208
|
+
const updates: CapturedUpdate[] = [];
|
|
209
|
+
const { service } = buildService({
|
|
210
|
+
stored,
|
|
211
|
+
updates,
|
|
212
|
+
strategyRegistered: true,
|
|
213
|
+
registeredCollectorIds: [],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// UI editor omits the redacted secret -> keep existing.
|
|
217
|
+
await service.updateConfiguration("cfg-3", { config: { url: "https://y" } });
|
|
218
|
+
|
|
219
|
+
const persisted = updates.at(-1)?.set.config as Record<string, unknown>;
|
|
220
|
+
expect(persisted.url).toBe("https://y");
|
|
221
|
+
expect(persisted.password).toBe(healthcheckSecretMarker("password"));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("removes an omitted secret declaratively (GitOps, mergeSecrets=false)", async () => {
|
|
225
|
+
const stored = {
|
|
226
|
+
id: "cfg-4",
|
|
227
|
+
name: "old",
|
|
228
|
+
strategyId: "reg.strategy",
|
|
229
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
230
|
+
collectors: null,
|
|
231
|
+
intervalSeconds: 60,
|
|
232
|
+
isTemplate: false,
|
|
233
|
+
paused: false,
|
|
234
|
+
createdAt: new Date(),
|
|
235
|
+
updatedAt: new Date(),
|
|
236
|
+
};
|
|
237
|
+
const updates: CapturedUpdate[] = [];
|
|
238
|
+
const { service } = buildService({
|
|
239
|
+
stored,
|
|
240
|
+
updates,
|
|
241
|
+
strategyRegistered: true,
|
|
242
|
+
registeredCollectorIds: [],
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// GitOps applies the authored config wholesale; the omitted secret is gone.
|
|
246
|
+
await service.updateConfiguration(
|
|
247
|
+
"cfg-4",
|
|
248
|
+
{ config: { url: "https://y" } },
|
|
249
|
+
{ mergeSecrets: false },
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const persisted = updates.at(-1)?.set.config as Record<string, unknown>;
|
|
253
|
+
expect(persisted.url).toBe("https://y");
|
|
254
|
+
expect(persisted.password).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("updateConfiguration orphan-secret cleanup", () => {
|
|
259
|
+
it("deletes the internal secret when a stored secret is CLEARED", async () => {
|
|
260
|
+
const stored = {
|
|
261
|
+
id: "cfg-5",
|
|
262
|
+
name: "old",
|
|
263
|
+
strategyId: "reg.strategy",
|
|
264
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
265
|
+
collectors: null,
|
|
266
|
+
intervalSeconds: 60,
|
|
267
|
+
isTemplate: false,
|
|
268
|
+
paused: false,
|
|
269
|
+
createdAt: new Date(),
|
|
270
|
+
updatedAt: new Date(),
|
|
271
|
+
};
|
|
272
|
+
const internalSecrets = fakeInternalSecrets();
|
|
273
|
+
const passwordParts = healthcheckSecretParts({
|
|
274
|
+
configurationId: "cfg-5",
|
|
275
|
+
scope: { kind: "strategy" },
|
|
276
|
+
secretId: "password",
|
|
277
|
+
});
|
|
278
|
+
await internalSecrets.set({ parts: passwordParts, value: "old-pw" });
|
|
279
|
+
|
|
280
|
+
const updates: CapturedUpdate[] = [];
|
|
281
|
+
const { service } = buildService({
|
|
282
|
+
stored,
|
|
283
|
+
updates,
|
|
284
|
+
strategyRegistered: true,
|
|
285
|
+
registeredCollectorIds: [],
|
|
286
|
+
internalSecrets,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Editor clears the optional secret via the sentinel.
|
|
290
|
+
await service.updateConfiguration("cfg-5", {
|
|
291
|
+
config: { url: "https://x", password: SECRET_CLEAR_SENTINEL },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Field dropped from the persisted config AND its internal secret removed.
|
|
295
|
+
const persisted = updates.at(-1)?.set.config as Record<string, unknown>;
|
|
296
|
+
expect(persisted.password).toBeUndefined();
|
|
297
|
+
expect(await internalSecrets.get({ parts: passwordParts })).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("updateConfiguration registered collector under UNREGISTERED strategy", () => {
|
|
302
|
+
it("extracts a registered collector's inline secret (never plaintext at rest)", async () => {
|
|
303
|
+
const stored = {
|
|
304
|
+
id: "cfg-6",
|
|
305
|
+
name: "old",
|
|
306
|
+
strategyId: "gone.strategy", // strategy plugin uninstalled
|
|
307
|
+
config: { url: "https://x" },
|
|
308
|
+
collectors: [
|
|
309
|
+
{
|
|
310
|
+
id: "c1",
|
|
311
|
+
collectorId: "reg.collector",
|
|
312
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
intervalSeconds: 60,
|
|
316
|
+
isTemplate: false,
|
|
317
|
+
paused: false,
|
|
318
|
+
createdAt: new Date(),
|
|
319
|
+
updatedAt: new Date(),
|
|
320
|
+
};
|
|
321
|
+
const updates: CapturedUpdate[] = [];
|
|
322
|
+
const { service, internalSecrets } = buildService({
|
|
323
|
+
stored,
|
|
324
|
+
updates,
|
|
325
|
+
strategyRegistered: false, // unregistered strategy
|
|
326
|
+
registeredCollectorIds: ["reg.collector"], // but a REGISTERED collector
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// A direct (non-UI) client types a NEW inline collector secret.
|
|
330
|
+
await service.updateConfiguration("cfg-6", {
|
|
331
|
+
collectors: [
|
|
332
|
+
{
|
|
333
|
+
id: "c1",
|
|
334
|
+
collectorId: "reg.collector",
|
|
335
|
+
config: { url: "https://y", password: "newpw" },
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const persisted = updates.at(-1)?.set.collectors as Array<{
|
|
341
|
+
config: Record<string, unknown>;
|
|
342
|
+
}>;
|
|
343
|
+
// Persisted as a MARKER, never the plaintext; plaintext lives only in the store.
|
|
344
|
+
expect(isHealthcheckSecretMarker(persisted[0].config.password as string)).toBe(true);
|
|
345
|
+
expect(JSON.stringify(updates)).not.toContain("newpw");
|
|
346
|
+
expect([...internalSecrets.store.values()]).toContain("newpw");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("redacted read reports configuredSecrets (finding 4)", () => {
|
|
351
|
+
it("lists only secret fields that actually have a stored value", async () => {
|
|
352
|
+
const stored = {
|
|
353
|
+
id: "cfg-9",
|
|
354
|
+
name: "n",
|
|
355
|
+
strategyId: "reg.strategy",
|
|
356
|
+
// `password` is stored (marker); the schema's other optional secret is
|
|
357
|
+
// never set, so it must NOT be reported as configured.
|
|
358
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
359
|
+
collectors: [
|
|
360
|
+
{
|
|
361
|
+
id: "c1",
|
|
362
|
+
collectorId: "reg.collector",
|
|
363
|
+
config: { url: "https://y" }, // no stored secret
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
intervalSeconds: 60,
|
|
367
|
+
isTemplate: false,
|
|
368
|
+
paused: false,
|
|
369
|
+
createdAt: new Date(),
|
|
370
|
+
updatedAt: new Date(),
|
|
371
|
+
};
|
|
372
|
+
const { service } = buildService({
|
|
373
|
+
stored,
|
|
374
|
+
updates: [],
|
|
375
|
+
strategyRegistered: true,
|
|
376
|
+
registeredCollectorIds: ["reg.collector"],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const redacted = await service.getConfigurationRedacted("cfg-9");
|
|
380
|
+
|
|
381
|
+
expect(redacted?.configuredSecrets?.strategy).toEqual(["password"]);
|
|
382
|
+
expect(redacted?.configuredSecrets?.collectors.c1).toEqual([]);
|
|
383
|
+
// And the value itself never leaves the backend.
|
|
384
|
+
expect(redacted?.config.password).toBeUndefined();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("updateConfiguration concurrency serialization (advisory lock)", () => {
|
|
389
|
+
it("runs the secret path under a per-config advisory lock", async () => {
|
|
390
|
+
const stored = {
|
|
391
|
+
id: "cfg-7",
|
|
392
|
+
name: "old",
|
|
393
|
+
strategyId: "reg.strategy",
|
|
394
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
395
|
+
collectors: null,
|
|
396
|
+
intervalSeconds: 60,
|
|
397
|
+
isTemplate: false,
|
|
398
|
+
paused: false,
|
|
399
|
+
createdAt: new Date(),
|
|
400
|
+
updatedAt: new Date(),
|
|
401
|
+
};
|
|
402
|
+
const keys: string[] = [];
|
|
403
|
+
const advisoryLock = {
|
|
404
|
+
withXactLock: async ({ key, fn }: { key: string; fn: () => Promise<unknown> }) => {
|
|
405
|
+
keys.push(key);
|
|
406
|
+
return fn();
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
const updates: CapturedUpdate[] = [];
|
|
410
|
+
const { service } = buildService({
|
|
411
|
+
stored,
|
|
412
|
+
updates,
|
|
413
|
+
strategyRegistered: true,
|
|
414
|
+
registeredCollectorIds: [],
|
|
415
|
+
advisoryLock,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await service.updateConfiguration("cfg-7", { config: { url: "https://y" } });
|
|
419
|
+
|
|
420
|
+
expect(keys).toEqual([healthcheckConfigLockKey("cfg-7")]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("serialized concurrent clear + set leaves NO dangling marker (row and store agree)", async () => {
|
|
424
|
+
// Stateful db: the update mutates the served row so a serialized second
|
|
425
|
+
// writer reads the first writer's committed state.
|
|
426
|
+
let row: Record<string, unknown> = {
|
|
427
|
+
id: "cfg-8",
|
|
428
|
+
name: "old",
|
|
429
|
+
strategyId: "reg.strategy",
|
|
430
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
431
|
+
collectors: null,
|
|
432
|
+
intervalSeconds: 60,
|
|
433
|
+
isTemplate: false,
|
|
434
|
+
paused: false,
|
|
435
|
+
createdAt: new Date(),
|
|
436
|
+
updatedAt: new Date(),
|
|
437
|
+
};
|
|
438
|
+
const db = {
|
|
439
|
+
select: () => ({ from: () => ({ where: () => Promise.resolve([row]) }) }),
|
|
440
|
+
update: () => ({
|
|
441
|
+
set: (set: Record<string, unknown>) => ({
|
|
442
|
+
where: () => ({
|
|
443
|
+
returning: () => {
|
|
444
|
+
row = { ...row, ...set };
|
|
445
|
+
return Promise.resolve([row]);
|
|
446
|
+
},
|
|
447
|
+
}),
|
|
448
|
+
}),
|
|
449
|
+
}),
|
|
450
|
+
};
|
|
451
|
+
// A real per-key mutex, so overlapping calls serialize.
|
|
452
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
453
|
+
const advisoryLock = {
|
|
454
|
+
withXactLock: async ({ key, fn }: { key: string; fn: () => Promise<unknown> }) => {
|
|
455
|
+
const prev = chains.get(key) ?? Promise.resolve();
|
|
456
|
+
let release: () => void = () => {};
|
|
457
|
+
const gate = new Promise<void>((r) => (release = r));
|
|
458
|
+
chains.set(key, prev.then(() => gate));
|
|
459
|
+
await prev;
|
|
460
|
+
try {
|
|
461
|
+
return await fn();
|
|
462
|
+
} finally {
|
|
463
|
+
release();
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
const internalSecrets = fakeInternalSecrets();
|
|
468
|
+
const passwordParts = healthcheckSecretParts({
|
|
469
|
+
configurationId: "cfg-8",
|
|
470
|
+
scope: { kind: "strategy" },
|
|
471
|
+
secretId: "password",
|
|
472
|
+
});
|
|
473
|
+
await internalSecrets.set({ parts: passwordParts, value: "v0" });
|
|
474
|
+
|
|
475
|
+
const { service } = buildService({
|
|
476
|
+
stored: row,
|
|
477
|
+
updates: [],
|
|
478
|
+
strategyRegistered: true,
|
|
479
|
+
registeredCollectorIds: [],
|
|
480
|
+
internalSecrets,
|
|
481
|
+
advisoryLock,
|
|
482
|
+
db,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Concurrent: A clears the secret; B sets a new one. The lock serializes.
|
|
486
|
+
await Promise.all([
|
|
487
|
+
service.updateConfiguration("cfg-8", {
|
|
488
|
+
config: { url: "https://x", password: SECRET_CLEAR_SENTINEL },
|
|
489
|
+
}),
|
|
490
|
+
service.updateConfiguration("cfg-8", {
|
|
491
|
+
config: { url: "https://x", password: "newpw" },
|
|
492
|
+
}),
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
// Invariant: the final row's marker presence and the store must AGREE - a
|
|
496
|
+
// marker in the row iff its internal secret exists. Never a dangling marker.
|
|
497
|
+
const finalConfig = row.config as Record<string, unknown>;
|
|
498
|
+
const rowHasMarker = isHealthcheckSecretMarker(
|
|
499
|
+
(finalConfig.password ?? "") as string,
|
|
500
|
+
);
|
|
501
|
+
const storeHasValue =
|
|
502
|
+
(await internalSecrets.get({ parts: passwordParts })) !== undefined;
|
|
503
|
+
expect(rowHasMarker).toBe(storeHasValue);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
/** DB that captures inserted rows and can be told to fail the insert. */
|
|
508
|
+
function createInsertDb({
|
|
509
|
+
inserted,
|
|
510
|
+
throwOnInsert = false,
|
|
511
|
+
}: {
|
|
512
|
+
inserted: Record<string, unknown>[];
|
|
513
|
+
throwOnInsert?: boolean;
|
|
514
|
+
}) {
|
|
515
|
+
return {
|
|
516
|
+
insert: () => ({
|
|
517
|
+
values: (values: Record<string, unknown>) => ({
|
|
518
|
+
returning: () => {
|
|
519
|
+
if (throwOnInsert) return Promise.reject(new Error("insert failed"));
|
|
520
|
+
inserted.push(values);
|
|
521
|
+
return Promise.resolve([
|
|
522
|
+
{ paused: false, createdAt: new Date(), updatedAt: new Date(), ...values },
|
|
523
|
+
]);
|
|
524
|
+
},
|
|
525
|
+
}),
|
|
526
|
+
}),
|
|
527
|
+
select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }),
|
|
528
|
+
delete: () => ({ where: () => Promise.resolve(undefined) }),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
describe("createConfiguration secret extraction (finding A / plaintext-at-rest)", () => {
|
|
533
|
+
it("extracts a registered collector's inline secret even when the STRATEGY is unregistered", async () => {
|
|
534
|
+
const inserted: Record<string, unknown>[] = [];
|
|
535
|
+
const { service, internalSecrets } = buildService({
|
|
536
|
+
stored: {},
|
|
537
|
+
updates: [],
|
|
538
|
+
strategyRegistered: false, // strategy plugin uninstalled on this pod
|
|
539
|
+
registeredCollectorIds: ["reg.collector"], // but the collector IS registered
|
|
540
|
+
db: createInsertDb({ inserted }),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
await service.createConfiguration({
|
|
544
|
+
name: "n",
|
|
545
|
+
strategyId: "gone.strategy",
|
|
546
|
+
config: { url: "https://x" },
|
|
547
|
+
collectors: [
|
|
548
|
+
{
|
|
549
|
+
id: "c1",
|
|
550
|
+
collectorId: "reg.collector",
|
|
551
|
+
config: { url: "https://y", password: "plainpw" },
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
intervalSeconds: 60,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const collectors = inserted.at(-1)?.collectors as Array<{
|
|
558
|
+
config: Record<string, unknown>;
|
|
559
|
+
}>;
|
|
560
|
+
// Persisted as a MARKER, never the plaintext; plaintext lives only in the store.
|
|
561
|
+
expect(isHealthcheckSecretMarker(collectors[0].config.password as string)).toBe(
|
|
562
|
+
true,
|
|
563
|
+
);
|
|
564
|
+
expect(JSON.stringify(inserted)).not.toContain("plainpw");
|
|
565
|
+
expect([...internalSecrets.store.values()]).toContain("plainpw");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("deletes the extracted secrets when the insert rolls back (finding H / no orphan)", async () => {
|
|
569
|
+
const internalSecrets = fakeInternalSecrets();
|
|
570
|
+
const { service } = buildService({
|
|
571
|
+
stored: {},
|
|
572
|
+
updates: [],
|
|
573
|
+
strategyRegistered: true,
|
|
574
|
+
registeredCollectorIds: [],
|
|
575
|
+
internalSecrets,
|
|
576
|
+
db: createInsertDb({ inserted: [], throwOnInsert: true }),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await expect(
|
|
580
|
+
service.createConfiguration({
|
|
581
|
+
name: "n",
|
|
582
|
+
strategyId: "reg.strategy",
|
|
583
|
+
config: { url: "https://x", password: "pw" },
|
|
584
|
+
collectors: undefined,
|
|
585
|
+
intervalSeconds: 60,
|
|
586
|
+
}),
|
|
587
|
+
).rejects.toThrow("insert failed");
|
|
588
|
+
|
|
589
|
+
// The secret was extracted before the (failed) insert; cleanup removed it.
|
|
590
|
+
expect(internalSecrets.store.size).toBe(0);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("deleteConfiguration concurrency serialization (finding G)", () => {
|
|
595
|
+
it("runs delete under the per-config advisory lock", async () => {
|
|
596
|
+
const stored = {
|
|
597
|
+
id: "cfg-del",
|
|
598
|
+
name: "n",
|
|
599
|
+
strategyId: "reg.strategy",
|
|
600
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
601
|
+
collectors: null,
|
|
602
|
+
intervalSeconds: 60,
|
|
603
|
+
isTemplate: false,
|
|
604
|
+
paused: false,
|
|
605
|
+
createdAt: new Date(),
|
|
606
|
+
updatedAt: new Date(),
|
|
607
|
+
};
|
|
608
|
+
const keys: string[] = [];
|
|
609
|
+
const advisoryLock = {
|
|
610
|
+
withXactLock: async ({ key, fn }: { key: string; fn: () => Promise<unknown> }) => {
|
|
611
|
+
keys.push(key);
|
|
612
|
+
return fn();
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
const { service } = buildService({
|
|
616
|
+
stored,
|
|
617
|
+
updates: [],
|
|
618
|
+
strategyRegistered: true,
|
|
619
|
+
registeredCollectorIds: [],
|
|
620
|
+
advisoryLock,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
await service.deleteConfiguration("cfg-del");
|
|
624
|
+
|
|
625
|
+
expect(keys).toEqual([healthcheckConfigLockKey("cfg-del")]);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
describe("deleteConfiguration cleans up secrets (never orphans)", () => {
|
|
630
|
+
it("deletes the config's internal secrets even when the strategy plugin is UNINSTALLED", async () => {
|
|
631
|
+
const stored = {
|
|
632
|
+
id: "cfg-10",
|
|
633
|
+
name: "n",
|
|
634
|
+
strategyId: "gone.strategy", // plugin uninstalled -> no schema
|
|
635
|
+
config: { url: "https://x", password: healthcheckSecretMarker("password") },
|
|
636
|
+
collectors: [
|
|
637
|
+
{
|
|
638
|
+
id: "c1",
|
|
639
|
+
collectorId: "gone.collector",
|
|
640
|
+
config: { apiKey: healthcheckSecretMarker("apiKey") },
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
intervalSeconds: 60,
|
|
644
|
+
isTemplate: false,
|
|
645
|
+
paused: false,
|
|
646
|
+
createdAt: new Date(),
|
|
647
|
+
updatedAt: new Date(),
|
|
648
|
+
};
|
|
649
|
+
const internalSecrets = fakeInternalSecrets();
|
|
650
|
+
await internalSecrets.set({
|
|
651
|
+
parts: healthcheckSecretParts({
|
|
652
|
+
configurationId: "cfg-10",
|
|
653
|
+
scope: { kind: "strategy" },
|
|
654
|
+
secretId: "password",
|
|
655
|
+
}),
|
|
656
|
+
value: "pw",
|
|
657
|
+
});
|
|
658
|
+
await internalSecrets.set({
|
|
659
|
+
parts: healthcheckSecretParts({
|
|
660
|
+
configurationId: "cfg-10",
|
|
661
|
+
scope: { kind: "collector", entryId: "c1" },
|
|
662
|
+
secretId: "apiKey",
|
|
663
|
+
}),
|
|
664
|
+
value: "key",
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const { service } = buildService({
|
|
668
|
+
stored,
|
|
669
|
+
updates: [],
|
|
670
|
+
strategyRegistered: false, // strategy AND collector unregistered
|
|
671
|
+
registeredCollectorIds: [],
|
|
672
|
+
internalSecrets,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
await service.deleteConfiguration("cfg-10");
|
|
676
|
+
|
|
677
|
+
// Schema-free cleanup removed BOTH secrets despite the missing plugins.
|
|
678
|
+
expect(internalSecrets.store.size).toBe(0);
|
|
679
|
+
});
|
|
680
|
+
});
|