@obsunified/collector 2.2.0 → 2.2.3
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.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/index.d.ts +61 -3
- package/dist/index.js +10933 -9769
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +237 -44
- package/src/lib/autosre-ontology-cache.test.ts +78 -0
- package/src/lib/autosre-ontology.ts +52 -28
- package/src/lib/autosre-remediation-gate.ts +942 -348
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- 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/store/ingest.ts +8 -56
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
- 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 +10 -0
- package/src/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +374 -108
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +142 -0
- package/src/plugins/connected-routes/manifest.ts +123 -6
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +3 -3
- package/src/plugins/connected-routes.ts +56 -83
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +397 -25
- package/dist/chunk-KMEZKGSO.js.map +0 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
buildCanonicalNodeId,
|
|
3
|
+
buildFindingFingerprint,
|
|
4
|
+
} from "@obsunified/types";
|
|
2
5
|
import { Hono } from "hono";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
7
|
import { CollectorRuntime } from "../framework/collector";
|
|
5
8
|
import type { CollectorEnv } from "../framework/env";
|
|
6
9
|
import {
|
|
@@ -8,13 +11,25 @@ import {
|
|
|
8
11
|
findCrossTraceContention,
|
|
9
12
|
recordChangeEvent,
|
|
10
13
|
} from "../lib/autosre-change-correlation";
|
|
14
|
+
import { recordInvestigationFinding } from "../lib/autosre-findings";
|
|
11
15
|
import {
|
|
12
16
|
computeTarjanScc,
|
|
13
17
|
extractSpanLineageAndTopology,
|
|
14
18
|
localizeTraceFaultLocus,
|
|
15
19
|
upsertOntologyFromSpans,
|
|
16
20
|
} from "../lib/autosre-ontology";
|
|
17
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
acquireIncidentLease,
|
|
23
|
+
claimRemediationGrant,
|
|
24
|
+
completeRemediationProposal,
|
|
25
|
+
dispatchIncidentWebhook,
|
|
26
|
+
evaluateRemediationWatchdogs,
|
|
27
|
+
failRemediationProposal,
|
|
28
|
+
openOrDeduplicateIncident,
|
|
29
|
+
proposeRemediationAction,
|
|
30
|
+
RemediationGateError,
|
|
31
|
+
rejectRemediationProposal,
|
|
32
|
+
} from "../lib/autosre-remediation-gate";
|
|
18
33
|
import {
|
|
19
34
|
createMigratedSqliteDb,
|
|
20
35
|
type NodeSqliteDb,
|
|
@@ -207,7 +222,8 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
207
222
|
|
|
208
223
|
// Verify unscoped DB contention caps relationshipCertainty at "inferred"
|
|
209
224
|
const baseMs = Date.parse("2026-10-15T01:10:00.000Z");
|
|
210
|
-
const unscopedNodeId = unscopedCtx.targetNode
|
|
225
|
+
const unscopedNodeId = unscopedCtx.targetNode?.id;
|
|
226
|
+
if (!unscopedNodeId) throw new Error("expected an unscoped target node");
|
|
211
227
|
for (let i = 0; i < 6; i++) {
|
|
212
228
|
insertSpan(db, {
|
|
213
229
|
traceId: "tr_aggressor_unscoped",
|
|
@@ -629,8 +645,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
629
645
|
|
|
630
646
|
// 1. Seed concurrent v2.4.0 traffic (60 spans, 1 error = 1.67% error rate)
|
|
631
647
|
// and canary v2.4.1 traffic (60 spans, 15 errors = 25% error rate) with code stackframe in src/checkout/inventory.ts
|
|
632
|
-
const dbNodeId =
|
|
633
|
-
"database:prod:postgresql:pg-primary.internal:5432/orders";
|
|
648
|
+
const dbNodeId = "database:prod:postgresql:pg-primary.internal:5432/orders";
|
|
634
649
|
const serviceNodeId = "service:prod:default:checkout-api";
|
|
635
650
|
|
|
636
651
|
for (let i = 0; i < 60; i++) {
|
|
@@ -704,10 +719,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
704
719
|
commitSha: "8f3a1b2",
|
|
705
720
|
previousCommitSha: "1111111",
|
|
706
721
|
summary: "Deploy checkout-api v2.4.1 (batch inventory lock)",
|
|
707
|
-
touchedPaths: [
|
|
708
|
-
"src/checkout/inventory.ts",
|
|
709
|
-
"src/checkout/routes.ts",
|
|
710
|
-
],
|
|
722
|
+
touchedPaths: ["src/checkout/inventory.ts", "src/checkout/routes.ts"],
|
|
711
723
|
occurredAt: new Date(deployTime).toISOString(),
|
|
712
724
|
}),
|
|
713
725
|
},
|
|
@@ -835,7 +847,8 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
835
847
|
faultLocusEntityId: "sp_new_0",
|
|
836
848
|
rootCauseEntityKind: "change",
|
|
837
849
|
rootCauseEntityId: "chg_deploy_241",
|
|
838
|
-
contributingChangeId:
|
|
850
|
+
// No contributingChangeId: the server cannot recompute a cohort
|
|
851
|
+
// verdict, so the proof tier stays corroborated_candidate.
|
|
839
852
|
cohortVerdict: "shared_degradation_new_version_aggressor",
|
|
840
853
|
causalChain: [
|
|
841
854
|
{
|
|
@@ -852,7 +865,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
852
865
|
{
|
|
853
866
|
subjectKind: "span",
|
|
854
867
|
subjectId: "sp_new_0",
|
|
855
|
-
predicate: "
|
|
868
|
+
predicate: "touched_same_code_path",
|
|
856
869
|
objectKind: "change",
|
|
857
870
|
objectId: "chg_deploy_241",
|
|
858
871
|
relationshipCertainty: "exact",
|
|
@@ -926,9 +939,13 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
926
939
|
headers: {
|
|
927
940
|
"Content-Type": "application/json",
|
|
928
941
|
Cookie: "obs_session=sess_human_operator_123",
|
|
942
|
+
// Client-supplied identities must be ignored (B15).
|
|
929
943
|
"X-Obs-User-Actor": "sawan@obsunified.dev",
|
|
930
944
|
},
|
|
931
|
-
body: JSON.stringify({
|
|
945
|
+
body: JSON.stringify({
|
|
946
|
+
environment: "prod",
|
|
947
|
+
approvedByUserId: "ceo@example.com",
|
|
948
|
+
}),
|
|
932
949
|
},
|
|
933
950
|
env,
|
|
934
951
|
);
|
|
@@ -938,8 +955,11 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
938
955
|
grantToken: string;
|
|
939
956
|
};
|
|
940
957
|
expect(humanApproveBody.proposal.status).toBe("grant_issued");
|
|
941
|
-
expect(humanApproveBody.proposal.approvedByUserId).
|
|
942
|
-
|
|
958
|
+
expect(humanApproveBody.proposal.approvedByUserId).toMatch(
|
|
959
|
+
/^dashboard_session:[0-9a-f]{16}$/,
|
|
960
|
+
);
|
|
961
|
+
expect(humanApproveBody.proposal.approvedByUserId).not.toContain(
|
|
962
|
+
"sess_human_operator_123",
|
|
943
963
|
);
|
|
944
964
|
expect(humanApproveBody.grantToken).toMatch(/^rgt_/);
|
|
945
965
|
|
|
@@ -954,7 +974,8 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
954
974
|
sourceEntityType: "trace",
|
|
955
975
|
sourceEntityId: "tr_new_0",
|
|
956
976
|
name: "Reproduce checkout lock timeout",
|
|
957
|
-
expectedOutcome:
|
|
977
|
+
expectedOutcome:
|
|
978
|
+
"Checkout completes under 200ms without lock timeout",
|
|
958
979
|
}),
|
|
959
980
|
},
|
|
960
981
|
env,
|
|
@@ -1136,6 +1157,7 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
1136
1157
|
headers: { "Content-Type": "application/json" },
|
|
1137
1158
|
body: JSON.stringify({
|
|
1138
1159
|
environment: "prod",
|
|
1160
|
+
runnerId: "runner-alpha",
|
|
1139
1161
|
resultingChangeId: rollbackChange.id,
|
|
1140
1162
|
remediationAgentRunId: "arun_autosre_1",
|
|
1141
1163
|
}),
|
|
@@ -1185,9 +1207,20 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
1185
1207
|
)
|
|
1186
1208
|
.run(new Date(postRollbackBase + 40_000).toISOString(), finding.id);
|
|
1187
1209
|
|
|
1188
|
-
|
|
1210
|
+
// A single healthy checkpoint is not enough (B11): two consecutive
|
|
1211
|
+
// sufficient-traffic ok checkpoints are required before `recovered`.
|
|
1212
|
+
const watchdogFirstOk = await evaluateRemediationWatchdogs(db, {
|
|
1189
1213
|
nowIso: new Date(postRollbackBase + 50_000).toISOString(),
|
|
1190
1214
|
});
|
|
1215
|
+
expect(watchdogFirstOk.recovered).toBe(0);
|
|
1216
|
+
db.raw
|
|
1217
|
+
.prepare(
|
|
1218
|
+
`UPDATE investigation_findings SET next_watchdog_check_at = ? WHERE id = ?`,
|
|
1219
|
+
)
|
|
1220
|
+
.run(new Date(postRollbackBase + 60_000).toISOString(), finding.id);
|
|
1221
|
+
const watchdogRecovered = await evaluateRemediationWatchdogs(db, {
|
|
1222
|
+
nowIso: new Date(postRollbackBase + 70_000).toISOString(),
|
|
1223
|
+
});
|
|
1191
1224
|
expect(watchdogRecovered.recovered).toBe(1);
|
|
1192
1225
|
|
|
1193
1226
|
// Verify incident is now resolved and finding recoveryStatus === 'recovered'
|
|
@@ -1270,6 +1303,1510 @@ describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop
|
|
|
1270
1303
|
),
|
|
1271
1304
|
).toBe(true);
|
|
1272
1305
|
|
|
1306
|
+
const changeBundleRes = await app.request(
|
|
1307
|
+
`/internal/evidence/bundle`,
|
|
1308
|
+
{
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: { "Content-Type": "application/json" },
|
|
1311
|
+
body: JSON.stringify({
|
|
1312
|
+
intent: "triage_incident",
|
|
1313
|
+
anchor: {
|
|
1314
|
+
entityKind: "change",
|
|
1315
|
+
entityId: "chg_deploy_241",
|
|
1316
|
+
},
|
|
1317
|
+
}),
|
|
1318
|
+
},
|
|
1319
|
+
env,
|
|
1320
|
+
);
|
|
1321
|
+
expect(changeBundleRes.status).toBe(200);
|
|
1322
|
+
const changeBundleBody = (await changeBundleRes.json()) as {
|
|
1323
|
+
anchor: { entityKind: string; entityId: string };
|
|
1324
|
+
summary: string;
|
|
1325
|
+
};
|
|
1326
|
+
expect(changeBundleBody.anchor).toEqual({
|
|
1327
|
+
entityKind: "change",
|
|
1328
|
+
entityId: "chg_deploy_241",
|
|
1329
|
+
});
|
|
1330
|
+
expect(changeBundleBody.summary).toContain("chg_deploy_241");
|
|
1331
|
+
|
|
1332
|
+
const findingBundleRes = await app.request(
|
|
1333
|
+
`/internal/evidence/bundle`,
|
|
1334
|
+
{
|
|
1335
|
+
method: "POST",
|
|
1336
|
+
headers: { "Content-Type": "application/json" },
|
|
1337
|
+
body: JSON.stringify({
|
|
1338
|
+
intent: "triage_incident",
|
|
1339
|
+
anchor: {
|
|
1340
|
+
entityKind: "finding",
|
|
1341
|
+
entityId: finding.id,
|
|
1342
|
+
},
|
|
1343
|
+
}),
|
|
1344
|
+
},
|
|
1345
|
+
env,
|
|
1346
|
+
);
|
|
1347
|
+
expect(findingBundleRes.status).toBe(200);
|
|
1348
|
+
const findingBundleBody = (await findingBundleRes.json()) as {
|
|
1349
|
+
anchor: { entityKind: string; entityId: string };
|
|
1350
|
+
findings: Array<{ title: string }>;
|
|
1351
|
+
};
|
|
1352
|
+
expect(findingBundleBody.anchor).toEqual({
|
|
1353
|
+
entityKind: "finding",
|
|
1354
|
+
entityId: finding.id,
|
|
1355
|
+
});
|
|
1356
|
+
expect(
|
|
1357
|
+
findingBundleBody.findings.some((f) =>
|
|
1358
|
+
f.title.includes("Remediation proposal"),
|
|
1359
|
+
),
|
|
1360
|
+
).toBe(true);
|
|
1361
|
+
|
|
1362
|
+
db.close();
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
describe("AutoSRE governance hardening", () => {
|
|
1367
|
+
const DEPLOY = Date.parse("2026-10-15T03:00:00.000Z");
|
|
1368
|
+
const SERVICE_NODE = "service:prod:default:checkout-api";
|
|
1369
|
+
|
|
1370
|
+
afterEach(() => {
|
|
1371
|
+
vi.unstubAllGlobals();
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
/** v2.4.0 healthy, v2.4.1 at 25% errors, plus the deploy change event. */
|
|
1375
|
+
const seedIsolatedCohort = async (db: NodeSqliteDb) => {
|
|
1376
|
+
for (let i = 0; i < 60; i++) {
|
|
1377
|
+
const t = new Date(DEPLOY + (i + 1) * 1000).toISOString();
|
|
1378
|
+
const end = new Date(DEPLOY + (i + 1) * 1000 + 40).toISOString();
|
|
1379
|
+
insertSpan(db, {
|
|
1380
|
+
traceId: `g_base_${i}`,
|
|
1381
|
+
spanId: `g_base_sp_${i}`,
|
|
1382
|
+
serviceName: "checkout-api",
|
|
1383
|
+
spanName: "POST /checkout",
|
|
1384
|
+
statusCode: i === 0 ? 2 : 1,
|
|
1385
|
+
startTime: t,
|
|
1386
|
+
endTime: end,
|
|
1387
|
+
durationMs: 40,
|
|
1388
|
+
serviceVersion: "v2.4.0",
|
|
1389
|
+
});
|
|
1390
|
+
insertSpan(db, {
|
|
1391
|
+
traceId: `g_new_${i}`,
|
|
1392
|
+
spanId: `g_new_sp_${i}`,
|
|
1393
|
+
serviceName: "checkout-api",
|
|
1394
|
+
spanName: "POST /checkout",
|
|
1395
|
+
statusCode: i < 15 ? 2 : 1,
|
|
1396
|
+
startTime: t,
|
|
1397
|
+
endTime: end,
|
|
1398
|
+
durationMs: 40,
|
|
1399
|
+
serviceVersion: "v2.4.1",
|
|
1400
|
+
errorSignature: "checkout 500",
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
await recordChangeEvent(db, "default", {
|
|
1404
|
+
id: "chg_gov",
|
|
1405
|
+
environment: "prod",
|
|
1406
|
+
kind: "deploy",
|
|
1407
|
+
serviceName: "checkout-api",
|
|
1408
|
+
version: "v2.4.1",
|
|
1409
|
+
previousVersion: "v2.4.0",
|
|
1410
|
+
summary: "Deploy v2.4.1",
|
|
1411
|
+
occurredAt: new Date(DEPLOY).toISOString(),
|
|
1412
|
+
});
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
const newIncident = async (db: NodeSqliteDb, fingerprint = "fp_gov") =>
|
|
1416
|
+
(
|
|
1417
|
+
await openOrDeduplicateIncident(db, "default", {
|
|
1418
|
+
environment: "prod",
|
|
1419
|
+
targetNodeId: SERVICE_NODE,
|
|
1420
|
+
symptomAnchorKind: "trace",
|
|
1421
|
+
symptomAnchorId: "t1",
|
|
1422
|
+
symptomFingerprint: fingerprint,
|
|
1423
|
+
symptomCategory: fingerprint,
|
|
1424
|
+
})
|
|
1425
|
+
).incident;
|
|
1426
|
+
|
|
1427
|
+
const newFinding = (
|
|
1428
|
+
db: NodeSqliteDb,
|
|
1429
|
+
incidentId: string,
|
|
1430
|
+
extra: Partial<Parameters<typeof recordInvestigationFinding>[2]> = {},
|
|
1431
|
+
) =>
|
|
1432
|
+
recordInvestigationFinding(db, "default", {
|
|
1433
|
+
environment: "prod",
|
|
1434
|
+
incidentId,
|
|
1435
|
+
title: "Checkout regression",
|
|
1436
|
+
summary: "Errors after deploy",
|
|
1437
|
+
causalProofTier: "corroborated_candidate",
|
|
1438
|
+
symptomEntityKind: "trace",
|
|
1439
|
+
symptomEntityId: "t1",
|
|
1440
|
+
faultLocusEntityKind: "service",
|
|
1441
|
+
faultLocusEntityId: SERVICE_NODE,
|
|
1442
|
+
causalChain: [],
|
|
1443
|
+
citedEvidenceIds: [],
|
|
1444
|
+
...extra,
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
const propose = (
|
|
1448
|
+
db: NodeSqliteDb,
|
|
1449
|
+
incidentId: string,
|
|
1450
|
+
findingId: string,
|
|
1451
|
+
key: string,
|
|
1452
|
+
extra: Record<string, unknown> = {},
|
|
1453
|
+
) =>
|
|
1454
|
+
proposeRemediationAction(db, "default", {
|
|
1455
|
+
environment: "prod",
|
|
1456
|
+
incidentId,
|
|
1457
|
+
findingId,
|
|
1458
|
+
idempotencyKey: key,
|
|
1459
|
+
actionKind: "rollback_deploy",
|
|
1460
|
+
targetNodeId: SERVICE_NODE,
|
|
1461
|
+
expectedCurrentState: {},
|
|
1462
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
1463
|
+
requestedAutonomy: "autonomous_write",
|
|
1464
|
+
...extra,
|
|
1465
|
+
} as Parameters<typeof proposeRemediationAction>[2]);
|
|
1466
|
+
|
|
1467
|
+
const grantedProposal = async (db: NodeSqliteDb, key = "idem_granted") => {
|
|
1468
|
+
await seedIsolatedCohort(db);
|
|
1469
|
+
const incident = await newIncident(db);
|
|
1470
|
+
const finding = await newFinding(db, incident.id, {
|
|
1471
|
+
contributingChangeId: "chg_gov",
|
|
1472
|
+
});
|
|
1473
|
+
const proposal = await propose(db, incident.id, finding.id, key);
|
|
1474
|
+
return { incident, finding, proposal };
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
it("B1: a client-asserted isolated_to_new_version verdict cannot buy an autonomous rollback grant", async () => {
|
|
1478
|
+
const db = createMigratedSqliteDb();
|
|
1479
|
+
const incident = await newIncident(db);
|
|
1480
|
+
const finding = await newFinding(db, incident.id, {
|
|
1481
|
+
cohortVerdict: "isolated_to_new_version",
|
|
1482
|
+
causalProofTier: "cohort_rate_isolated",
|
|
1483
|
+
causalChain: [
|
|
1484
|
+
{
|
|
1485
|
+
subjectKind: "service",
|
|
1486
|
+
subjectId: SERVICE_NODE,
|
|
1487
|
+
predicate: "isolated_to_version_cohort",
|
|
1488
|
+
objectKind: "change",
|
|
1489
|
+
objectId: "chg_missing",
|
|
1490
|
+
relationshipCertainty: "exact",
|
|
1491
|
+
causalProofTier: "cohort_rate_isolated",
|
|
1492
|
+
confidence: 0.99,
|
|
1493
|
+
reason: "asserted by client",
|
|
1494
|
+
},
|
|
1495
|
+
],
|
|
1496
|
+
});
|
|
1497
|
+
expect(finding.causalProofTier).toBe("corroborated_candidate");
|
|
1498
|
+
expect(finding.cohortVerdict).toBeNull();
|
|
1499
|
+
|
|
1500
|
+
const proposal = await propose(db, incident.id, finding.id, "idem_b1");
|
|
1501
|
+
expect(proposal.status).toBe("pending_human_approval");
|
|
1502
|
+
expect(proposal.grantToken).toBeUndefined();
|
|
1503
|
+
|
|
1504
|
+
// Pointing at a real change whose telemetry does not show isolation
|
|
1505
|
+
// (no spans at all) does not help either.
|
|
1506
|
+
await recordChangeEvent(db, "default", {
|
|
1507
|
+
id: "chg_empty",
|
|
1508
|
+
environment: "prod",
|
|
1509
|
+
kind: "deploy",
|
|
1510
|
+
serviceName: "checkout-api",
|
|
1511
|
+
version: "v9",
|
|
1512
|
+
summary: "no telemetry",
|
|
1513
|
+
});
|
|
1514
|
+
const f2 = await newFinding(db, incident.id, {
|
|
1515
|
+
cohortVerdict: "isolated_to_new_version",
|
|
1516
|
+
contributingChangeId: "chg_empty",
|
|
1517
|
+
});
|
|
1518
|
+
expect(f2.causalProofTier).toBe("corroborated_candidate");
|
|
1519
|
+
db.close();
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it("B1: the server-recomputed isolated verdict is what yields cohort_rate_isolated and an autonomous grant", async () => {
|
|
1523
|
+
const db = createMigratedSqliteDb();
|
|
1524
|
+
const { finding, proposal } = await grantedProposal(db);
|
|
1525
|
+
expect(finding.causalProofTier).toBe("cohort_rate_isolated");
|
|
1526
|
+
expect(finding.cohortVerdict).toBe("isolated_to_new_version");
|
|
1527
|
+
expect(proposal.status).toBe("grant_issued");
|
|
1528
|
+
expect(proposal.grantToken).toMatch(/^rgt_/);
|
|
1529
|
+
db.close();
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it("B2: a grant token is single-use, sequentially and under concurrent claims", async () => {
|
|
1533
|
+
const db = createMigratedSqliteDb();
|
|
1534
|
+
const { proposal } = await grantedProposal(db);
|
|
1535
|
+
const token = proposal.grantToken as string;
|
|
1536
|
+
|
|
1537
|
+
await claimRemediationGrant(db, "default", proposal.id, {
|
|
1538
|
+
environment: "prod",
|
|
1539
|
+
grantToken: token,
|
|
1540
|
+
runnerId: "r1",
|
|
1541
|
+
});
|
|
1542
|
+
await expect(
|
|
1543
|
+
claimRemediationGrant(db, "default", proposal.id, {
|
|
1544
|
+
environment: "prod",
|
|
1545
|
+
grantToken: token,
|
|
1546
|
+
runnerId: "r2",
|
|
1547
|
+
}),
|
|
1548
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1549
|
+
|
|
1550
|
+
// Concurrent claims on a fresh grant: exactly one wins.
|
|
1551
|
+
const second = await propose(
|
|
1552
|
+
db,
|
|
1553
|
+
proposal.incidentId,
|
|
1554
|
+
proposal.findingId,
|
|
1555
|
+
"idem_b2_conc",
|
|
1556
|
+
{ actionKind: "toggle_feature_flag" },
|
|
1557
|
+
);
|
|
1558
|
+
const results = await Promise.allSettled(
|
|
1559
|
+
["a", "b", "c", "d"].map((runnerId) =>
|
|
1560
|
+
claimRemediationGrant(db, "default", second.id, {
|
|
1561
|
+
environment: "prod",
|
|
1562
|
+
grantToken: second.grantToken as string,
|
|
1563
|
+
runnerId,
|
|
1564
|
+
}),
|
|
1565
|
+
),
|
|
1566
|
+
);
|
|
1567
|
+
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
|
1568
|
+
for (const r of results) {
|
|
1569
|
+
if (r.status === "rejected") {
|
|
1570
|
+
expect(r.reason).toBeInstanceOf(RemediationGateError);
|
|
1571
|
+
expect((r.reason as RemediationGateError).statusCode).toBe(409);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
db.close();
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it("B2: a wrong token is rejected with 403 and does not consume the grant", async () => {
|
|
1578
|
+
const db = createMigratedSqliteDb();
|
|
1579
|
+
const { proposal } = await grantedProposal(db);
|
|
1580
|
+
await expect(
|
|
1581
|
+
claimRemediationGrant(db, "default", proposal.id, {
|
|
1582
|
+
environment: "prod",
|
|
1583
|
+
grantToken: "rgt_wrong",
|
|
1584
|
+
}),
|
|
1585
|
+
).rejects.toMatchObject({ statusCode: 403 });
|
|
1586
|
+
const ok = await claimRemediationGrant(db, "default", proposal.id, {
|
|
1587
|
+
environment: "prod",
|
|
1588
|
+
grantToken: proposal.grantToken as string,
|
|
1589
|
+
});
|
|
1590
|
+
expect(ok.status).toBe("claimed_executing");
|
|
1591
|
+
db.close();
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
it("B3: concurrent lease acquisition has exactly one winner; expired leases can be taken over", async () => {
|
|
1595
|
+
const db = createMigratedSqliteDb();
|
|
1596
|
+
const incident = await newIncident(db);
|
|
1597
|
+
const results = await Promise.all(
|
|
1598
|
+
["a", "b", "c"].map((runnerId) =>
|
|
1599
|
+
acquireIncidentLease(db, "default", incident.id, {
|
|
1600
|
+
environment: "prod",
|
|
1601
|
+
runnerId,
|
|
1602
|
+
}),
|
|
1603
|
+
),
|
|
1604
|
+
);
|
|
1605
|
+
const winners = results.filter((r) => r.acquired);
|
|
1606
|
+
expect(winners).toHaveLength(1);
|
|
1607
|
+
const owner = (winners[0] as { incident: { leaseOwnerId: string } })
|
|
1608
|
+
.incident.leaseOwnerId;
|
|
1609
|
+
// The owner can renew; a rival cannot until expiry.
|
|
1610
|
+
expect(
|
|
1611
|
+
(
|
|
1612
|
+
await acquireIncidentLease(db, "default", incident.id, {
|
|
1613
|
+
environment: "prod",
|
|
1614
|
+
runnerId: owner,
|
|
1615
|
+
})
|
|
1616
|
+
).acquired,
|
|
1617
|
+
).toBe(true);
|
|
1618
|
+
const rival = owner === "a" ? "b" : "a";
|
|
1619
|
+
expect(
|
|
1620
|
+
(
|
|
1621
|
+
await acquireIncidentLease(db, "default", incident.id, {
|
|
1622
|
+
environment: "prod",
|
|
1623
|
+
runnerId: rival,
|
|
1624
|
+
})
|
|
1625
|
+
).acquired,
|
|
1626
|
+
).toBe(false);
|
|
1627
|
+
const later = new Date(Date.now() + 3600_000).toISOString();
|
|
1628
|
+
const takeover = await acquireIncidentLease(db, "default", incident.id, {
|
|
1629
|
+
environment: "prod",
|
|
1630
|
+
runnerId: rival,
|
|
1631
|
+
nowIso: later,
|
|
1632
|
+
});
|
|
1633
|
+
expect(takeover.acquired).toBe(true);
|
|
1634
|
+
db.close();
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
it("B4: concurrent alerts for one symptom open exactly one active incident", async () => {
|
|
1638
|
+
const db = createMigratedSqliteDb();
|
|
1639
|
+
const results = await Promise.all(
|
|
1640
|
+
Array.from({ length: 6 }, () =>
|
|
1641
|
+
openOrDeduplicateIncident(db, "default", {
|
|
1642
|
+
environment: "prod",
|
|
1643
|
+
targetNodeId: SERVICE_NODE,
|
|
1644
|
+
symptomAnchorKind: "alert",
|
|
1645
|
+
symptomAnchorId: "a1",
|
|
1646
|
+
symptomCategory: "race",
|
|
1647
|
+
}),
|
|
1648
|
+
),
|
|
1649
|
+
);
|
|
1650
|
+
expect(results.filter((r) => !r.deduplicated)).toHaveLength(1);
|
|
1651
|
+
expect(new Set(results.map((r) => r.incident.id)).size).toBe(1);
|
|
1652
|
+
const count = db.raw
|
|
1653
|
+
.prepare(`SELECT COUNT(*) AS n FROM incident_investigations`)
|
|
1654
|
+
.get() as { n: number };
|
|
1655
|
+
expect(count.n).toBe(1);
|
|
1656
|
+
expect(results[0].incident.alertCount).toBeGreaterThanOrEqual(1);
|
|
1657
|
+
|
|
1658
|
+
// The database itself refuses a second active row for the same key.
|
|
1659
|
+
expect(() =>
|
|
1660
|
+
db.raw
|
|
1661
|
+
.prepare(
|
|
1662
|
+
`INSERT INTO incident_investigations (project_id, environment, id, dedup_key, status, target_node_id, symptom_anchor_kind, symptom_anchor_id, opened_at, updated_at)
|
|
1663
|
+
SELECT project_id, environment, 'dupe', dedup_key, 'open', target_node_id, symptom_anchor_kind, symptom_anchor_id, opened_at, updated_at FROM incident_investigations`,
|
|
1664
|
+
)
|
|
1665
|
+
.run(),
|
|
1666
|
+
).toThrow(/UNIQUE/i);
|
|
1667
|
+
// Once resolved, a new incident for the same symptom is allowed.
|
|
1668
|
+
db.raw
|
|
1669
|
+
.prepare(`UPDATE incident_investigations SET status = 'resolved'`)
|
|
1670
|
+
.run();
|
|
1671
|
+
const reopened = await openOrDeduplicateIncident(db, "default", {
|
|
1672
|
+
environment: "prod",
|
|
1673
|
+
targetNodeId: SERVICE_NODE,
|
|
1674
|
+
symptomAnchorKind: "alert",
|
|
1675
|
+
symptomAnchorId: "a1",
|
|
1676
|
+
symptomCategory: "race",
|
|
1677
|
+
});
|
|
1678
|
+
expect(reopened.deduplicated).toBe(false);
|
|
1679
|
+
db.close();
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it("B5: concurrent proposals with one idempotency key return the same row instead of a DB error", async () => {
|
|
1683
|
+
const db = createMigratedSqliteDb();
|
|
1684
|
+
const incident = await newIncident(db);
|
|
1685
|
+
const finding = await newFinding(db, incident.id);
|
|
1686
|
+
const results = await Promise.all(
|
|
1687
|
+
Array.from({ length: 4 }, () =>
|
|
1688
|
+
propose(db, incident.id, finding.id, "idem_same"),
|
|
1689
|
+
),
|
|
1690
|
+
);
|
|
1691
|
+
expect(new Set(results.map((r) => r.id)).size).toBe(1);
|
|
1692
|
+
await expect(
|
|
1693
|
+
propose(db, incident.id, finding.id, "idem_same", {
|
|
1694
|
+
actionKind: "apply_config",
|
|
1695
|
+
}),
|
|
1696
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1697
|
+
db.close();
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it("B6: the watchdog route ignores a client clock and only evaluates the caller's project", async () => {
|
|
1701
|
+
const db = createMigratedSqliteDb();
|
|
1702
|
+
const { app, env } = createTestApp(db);
|
|
1703
|
+
db.raw
|
|
1704
|
+
.prepare(
|
|
1705
|
+
`INSERT INTO investigation_findings (id, project_id, environment, fingerprint, fingerprint_input_json, title, summary, causal_proof_tier, causal_verification_status, confidence, confidence_breakdown_json, symptom_entity_kind, symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id, causal_chain_json, cited_evidence_ids_json, actor_type, actor_id, created_at, updated_at, recovery_status, watchdog_step, next_watchdog_check_at)
|
|
1706
|
+
VALUES ('fnd_other', 'other-project', 'prod', 'fp', '{}', 't', 's', 'corroborated_candidate', 'unverified', 0.5, '{}', 'trace', 't', 'service', 'service:prod:default:x', '[]', '[]', 'agent', 'a', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', 'monitoring', 1, '2026-01-01T00:02:00.000Z')`,
|
|
1707
|
+
)
|
|
1708
|
+
.run();
|
|
1709
|
+
const res = await app.request(
|
|
1710
|
+
"/internal/remediations/watchdog",
|
|
1711
|
+
{
|
|
1712
|
+
method: "POST",
|
|
1713
|
+
headers: { "Content-Type": "application/json" },
|
|
1714
|
+
body: JSON.stringify({
|
|
1715
|
+
nowIso: "2099-01-01T00:00:00.000Z",
|
|
1716
|
+
minComparableRequests: 0,
|
|
1717
|
+
}),
|
|
1718
|
+
},
|
|
1719
|
+
env,
|
|
1720
|
+
);
|
|
1721
|
+
expect(res.status).toBe(200);
|
|
1722
|
+
expect(((await res.json()) as { evaluated: number }).evaluated).toBe(0);
|
|
1723
|
+
const row = db.raw
|
|
1724
|
+
.prepare(
|
|
1725
|
+
`SELECT recovery_status FROM investigation_findings WHERE id = 'fnd_other'`,
|
|
1726
|
+
)
|
|
1727
|
+
.get() as { recovery_status: string };
|
|
1728
|
+
expect(row.recovery_status).toBe("monitoring");
|
|
1729
|
+
db.close();
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it("B7: only the claiming runner can complete or fail, and only while claimed (and, for complete, unexpired)", async () => {
|
|
1733
|
+
const db = createMigratedSqliteDb();
|
|
1734
|
+
const { proposal } = await grantedProposal(db);
|
|
1735
|
+
// Not yet claimed.
|
|
1736
|
+
await expect(
|
|
1737
|
+
completeRemediationProposal(db, "default", proposal.id, {
|
|
1738
|
+
environment: "prod",
|
|
1739
|
+
runnerId: "r1",
|
|
1740
|
+
}),
|
|
1741
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1742
|
+
await expect(
|
|
1743
|
+
failRemediationProposal(db, "default", proposal.id, {
|
|
1744
|
+
environment: "prod",
|
|
1745
|
+
runnerId: "r1",
|
|
1746
|
+
failureReason: "x",
|
|
1747
|
+
}),
|
|
1748
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1749
|
+
|
|
1750
|
+
const t0 = new Date().toISOString();
|
|
1751
|
+
await claimRemediationGrant(db, "default", proposal.id, {
|
|
1752
|
+
environment: "prod",
|
|
1753
|
+
grantToken: proposal.grantToken as string,
|
|
1754
|
+
runnerId: "r1",
|
|
1755
|
+
nowIso: t0,
|
|
1756
|
+
});
|
|
1757
|
+
await expect(
|
|
1758
|
+
completeRemediationProposal(db, "default", proposal.id, {
|
|
1759
|
+
environment: "prod",
|
|
1760
|
+
runnerId: "someone-else",
|
|
1761
|
+
}),
|
|
1762
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1763
|
+
await expect(
|
|
1764
|
+
completeRemediationProposal(db, "default", proposal.id, {
|
|
1765
|
+
environment: "prod",
|
|
1766
|
+
}),
|
|
1767
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1768
|
+
await expect(
|
|
1769
|
+
failRemediationProposal(db, "default", proposal.id, {
|
|
1770
|
+
environment: "prod",
|
|
1771
|
+
runnerId: "someone-else",
|
|
1772
|
+
failureReason: "x",
|
|
1773
|
+
}),
|
|
1774
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1775
|
+
await expect(
|
|
1776
|
+
completeRemediationProposal(db, "default", proposal.id, {
|
|
1777
|
+
environment: "prod",
|
|
1778
|
+
runnerId: "r1",
|
|
1779
|
+
nowIso: new Date(Date.parse(t0) + 6 * 60_000).toISOString(),
|
|
1780
|
+
}),
|
|
1781
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1782
|
+
|
|
1783
|
+
const failed = await failRemediationProposal(db, "default", proposal.id, {
|
|
1784
|
+
environment: "prod",
|
|
1785
|
+
runnerId: "r1",
|
|
1786
|
+
failureReason: "boom",
|
|
1787
|
+
});
|
|
1788
|
+
expect(failed.status).toBe("failed_execution");
|
|
1789
|
+
// Terminal: cannot be failed or completed again.
|
|
1790
|
+
await expect(
|
|
1791
|
+
failRemediationProposal(db, "default", proposal.id, {
|
|
1792
|
+
environment: "prod",
|
|
1793
|
+
runnerId: "r1",
|
|
1794
|
+
failureReason: "again",
|
|
1795
|
+
}),
|
|
1796
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
1797
|
+
db.close();
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
it("B8: incident webhooks refuse private targets and redirects, never follow redirects, and time out", async () => {
|
|
1801
|
+
const db = createMigratedSqliteDb();
|
|
1802
|
+
const incident = await newIncident(db);
|
|
1803
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
1804
|
+
const okFetch = (async (url: string, init: RequestInit) => {
|
|
1805
|
+
calls.push({ url, init });
|
|
1806
|
+
return new Response("ok", { status: 200 });
|
|
1807
|
+
}) as unknown as typeof fetch;
|
|
1808
|
+
|
|
1809
|
+
const privateLookup = vi.fn(async () => ["10.0.0.5"]);
|
|
1810
|
+
const refused = await dispatchIncidentWebhook(db, "default", incident.id, {
|
|
1811
|
+
environment: "prod",
|
|
1812
|
+
url: "https://internal.example.com/hook",
|
|
1813
|
+
hostLookup: privateLookup,
|
|
1814
|
+
fetchImpl: okFetch,
|
|
1815
|
+
});
|
|
1816
|
+
expect(refused.delivered).toBe(false);
|
|
1817
|
+
expect(refused.error).toMatch(/private/);
|
|
1818
|
+
const literal = await dispatchIncidentWebhook(db, "default", incident.id, {
|
|
1819
|
+
environment: "prod",
|
|
1820
|
+
url: "http://127.0.0.1:8080/hook",
|
|
1821
|
+
fetchImpl: okFetch,
|
|
1822
|
+
});
|
|
1823
|
+
expect(literal.delivered).toBe(false);
|
|
1824
|
+
const unresolvable = await dispatchIncidentWebhook(
|
|
1825
|
+
db,
|
|
1826
|
+
"default",
|
|
1827
|
+
incident.id,
|
|
1828
|
+
{
|
|
1829
|
+
environment: "prod",
|
|
1830
|
+
url: "https://nowhere.example.com/hook",
|
|
1831
|
+
hostLookup: async () => {
|
|
1832
|
+
throw new Error("ENOTFOUND");
|
|
1833
|
+
},
|
|
1834
|
+
fetchImpl: okFetch,
|
|
1835
|
+
},
|
|
1836
|
+
);
|
|
1837
|
+
expect(unresolvable.delivered).toBe(false);
|
|
1838
|
+
expect(calls).toHaveLength(0);
|
|
1839
|
+
|
|
1840
|
+
const delivered = await dispatchIncidentWebhook(
|
|
1841
|
+
db,
|
|
1842
|
+
"default",
|
|
1843
|
+
incident.id,
|
|
1844
|
+
{
|
|
1845
|
+
environment: "prod",
|
|
1846
|
+
url: "https://hooks.example.com/hook",
|
|
1847
|
+
secret: "s3cret",
|
|
1848
|
+
hostLookup: async () => ["93.184.216.34"],
|
|
1849
|
+
fetchImpl: okFetch,
|
|
1850
|
+
},
|
|
1851
|
+
);
|
|
1852
|
+
expect(delivered.delivered).toBe(true);
|
|
1853
|
+
expect(calls).toHaveLength(1);
|
|
1854
|
+
expect(calls[0].init.redirect).toBe("manual");
|
|
1855
|
+
expect(calls[0].init.signal).toBeInstanceOf(AbortSignal);
|
|
1856
|
+
expect(
|
|
1857
|
+
(calls[0].init.headers as Record<string, string>)["X-Obs-Signature-256"],
|
|
1858
|
+
).toMatch(/^sha256=[0-9a-f]{64}$/);
|
|
1859
|
+
|
|
1860
|
+
const redirected = await dispatchIncidentWebhook(
|
|
1861
|
+
db,
|
|
1862
|
+
"default",
|
|
1863
|
+
incident.id,
|
|
1864
|
+
{
|
|
1865
|
+
environment: "prod",
|
|
1866
|
+
url: "https://hooks.example.com/hook",
|
|
1867
|
+
hostLookup: async () => ["93.184.216.34"],
|
|
1868
|
+
fetchImpl: (async () =>
|
|
1869
|
+
new Response(null, {
|
|
1870
|
+
status: 302,
|
|
1871
|
+
headers: { Location: "http://169.254.169.254/" },
|
|
1872
|
+
})) as unknown as typeof fetch,
|
|
1873
|
+
},
|
|
1874
|
+
);
|
|
1875
|
+
expect(redirected.delivered).toBe(false);
|
|
1876
|
+
expect(redirected.error).toMatch(/Redirect/);
|
|
1877
|
+
db.close();
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
it("B8: the dispatch route cannot disable the SSRF guard from the request body", async () => {
|
|
1881
|
+
const db = createMigratedSqliteDb();
|
|
1882
|
+
const { app, env } = createTestApp(db);
|
|
1883
|
+
const incident = await newIncident(db);
|
|
1884
|
+
const fetchSpy = vi.fn(async () => new Response("ok"));
|
|
1885
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
1886
|
+
const res = await app.request(
|
|
1887
|
+
`/internal/incidents/${incident.id}/dispatch`,
|
|
1888
|
+
{
|
|
1889
|
+
method: "POST",
|
|
1890
|
+
headers: { "Content-Type": "application/json" },
|
|
1891
|
+
body: JSON.stringify({
|
|
1892
|
+
environment: "prod",
|
|
1893
|
+
webhookUrl: "http://127.0.0.1:9/hook",
|
|
1894
|
+
allowPrivateNetworks: true,
|
|
1895
|
+
allowPrivateWebhookTargets: true,
|
|
1896
|
+
}),
|
|
1897
|
+
},
|
|
1898
|
+
env,
|
|
1899
|
+
);
|
|
1900
|
+
expect(res.status).toBe(502);
|
|
1901
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1902
|
+
|
|
1903
|
+
// The operator-level env flag is the only way to allow private targets.
|
|
1904
|
+
const allowed = await app.request(
|
|
1905
|
+
`/internal/incidents/${incident.id}/dispatch`,
|
|
1906
|
+
{
|
|
1907
|
+
method: "POST",
|
|
1908
|
+
headers: { "Content-Type": "application/json" },
|
|
1909
|
+
body: JSON.stringify({
|
|
1910
|
+
environment: "prod",
|
|
1911
|
+
webhookUrl: "http://127.0.0.1:9/hook",
|
|
1912
|
+
}),
|
|
1913
|
+
},
|
|
1914
|
+
{ ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" },
|
|
1915
|
+
);
|
|
1916
|
+
expect(allowed.status).toBe(200);
|
|
1917
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
1918
|
+
db.close();
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
it("B9: approve -> grant token pushed over the webhook -> claim succeeds once -> second claim 409", async () => {
|
|
1922
|
+
const db = createMigratedSqliteDb();
|
|
1923
|
+
const { app, env } = createTestApp(db);
|
|
1924
|
+
const incident = await newIncident(db);
|
|
1925
|
+
const finding = await newFinding(db, incident.id); // corroborated: needs a human
|
|
1926
|
+
const proposal = await propose(db, incident.id, finding.id, "idem_b9");
|
|
1927
|
+
expect(proposal.status).toBe("pending_human_approval");
|
|
1928
|
+
|
|
1929
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
1930
|
+
vi.stubGlobal(
|
|
1931
|
+
"fetch",
|
|
1932
|
+
vi.fn(async (_url: string, init: RequestInit) => {
|
|
1933
|
+
bodies.push(JSON.parse(String(init.body)));
|
|
1934
|
+
return new Response("ok", { status: 200 });
|
|
1935
|
+
}),
|
|
1936
|
+
);
|
|
1937
|
+
const approveEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
1938
|
+
const approve = await app.request(
|
|
1939
|
+
`/internal/remediations/${proposal.id}/approve`,
|
|
1940
|
+
{
|
|
1941
|
+
method: "POST",
|
|
1942
|
+
headers: {
|
|
1943
|
+
"Content-Type": "application/json",
|
|
1944
|
+
Cookie: "obs_session=sess_b9",
|
|
1945
|
+
},
|
|
1946
|
+
body: JSON.stringify({
|
|
1947
|
+
environment: "prod",
|
|
1948
|
+
webhookUrl: "https://runner.example.com/grants",
|
|
1949
|
+
secret: "hmac-secret",
|
|
1950
|
+
}),
|
|
1951
|
+
},
|
|
1952
|
+
approveEnv,
|
|
1953
|
+
);
|
|
1954
|
+
expect(approve.status).toBe(200);
|
|
1955
|
+
const approved = (await approve.json()) as {
|
|
1956
|
+
grantToken: string;
|
|
1957
|
+
webhookDelivery?: { delivered: boolean };
|
|
1958
|
+
};
|
|
1959
|
+
expect(approved.grantToken).toMatch(/^rgt_/);
|
|
1960
|
+
expect(approved.webhookDelivery?.delivered).toBe(true);
|
|
1961
|
+
expect(bodies).toHaveLength(1);
|
|
1962
|
+
expect(bodies[0]).toMatchObject({
|
|
1963
|
+
event: "autosre.remediation.grant_issued",
|
|
1964
|
+
proposalId: proposal.id,
|
|
1965
|
+
status: "grant_issued",
|
|
1966
|
+
grantToken: approved.grantToken,
|
|
1967
|
+
incidentId: incident.id,
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
const claim = (runnerId: string) =>
|
|
1971
|
+
app.request(
|
|
1972
|
+
`/internal/remediations/${proposal.id}/claim`,
|
|
1973
|
+
{
|
|
1974
|
+
method: "POST",
|
|
1975
|
+
headers: { "Content-Type": "application/json" },
|
|
1976
|
+
body: JSON.stringify({
|
|
1977
|
+
environment: "prod",
|
|
1978
|
+
grantToken: approved.grantToken,
|
|
1979
|
+
runnerId,
|
|
1980
|
+
}),
|
|
1981
|
+
},
|
|
1982
|
+
env,
|
|
1983
|
+
);
|
|
1984
|
+
expect((await claim("runner-x")).status).toBe(200);
|
|
1985
|
+
expect((await claim("runner-y")).status).toBe(409);
|
|
1986
|
+
db.close();
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
it("B12: the circuit breaker is scoped to fingerprint + action kind and ignores rejected proposals", async () => {
|
|
1990
|
+
const db = createMigratedSqliteDb();
|
|
1991
|
+
const incident = await newIncident(db);
|
|
1992
|
+
const finding = await newFinding(db, incident.id);
|
|
1993
|
+
const mk = (key: string, extra: Record<string, unknown> = {}) =>
|
|
1994
|
+
propose(db, incident.id, finding.id, key, extra);
|
|
1995
|
+
|
|
1996
|
+
const a = await mk("k1");
|
|
1997
|
+
await mk("k2");
|
|
1998
|
+
await mk("k3");
|
|
1999
|
+
// A different action kind has its own budget.
|
|
2000
|
+
expect(
|
|
2001
|
+
(await mk("k_other", { actionKind: "apply_config" })).status,
|
|
2002
|
+
).not.toBe("rejected");
|
|
2003
|
+
// Rejecting one frees a slot, so the next proposal is not blocked.
|
|
2004
|
+
await rejectRemediationProposal(db, "default", a.id, {
|
|
2005
|
+
environment: "prod",
|
|
2006
|
+
rejectedByUserId: "tester",
|
|
2007
|
+
});
|
|
2008
|
+
expect((await mk("k4")).status).toBe("pending_human_approval");
|
|
2009
|
+
// Three live ones now exist: the next is blocked by policy.
|
|
2010
|
+
const blocked = await mk("k5");
|
|
2011
|
+
expect(blocked.status).toBe("rejected");
|
|
2012
|
+
expect(blocked.policyDecisionReason).toMatch(/Circuit breaker/);
|
|
2013
|
+
db.close();
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
it("B18: unknown incidents 404 and read_only/suggested_action requests never become grants", async () => {
|
|
2017
|
+
const db = createMigratedSqliteDb();
|
|
2018
|
+
const { incident, finding } = await (async () => {
|
|
2019
|
+
await seedIsolatedCohort(db);
|
|
2020
|
+
const incident = await newIncident(db);
|
|
2021
|
+
const finding = await newFinding(db, incident.id, {
|
|
2022
|
+
contributingChangeId: "chg_gov",
|
|
2023
|
+
});
|
|
2024
|
+
return { incident, finding };
|
|
2025
|
+
})();
|
|
2026
|
+
await expect(
|
|
2027
|
+
propose(db, "inc_missing", finding.id, "idem_404"),
|
|
2028
|
+
).rejects.toMatchObject({ statusCode: 404 });
|
|
2029
|
+
for (const level of ["read_only", "suggested_action"]) {
|
|
2030
|
+
const p = await propose(db, incident.id, finding.id, `idem_${level}`, {
|
|
2031
|
+
requestedAutonomy: level,
|
|
2032
|
+
});
|
|
2033
|
+
expect(p.status).toBe("pending_human_approval");
|
|
2034
|
+
expect(p.effectiveAutonomy).toBe(level);
|
|
2035
|
+
expect(p.grantToken).toBeUndefined();
|
|
2036
|
+
}
|
|
2037
|
+
db.close();
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
it("B11: watchdog checkpoints are anchored to completion time and recovery needs two consecutive healthy checkpoints", async () => {
|
|
2041
|
+
const db = createMigratedSqliteDb();
|
|
2042
|
+
const { proposal, incident, finding } = await grantedProposal(db);
|
|
2043
|
+
const t0 = Date.now();
|
|
2044
|
+
const at = (offsetMs: number) => new Date(t0 + offsetMs).toISOString();
|
|
2045
|
+
await claimRemediationGrant(db, "default", proposal.id, {
|
|
2046
|
+
environment: "prod",
|
|
2047
|
+
grantToken: proposal.grantToken as string,
|
|
2048
|
+
runnerId: "r1",
|
|
2049
|
+
nowIso: at(0),
|
|
2050
|
+
});
|
|
2051
|
+
await completeRemediationProposal(db, "default", proposal.id, {
|
|
2052
|
+
environment: "prod",
|
|
2053
|
+
runnerId: "r1",
|
|
2054
|
+
nowIso: at(60_000),
|
|
2055
|
+
});
|
|
2056
|
+
const completedAt = t0 + 60_000;
|
|
2057
|
+
const state = () =>
|
|
2058
|
+
db.raw
|
|
2059
|
+
.prepare(
|
|
2060
|
+
`SELECT recovery_status, watchdog_step, consecutive_ok_checks, next_watchdog_check_at FROM investigation_findings WHERE id = ?`,
|
|
2061
|
+
)
|
|
2062
|
+
.get(finding.id) as {
|
|
2063
|
+
recovery_status: string;
|
|
2064
|
+
watchdog_step: number;
|
|
2065
|
+
consecutive_ok_checks: number;
|
|
2066
|
+
next_watchdog_check_at: string | null;
|
|
2067
|
+
};
|
|
2068
|
+
expect(state().next_watchdog_check_at).toBe(
|
|
2069
|
+
new Date(completedAt + 2 * 60_000).toISOString(),
|
|
2070
|
+
);
|
|
2071
|
+
|
|
2072
|
+
for (let i = 0; i < 60; i++) {
|
|
2073
|
+
const t = new Date(completedAt + 30_000 + i * 100).toISOString();
|
|
2074
|
+
insertSpan(db, {
|
|
2075
|
+
traceId: `g_ok_${i}`,
|
|
2076
|
+
spanId: `g_ok_sp_${i}`,
|
|
2077
|
+
serviceName: "checkout-api",
|
|
2078
|
+
spanName: "POST /checkout",
|
|
2079
|
+
statusCode: 1,
|
|
2080
|
+
startTime: t,
|
|
2081
|
+
endTime: t,
|
|
2082
|
+
durationMs: 20,
|
|
2083
|
+
serviceVersion: "v2.4.0",
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Checkpoint 1 runs late (+10m): next checkpoint is still anchored on
|
|
2088
|
+
// completion (+5m), not on "now + offset".
|
|
2089
|
+
const first = await evaluateRemediationWatchdogs(db, {
|
|
2090
|
+
projectId: "default",
|
|
2091
|
+
nowIso: at(60_000 + 10 * 60_000),
|
|
2092
|
+
});
|
|
2093
|
+
expect(first.recovered).toBe(0);
|
|
2094
|
+
expect(state()).toMatchObject({
|
|
2095
|
+
recovery_status: "monitoring",
|
|
2096
|
+
watchdog_step: 2,
|
|
2097
|
+
consecutive_ok_checks: 1,
|
|
2098
|
+
next_watchdog_check_at: new Date(completedAt + 5 * 60_000).toISOString(),
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
const second = await evaluateRemediationWatchdogs(db, {
|
|
2102
|
+
projectId: "default",
|
|
2103
|
+
nowIso: at(60_000 + 11 * 60_000),
|
|
2104
|
+
});
|
|
2105
|
+
expect(second.recovered).toBe(1);
|
|
2106
|
+
expect(state().recovery_status).toBe("recovered");
|
|
2107
|
+
const inc = db.raw
|
|
2108
|
+
.prepare(`SELECT status FROM incident_investigations WHERE id = ?`)
|
|
2109
|
+
.get(incident.id) as { status: string };
|
|
2110
|
+
expect(inc.status).toBe("resolved");
|
|
2111
|
+
db.close();
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
it("B14/B17: routes reject unknown predicates and invalid timestamps", async () => {
|
|
2115
|
+
const db = createMigratedSqliteDb();
|
|
2116
|
+
const { app, env } = createTestApp(db);
|
|
2117
|
+
const post = (path: string, body: unknown) =>
|
|
2118
|
+
app.request(
|
|
2119
|
+
path,
|
|
2120
|
+
{
|
|
2121
|
+
method: "POST",
|
|
2122
|
+
headers: { "Content-Type": "application/json" },
|
|
2123
|
+
body: JSON.stringify(body),
|
|
2124
|
+
},
|
|
2125
|
+
env,
|
|
2126
|
+
);
|
|
2127
|
+
|
|
2128
|
+
const badPredicate = await post("/internal/findings", {
|
|
2129
|
+
title: "t",
|
|
2130
|
+
summary: "s",
|
|
2131
|
+
symptomEntityKind: "trace",
|
|
2132
|
+
symptomEntityId: "t1",
|
|
2133
|
+
faultLocusEntityKind: "service",
|
|
2134
|
+
faultLocusEntityId: SERVICE_NODE,
|
|
2135
|
+
causalChain: [
|
|
2136
|
+
{
|
|
2137
|
+
subjectKind: "trace",
|
|
2138
|
+
subjectId: "t1",
|
|
2139
|
+
predicate: "made_up_predicate",
|
|
2140
|
+
objectKind: "span",
|
|
2141
|
+
objectId: "s1",
|
|
2142
|
+
},
|
|
2143
|
+
],
|
|
2144
|
+
});
|
|
2145
|
+
expect(badPredicate.status).toBe(400);
|
|
2146
|
+
|
|
2147
|
+
const badDate = await post("/v1/changes", {
|
|
2148
|
+
kind: "deploy",
|
|
2149
|
+
summary: "x",
|
|
2150
|
+
serviceName: "checkout-api",
|
|
2151
|
+
occurredAt: "yesterday-ish",
|
|
2152
|
+
});
|
|
2153
|
+
expect(badDate.status).toBe(400);
|
|
2154
|
+
const badKind = await post("/v1/changes", { kind: "nope", summary: "x" });
|
|
2155
|
+
expect(badKind.status).toBe(400);
|
|
2156
|
+
|
|
2157
|
+
const offset = await post("/v1/changes", {
|
|
2158
|
+
id: "chg_tz",
|
|
2159
|
+
kind: "deploy",
|
|
2160
|
+
summary: "x",
|
|
2161
|
+
serviceName: "checkout-api",
|
|
2162
|
+
environment: "prod",
|
|
2163
|
+
source: "ci_webhook",
|
|
2164
|
+
occurredAt: "2026-10-15T05:00:00+02:00",
|
|
2165
|
+
});
|
|
2166
|
+
expect(offset.status).toBe(201);
|
|
2167
|
+
const created = (await offset.json()) as {
|
|
2168
|
+
changeEvent: { occurredAt: string; source: string };
|
|
2169
|
+
};
|
|
2170
|
+
expect(created.changeEvent.occurredAt).toBe("2026-10-15T03:00:00.000Z");
|
|
2171
|
+
expect(created.changeEvent.source).toBe("ci_webhook");
|
|
2172
|
+
|
|
2173
|
+
const badSince = await app.request(
|
|
2174
|
+
"/internal/changes?since=garbage",
|
|
2175
|
+
{},
|
|
2176
|
+
env,
|
|
2177
|
+
);
|
|
2178
|
+
expect(badSince.status).toBe(400);
|
|
2179
|
+
const okSince = await app.request(
|
|
2180
|
+
"/internal/changes?since=2026-10-15T04:30:00%2B02:00",
|
|
2181
|
+
{},
|
|
2182
|
+
env,
|
|
2183
|
+
);
|
|
2184
|
+
expect(okSince.status).toBe(200);
|
|
2185
|
+
expect(
|
|
2186
|
+
((await okSince.json()) as { changes: unknown[] }).changes,
|
|
2187
|
+
).toHaveLength(1);
|
|
2188
|
+
db.close();
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
it("B15: the approver is derived from the session, never from client-supplied identity", async () => {
|
|
2192
|
+
const db = createMigratedSqliteDb();
|
|
2193
|
+
const { app, env } = createTestApp(db);
|
|
2194
|
+
const incident = await newIncident(db);
|
|
2195
|
+
const finding = await newFinding(db, incident.id);
|
|
2196
|
+
const proposal = await propose(db, incident.id, finding.id, "idem_b15");
|
|
2197
|
+
const res = await app.request(
|
|
2198
|
+
`/internal/remediations/${proposal.id}/approve`,
|
|
2199
|
+
{
|
|
2200
|
+
method: "POST",
|
|
2201
|
+
headers: {
|
|
2202
|
+
"Content-Type": "application/json",
|
|
2203
|
+
Cookie: "obs_session=sess_secret_cookie_value",
|
|
2204
|
+
"X-Obs-User-Actor": "vp-of-engineering",
|
|
2205
|
+
},
|
|
2206
|
+
body: JSON.stringify({
|
|
2207
|
+
environment: "prod",
|
|
2208
|
+
approvedByUserId: "cto",
|
|
2209
|
+
approvedBySession: "cfo",
|
|
2210
|
+
}),
|
|
2211
|
+
},
|
|
2212
|
+
env,
|
|
2213
|
+
);
|
|
2214
|
+
expect(res.status).toBe(200);
|
|
2215
|
+
const body = (await res.json()) as {
|
|
2216
|
+
proposal: { approvedByUserId: string };
|
|
2217
|
+
};
|
|
2218
|
+
expect(body.proposal.approvedByUserId).toMatch(
|
|
2219
|
+
/^dashboard_session:[0-9a-f]{16}$/,
|
|
2220
|
+
);
|
|
2221
|
+
db.close();
|
|
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);
|
|
1273
2423
|
db.close();
|
|
1274
2424
|
});
|
|
2425
|
+
|
|
2426
|
+
it("J9: GET /internal/evals/runs/:id reports existence so counterfactual prerequisites can be checked first", async () => {
|
|
2427
|
+
const db = createMigratedSqliteDb();
|
|
2428
|
+
const { app, env } = createTestApp(db);
|
|
2429
|
+
const missing = await app.request("/internal/evals/runs/nope", {}, env);
|
|
2430
|
+
expect(missing.status).toBe(404);
|
|
2431
|
+
db.raw
|
|
2432
|
+
.prepare(
|
|
2433
|
+
`INSERT INTO eval_runs (project_id, id, eval_case_id, status, pass_count, fail_count, created_at)
|
|
2434
|
+
VALUES ('default', 'run_exists', 'case_1', 'completed', 0, 1, ?)`,
|
|
2435
|
+
)
|
|
2436
|
+
.run(new Date().toISOString());
|
|
2437
|
+
const found = await app.request("/internal/evals/runs/run_exists", {}, env);
|
|
2438
|
+
expect(found.status).toBe(200);
|
|
2439
|
+
expect(((await found.json()) as { run: { id: string } }).run.id).toBe(
|
|
2440
|
+
"run_exists",
|
|
2441
|
+
);
|
|
2442
|
+
db.close();
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
it("J17: callback stored at propose time -> approve without callback -> webhook receives the token -> claim succeeds", async () => {
|
|
2446
|
+
const db = createMigratedSqliteDb();
|
|
2447
|
+
const { app, env } = createTestApp(db);
|
|
2448
|
+
const incident = await newIncident(db);
|
|
2449
|
+
const finding = await newFinding(db, incident.id); // corroborated: needs a human
|
|
2450
|
+
const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
2451
|
+
|
|
2452
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
2453
|
+
const headers: Array<Record<string, string>> = [];
|
|
2454
|
+
vi.stubGlobal(
|
|
2455
|
+
"fetch",
|
|
2456
|
+
vi.fn(async (_url: string, init: RequestInit) => {
|
|
2457
|
+
bodies.push(JSON.parse(String(init.body)));
|
|
2458
|
+
headers.push(init.headers as Record<string, string>);
|
|
2459
|
+
return new Response("ok", { status: 200 });
|
|
2460
|
+
}),
|
|
2461
|
+
);
|
|
2462
|
+
|
|
2463
|
+
const proposeRes = await app.request(
|
|
2464
|
+
"/internal/remediations/propose",
|
|
2465
|
+
json({
|
|
2466
|
+
environment: "prod",
|
|
2467
|
+
incidentId: incident.id,
|
|
2468
|
+
findingId: finding.id,
|
|
2469
|
+
idempotencyKey: "j17",
|
|
2470
|
+
actionKind: "rollback_deploy",
|
|
2471
|
+
targetNodeId: SERVICE_NODE,
|
|
2472
|
+
expectedCurrentState: {},
|
|
2473
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
2474
|
+
webhookUrl: "https://runner.example.com/grants?token=urlsecret",
|
|
2475
|
+
secret: "hmac-secret-j17",
|
|
2476
|
+
}),
|
|
2477
|
+
callbackEnv,
|
|
2478
|
+
);
|
|
2479
|
+
expect(proposeRes.status).toBe(201);
|
|
2480
|
+
const proposed = (await proposeRes.json()) as {
|
|
2481
|
+
proposal: { id: string; status: string; callbackHost: string };
|
|
2482
|
+
};
|
|
2483
|
+
expect(proposed.proposal.status).toBe("pending_human_approval");
|
|
2484
|
+
expect(proposed.proposal.callbackHost).toBe("runner.example.com");
|
|
2485
|
+
expect(bodies).toHaveLength(0);
|
|
2486
|
+
|
|
2487
|
+
// Neither the URL path/query nor the secret is ever returned by a GET.
|
|
2488
|
+
const getRes = await app.request(
|
|
2489
|
+
`/internal/remediations/${proposed.proposal.id}?environment=prod`,
|
|
2490
|
+
{},
|
|
2491
|
+
callbackEnv,
|
|
2492
|
+
);
|
|
2493
|
+
const getText = await getRes.text();
|
|
2494
|
+
expect(getText).toContain("runner.example.com");
|
|
2495
|
+
expect(getText).not.toContain("urlsecret");
|
|
2496
|
+
expect(getText).not.toContain("hmac-secret-j17");
|
|
2497
|
+
expect(getText).not.toContain("/grants");
|
|
2498
|
+
const list = await (
|
|
2499
|
+
await app.request(
|
|
2500
|
+
"/internal/remediations?environment=prod",
|
|
2501
|
+
{},
|
|
2502
|
+
callbackEnv,
|
|
2503
|
+
)
|
|
2504
|
+
).text();
|
|
2505
|
+
expect(list).not.toContain("hmac-secret-j17");
|
|
2506
|
+
|
|
2507
|
+
const approve = await app.request(
|
|
2508
|
+
`/internal/remediations/${proposed.proposal.id}/approve`,
|
|
2509
|
+
approveInit({ environment: "prod" }),
|
|
2510
|
+
callbackEnv,
|
|
2511
|
+
);
|
|
2512
|
+
expect(approve.status).toBe(200);
|
|
2513
|
+
const approved = (await approve.json()) as {
|
|
2514
|
+
grantToken: string;
|
|
2515
|
+
webhookDelivery?: { delivered: boolean };
|
|
2516
|
+
proposal: { callbackHost: string | null };
|
|
2517
|
+
};
|
|
2518
|
+
expect(approved.webhookDelivery?.delivered).toBe(true);
|
|
2519
|
+
expect(approved.proposal.callbackHost).toBeNull();
|
|
2520
|
+
expect(bodies).toHaveLength(1);
|
|
2521
|
+
expect(bodies[0]).toMatchObject({
|
|
2522
|
+
event: "autosre.remediation.grant_issued",
|
|
2523
|
+
proposalId: proposed.proposal.id,
|
|
2524
|
+
grantToken: approved.grantToken,
|
|
2525
|
+
});
|
|
2526
|
+
expect(headers[0]["X-Obs-Signature-256"]).toMatch(/^sha256=[0-9a-f]{64}$/);
|
|
2527
|
+
|
|
2528
|
+
const claim = await app.request(
|
|
2529
|
+
`/internal/remediations/${proposed.proposal.id}/claim`,
|
|
2530
|
+
json({
|
|
2531
|
+
environment: "prod",
|
|
2532
|
+
grantToken: approved.grantToken,
|
|
2533
|
+
runnerId: "runner-j17",
|
|
2534
|
+
}),
|
|
2535
|
+
callbackEnv,
|
|
2536
|
+
);
|
|
2537
|
+
expect(claim.status).toBe(200);
|
|
2538
|
+
|
|
2539
|
+
// The stored callback is gone from the row once consumed.
|
|
2540
|
+
const row = db.raw
|
|
2541
|
+
.prepare(
|
|
2542
|
+
`SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
|
|
2543
|
+
)
|
|
2544
|
+
.get(proposed.proposal.id) as {
|
|
2545
|
+
callback_url: string | null;
|
|
2546
|
+
callback_secret: string | null;
|
|
2547
|
+
};
|
|
2548
|
+
expect(row.callback_url).toBeNull();
|
|
2549
|
+
expect(row.callback_secret).toBeNull();
|
|
2550
|
+
db.close();
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
it("J17: a callback on the approve request wins, a bad propose callback is a 400, and reject clears the stored one", async () => {
|
|
2554
|
+
const db = createMigratedSqliteDb();
|
|
2555
|
+
const { app, env } = createTestApp(db);
|
|
2556
|
+
const incident = await newIncident(db);
|
|
2557
|
+
const finding = await newFinding(db, incident.id);
|
|
2558
|
+
const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
2559
|
+
const urls: string[] = [];
|
|
2560
|
+
vi.stubGlobal(
|
|
2561
|
+
"fetch",
|
|
2562
|
+
vi.fn(async (url: string) => {
|
|
2563
|
+
urls.push(url);
|
|
2564
|
+
return new Response("ok", { status: 200 });
|
|
2565
|
+
}),
|
|
2566
|
+
);
|
|
2567
|
+
const mk = (key: string, webhookUrl: string) =>
|
|
2568
|
+
app.request(
|
|
2569
|
+
"/internal/remediations/propose",
|
|
2570
|
+
json({
|
|
2571
|
+
environment: "prod",
|
|
2572
|
+
incidentId: incident.id,
|
|
2573
|
+
findingId: finding.id,
|
|
2574
|
+
idempotencyKey: key,
|
|
2575
|
+
actionKind: "rollback_deploy",
|
|
2576
|
+
targetNodeId: SERVICE_NODE,
|
|
2577
|
+
expectedCurrentState: {},
|
|
2578
|
+
desiredTargetState: {},
|
|
2579
|
+
webhookUrl,
|
|
2580
|
+
}),
|
|
2581
|
+
callbackEnv,
|
|
2582
|
+
);
|
|
2583
|
+
expect((await mk("j17_bad", "ftp://example.com/x")).status).toBe(400);
|
|
2584
|
+
|
|
2585
|
+
const one = (await (
|
|
2586
|
+
await mk("j17_a", "https://stored.example.com/a")
|
|
2587
|
+
).json()) as {
|
|
2588
|
+
proposal: { id: string };
|
|
2589
|
+
};
|
|
2590
|
+
const approve = await app.request(
|
|
2591
|
+
`/internal/remediations/${one.proposal.id}/approve`,
|
|
2592
|
+
approveInit({
|
|
2593
|
+
environment: "prod",
|
|
2594
|
+
webhookUrl: "https://request.example.com/b",
|
|
2595
|
+
}),
|
|
2596
|
+
callbackEnv,
|
|
2597
|
+
);
|
|
2598
|
+
expect(approve.status).toBe(200);
|
|
2599
|
+
expect(urls).toEqual(["https://request.example.com/b"]);
|
|
2600
|
+
|
|
2601
|
+
const two = (await (
|
|
2602
|
+
await mk("j17_b", "https://stored.example.com/c")
|
|
2603
|
+
).json()) as {
|
|
2604
|
+
proposal: { id: string };
|
|
2605
|
+
};
|
|
2606
|
+
await rejectRemediationProposal(db, "default", two.proposal.id, {
|
|
2607
|
+
environment: "prod",
|
|
2608
|
+
rejectedByUserId: "tester",
|
|
2609
|
+
});
|
|
2610
|
+
const row = db.raw
|
|
2611
|
+
.prepare(`SELECT callback_url FROM remediation_proposals WHERE id = ?`)
|
|
2612
|
+
.get(two.proposal.id) as { callback_url: string | null };
|
|
2613
|
+
expect(row.callback_url).toBeNull();
|
|
2614
|
+
db.close();
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
describe("grant delivery retries", () => {
|
|
2618
|
+
type Delivery = {
|
|
2619
|
+
attempted: boolean;
|
|
2620
|
+
delivered: boolean;
|
|
2621
|
+
attempts: number;
|
|
2622
|
+
host: string | null;
|
|
2623
|
+
error?: string;
|
|
2624
|
+
};
|
|
2625
|
+
const setup = async (key: string, webhookUrl: string, secret?: string) => {
|
|
2626
|
+
const db = createMigratedSqliteDb();
|
|
2627
|
+
const { app, env } = createTestApp(db);
|
|
2628
|
+
const incident = await newIncident(db);
|
|
2629
|
+
const finding = await newFinding(db, incident.id);
|
|
2630
|
+
const callbackEnv = { ...env, ALERT_WEBHOOK_ALLOW_PRIVATE: "true" };
|
|
2631
|
+
const proposed = (await (
|
|
2632
|
+
await app.request(
|
|
2633
|
+
"/internal/remediations/propose",
|
|
2634
|
+
json({
|
|
2635
|
+
environment: "prod",
|
|
2636
|
+
incidentId: incident.id,
|
|
2637
|
+
findingId: finding.id,
|
|
2638
|
+
idempotencyKey: key,
|
|
2639
|
+
actionKind: "rollback_deploy",
|
|
2640
|
+
targetNodeId: SERVICE_NODE,
|
|
2641
|
+
expectedCurrentState: {},
|
|
2642
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
2643
|
+
webhookUrl,
|
|
2644
|
+
...(secret ? { secret } : {}),
|
|
2645
|
+
}),
|
|
2646
|
+
callbackEnv,
|
|
2647
|
+
)
|
|
2648
|
+
).json()) as { proposal: { id: string } };
|
|
2649
|
+
const approve = async () =>
|
|
2650
|
+
(await (
|
|
2651
|
+
await app.request(
|
|
2652
|
+
`/internal/remediations/${proposed.proposal.id}/approve`,
|
|
2653
|
+
approveInit({ environment: "prod" }),
|
|
2654
|
+
callbackEnv,
|
|
2655
|
+
)
|
|
2656
|
+
).json()) as {
|
|
2657
|
+
grantToken: string;
|
|
2658
|
+
grantDelivery: Delivery;
|
|
2659
|
+
proposal: { callbackHost: string | null };
|
|
2660
|
+
};
|
|
2661
|
+
const get = async () =>
|
|
2662
|
+
(await (
|
|
2663
|
+
await app.request(
|
|
2664
|
+
`/internal/remediations/${proposed.proposal.id}?environment=prod`,
|
|
2665
|
+
{},
|
|
2666
|
+
callbackEnv,
|
|
2667
|
+
)
|
|
2668
|
+
).json()) as {
|
|
2669
|
+
proposal: {
|
|
2670
|
+
callbackDelivery: {
|
|
2671
|
+
status: string;
|
|
2672
|
+
attempts: number;
|
|
2673
|
+
lastError: string | null;
|
|
2674
|
+
at: string;
|
|
2675
|
+
} | null;
|
|
2676
|
+
};
|
|
2677
|
+
};
|
|
2678
|
+
return { db, proposed, approve, get };
|
|
2679
|
+
};
|
|
2680
|
+
|
|
2681
|
+
it("delivers on the 2nd attempt after a 503", async () => {
|
|
2682
|
+
const statuses = [503, 200];
|
|
2683
|
+
const fetchMock = vi.fn(
|
|
2684
|
+
async () => new Response("x", { status: statuses.shift() ?? 200 }),
|
|
2685
|
+
);
|
|
2686
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2687
|
+
const { db, approve, get } = await setup(
|
|
2688
|
+
"retry-503",
|
|
2689
|
+
"https://runner.example.com/g?token=urlsecret",
|
|
2690
|
+
"hmac-retry",
|
|
2691
|
+
);
|
|
2692
|
+
const approved = await approve();
|
|
2693
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2694
|
+
expect(approved.grantDelivery).toEqual({
|
|
2695
|
+
attempted: true,
|
|
2696
|
+
delivered: true,
|
|
2697
|
+
attempts: 2,
|
|
2698
|
+
host: "runner.example.com",
|
|
2699
|
+
});
|
|
2700
|
+
expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
|
|
2701
|
+
const got = await get();
|
|
2702
|
+
expect(got.proposal.callbackDelivery).toMatchObject({
|
|
2703
|
+
status: "delivered",
|
|
2704
|
+
attempts: 2,
|
|
2705
|
+
lastError: null,
|
|
2706
|
+
});
|
|
2707
|
+
db.close();
|
|
2708
|
+
}, 15_000);
|
|
2709
|
+
|
|
2710
|
+
it("fails after 3 attempts on a closed port but still returns the token and clears the callback", async () => {
|
|
2711
|
+
vi.unstubAllGlobals();
|
|
2712
|
+
const net = await import("node:net");
|
|
2713
|
+
const port = await new Promise<number>((resolve) => {
|
|
2714
|
+
const srv = net.createServer();
|
|
2715
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
2716
|
+
const { port: p } = srv.address() as { port: number };
|
|
2717
|
+
srv.close(() => resolve(p));
|
|
2718
|
+
});
|
|
2719
|
+
});
|
|
2720
|
+
const { db, proposed, approve, get } = await setup(
|
|
2721
|
+
"retry-closed",
|
|
2722
|
+
`http://127.0.0.1:${port}/grant?token=urlsecret`,
|
|
2723
|
+
"hmac-closed",
|
|
2724
|
+
);
|
|
2725
|
+
const approved = await approve();
|
|
2726
|
+
expect(approved.grantToken).toMatch(/^rgt_/);
|
|
2727
|
+
expect(approved.grantDelivery).toMatchObject({
|
|
2728
|
+
attempted: true,
|
|
2729
|
+
delivered: false,
|
|
2730
|
+
attempts: 3,
|
|
2731
|
+
host: `127.0.0.1:${port}`,
|
|
2732
|
+
});
|
|
2733
|
+
expect(approved.grantDelivery.error).toBeTruthy();
|
|
2734
|
+
expect(JSON.stringify(approved.grantDelivery)).not.toContain("urlsecret");
|
|
2735
|
+
expect(approved.proposal.callbackHost).toBeNull();
|
|
2736
|
+
const row = db.raw
|
|
2737
|
+
.prepare(
|
|
2738
|
+
`SELECT callback_url, callback_secret FROM remediation_proposals WHERE id = ?`,
|
|
2739
|
+
)
|
|
2740
|
+
.get(proposed.proposal.id) as {
|
|
2741
|
+
callback_url: string | null;
|
|
2742
|
+
callback_secret: string | null;
|
|
2743
|
+
};
|
|
2744
|
+
expect(row.callback_url).toBeNull();
|
|
2745
|
+
expect(row.callback_secret).toBeNull();
|
|
2746
|
+
const got = await get();
|
|
2747
|
+
expect(got.proposal.callbackDelivery).toMatchObject({
|
|
2748
|
+
status: "failed",
|
|
2749
|
+
attempts: 3,
|
|
2750
|
+
});
|
|
2751
|
+
expect(got.proposal.callbackDelivery?.lastError).toBeTruthy();
|
|
2752
|
+
db.close();
|
|
2753
|
+
}, 20_000);
|
|
2754
|
+
|
|
2755
|
+
it("does not retry a 4xx", async () => {
|
|
2756
|
+
const fetchMock = vi.fn(async () => new Response("no", { status: 403 }));
|
|
2757
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2758
|
+
const { db, approve, get } = await setup(
|
|
2759
|
+
"retry-4xx",
|
|
2760
|
+
"https://runner.example.com/g",
|
|
2761
|
+
);
|
|
2762
|
+
const approved = await approve();
|
|
2763
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
2764
|
+
expect(approved.grantToken).toMatch(/^rgt_/);
|
|
2765
|
+
expect(approved.grantDelivery).toMatchObject({
|
|
2766
|
+
attempted: true,
|
|
2767
|
+
delivered: false,
|
|
2768
|
+
attempts: 1,
|
|
2769
|
+
error: "HTTP 403",
|
|
2770
|
+
});
|
|
2771
|
+
expect((await get()).proposal.callbackDelivery?.status).toBe("failed");
|
|
2772
|
+
db.close();
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
it("reports attempted=false when no callback exists", async () => {
|
|
2776
|
+
const db = createMigratedSqliteDb();
|
|
2777
|
+
const { app, env } = createTestApp(db);
|
|
2778
|
+
const incident = await newIncident(db);
|
|
2779
|
+
const finding = await newFinding(db, incident.id);
|
|
2780
|
+
const proposed = (await (
|
|
2781
|
+
await app.request(
|
|
2782
|
+
"/internal/remediations/propose",
|
|
2783
|
+
json({
|
|
2784
|
+
environment: "prod",
|
|
2785
|
+
incidentId: incident.id,
|
|
2786
|
+
findingId: finding.id,
|
|
2787
|
+
idempotencyKey: "no-cb",
|
|
2788
|
+
actionKind: "rollback_deploy",
|
|
2789
|
+
targetNodeId: SERVICE_NODE,
|
|
2790
|
+
expectedCurrentState: {},
|
|
2791
|
+
desiredTargetState: {},
|
|
2792
|
+
}),
|
|
2793
|
+
env,
|
|
2794
|
+
)
|
|
2795
|
+
).json()) as { proposal: { id: string } };
|
|
2796
|
+
const approved = (await (
|
|
2797
|
+
await app.request(
|
|
2798
|
+
`/internal/remediations/${proposed.proposal.id}/approve`,
|
|
2799
|
+
approveInit({ environment: "prod" }),
|
|
2800
|
+
env,
|
|
2801
|
+
)
|
|
2802
|
+
).json()) as { grantDelivery: Delivery };
|
|
2803
|
+
expect(approved.grantDelivery).toEqual({
|
|
2804
|
+
attempted: false,
|
|
2805
|
+
delivered: false,
|
|
2806
|
+
attempts: 0,
|
|
2807
|
+
host: null,
|
|
2808
|
+
});
|
|
2809
|
+
db.close();
|
|
2810
|
+
});
|
|
2811
|
+
});
|
|
1275
2812
|
});
|