@dogfood-lab/verify 1.8.0 → 1.10.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/README.md +21 -17
- package/cli-lint.js +81 -36
- package/cli.js +99 -15
- package/index.js +205 -61
- package/package.json +1 -1
- package/parse-rejection.js +125 -27
- package/validators/f-2a5ddafa-provenance-retry-warn.test.js +226 -0
- package/validators/f-5fd3f832-retry-after-clamp.test.js +96 -0
- package/validators/f-ad98b5ac-required-steps-map-guard.test.js +85 -0
- package/validators/f-dac7e08c-transport-error-operational.test.js +121 -0
- package/validators/f-f50e779b-retry-warn-fallback-guard.test.js +153 -0
- package/validators/lint-scenario.js +141 -0
- package/validators/policy.js +31 -8
- package/validators/provenance-gitlab.test.js +10 -3
- package/validators/provenance-registry.test.js +35 -0
- package/validators/provenance.js +183 -17
- package/validators/repo-binding.js +35 -12
- package/validators/repo-binding.test.js +40 -0
- package/validators/steps.js +116 -9
- package/validators/verdict.js +9 -1
- package/validators/w4-f-8e72d0de-timeout-retry.test.js +118 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* f-2a5ddafa-provenance-retry-warn.test.js
|
|
3
|
+
*
|
|
4
|
+
* F-2a5ddafa (Stage C humanization) — both provenance adapters retried
|
|
5
|
+
* transient faults (429/5xx/timeout/network) with exponential backoff, but
|
|
6
|
+
* the retry loop itself was completely silent: `await sleep(...); continue;`
|
|
7
|
+
* fired with no logStage/log call anywhere inside either loop. A submission
|
|
8
|
+
* that succeeded only after 1-2 retries against a degrading GitHub/GitLab API
|
|
9
|
+
* was byte-for-byte indistinguishable in the log from one that succeeded on
|
|
10
|
+
* the first try — an operator had zero early-warning signal of a degrading
|
|
11
|
+
* provider until the retry budget fully exhausted and threw.
|
|
12
|
+
*
|
|
13
|
+
* Fix: `onRetryWarn(fields)` fires at the point each loop decides to retry,
|
|
14
|
+
* defaulting to a structured NDJSON stderr line (`component:'verify'`,
|
|
15
|
+
* `stage:'warn'`) and injectable via `opts.onRetryWarn` for tests (mirrors
|
|
16
|
+
* the already-injectable `opts.fetchImpl` / `opts.sleepImpl` on this file).
|
|
17
|
+
*
|
|
18
|
+
* Why NOT `logStage` from `@dogfood-lab/dogfood-swarm/lib/log-stage.js`:
|
|
19
|
+
* packages/verify has no dependency on dogfood-swarm and taking one would
|
|
20
|
+
* close a SECOND, larger workspace cycle (verify -> dogfood-swarm ->
|
|
21
|
+
* findings -> ingest -> verify) — see provenance.js's `defaultOnRetryWarn`
|
|
22
|
+
* doc block for the full reasoning. This suite pins that the emitted shape
|
|
23
|
+
* is real and correct without that import.
|
|
24
|
+
*
|
|
25
|
+
* RED proof (reasoned, not re-executed as a hang risk): before this fix,
|
|
26
|
+
* `onRetryWarn` did not exist on the opts object at all — every assertion
|
|
27
|
+
* in the "onRetryWarn fires" describe blocks below would fail with "onRetryWarn
|
|
28
|
+
* was never called" (the array stays empty), since nothing invoked it. This is
|
|
29
|
+
* independently re-derived from reading the pre-fix source (confirmed: zero
|
|
30
|
+
* occurrences of any warn/log call inside either retry loop), not carried
|
|
31
|
+
* over from the finding's own prose.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it } from 'node:test';
|
|
35
|
+
import assert from 'node:assert/strict';
|
|
36
|
+
|
|
37
|
+
import { githubProvenance, gitlabProvenance } from './provenance.js';
|
|
38
|
+
|
|
39
|
+
const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
|
|
40
|
+
|
|
41
|
+
const GH_SOURCE = {
|
|
42
|
+
provider: 'github',
|
|
43
|
+
workflow: 'dogfood.yml',
|
|
44
|
+
provider_run_id: '9123456789',
|
|
45
|
+
run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const GL_SOURCE = {
|
|
49
|
+
provider: 'gitlab',
|
|
50
|
+
workflow: '.gitlab-ci.yml',
|
|
51
|
+
provider_run_id: '424242',
|
|
52
|
+
run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
|
|
53
|
+
repo: 'acme/widget'
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function abortError() {
|
|
57
|
+
const e = new Error('This operation was aborted');
|
|
58
|
+
e.name = 'AbortError';
|
|
59
|
+
return e;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const noSleep = async () => {};
|
|
63
|
+
|
|
64
|
+
function okGithubResp() {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
status: 200,
|
|
68
|
+
json: async () => ({
|
|
69
|
+
id: 9123456789,
|
|
70
|
+
status: 'completed',
|
|
71
|
+
head_sha: RUN_HEAD,
|
|
72
|
+
repository: { full_name: 'acme/widget' }
|
|
73
|
+
})
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function okGitlabResp() {
|
|
78
|
+
return { ok: true, status: 200, json: async () => ({ id: 424242, status: 'success', sha: RUN_HEAD }) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @pins F-2a5ddafa */
|
|
82
|
+
describe('F-2a5ddafa — githubProvenance emits onRetryWarn before each retry', () => {
|
|
83
|
+
it('a timeout then a 200: onRetryWarn fires once with status_or_reason="timeout"', async () => {
|
|
84
|
+
let calls = 0;
|
|
85
|
+
const warnings = [];
|
|
86
|
+
const adapter = githubProvenance('token', {
|
|
87
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
88
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
89
|
+
fetchImpl: async () => {
|
|
90
|
+
calls++;
|
|
91
|
+
if (calls === 1) throw abortError();
|
|
92
|
+
return okGithubResp();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
96
|
+
assert.equal(ok, true);
|
|
97
|
+
assert.equal(warnings.length, 1, `expected exactly one retry warning; got ${JSON.stringify(warnings)}`);
|
|
98
|
+
assert.equal(warnings[0].kind, 'provenance_retry');
|
|
99
|
+
assert.equal(warnings[0].provider, 'github');
|
|
100
|
+
assert.equal(warnings[0].attempt, 1);
|
|
101
|
+
assert.equal(warnings[0].status_or_reason, 'timeout');
|
|
102
|
+
assert.equal(typeof warnings[0].next_backoff_ms, 'number');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('a 503 then a 200: onRetryWarn fires with status_or_reason=503 (the numeric status)', async () => {
|
|
106
|
+
let calls = 0;
|
|
107
|
+
const warnings = [];
|
|
108
|
+
const adapter = githubProvenance('token', {
|
|
109
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
110
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
111
|
+
fetchImpl: async () => {
|
|
112
|
+
calls++;
|
|
113
|
+
return calls === 1 ? { ok: false, status: 503 } : okGithubResp();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
117
|
+
assert.equal(warnings.length, 1);
|
|
118
|
+
assert.equal(warnings[0].status_or_reason, 503);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('a transport reject then a 200: onRetryWarn carries the underlying error message', async () => {
|
|
122
|
+
let calls = 0;
|
|
123
|
+
const warnings = [];
|
|
124
|
+
const adapter = githubProvenance('token', {
|
|
125
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
126
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
127
|
+
fetchImpl: async () => {
|
|
128
|
+
calls++;
|
|
129
|
+
if (calls === 1) throw new Error('ECONNRESET');
|
|
130
|
+
return okGithubResp();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
134
|
+
assert.equal(warnings.length, 1);
|
|
135
|
+
assert.match(warnings[0].status_or_reason, /ECONNRESET/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('exhausting the retry budget fires onRetryWarn exactly `retries` times, not on the final throw', async () => {
|
|
139
|
+
const warnings = [];
|
|
140
|
+
const adapter = githubProvenance('token', {
|
|
141
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
142
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
143
|
+
fetchImpl: async () => { throw abortError(); }
|
|
144
|
+
});
|
|
145
|
+
await assert.rejects(() => adapter.confirm(GH_SOURCE), /provenance: GitHub API timeout/);
|
|
146
|
+
assert.equal(warnings.length, 2, 'retries=2 → 2 warn events before the 3rd attempt throws');
|
|
147
|
+
assert.deepEqual(warnings.map(w => w.attempt), [1, 2]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('DEFAULT onRetryWarn (no opts override): writes a structured NDJSON warn line to stderr', async () => {
|
|
151
|
+
let calls = 0;
|
|
152
|
+
const captured = [];
|
|
153
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
154
|
+
process.stderr.write = (chunk) => { captured.push(chunk.toString()); return true; };
|
|
155
|
+
try {
|
|
156
|
+
const adapter = githubProvenance('token', {
|
|
157
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
158
|
+
fetchImpl: async () => {
|
|
159
|
+
calls++;
|
|
160
|
+
if (calls === 1) throw abortError();
|
|
161
|
+
return okGithubResp();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
165
|
+
} finally {
|
|
166
|
+
process.stderr.write = origErr;
|
|
167
|
+
}
|
|
168
|
+
const jsonLine = captured.find((c) => c.trim().startsWith('{'));
|
|
169
|
+
assert.ok(jsonLine, `expected a JSON warn line on stderr; got ${JSON.stringify(captured)}`);
|
|
170
|
+
const parsed = JSON.parse(jsonLine.trim());
|
|
171
|
+
assert.equal(parsed.component, 'verify');
|
|
172
|
+
assert.equal(parsed.stage, 'warn');
|
|
173
|
+
assert.equal(parsed.kind, 'provenance_retry');
|
|
174
|
+
assert.equal(parsed.provider, 'github');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** @pins F-2a5ddafa */
|
|
179
|
+
describe('F-2a5ddafa — gitlabProvenance mirrors the onRetryWarn discipline', () => {
|
|
180
|
+
it('a timeout then a 200: onRetryWarn fires once with provider="gitlab"', async () => {
|
|
181
|
+
let calls = 0;
|
|
182
|
+
const warnings = [];
|
|
183
|
+
const adapter = gitlabProvenance('token', {
|
|
184
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
185
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
186
|
+
fetchImpl: async () => {
|
|
187
|
+
calls++;
|
|
188
|
+
if (calls === 1) throw abortError();
|
|
189
|
+
return okGitlabResp();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
const ok = await adapter.confirm(GL_SOURCE, { refCommitSha: RUN_HEAD });
|
|
193
|
+
assert.equal(ok, true);
|
|
194
|
+
assert.equal(warnings.length, 1);
|
|
195
|
+
assert.equal(warnings[0].kind, 'provenance_retry');
|
|
196
|
+
assert.equal(warnings[0].provider, 'gitlab');
|
|
197
|
+
assert.equal(warnings[0].status_or_reason, 'timeout');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('a 429 then a 200: onRetryWarn carries the numeric status', async () => {
|
|
201
|
+
let calls = 0;
|
|
202
|
+
const warnings = [];
|
|
203
|
+
const adapter = gitlabProvenance('token', {
|
|
204
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
205
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
206
|
+
fetchImpl: async () => {
|
|
207
|
+
calls++;
|
|
208
|
+
return calls === 1 ? { ok: false, status: 429 } : okGitlabResp();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
await adapter.confirm(GL_SOURCE, { refCommitSha: RUN_HEAD });
|
|
212
|
+
assert.equal(warnings.length, 1);
|
|
213
|
+
assert.equal(warnings[0].status_or_reason, 429);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('exhausting the retry budget fires onRetryWarn exactly `retries` times', async () => {
|
|
217
|
+
const warnings = [];
|
|
218
|
+
const adapter = gitlabProvenance('token', {
|
|
219
|
+
timeoutMs: 1000, retries: 1, sleepImpl: noSleep,
|
|
220
|
+
onRetryWarn: (f) => warnings.push(f),
|
|
221
|
+
fetchImpl: async () => { throw abortError(); }
|
|
222
|
+
});
|
|
223
|
+
await assert.rejects(() => adapter.confirm(GL_SOURCE), /provenance: GitLab API timeout/);
|
|
224
|
+
assert.equal(warnings.length, 1, 'retries=1 → 1 warn event before the 2nd attempt throws');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-5fd3f832: a provider-sent Retry-After header was honored with NO upper
|
|
3
|
+
* bound — `Retry-After: 86400` (or an HTTP-date hours ahead) made the adapter
|
|
4
|
+
* sleep that long between attempts. The per-request AbortController timeout
|
|
5
|
+
* bounds each REQUEST but not the inter-attempt sleep, so one hostile/broken
|
|
6
|
+
* 429 (plausible via gitlabProvenance's self-hosted apiBase) could wedge the
|
|
7
|
+
* concurrency-serialized ingest.yml queue for hours.
|
|
8
|
+
*
|
|
9
|
+
* Contract: the Retry-After-derived wait is clamped to MAX_RETRY_AFTER_MS
|
|
10
|
+
* (30s) in both the delta-seconds and HTTP-date branches; the exponential
|
|
11
|
+
* fallback was already bounded by the retry budget.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
|
|
16
|
+
import { githubProvenance, gitlabProvenance, MAX_RETRY_AFTER_MS } from './provenance.js';
|
|
17
|
+
|
|
18
|
+
const GH_SOURCE = {
|
|
19
|
+
provider: 'github',
|
|
20
|
+
workflow: 'dogfood.yml',
|
|
21
|
+
provider_run_id: '9123456789',
|
|
22
|
+
run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const GL_SOURCE = {
|
|
26
|
+
provider: 'gitlab',
|
|
27
|
+
workflow: '.gitlab-ci.yml',
|
|
28
|
+
provider_run_id: '424242',
|
|
29
|
+
run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
|
|
30
|
+
repo: 'acme/widget'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function throttled429(retryAfterValue) {
|
|
34
|
+
return async () => ({
|
|
35
|
+
ok: false,
|
|
36
|
+
status: 429,
|
|
37
|
+
headers: { get: h => (h === 'retry-after' ? retryAfterValue : null) },
|
|
38
|
+
json: async () => ({})
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collectSleeps() {
|
|
43
|
+
const waited = [];
|
|
44
|
+
return { waited, sleepImpl: async (ms) => { waited.push(ms); } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('F-5fd3f832: Retry-After waits are clamped (github adapter)', () => {
|
|
48
|
+
it('clamps a huge delta-seconds Retry-After to MAX_RETRY_AFTER_MS', async () => {
|
|
49
|
+
const { waited, sleepImpl } = collectSleeps();
|
|
50
|
+
const adapter = githubProvenance('token', {
|
|
51
|
+
timeoutMs: 1000, retries: 2, fetchImpl: throttled429('86400'), sleepImpl
|
|
52
|
+
});
|
|
53
|
+
await assert.rejects(() => adapter.confirm(GH_SOURCE), /provenance: GitHub API returned 429/);
|
|
54
|
+
assert.equal(waited.length, 2);
|
|
55
|
+
for (const ms of waited) {
|
|
56
|
+
assert.ok(ms <= MAX_RETRY_AFTER_MS,
|
|
57
|
+
`wait ${ms}ms must be clamped to ${MAX_RETRY_AFTER_MS}ms`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('clamps a far-future HTTP-date Retry-After to MAX_RETRY_AFTER_MS', async () => {
|
|
62
|
+
const { waited, sleepImpl } = collectSleeps();
|
|
63
|
+
const farFuture = new Date(Date.now() + 6 * 3600_000).toUTCString();
|
|
64
|
+
const adapter = githubProvenance('token', {
|
|
65
|
+
timeoutMs: 1000, retries: 1, fetchImpl: throttled429(farFuture), sleepImpl
|
|
66
|
+
});
|
|
67
|
+
await assert.rejects(() => adapter.confirm(GH_SOURCE));
|
|
68
|
+
assert.equal(waited.length, 1);
|
|
69
|
+
assert.ok(waited[0] <= MAX_RETRY_AFTER_MS,
|
|
70
|
+
`HTTP-date wait ${waited[0]}ms must be clamped to ${MAX_RETRY_AFTER_MS}ms`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('still honors a small Retry-After exactly (no over-clamping)', async () => {
|
|
74
|
+
const { waited, sleepImpl } = collectSleeps();
|
|
75
|
+
const adapter = githubProvenance('token', {
|
|
76
|
+
timeoutMs: 1000, retries: 1, fetchImpl: throttled429('2'), sleepImpl
|
|
77
|
+
});
|
|
78
|
+
await assert.rejects(() => adapter.confirm(GH_SOURCE));
|
|
79
|
+
assert.deepEqual(waited, [2000]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('F-5fd3f832: Retry-After waits are clamped (gitlab adapter)', () => {
|
|
84
|
+
it('clamps a huge delta-seconds Retry-After to MAX_RETRY_AFTER_MS', async () => {
|
|
85
|
+
const { waited, sleepImpl } = collectSleeps();
|
|
86
|
+
const adapter = gitlabProvenance('token', {
|
|
87
|
+
timeoutMs: 1000, retries: 2, fetchImpl: throttled429('86400'), sleepImpl
|
|
88
|
+
});
|
|
89
|
+
await assert.rejects(() => adapter.confirm(GL_SOURCE), /provenance: GitLab API returned 429/);
|
|
90
|
+
assert.equal(waited.length, 2);
|
|
91
|
+
for (const ms of waited) {
|
|
92
|
+
assert.ok(ms <= MAX_RETRY_AFTER_MS,
|
|
93
|
+
`wait ${ms}ms must be clamped to ${MAX_RETRY_AFTER_MS}ms`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-ad98b5ac (defensive) — validateRequiredSteps' map build was unguarded.
|
|
3
|
+
*
|
|
4
|
+
* `new Map(step_results.map(s => [s.step_id, s]))` dereferences `s.step_id` on
|
|
5
|
+
* every element with no per-element guard. A null / non-object element throws a
|
|
6
|
+
* TypeError that `runValidator('steps', ...)` mislabels as an operational
|
|
7
|
+
* `VALIDATOR_FAULT_STEPS` — the submission-bad→ops inversion the F-efe4f893
|
|
8
|
+
* family fought: a MALFORMED SUBMISSION is a caller problem (reject the
|
|
9
|
+
* submission), not an operator fault (page the pipeline owner).
|
|
10
|
+
*
|
|
11
|
+
* The sibling `validateStepResults` already applies this floor (its verify-B-004
|
|
12
|
+
* comment guards `s != null`). Fix: build the map defensively, admitting only
|
|
13
|
+
* `{ ...typeof s === 'object', typeof s.step_id === 'string' }` elements. This
|
|
14
|
+
* only stops the map build from throwing; structural malformed-step *reporting*
|
|
15
|
+
* stays validateStepResults' job.
|
|
16
|
+
*
|
|
17
|
+
* Pin: RED = a `step_results: [null]` (or non-object element) input throws a
|
|
18
|
+
* TypeError before the fix; GREEN = it builds the map without throwing after.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
|
|
24
|
+
import { validateRequiredSteps } from './steps.js';
|
|
25
|
+
|
|
26
|
+
describe('F-ad98b5ac: validateRequiredSteps map build tolerates malformed elements', () => {
|
|
27
|
+
it('POSITIVE: a null element in step_results does not throw a TypeError', () => {
|
|
28
|
+
const scenarioResult = {
|
|
29
|
+
scenario_id: 'sc-1',
|
|
30
|
+
verdict: 'pass',
|
|
31
|
+
step_results: [null],
|
|
32
|
+
};
|
|
33
|
+
// Before the fix: `s.step_id` on null throws
|
|
34
|
+
// `TypeError: Cannot read properties of null (reading 'step_id')`.
|
|
35
|
+
assert.doesNotThrow(
|
|
36
|
+
() => validateRequiredSteps(scenarioResult, ['step-a']),
|
|
37
|
+
'a null element must not throw — the map build must guard per element'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('POSITIVE: a non-object element (string/number) does not throw', () => {
|
|
42
|
+
const scenarioResult = {
|
|
43
|
+
scenario_id: 'sc-2',
|
|
44
|
+
verdict: 'fail',
|
|
45
|
+
step_results: ['not-an-object', 42],
|
|
46
|
+
};
|
|
47
|
+
assert.doesNotThrow(
|
|
48
|
+
() => validateRequiredSteps(scenarioResult, ['step-a']),
|
|
49
|
+
'a non-object element must not throw'
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('POSITIVE: an object element missing a string step_id does not throw', () => {
|
|
54
|
+
const scenarioResult = {
|
|
55
|
+
scenario_id: 'sc-3',
|
|
56
|
+
verdict: 'pass',
|
|
57
|
+
step_results: [{ status: 'pass' }],
|
|
58
|
+
};
|
|
59
|
+
assert.doesNotThrow(
|
|
60
|
+
() => validateRequiredSteps(scenarioResult, ['step-a']),
|
|
61
|
+
'an object without a string step_id must not throw'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('NEGATIVE: well-formed step_results still enforce required-step presence + verdict consistency', () => {
|
|
66
|
+
// A malformed element must not silently disable the real checks. A valid
|
|
67
|
+
// step keyed correctly still participates; a missing required step is still
|
|
68
|
+
// reported.
|
|
69
|
+
const scenarioResult = {
|
|
70
|
+
scenario_id: 'sc-ok',
|
|
71
|
+
verdict: 'pass',
|
|
72
|
+
step_results: [
|
|
73
|
+
null, // tolerated, ignored
|
|
74
|
+
{ step_id: 'step-a', status: 'pass' }, // valid, keyed into the map
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
const errors = validateRequiredSteps(scenarioResult, ['step-a', 'step-missing']);
|
|
78
|
+
// step-a is present + passing → no error for it.
|
|
79
|
+
assert.ok(!errors.some(e => e.includes('"step-a"')),
|
|
80
|
+
`a present, passing required step must not error; got: ${JSON.stringify(errors)}`);
|
|
81
|
+
// step-missing has no result → the presence rule still fires.
|
|
82
|
+
assert.ok(errors.some(e => e.includes('step-missing')),
|
|
83
|
+
`a missing required step must still be reported; got: ${JSON.stringify(errors)}`);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-dac7e08c: a genuine transport error (DNS failure, ECONNREFUSED — the
|
|
3
|
+
* provider or the runner's network is DOWN) was `return false`, which
|
|
4
|
+
* verify/index.js turned into `provenance: source run could not be confirmed`
|
|
5
|
+
* — classified submission-bad and bounced to the submitter, permanently
|
|
6
|
+
* persisting a REJECTED record whose run_id then trips the duplicate guard on
|
|
7
|
+
* a clean resubmission. That inverted the verify-A-002 taxonomy (429/5xx
|
|
8
|
+
* throw → operational).
|
|
9
|
+
*
|
|
10
|
+
* Contract: transport rejects are retried within the PROVENANCE_RETRIES
|
|
11
|
+
* budget (at least as transient as a 5xx) and on exhaustion THROW
|
|
12
|
+
* `provenance: network error: …` so the reason lands under the operational
|
|
13
|
+
* `provenance-fault:` prefix. `return false` is reserved for HTTP 404.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
|
|
18
|
+
import { githubProvenance, gitlabProvenance } from './provenance.js';
|
|
19
|
+
import { parseRejectionReason } from '../parse-rejection.js';
|
|
20
|
+
|
|
21
|
+
const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
|
|
22
|
+
|
|
23
|
+
const GH_SOURCE = {
|
|
24
|
+
provider: 'github',
|
|
25
|
+
workflow: 'dogfood.yml',
|
|
26
|
+
provider_run_id: '9123456789',
|
|
27
|
+
run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const GL_SOURCE = {
|
|
31
|
+
provider: 'gitlab',
|
|
32
|
+
workflow: '.gitlab-ci.yml',
|
|
33
|
+
provider_run_id: '424242',
|
|
34
|
+
run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
|
|
35
|
+
repo: 'acme/widget'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function transportError() {
|
|
39
|
+
const err = new TypeError('fetch failed');
|
|
40
|
+
err.cause = { code: 'ECONNREFUSED' };
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const noSleep = async () => {};
|
|
45
|
+
|
|
46
|
+
describe('F-dac7e08c: transport errors are operational, not submission-bad (github)', () => {
|
|
47
|
+
it('throws provenance: network error after exhausting retries', async () => {
|
|
48
|
+
let calls = 0;
|
|
49
|
+
const adapter = githubProvenance('token', {
|
|
50
|
+
timeoutMs: 1000,
|
|
51
|
+
retries: 2,
|
|
52
|
+
sleepImpl: noSleep,
|
|
53
|
+
fetchImpl: async () => { calls++; throw transportError(); }
|
|
54
|
+
});
|
|
55
|
+
await assert.rejects(() => adapter.confirm(GH_SOURCE), /provenance: network error: fetch failed/);
|
|
56
|
+
assert.equal(calls, 3, 'transport rejects must be retried within the budget (retries=2 → 3 attempts)');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('the thrown reason classifies OPERATIONAL via the provenance-fault prefix', async () => {
|
|
60
|
+
const adapter = githubProvenance('token', {
|
|
61
|
+
timeoutMs: 1000, retries: 0, sleepImpl: noSleep,
|
|
62
|
+
fetchImpl: async () => { throw transportError(); }
|
|
63
|
+
});
|
|
64
|
+
let message;
|
|
65
|
+
try {
|
|
66
|
+
await adapter.confirm(GH_SOURCE);
|
|
67
|
+
assert.fail('expected a throw');
|
|
68
|
+
} catch (e) {
|
|
69
|
+
message = e.message;
|
|
70
|
+
}
|
|
71
|
+
// verify/index.js wraps a provenance throw as `provenance-fault: verification failed: <msg>`.
|
|
72
|
+
const parsed = parseRejectionReason(`provenance-fault: verification failed: ${message}`);
|
|
73
|
+
assert.equal(parsed.class, 'operational',
|
|
74
|
+
'a network outage must page ops, not bounce to the submitter');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('recovers when the transport blip clears on a retry', async () => {
|
|
78
|
+
let calls = 0;
|
|
79
|
+
const adapter = githubProvenance('token', {
|
|
80
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
81
|
+
fetchImpl: async () => {
|
|
82
|
+
calls++;
|
|
83
|
+
if (calls === 1) throw transportError();
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
status: 200,
|
|
87
|
+
json: async () => ({
|
|
88
|
+
id: 9123456789,
|
|
89
|
+
status: 'completed',
|
|
90
|
+
head_sha: RUN_HEAD,
|
|
91
|
+
repository: { full_name: 'acme/widget' }
|
|
92
|
+
})
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
97
|
+
assert.equal(ok, true, 'a momentary transport blip must not fail the submission');
|
|
98
|
+
assert.equal(calls, 2);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('F-dac7e08c: transport errors are operational (gitlab mirror)', () => {
|
|
103
|
+
it('throws provenance: network error after exhausting retries', async () => {
|
|
104
|
+
let calls = 0;
|
|
105
|
+
const adapter = gitlabProvenance('token', {
|
|
106
|
+
timeoutMs: 1000, retries: 1, sleepImpl: noSleep,
|
|
107
|
+
fetchImpl: async () => { calls++; throw transportError(); }
|
|
108
|
+
});
|
|
109
|
+
await assert.rejects(() => adapter.confirm(GL_SOURCE), /provenance: network error: fetch failed/);
|
|
110
|
+
assert.equal(calls, 2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still returns false on HTTP 404 (run genuinely absent — submission-bad)', async () => {
|
|
114
|
+
const adapter = gitlabProvenance('token', {
|
|
115
|
+
timeoutMs: 1000, retries: 1, sleepImpl: noSleep,
|
|
116
|
+
fetchImpl: async () => ({ ok: false, status: 404, json: async () => ({}) })
|
|
117
|
+
});
|
|
118
|
+
const ok = await adapter.confirm(GL_SOURCE);
|
|
119
|
+
assert.equal(ok, false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* f-f50e779b-retry-warn-fallback-guard.test.js
|
|
3
|
+
*
|
|
4
|
+
* F-f50e779b (wave 31, backend amend) — a defect INSIDE the wave-29 fix for
|
|
5
|
+
* F-2a5ddafa, found by this same lane's own wave-30 audit of its own prior
|
|
6
|
+
* work. defaultOnRetryWarn's doc comment states the invariant "the logger
|
|
7
|
+
* must never throw and mask a real retry" and wraps its primary
|
|
8
|
+
* `console.error(JSON.stringify(line))` call in try/catch — but the catch
|
|
9
|
+
* block's OWN recovery action, a second `console.error(...)` call, was not
|
|
10
|
+
* itself guarded. If console.error throws on BOTH calls (a broken stderr fd
|
|
11
|
+
* — EPIPE/ENOSPC, realistic on a CI runner), the second throw escaped
|
|
12
|
+
* defaultOnRetryWarn uncaught, out of the retry loop, out of confirm() —
|
|
13
|
+
* turning a retry that was about to SUCCEED into a rejected promise. Blast
|
|
14
|
+
* radius is bounded by an unrelated, pre-existing mechanism (verify/index.js
|
|
15
|
+
* reclassifies any confirm() throw as a structured PROVENANCE_FAULT, exit 2,
|
|
16
|
+
* nothing persisted) — so this was never a crash, but a submission whose
|
|
17
|
+
* provenance check would have succeeded after a retry was discarded solely
|
|
18
|
+
* because the NEW observability line could not be written: the opposite of
|
|
19
|
+
* what an observability feature should do to reliability.
|
|
20
|
+
*
|
|
21
|
+
* Fix: give the fallback console.error (provenance.js ~line 153-162) its own
|
|
22
|
+
* try/catch, mirroring the two-level guard `dogfood-swarm/lib/log-stage.js`
|
|
23
|
+
* already uses for the identical shape (`catch { try { ... } catch {} }`) —
|
|
24
|
+
* that file was independently swept and confirmed already-hardened; nothing
|
|
25
|
+
* else in this domain shares defaultOnRetryWarn (grepped for
|
|
26
|
+
* onRetryWarn/defaultOnRetryWarn/onWarn/onLog across every owned package —
|
|
27
|
+
* only provenance.js and its two test files match).
|
|
28
|
+
*
|
|
29
|
+
* RED proof — ACTUALLY EXECUTED, not just reasoned (per this wave's brief:
|
|
30
|
+
* "a verification that cannot fail is not a verification"). This exact
|
|
31
|
+
* fixture was run via `node --test` against a byte-identical scratch copy of
|
|
32
|
+
* the PRE-FIX provenance.js in an isolated temp dir (this file has zero
|
|
33
|
+
* imports, so a bare copy is fully self-contained — no workspace needed).
|
|
34
|
+
* Both cases below FAILED against the pre-fix copy, each stack trace pinned
|
|
35
|
+
* to `defaultOnRetryWarn (provenance.js:153)`:
|
|
36
|
+
* - the "resolves true" case rejected with the raw 'EPIPE: simulated
|
|
37
|
+
* broken stderr' error instead of resolving `true`.
|
|
38
|
+
* - the "scope" case rejected with that SAME raw EPIPE error instead of
|
|
39
|
+
* the expected 'provenance: GitHub API timeout' operational error —
|
|
40
|
+
* proving the pre-fix bug also clobbers a genuine exhausted-retry error
|
|
41
|
+
* with the logger's own unrelated failure.
|
|
42
|
+
* The identical fixture was then re-run against a fixed copy (the nested
|
|
43
|
+
* try/catch added, nothing else changed) and both cases passed. Only that
|
|
44
|
+
* one nested try/catch differed between the RED run and the GREEN run.
|
|
45
|
+
*
|
|
46
|
+
* Care taken against the "vacuous test" shape (this wave's own named lesson,
|
|
47
|
+
* re: F-2a5ddafa's original test asserting a fix fired without ever proving
|
|
48
|
+
* it fired ONLY where it should): `opts.onRetryWarn` is deliberately NEVER
|
|
49
|
+
* overridden below — overriding it would bypass defaultOnRetryWarn entirely
|
|
50
|
+
* and the test would pass whether or not this fix exists, regardless of the
|
|
51
|
+
* production code. The global `console.error` (not `process.stderr.write`,
|
|
52
|
+
* which the F-2a5ddafa suite already uses for a *different* assertion) is
|
|
53
|
+
* the monkey-patch target because that is exactly what defaultOnRetryWarn
|
|
54
|
+
* calls. The second test below additionally proves the guard is scoped
|
|
55
|
+
* correctly: it swallows ONLY the logger's own failure and does not mask a
|
|
56
|
+
* real, unrelated operational error the caller is entitled to see.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { describe, it } from 'node:test';
|
|
60
|
+
import assert from 'node:assert/strict';
|
|
61
|
+
|
|
62
|
+
import { githubProvenance } from './provenance.js';
|
|
63
|
+
|
|
64
|
+
const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
|
|
65
|
+
|
|
66
|
+
const GH_SOURCE = {
|
|
67
|
+
provider: 'github',
|
|
68
|
+
workflow: 'dogfood.yml',
|
|
69
|
+
provider_run_id: '9123456789',
|
|
70
|
+
run_url: 'https://github.com/acme/widget/actions/runs/9123456789'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function abortError() {
|
|
74
|
+
const e = new Error('This operation was aborted');
|
|
75
|
+
e.name = 'AbortError';
|
|
76
|
+
return e;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const noSleep = async () => {};
|
|
80
|
+
|
|
81
|
+
function okGithubResp() {
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
status: 200,
|
|
85
|
+
json: async () => ({
|
|
86
|
+
id: 9123456789,
|
|
87
|
+
status: 'completed',
|
|
88
|
+
head_sha: RUN_HEAD,
|
|
89
|
+
repository: { full_name: 'acme/widget' }
|
|
90
|
+
})
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Monkey-patch the GLOBAL console.error for the duration of `fn` so every
|
|
96
|
+
* call throws, simulating a broken stderr fd (EPIPE/ENOSPC) — the exact
|
|
97
|
+
* mechanism the finding proved live. Always restores the original in
|
|
98
|
+
* `finally` so a failing assertion cannot leak the patch into a sibling
|
|
99
|
+
* test (node:test runs this file's cases sequentially, but a leaked patch
|
|
100
|
+
* would still poison any later suite in the same process).
|
|
101
|
+
*
|
|
102
|
+
* @param {(getCallCount: () => number) => Promise<void>} fn
|
|
103
|
+
*/
|
|
104
|
+
async function withThrowingConsoleError(fn) {
|
|
105
|
+
const orig = console.error;
|
|
106
|
+
let calls = 0;
|
|
107
|
+
console.error = () => {
|
|
108
|
+
calls++;
|
|
109
|
+
throw new Error('EPIPE: simulated broken stderr');
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await fn(() => calls);
|
|
113
|
+
} finally {
|
|
114
|
+
console.error = orig;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @pins F-f50e779b */
|
|
119
|
+
describe('F-f50e779b — defaultOnRetryWarn fallback is itself guarded', () => {
|
|
120
|
+
it('a retry that would succeed still resolves true when console.error throws on every call', async () => {
|
|
121
|
+
await withThrowingConsoleError(async (getCalls) => {
|
|
122
|
+
let fetchCalls = 0;
|
|
123
|
+
const adapter = githubProvenance('token', {
|
|
124
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
125
|
+
// Deliberately NOT overriding onRetryWarn — must exercise the real
|
|
126
|
+
// defaultOnRetryWarn, or this test never reaches the guarded code
|
|
127
|
+
// (the exact vacuous-test shape this suite's header warns against).
|
|
128
|
+
fetchImpl: async () => {
|
|
129
|
+
fetchCalls++;
|
|
130
|
+
if (fetchCalls === 1) throw abortError();
|
|
131
|
+
return okGithubResp();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
const ok = await adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD });
|
|
135
|
+
assert.equal(ok, true, 'a retry that succeeds must resolve true, not reject');
|
|
136
|
+
assert.equal(getCalls(), 2, 'expected exactly 2 console.error attempts: primary (line ~149) + fallback (line ~153)');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('scope: a genuinely exhausted retry budget still throws the real operational error, not the logger error', async () => {
|
|
141
|
+
await withThrowingConsoleError(async () => {
|
|
142
|
+
const adapter = githubProvenance('token', {
|
|
143
|
+
timeoutMs: 1000, retries: 2, sleepImpl: noSleep,
|
|
144
|
+
fetchImpl: async () => { throw abortError(); } // never succeeds — budget exhausts
|
|
145
|
+
});
|
|
146
|
+
await assert.rejects(
|
|
147
|
+
() => adapter.confirm(GH_SOURCE, { refCommitSha: RUN_HEAD }),
|
|
148
|
+
/provenance: GitHub API timeout/,
|
|
149
|
+
'the fallback guard must swallow ONLY the logger failure — it must never mask the real exhausted-retry error behind it'
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|