@obsunified/collector 2.2.1 → 2.3.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/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +2892 -2240
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -3
- package/src/__tests__/autosre-closed-loop.test.ts +700 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/framework/env.ts +105 -0
- package/src/index.ts +13 -4
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/autosre-findings.ts +169 -14
- package/src/lib/autosre-remediation-gate.ts +156 -26
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +15 -0
- package/src/plugins/autosre-routes.ts +409 -147
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +224 -0
- package/src/plugins/connected-routes/manifest.ts +131 -28
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +13 -11
- package/src/plugins/connected-routes.ts +53 -20
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +89 -24
- package/dist/chunk-KMEZKGSO.js.map +0 -1
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -1397,6 +1397,7 @@ describe("AutoSRE governance hardening", () => {
|
|
|
1397
1397
|
endTime: end,
|
|
1398
1398
|
durationMs: 40,
|
|
1399
1399
|
serviceVersion: "v2.4.1",
|
|
1400
|
+
errorSignature: "checkout 500",
|
|
1400
1401
|
});
|
|
1401
1402
|
}
|
|
1402
1403
|
await recordChangeEvent(db, "default", {
|
|
@@ -2219,4 +2220,703 @@ describe("AutoSRE governance hardening", () => {
|
|
|
2219
2220
|
);
|
|
2220
2221
|
db.close();
|
|
2221
2222
|
});
|
|
2223
|
+
|
|
2224
|
+
const json = (body: unknown) => ({
|
|
2225
|
+
method: "POST",
|
|
2226
|
+
headers: { "Content-Type": "application/json" },
|
|
2227
|
+
body: JSON.stringify(body),
|
|
2228
|
+
});
|
|
2229
|
+
const approveInit = (body: Record<string, unknown>) => ({
|
|
2230
|
+
method: "POST",
|
|
2231
|
+
headers: {
|
|
2232
|
+
"Content-Type": "application/json",
|
|
2233
|
+
Cookie: "obs_session=sess_lifecycle",
|
|
2234
|
+
},
|
|
2235
|
+
body: JSON.stringify(body),
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
it("J10: span-id fault loci on one service/route share a fingerprint, so the breaker is not fragmented", async () => {
|
|
2239
|
+
const db = createMigratedSqliteDb();
|
|
2240
|
+
await seedIsolatedCohort(db);
|
|
2241
|
+
const incident = await newIncident(db);
|
|
2242
|
+
const spanFinding = (n: number) =>
|
|
2243
|
+
newFinding(db, incident.id, {
|
|
2244
|
+
title: `Checkout regression attempt ${n}`,
|
|
2245
|
+
summary: `Investigation ${n} found errors after the deploy`,
|
|
2246
|
+
contributingChangeId: "chg_gov",
|
|
2247
|
+
faultLocusEntityKind: "span",
|
|
2248
|
+
faultLocusEntityId: `g_new_sp_${n}`,
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
const findings = [];
|
|
2252
|
+
for (let n = 0; n < 4; n++) findings.push(await spanFinding(n));
|
|
2253
|
+
expect(new Set(findings.map((f) => f.fingerprint)).size).toBe(1);
|
|
2254
|
+
expect(findings[0].fingerprintInput.originatingNodeId).toBe(SERVICE_NODE);
|
|
2255
|
+
expect(findings[0].fingerprintInput.normalizedOperation).toContain(
|
|
2256
|
+
"/checkout",
|
|
2257
|
+
);
|
|
2258
|
+
|
|
2259
|
+
for (let n = 0; n < 3; n++) {
|
|
2260
|
+
const granted = await propose(
|
|
2261
|
+
db,
|
|
2262
|
+
incident.id,
|
|
2263
|
+
findings[n].id,
|
|
2264
|
+
`j10_${n}`,
|
|
2265
|
+
);
|
|
2266
|
+
expect(granted.status).toBe("grant_issued");
|
|
2267
|
+
}
|
|
2268
|
+
const blocked = await propose(db, incident.id, findings[3].id, "j10_3");
|
|
2269
|
+
expect(blocked.status).toBe("rejected");
|
|
2270
|
+
expect(blocked.policyDecisionReason).toMatch(/Circuit breaker/);
|
|
2271
|
+
|
|
2272
|
+
// A span that is not in telemetry keeps the legacy behaviour.
|
|
2273
|
+
const unknown = await spanFinding(99);
|
|
2274
|
+
expect(unknown.fingerprint).not.toBe(findings[0].fingerprint);
|
|
2275
|
+
db.close();
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
it("J10: traceId:spanId span loci resolve the service, and empty error signatures fall back to the summary", async () => {
|
|
2279
|
+
const db = createMigratedSqliteDb();
|
|
2280
|
+
const t0 = Date.parse("2026-10-15T06:00:00.000Z");
|
|
2281
|
+
const start = new Date(t0).toISOString();
|
|
2282
|
+
const end = new Date(t0 + 50).toISOString();
|
|
2283
|
+
const traceId = "a".repeat(32);
|
|
2284
|
+
insertSpan(db, {
|
|
2285
|
+
traceId,
|
|
2286
|
+
spanId: "b".repeat(16),
|
|
2287
|
+
serviceName: "pay-api",
|
|
2288
|
+
spanName: "POST /pay",
|
|
2289
|
+
statusCode: 2,
|
|
2290
|
+
statusMessage: "card declined",
|
|
2291
|
+
startTime: start,
|
|
2292
|
+
endTime: end,
|
|
2293
|
+
durationMs: 50,
|
|
2294
|
+
});
|
|
2295
|
+
insertSpan(db, {
|
|
2296
|
+
traceId,
|
|
2297
|
+
spanId: "c".repeat(16),
|
|
2298
|
+
serviceName: "pay-api",
|
|
2299
|
+
spanName: "POST /refund",
|
|
2300
|
+
statusCode: 2,
|
|
2301
|
+
statusMessage: "",
|
|
2302
|
+
errorSignature: "",
|
|
2303
|
+
startTime: start,
|
|
2304
|
+
endTime: end,
|
|
2305
|
+
durationMs: 50,
|
|
2306
|
+
});
|
|
2307
|
+
const incident = await newIncident(db);
|
|
2308
|
+
const mk = (id: string, summary = "Declines after deploy") =>
|
|
2309
|
+
newFinding(db, incident.id, {
|
|
2310
|
+
title: "Pay declines",
|
|
2311
|
+
summary,
|
|
2312
|
+
faultLocusEntityKind: "span",
|
|
2313
|
+
faultLocusEntityId: id,
|
|
2314
|
+
});
|
|
2315
|
+
const bare = await mk("b".repeat(16));
|
|
2316
|
+
const qualified = await mk(`${traceId}:${"b".repeat(16)}`);
|
|
2317
|
+
expect(qualified.fingerprintInput.originatingNodeId).toBe(
|
|
2318
|
+
"service:prod:default:pay-api",
|
|
2319
|
+
);
|
|
2320
|
+
expect(qualified.fingerprint).toBe(bare.fingerprint);
|
|
2321
|
+
|
|
2322
|
+
// No error signature or status message: the summary is the signature.
|
|
2323
|
+
const noSig = await mk("c".repeat(16), "Refunds time out");
|
|
2324
|
+
expect(noSig.fingerprintInput.errorSignature).toBe("Refunds time out");
|
|
2325
|
+
expect(noSig.fingerprintInput.normalizedOperation).toBe("POST /refund");
|
|
2326
|
+
db.close();
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
it("J10: a recurring failure on a different trace surfaces the prior finding by fingerprint", async () => {
|
|
2330
|
+
const db = createMigratedSqliteDb();
|
|
2331
|
+
const { app, env } = createTestApp(db);
|
|
2332
|
+
const t0 = Date.parse("2026-10-15T05:00:00.000Z");
|
|
2333
|
+
for (const [trace, offset] of [
|
|
2334
|
+
["tr_prior", 0],
|
|
2335
|
+
["tr_recur", 60_000],
|
|
2336
|
+
] as const) {
|
|
2337
|
+
const start = new Date(t0 + offset).toISOString();
|
|
2338
|
+
const end = new Date(t0 + offset + 80).toISOString();
|
|
2339
|
+
insertSpan(db, {
|
|
2340
|
+
traceId: trace,
|
|
2341
|
+
spanId: `${trace}_root`,
|
|
2342
|
+
serviceName: "pay-api",
|
|
2343
|
+
spanName: "POST /pay",
|
|
2344
|
+
statusCode: 2,
|
|
2345
|
+
startTime: start,
|
|
2346
|
+
endTime: end,
|
|
2347
|
+
durationMs: 80,
|
|
2348
|
+
});
|
|
2349
|
+
insertSpan(db, {
|
|
2350
|
+
traceId: trace,
|
|
2351
|
+
spanId: `${trace}_err`,
|
|
2352
|
+
parentSpanId: `${trace}_root`,
|
|
2353
|
+
serviceName: "pay-api",
|
|
2354
|
+
spanName: "POST /pay",
|
|
2355
|
+
statusCode: 2,
|
|
2356
|
+
statusMessage: "card declined",
|
|
2357
|
+
startTime: start,
|
|
2358
|
+
endTime: end,
|
|
2359
|
+
durationMs: 70,
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
const incident = await newIncident(db);
|
|
2363
|
+
const prior = await newFinding(db, incident.id, {
|
|
2364
|
+
title: "Pay declines",
|
|
2365
|
+
summary: "Provider returns declines",
|
|
2366
|
+
symptomEntityId: "tr_prior",
|
|
2367
|
+
faultLocusEntityKind: "span",
|
|
2368
|
+
faultLocusEntityId: "tr_prior_err",
|
|
2369
|
+
});
|
|
2370
|
+
const bundleFor = async (trace: string) => {
|
|
2371
|
+
const res = await app.request(
|
|
2372
|
+
"/internal/evidence/bundle",
|
|
2373
|
+
json({
|
|
2374
|
+
intent: "triage_incident",
|
|
2375
|
+
anchor: { entityKind: "trace", entityId: trace },
|
|
2376
|
+
}),
|
|
2377
|
+
env,
|
|
2378
|
+
);
|
|
2379
|
+
expect(res.status).toBe(200);
|
|
2380
|
+
return (await res.json()) as {
|
|
2381
|
+
autosre?: { priorFindings?: Array<{ findingId: string }> };
|
|
2382
|
+
};
|
|
2383
|
+
};
|
|
2384
|
+
const recur = await bundleFor("tr_recur");
|
|
2385
|
+
expect(
|
|
2386
|
+
recur.autosre?.priorFindings?.some((f) => f.findingId === prior.id),
|
|
2387
|
+
).toBe(true);
|
|
2388
|
+
db.close();
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
it("J9: a finding idempotencyKey returns the stored finding on retry and survives route round-trips", async () => {
|
|
2392
|
+
const db = createMigratedSqliteDb();
|
|
2393
|
+
const { app, env } = createTestApp(db);
|
|
2394
|
+
const body = {
|
|
2395
|
+
environment: "prod",
|
|
2396
|
+
idempotencyKey: "retry-me",
|
|
2397
|
+
title: "Idempotent finding",
|
|
2398
|
+
summary: "Same call twice",
|
|
2399
|
+
symptomEntityKind: "trace",
|
|
2400
|
+
symptomEntityId: "t1",
|
|
2401
|
+
faultLocusEntityKind: "service",
|
|
2402
|
+
faultLocusEntityId: SERVICE_NODE,
|
|
2403
|
+
causalChain: [],
|
|
2404
|
+
citedEvidenceIds: [],
|
|
2405
|
+
};
|
|
2406
|
+
const first = await app.request("/internal/findings", json(body), env);
|
|
2407
|
+
const second = await app.request("/internal/findings", json(body), env);
|
|
2408
|
+
expect(first.status).toBe(201);
|
|
2409
|
+
const a = (await first.json()) as { id: string };
|
|
2410
|
+
const b = (await second.json()) as { id: string };
|
|
2411
|
+
expect(b.id).toBe(a.id);
|
|
2412
|
+
const n = db.raw
|
|
2413
|
+
.prepare(`SELECT COUNT(*) AS n FROM investigation_findings`)
|
|
2414
|
+
.get() as { n: number };
|
|
2415
|
+
expect(n.n).toBe(1);
|
|
2416
|
+
// A different key is a different finding.
|
|
2417
|
+
const third = await app.request(
|
|
2418
|
+
"/internal/findings",
|
|
2419
|
+
json({ ...body, idempotencyKey: "other" }),
|
|
2420
|
+
env,
|
|
2421
|
+
);
|
|
2422
|
+
expect(((await third.json()) as { id: string }).id).not.toBe(a.id);
|
|
2423
|
+
db.close();
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
it("POST /internal/findings: schema-backed validation, trimming and defaults", async () => {
|
|
2427
|
+
const db = createMigratedSqliteDb();
|
|
2428
|
+
const { app, env } = createTestApp(db);
|
|
2429
|
+
const post = (body: unknown) =>
|
|
2430
|
+
app.request(
|
|
2431
|
+
"/internal/findings",
|
|
2432
|
+
{
|
|
2433
|
+
method: "POST",
|
|
2434
|
+
headers: { "Content-Type": "application/json" },
|
|
2435
|
+
body: JSON.stringify(body),
|
|
2436
|
+
},
|
|
2437
|
+
env,
|
|
2438
|
+
);
|
|
2439
|
+
const base = {
|
|
2440
|
+
title: "t",
|
|
2441
|
+
summary: "s",
|
|
2442
|
+
symptomEntityKind: "trace",
|
|
2443
|
+
symptomEntityId: "t1",
|
|
2444
|
+
faultLocusEntityKind: "service",
|
|
2445
|
+
faultLocusEntityId: SERVICE_NODE,
|
|
2446
|
+
};
|
|
2447
|
+
const required =
|
|
2448
|
+
"title, summary, symptomEntityKind, symptomEntityId, faultLocusEntityKind, and faultLocusEntityId are required";
|
|
2449
|
+
|
|
2450
|
+
// Combined required message: missing and empty-after-trim.
|
|
2451
|
+
for (const bad of [
|
|
2452
|
+
{ ...base, title: undefined },
|
|
2453
|
+
{ ...base, summary: " " },
|
|
2454
|
+
{ ...base, faultLocusEntityId: 5 },
|
|
2455
|
+
]) {
|
|
2456
|
+
const res = await post(bad);
|
|
2457
|
+
expect(res.status).toBe(400);
|
|
2458
|
+
expect(((await res.json()) as { error: string }).error).toBe(required);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// causalChain explicit messages.
|
|
2462
|
+
const notArray = await post({ ...base, causalChain: "nope" });
|
|
2463
|
+
expect(notArray.status).toBe(400);
|
|
2464
|
+
expect(((await notArray.json()) as { error: string }).error).toBe(
|
|
2465
|
+
"causalChain must be an array",
|
|
2466
|
+
);
|
|
2467
|
+
const badPred = await post({
|
|
2468
|
+
...base,
|
|
2469
|
+
causalChain: [
|
|
2470
|
+
{
|
|
2471
|
+
subjectKind: "trace",
|
|
2472
|
+
subjectId: "t1",
|
|
2473
|
+
predicate: "made_up",
|
|
2474
|
+
objectKind: "span",
|
|
2475
|
+
objectId: "s1",
|
|
2476
|
+
},
|
|
2477
|
+
],
|
|
2478
|
+
});
|
|
2479
|
+
expect(badPred.status).toBe(400);
|
|
2480
|
+
expect(((await badPred.json()) as { error: string }).error).toBe(
|
|
2481
|
+
"causalChain[0].predicate is not a known evidence predicate: made_up",
|
|
2482
|
+
);
|
|
2483
|
+
|
|
2484
|
+
// Tightened: wrong-typed optional fields now 400 with path: message.
|
|
2485
|
+
const badConf = await post({ ...base, confidence: "high" });
|
|
2486
|
+
expect(badConf.status).toBe(400);
|
|
2487
|
+
expect(((await badConf.json()) as { error: string }).error).toContain(
|
|
2488
|
+
"confidence:",
|
|
2489
|
+
);
|
|
2490
|
+
const badTier = await post({ ...base, causalProofTier: "bogus" });
|
|
2491
|
+
expect(badTier.status).toBe(400);
|
|
2492
|
+
expect(((await badTier.json()) as { error: string }).error).toContain(
|
|
2493
|
+
"causalProofTier:",
|
|
2494
|
+
);
|
|
2495
|
+
const badKind = await post({ ...base, symptomEntityKind: "not_a_kind" });
|
|
2496
|
+
expect(badKind.status).toBe(400);
|
|
2497
|
+
|
|
2498
|
+
// Trimming + defaults.
|
|
2499
|
+
const ok = await post({
|
|
2500
|
+
title: " Padded title ",
|
|
2501
|
+
summary: " Padded summary ",
|
|
2502
|
+
symptomEntityKind: " trace ",
|
|
2503
|
+
symptomEntityId: " t1 ",
|
|
2504
|
+
faultLocusEntityKind: " service ",
|
|
2505
|
+
faultLocusEntityId: ` ${SERVICE_NODE} `,
|
|
2506
|
+
idempotencyKey: " ",
|
|
2507
|
+
incidentId: null,
|
|
2508
|
+
rootCauseEntityKind: null,
|
|
2509
|
+
cohortVerdict: null,
|
|
2510
|
+
});
|
|
2511
|
+
expect(ok.status).toBe(201);
|
|
2512
|
+
const created = (await ok.json()) as {
|
|
2513
|
+
finding: Record<string, unknown>;
|
|
2514
|
+
};
|
|
2515
|
+
expect(created.finding.title).toBe("Padded title");
|
|
2516
|
+
expect(created.finding.summary).toBe("Padded summary");
|
|
2517
|
+
expect(
|
|
2518
|
+
created.finding.symptom_entity_kind ?? created.finding.symptomEntityKind,
|
|
2519
|
+
).toBe("trace");
|
|
2520
|
+
// Blank idempotencyKey is ignored: two identical posts create two findings.
|
|
2521
|
+
const again = await post({
|
|
2522
|
+
title: " Padded title ",
|
|
2523
|
+
summary: " Padded summary ",
|
|
2524
|
+
symptomEntityKind: "trace",
|
|
2525
|
+
symptomEntityId: "t1",
|
|
2526
|
+
faultLocusEntityKind: "service",
|
|
2527
|
+
faultLocusEntityId: SERVICE_NODE,
|
|
2528
|
+
idempotencyKey: " ",
|
|
2529
|
+
});
|
|
2530
|
+
expect(((await again.json()) as { id: string }).id).not.toBe(
|
|
2531
|
+
(created.finding as { id: string }).id,
|
|
2532
|
+
);
|
|
2533
|
+
db.close();
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
it("J9: GET /internal/evals/runs/:id reports existence so counterfactual prerequisites can be checked first", async () => {
|
|
2537
|
+
const db = createMigratedSqliteDb();
|
|
2538
|
+
const { app, env } = createTestApp(db);
|
|
2539
|
+
const missing = await app.request("/internal/evals/runs/nope", {}, env);
|
|
2540
|
+
expect(missing.status).toBe(404);
|
|
2541
|
+
db.raw
|
|
2542
|
+
.prepare(
|
|
2543
|
+
`INSERT INTO eval_runs (project_id, id, eval_case_id, status, pass_count, fail_count, created_at)
|
|
2544
|
+
VALUES ('default', 'run_exists', 'case_1', 'completed', 0, 1, ?)`,
|
|
2545
|
+
)
|
|
2546
|
+
.run(new Date().toISOString());
|
|
2547
|
+
const found = await app.request("/internal/evals/runs/run_exists", {}, env);
|
|
2548
|
+
expect(found.status).toBe(200);
|
|
2549
|
+
expect(((await found.json()) as { run: { id: string } }).run.id).toBe(
|
|
2550
|
+
"run_exists",
|
|
2551
|
+
);
|
|
2552
|
+
db.close();
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
it("J17: callback stored at propose time -> approve without callback -> webhook receives the token -> claim succeeds", async () => {
|
|
2556
|
+
const db = createMigratedSqliteDb();
|
|
2557
|
+
const { app, env } = createTestApp(db);
|
|
2558
|
+
const incident = await newIncident(db);
|
|
2559
|
+
const finding = await newFinding(db, incident.id); // corroborated: needs a human
|
|
2560
|
+
const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
2561
|
+
|
|
2562
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
2563
|
+
const headers: Array<Record<string, string>> = [];
|
|
2564
|
+
vi.stubGlobal(
|
|
2565
|
+
"fetch",
|
|
2566
|
+
vi.fn(async (_url: string, init: RequestInit) => {
|
|
2567
|
+
bodies.push(JSON.parse(String(init.body)));
|
|
2568
|
+
headers.push(init.headers as Record<string, string>);
|
|
2569
|
+
return new Response("ok", { status: 200 });
|
|
2570
|
+
}),
|
|
2571
|
+
);
|
|
2572
|
+
|
|
2573
|
+
const proposeRes = await app.request(
|
|
2574
|
+
"/internal/remediations/propose",
|
|
2575
|
+
json({
|
|
2576
|
+
environment: "prod",
|
|
2577
|
+
incidentId: incident.id,
|
|
2578
|
+
findingId: finding.id,
|
|
2579
|
+
idempotencyKey: "j17",
|
|
2580
|
+
actionKind: "rollback_deploy",
|
|
2581
|
+
targetNodeId: SERVICE_NODE,
|
|
2582
|
+
expectedCurrentState: {},
|
|
2583
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
2584
|
+
webhookUrl: "https://runner.example.com/grants?token=urlsecret",
|
|
2585
|
+
secret: "hmac-secret-j17",
|
|
2586
|
+
}),
|
|
2587
|
+
callbackEnv,
|
|
2588
|
+
);
|
|
2589
|
+
expect(proposeRes.status).toBe(201);
|
|
2590
|
+
const proposed = (await proposeRes.json()) as {
|
|
2591
|
+
proposal: { id: string; status: string; callbackHost: string };
|
|
2592
|
+
};
|
|
2593
|
+
expect(proposed.proposal.status).toBe("pending_human_approval");
|
|
2594
|
+
expect(proposed.proposal.callbackHost).toBe("runner.example.com");
|
|
2595
|
+
expect(bodies).toHaveLength(0);
|
|
2596
|
+
|
|
2597
|
+
// Neither the URL path/query nor the secret is ever returned by a GET.
|
|
2598
|
+
const getRes = await app.request(
|
|
2599
|
+
`/internal/remediations/${proposed.proposal.id}?environment=prod`,
|
|
2600
|
+
{},
|
|
2601
|
+
callbackEnv,
|
|
2602
|
+
);
|
|
2603
|
+
const getText = await getRes.text();
|
|
2604
|
+
expect(getText).toContain("runner.example.com");
|
|
2605
|
+
expect(getText).not.toContain("urlsecret");
|
|
2606
|
+
expect(getText).not.toContain("hmac-secret-j17");
|
|
2607
|
+
expect(getText).not.toContain("/grants");
|
|
2608
|
+
const list = await (
|
|
2609
|
+
await app.request(
|
|
2610
|
+
"/internal/remediations?environment=prod",
|
|
2611
|
+
{},
|
|
2612
|
+
callbackEnv,
|
|
2613
|
+
)
|
|
2614
|
+
).text();
|
|
2615
|
+
expect(list).not.toContain("hmac-secret-j17");
|
|
2616
|
+
|
|
2617
|
+
const approve = await app.request(
|
|
2618
|
+
`/internal/remediations/${proposed.proposal.id}/approve`,
|
|
2619
|
+
approveInit({ environment: "prod" }),
|
|
2620
|
+
callbackEnv,
|
|
2621
|
+
);
|
|
2622
|
+
expect(approve.status).toBe(200);
|
|
2623
|
+
const approved = (await approve.json()) as {
|
|
2624
|
+
grantToken: string;
|
|
2625
|
+
webhookDelivery?: { delivered: boolean };
|
|
2626
|
+
proposal: { callbackHost: string | null };
|
|
2627
|
+
};
|
|
2628
|
+
expect(approved.webhookDelivery?.delivered).toBe(true);
|
|
2629
|
+
expect(approved.proposal.callbackHost).toBeNull();
|
|
2630
|
+
expect(bodies).toHaveLength(1);
|
|
2631
|
+
expect(bodies[0]).toMatchObject({
|
|
2632
|
+
event: "autosre.remediation.grant_issued",
|
|
2633
|
+
proposalId: proposed.proposal.id,
|
|
2634
|
+
grantToken: approved.grantToken,
|
|
2635
|
+
});
|
|
2636
|
+
expect(headers[0]["X-Obs-Signature-256"]).toMatch(/^sha256=[0-9a-f]{64}$/);
|
|
2637
|
+
|
|
2638
|
+
const claim = await app.request(
|
|
2639
|
+
`/internal/remediations/${proposed.proposal.id}/claim`,
|
|
2640
|
+
json({
|
|
2641
|
+
environment: "prod",
|
|
2642
|
+
grantToken: approved.grantToken,
|
|
2643
|
+
runnerId: "runner-j17",
|
|
2644
|
+
}),
|
|
2645
|
+
callbackEnv,
|
|
2646
|
+
);
|
|
2647
|
+
expect(claim.status).toBe(200);
|
|
2648
|
+
|
|
2649
|
+
// The stored callback is gone from the row once consumed.
|
|
2650
|
+
const row = db.raw
|
|
2651
|
+
.prepare(
|
|
2652
|
+
`SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
|
|
2653
|
+
)
|
|
2654
|
+
.get(proposed.proposal.id) as {
|
|
2655
|
+
callback_url: string | null;
|
|
2656
|
+
callback_secret: string | null;
|
|
2657
|
+
};
|
|
2658
|
+
expect(row.callback_url).toBeNull();
|
|
2659
|
+
expect(row.callback_secret).toBeNull();
|
|
2660
|
+
db.close();
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
it("J17: a callback on the approve request wins, a bad propose callback is a 400, and reject clears the stored one", async () => {
|
|
2664
|
+
const db = createMigratedSqliteDb();
|
|
2665
|
+
const { app, env } = createTestApp(db);
|
|
2666
|
+
const incident = await newIncident(db);
|
|
2667
|
+
const finding = await newFinding(db, incident.id);
|
|
2668
|
+
const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
2669
|
+
const urls: string[] = [];
|
|
2670
|
+
vi.stubGlobal(
|
|
2671
|
+
"fetch",
|
|
2672
|
+
vi.fn(async (url: string) => {
|
|
2673
|
+
urls.push(url);
|
|
2674
|
+
return new Response("ok", { status: 200 });
|
|
2675
|
+
}),
|
|
2676
|
+
);
|
|
2677
|
+
const mk = (key: string, webhookUrl: string) =>
|
|
2678
|
+
app.request(
|
|
2679
|
+
"/internal/remediations/propose",
|
|
2680
|
+
json({
|
|
2681
|
+
environment: "prod",
|
|
2682
|
+
incidentId: incident.id,
|
|
2683
|
+
findingId: finding.id,
|
|
2684
|
+
idempotencyKey: key,
|
|
2685
|
+
actionKind: "rollback_deploy",
|
|
2686
|
+
targetNodeId: SERVICE_NODE,
|
|
2687
|
+
expectedCurrentState: {},
|
|
2688
|
+
desiredTargetState: {},
|
|
2689
|
+
webhookUrl,
|
|
2690
|
+
}),
|
|
2691
|
+
callbackEnv,
|
|
2692
|
+
);
|
|
2693
|
+
expect((await mk("j17_bad", "ftp://example.com/x")).status).toBe(400);
|
|
2694
|
+
|
|
2695
|
+
const one = (await (
|
|
2696
|
+
await mk("j17_a", "https://stored.example.com/a")
|
|
2697
|
+
).json()) as {
|
|
2698
|
+
proposal: { id: string };
|
|
2699
|
+
};
|
|
2700
|
+
const approve = await app.request(
|
|
2701
|
+
`/internal/remediations/${one.proposal.id}/approve`,
|
|
2702
|
+
approveInit({
|
|
2703
|
+
environment: "prod",
|
|
2704
|
+
webhookUrl: "https://request.example.com/b",
|
|
2705
|
+
}),
|
|
2706
|
+
callbackEnv,
|
|
2707
|
+
);
|
|
2708
|
+
expect(approve.status).toBe(200);
|
|
2709
|
+
expect(urls).toEqual(["https://request.example.com/b"]);
|
|
2710
|
+
|
|
2711
|
+
const two = (await (
|
|
2712
|
+
await mk("j17_b", "https://stored.example.com/c")
|
|
2713
|
+
).json()) as {
|
|
2714
|
+
proposal: { id: string };
|
|
2715
|
+
};
|
|
2716
|
+
await rejectRemediationProposal(db, "default", two.proposal.id, {
|
|
2717
|
+
environment: "prod",
|
|
2718
|
+
rejectedByUserId: "tester",
|
|
2719
|
+
});
|
|
2720
|
+
const row = db.raw
|
|
2721
|
+
.prepare(`SELECT callback_url FROM remediation_proposals WHERE id = ?`)
|
|
2722
|
+
.get(two.proposal.id) as { callback_url: string | null };
|
|
2723
|
+
expect(row.callback_url).toBeNull();
|
|
2724
|
+
db.close();
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
describe("grant delivery retries", () => {
|
|
2728
|
+
type Delivery = {
|
|
2729
|
+
attempted: boolean;
|
|
2730
|
+
delivered: boolean;
|
|
2731
|
+
attempts: number;
|
|
2732
|
+
host: string | null;
|
|
2733
|
+
error?: string;
|
|
2734
|
+
};
|
|
2735
|
+
const setup = async (key: string, webhookUrl: string, secret?: string) => {
|
|
2736
|
+
const db = createMigratedSqliteDb();
|
|
2737
|
+
const { app, env } = createTestApp(db);
|
|
2738
|
+
const incident = await newIncident(db);
|
|
2739
|
+
const finding = await newFinding(db, incident.id);
|
|
2740
|
+
const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
2741
|
+
const proposed = (await (
|
|
2742
|
+
await app.request(
|
|
2743
|
+
"/internal/remediations/propose",
|
|
2744
|
+
json({
|
|
2745
|
+
environment: "prod",
|
|
2746
|
+
incidentId: incident.id,
|
|
2747
|
+
findingId: finding.id,
|
|
2748
|
+
idempotencyKey: key,
|
|
2749
|
+
actionKind: "rollback_deploy",
|
|
2750
|
+
targetNodeId: SERVICE_NODE,
|
|
2751
|
+
expectedCurrentState: {},
|
|
2752
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
2753
|
+
webhookUrl,
|
|
2754
|
+
...(secret ? { secret } : {}),
|
|
2755
|
+
}),
|
|
2756
|
+
callbackEnv,
|
|
2757
|
+
)
|
|
2758
|
+
).json()) as { proposal: { id: string } };
|
|
2759
|
+
const approve = async () =>
|
|
2760
|
+
(await (
|
|
2761
|
+
await app.request(
|
|
2762
|
+
`/internal/remediations/${proposed.proposal.id}/approve`,
|
|
2763
|
+
approveInit({ environment: "prod" }),
|
|
2764
|
+
callbackEnv,
|
|
2765
|
+
)
|
|
2766
|
+
).json()) as {
|
|
2767
|
+
grantToken: string;
|
|
2768
|
+
grantDelivery: Delivery;
|
|
2769
|
+
proposal: { callbackHost: string | null };
|
|
2770
|
+
};
|
|
2771
|
+
const get = async () =>
|
|
2772
|
+
(await (
|
|
2773
|
+
await app.request(
|
|
2774
|
+
`/internal/remediations/${proposed.proposal.id}?environment=prod`,
|
|
2775
|
+
{},
|
|
2776
|
+
callbackEnv,
|
|
2777
|
+
)
|
|
2778
|
+
).json()) as {
|
|
2779
|
+
proposal: {
|
|
2780
|
+
callbackDelivery: {
|
|
2781
|
+
status: string;
|
|
2782
|
+
attempts: number;
|
|
2783
|
+
lastError: string | null;
|
|
2784
|
+
at: string;
|
|
2785
|
+
} | null;
|
|
2786
|
+
};
|
|
2787
|
+
};
|
|
2788
|
+
return { db, proposed, approve, get };
|
|
2789
|
+
};
|
|
2790
|
+
|
|
2791
|
+
it("delivers on the 2nd attempt after a 503", async () => {
|
|
2792
|
+
const statuses = [503, 200];
|
|
2793
|
+
const fetchMock = vi.fn(
|
|
2794
|
+
async () => new Response("x", { status: statuses.shift() ?? 200 }),
|
|
2795
|
+
);
|
|
2796
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2797
|
+
const { db, approve, get } = await setup(
|
|
2798
|
+
"retry-503",
|
|
2799
|
+
"https://runner.example.com/g?token=urlsecret",
|
|
2800
|
+
"hmac-retry",
|
|
2801
|
+
);
|
|
2802
|
+
const approved = await approve();
|
|
2803
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2804
|
+
expect(approved.grantDelivery).toEqual({
|
|
2805
|
+
attempted: true,
|
|
2806
|
+
delivered: true,
|
|
2807
|
+
attempts: 2,
|
|
2808
|
+
host: "runner.example.com",
|
|
2809
|
+
});
|
|
2810
|
+
expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
|
|
2811
|
+
const got = await get();
|
|
2812
|
+
expect(got.proposal.callbackDelivery).toMatchObject({
|
|
2813
|
+
status: "delivered",
|
|
2814
|
+
attempts: 2,
|
|
2815
|
+
lastError: null,
|
|
2816
|
+
});
|
|
2817
|
+
db.close();
|
|
2818
|
+
}, 15_000);
|
|
2819
|
+
|
|
2820
|
+
it("fails after 3 attempts on a closed port but still returns the token and clears the callback", async () => {
|
|
2821
|
+
vi.unstubAllGlobals();
|
|
2822
|
+
const net = await import("node:net");
|
|
2823
|
+
const port = await new Promise<number>((resolve) => {
|
|
2824
|
+
const srv = net.createServer();
|
|
2825
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
2826
|
+
const { port: p } = srv.address() as { port: number };
|
|
2827
|
+
srv.close(() => resolve(p));
|
|
2828
|
+
});
|
|
2829
|
+
});
|
|
2830
|
+
const { db, proposed, approve, get } = await setup(
|
|
2831
|
+
"retry-closed",
|
|
2832
|
+
`http://127.0.0.1:${port}/grant?token=urlsecret`,
|
|
2833
|
+
"hmac-closed",
|
|
2834
|
+
);
|
|
2835
|
+
const approved = await approve();
|
|
2836
|
+
expect(approved.grantToken).toMatch(/^rgt_/);
|
|
2837
|
+
expect(approved.grantDelivery).toMatchObject({
|
|
2838
|
+
attempted: true,
|
|
2839
|
+
delivered: false,
|
|
2840
|
+
attempts: 3,
|
|
2841
|
+
host: `127.0.0.1:${port}`,
|
|
2842
|
+
});
|
|
2843
|
+
expect(approved.grantDelivery.error).toBeTruthy();
|
|
2844
|
+
expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
|
|
2845
|
+
expect(approved.proposal.callbackHost).toBeNull();
|
|
2846
|
+
const row = db.raw
|
|
2847
|
+
.prepare(
|
|
2848
|
+
`SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
|
|
2849
|
+
)
|
|
2850
|
+
.get(proposed.proposal.id) as {
|
|
2851
|
+
callback_url: string | null;
|
|
2852
|
+
callback_secret: string | null;
|
|
2853
|
+
};
|
|
2854
|
+
expect(row.callback_url).toBeNull();
|
|
2855
|
+
expect(row.callback_secret).toBeNull();
|
|
2856
|
+
const got = await get();
|
|
2857
|
+
expect(got.proposal.callbackDelivery).toMatchObject({
|
|
2858
|
+
status: "failed",
|
|
2859
|
+
attempts: 3,
|
|
2860
|
+
});
|
|
2861
|
+
expect(got.proposal.callbackDelivery?.lastError).toBeTruthy();
|
|
2862
|
+
db.close();
|
|
2863
|
+
}, 20_000);
|
|
2864
|
+
|
|
2865
|
+
it("does not retry a 4xx", async () => {
|
|
2866
|
+
const fetchMock = vi.fn(async () => new Response("no", { status: 403 }));
|
|
2867
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2868
|
+
const { db, approve, get } = await setup(
|
|
2869
|
+
"retry-4xx",
|
|
2870
|
+
"https://runner.example.com/g",
|
|
2871
|
+
);
|
|
2872
|
+
const approved = await approve();
|
|
2873
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
2874
|
+
expect(approved.grantToken).toMatch(/^rgt_/);
|
|
2875
|
+
expect(approved.grantDelivery).toMatchObject({
|
|
2876
|
+
attempted: true,
|
|
2877
|
+
delivered: false,
|
|
2878
|
+
attempts: 1,
|
|
2879
|
+
error: "HTTP 403",
|
|
2880
|
+
});
|
|
2881
|
+
expect((await get()).proposal.callbackDelivery?.status).toBe("failed");
|
|
2882
|
+
db.close();
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
it("reports attempted=false when no callback exists", async () => {
|
|
2886
|
+
const db = createMigratedSqliteDb();
|
|
2887
|
+
const { app, env } = createTestApp(db);
|
|
2888
|
+
const incident = await newIncident(db);
|
|
2889
|
+
const finding = await newFinding(db, incident.id);
|
|
2890
|
+
const proposed = (await (
|
|
2891
|
+
await app.request(
|
|
2892
|
+
"/internal/remediations/propose",
|
|
2893
|
+
json({
|
|
2894
|
+
environment: "prod",
|
|
2895
|
+
incidentId: incident.id,
|
|
2896
|
+
findingId: finding.id,
|
|
2897
|
+
idempotencyKey: "no-cb",
|
|
2898
|
+
actionKind: "rollback_deploy",
|
|
2899
|
+
targetNodeId: SERVICE_NODE,
|
|
2900
|
+
expectedCurrentState: {},
|
|
2901
|
+
desiredTargetState: {},
|
|
2902
|
+
}),
|
|
2903
|
+
env,
|
|
2904
|
+
)
|
|
2905
|
+
).json()) as { proposal: { id: string } };
|
|
2906
|
+
const approved = (await (
|
|
2907
|
+
await app.request(
|
|
2908
|
+
`/internal/remediations/${proposed.proposal.id}/approve`,
|
|
2909
|
+
approveInit({ environment: "prod" }),
|
|
2910
|
+
env,
|
|
2911
|
+
)
|
|
2912
|
+
).json()) as { grantDelivery: Delivery };
|
|
2913
|
+
expect(approved.grantDelivery).toEqual({
|
|
2914
|
+
attempted: false,
|
|
2915
|
+
delivered: false,
|
|
2916
|
+
attempts: 0,
|
|
2917
|
+
host: null,
|
|
2918
|
+
});
|
|
2919
|
+
db.close();
|
|
2920
|
+
});
|
|
2921
|
+
});
|
|
2222
2922
|
});
|