@dogfood-lab/verify 1.8.0 → 1.9.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 +2 -1
- package/cli-lint.js +78 -33
- package/cli.js +94 -15
- package/index.js +95 -61
- package/package.json +1 -1
- package/parse-rejection.js +10 -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/lint-scenario.js +141 -0
- package/validators/policy.js +9 -1
- package/validators/provenance-gitlab.test.js +10 -3
- package/validators/provenance.js +60 -13
- package/validators/repo-binding.js +18 -10
- package/validators/steps.js +39 -9
- package/validators/w4-f-8e72d0de-timeout-retry.test.js +118 -0
package/index.js
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { validateSubmissionSchema as _defaultValidateSubmissionSchema } from './validators/schema.js';
|
|
10
10
|
import { validatePolicy as _defaultValidatePolicy } from './validators/policy.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
validateStepResults as _defaultValidateStepResults,
|
|
13
|
+
validateRequiredSteps as _defaultValidateRequiredSteps
|
|
14
|
+
} from './validators/steps.js';
|
|
12
15
|
import { validateSchemaVersion as _defaultValidateSchemaVersion } from './validators/schema-version.js';
|
|
13
16
|
import { computeVerdict } from './validators/verdict.js';
|
|
14
17
|
import { parseRunUrlRepo } from './validators/repo-binding.js';
|
|
@@ -33,23 +36,32 @@ export { provenanceForProvider, PROVENANCE_ADAPTERS } from './validators/provena
|
|
|
33
36
|
const RECORD_SCHEMA_VERSION = SUPPORTED_SCHEMA_VERSIONS.record.current;
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
|
-
* D1B-003 (Stage C humanization): the SOLE catch
|
|
37
|
-
* validator calls. Distinguishes operator-actionable
|
|
39
|
+
* D1B-003 (Stage C humanization) + F-82429f90 (wave 4): the SOLE catch
|
|
40
|
+
* wrapper for synchronous validator calls. Distinguishes operator-actionable
|
|
41
|
+
* signals:
|
|
38
42
|
*
|
|
39
43
|
* - Validator returned a structured result → caller pushes the existing
|
|
40
44
|
* `'<class>: <details>'` prefix into `rejection_reasons` (unchanged
|
|
41
45
|
* submission-bad path; back-compat is preserved).
|
|
42
46
|
* - Validator THREW (operational incident — ajv compile fault, policy
|
|
43
|
-
* merge cycle, out-of-memory) → wrapper
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
+
* merge cycle, out-of-memory) → wrapper RETHROWS a classified error
|
|
48
|
+
* whose message carries the STABLE coded prefix
|
|
49
|
+
* `'VALIDATOR_FAULT_<NAME>: <details>'` and whose `.code` is
|
|
50
|
+
* `VALIDATOR_FAULT_<NAME>`. The prefix is greppable across runner logs:
|
|
51
|
+
* `grep -E 'VALIDATOR_FAULT_' …` → every operational incident
|
|
47
52
|
* `grep -E '^(schema|policy|steps): ' …` → every submission-bad signal
|
|
48
53
|
*
|
|
54
|
+
* F-82429f90 changed the fault path from push-a-rejection-reason to
|
|
55
|
+
* propagate-the-throw: a validator crash is an OPERATIONAL incident, and
|
|
56
|
+
* persisting it as a `_rejected` record permanently poisoned the run_id via
|
|
57
|
+
* ingest's duplicate guard (the exact V2-CROSS-BO-001 pattern the sibling
|
|
58
|
+
* scenario-fetch path already fixed). Both production callers
|
|
59
|
+
* (packages/ingest/run.js's outer catch and cli.js's run() catch) map a
|
|
60
|
+
* verify() throw to exit 2 with NOTHING persisted, so a clean resubmission
|
|
61
|
+
* after recovery is accepted.
|
|
62
|
+
*
|
|
49
63
|
* `name` is upper-cased to match the prefix vocabulary documented in
|
|
50
|
-
* verify/README.md (SCHEMA / POLICY / STEPS).
|
|
51
|
-
* { ok: true, result } when fn() returned cleanly
|
|
52
|
-
* { ok: false, faultReason } when fn() threw
|
|
64
|
+
* verify/README.md (SCHEMA / POLICY / STEPS).
|
|
53
65
|
*
|
|
54
66
|
* The helper is the canonical seam for any new synchronous validator
|
|
55
67
|
* — adding a 4th validator class becomes a one-call site, not a
|
|
@@ -57,16 +69,20 @@ const RECORD_SCHEMA_VERSION = SUPPORTED_SCHEMA_VERSIONS.record.current;
|
|
|
57
69
|
*
|
|
58
70
|
* @param {string} name - Validator class name (will be upper-cased).
|
|
59
71
|
* @param {() => T} fn - The validator call.
|
|
60
|
-
* @returns {
|
|
72
|
+
* @returns {T} fn()'s result when it returned cleanly.
|
|
73
|
+
* @throws {Error} classified `VALIDATOR_FAULT_<NAME>` error when fn() threw.
|
|
61
74
|
* @template T
|
|
62
75
|
*/
|
|
63
76
|
function runValidator(name, fn) {
|
|
64
77
|
try {
|
|
65
|
-
return
|
|
78
|
+
return fn();
|
|
66
79
|
} catch (e) {
|
|
67
80
|
const cls = name.toUpperCase();
|
|
68
81
|
const detail = e && e.message ? e.message : String(e);
|
|
69
|
-
|
|
82
|
+
const fault = new Error(`VALIDATOR_FAULT_${cls}: ${detail}`);
|
|
83
|
+
fault.code = `VALIDATOR_FAULT_${cls}`;
|
|
84
|
+
fault.cause = e;
|
|
85
|
+
throw fault;
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
88
|
|
|
@@ -79,6 +95,12 @@ function runValidator(name, fn) {
|
|
|
79
95
|
* @param {object|null} options.repoPolicy - Parsed repo policy (null if none)
|
|
80
96
|
* @param {object} options.provenance - Provenance adapter { confirm(source) => Promise<boolean> }
|
|
81
97
|
* @param {string} options.policyVersion - Semver of the policy set being applied
|
|
98
|
+
* @param {Map<string, object>|null} [options.scenarios] - Loaded scenario
|
|
99
|
+
* definitions keyed by scenario_id (from loadScenarios). When present, each
|
|
100
|
+
* scenario_result is checked against its definition's
|
|
101
|
+
* success_criteria.required_steps — the REAL enforcement behind the
|
|
102
|
+
* `step-results-present` / `step-verdict-consistent` global reject rules
|
|
103
|
+
* (F-3bfc2885). Absent/null keeps the legacy structural-only behavior.
|
|
82
104
|
* @param {object} [options.validators] - Test-only override hook (D1B-003).
|
|
83
105
|
* Pass `{ validateSubmissionSchema, validateStepResults, validatePolicy }`
|
|
84
106
|
* to swap in fault-injecting stubs. Production callers leave this unset
|
|
@@ -114,12 +136,13 @@ export async function verify(submission, options) {
|
|
|
114
136
|
};
|
|
115
137
|
}
|
|
116
138
|
|
|
117
|
-
const { globalPolicy, repoPolicy, provenance, policyVersion, validators: validatorOverrides = {} } = options;
|
|
139
|
+
const { globalPolicy, repoPolicy, provenance, policyVersion, scenarios = null, validators: validatorOverrides = {} } = options;
|
|
118
140
|
// Resolve the three validators per-call so tests can inject fault-
|
|
119
141
|
// injecting stubs. Production callers leave `validators` unset and the
|
|
120
142
|
// module-level imports flow through unchanged.
|
|
121
143
|
const validateSubmissionSchema = validatorOverrides.validateSubmissionSchema || _defaultValidateSubmissionSchema;
|
|
122
144
|
const validateStepResults = validatorOverrides.validateStepResults || _defaultValidateStepResults;
|
|
145
|
+
const validateRequiredSteps = validatorOverrides.validateRequiredSteps || _defaultValidateRequiredSteps;
|
|
123
146
|
const validatePolicy = validatorOverrides.validatePolicy || _defaultValidatePolicy;
|
|
124
147
|
const validateSchemaVersion = validatorOverrides.validateSchemaVersion || _defaultValidateSchemaVersion;
|
|
125
148
|
const now = new Date().toISOString();
|
|
@@ -151,18 +174,12 @@ export async function verify(submission, options) {
|
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
// 1. Schema validation
|
|
154
|
-
// D1B-003
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
schemaResult = schemaRun.result;
|
|
161
|
-
if (!schemaResult.valid) {
|
|
162
|
-
reasons.push(...schemaResult.errors.map(e => `schema: ${e}`));
|
|
163
|
-
}
|
|
164
|
-
} else {
|
|
165
|
-
reasons.push(schemaRun.faultReason);
|
|
177
|
+
// D1B-003 / F-82429f90: submission-bad results push the `'schema: …'`
|
|
178
|
+
// prefix; a validator CRASH propagates out of verify() as a classified
|
|
179
|
+
// VALIDATOR_FAULT_SCHEMA throw (operational — exit 2, nothing persisted).
|
|
180
|
+
const schemaResult = runValidator('schema', () => validateSubmissionSchema(submission));
|
|
181
|
+
if (!schemaResult.valid) {
|
|
182
|
+
reasons.push(...schemaResult.errors.map(e => `schema: ${e}`));
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
// 1b. schema_version VALUE gate (F1-CONTRACTS-001)
|
|
@@ -174,15 +191,12 @@ export async function verify(submission, options) {
|
|
|
174
191
|
// a future-major payload may also fail shape, but the version refusal is the
|
|
175
192
|
// operator-actionable signal and must land regardless. The validator emits a
|
|
176
193
|
// fully-prefixed `CONTRACT_SCHEMA_TOO_NEW:` / `CONTRACT_SCHEMA_TOO_OLD:`
|
|
177
|
-
// reason; an unknown-contract throw
|
|
178
|
-
// `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION` via the same
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
} else {
|
|
185
|
-
reasons.push(versionRun.faultReason);
|
|
194
|
+
// reason; an unknown-contract throw propagates as a classified
|
|
195
|
+
// `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION` fault via the same
|
|
196
|
+
// runValidator seam (F-82429f90: operational, never persisted).
|
|
197
|
+
const versionResult = runValidator('contract_schema_version', () => validateSchemaVersion(submission, 'recordSubmission'));
|
|
198
|
+
if (!versionResult.valid) {
|
|
199
|
+
reasons.push(...versionResult.errors);
|
|
186
200
|
}
|
|
187
201
|
|
|
188
202
|
// 2. Reject if submission includes verifier-owned fields
|
|
@@ -209,15 +223,25 @@ export async function verify(submission, options) {
|
|
|
209
223
|
refCommitSha: submission.ref?.commit_sha
|
|
210
224
|
});
|
|
211
225
|
} catch (err) {
|
|
212
|
-
// verify-A-002: the adapter THROWS on operational provider
|
|
213
|
-
// (429 rate-limit, 5xx outage, 401/403 token
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
226
|
+
// verify-A-002 + F-82429f90: the adapter THROWS on operational provider
|
|
227
|
+
// faults (429 rate-limit, 5xx outage, 401/403 token, exhausted-retry
|
|
228
|
+
// transport errors — F-dac7e08c) and returns false only for a
|
|
229
|
+
// genuinely-absent run (HTTP 404). RETHROW as a classified
|
|
230
|
+
// PROVENANCE_FAULT so the incident propagates out of verify() —
|
|
231
|
+
// mirroring SCENARIO_FETCH_FAULT — and is NEVER assembled into a
|
|
232
|
+
// persisted `_rejected` record: persisting an outage-window rejection
|
|
233
|
+
// permanently blocked the run_id via ingest's duplicate guard. Both
|
|
234
|
+
// production callers (ingest run.js outer catch; verify cli.js run()
|
|
235
|
+
// catch) map the throw to exit 2 with nothing persisted. The message
|
|
236
|
+
// keeps the `provenance-fault:` prefix parseRejectionReason classifies
|
|
237
|
+
// as operational. The not-confirmed case below keeps the bare
|
|
238
|
+
// `provenance:` prefix (still submission-bad).
|
|
239
|
+
const fault = new Error(`provenance-fault: verification failed: ${err.message}`);
|
|
240
|
+
fault.code = 'PROVENANCE_FAULT';
|
|
241
|
+
fault.cause = err;
|
|
242
|
+
throw fault;
|
|
219
243
|
}
|
|
220
|
-
if (!provenanceConfirmed
|
|
244
|
+
if (!provenanceConfirmed) {
|
|
221
245
|
reasons.push('provenance: source run could not be confirmed');
|
|
222
246
|
}
|
|
223
247
|
}
|
|
@@ -226,11 +250,24 @@ export async function verify(submission, options) {
|
|
|
226
250
|
// D1B-003: same submission-bad-vs-operational-fault split as schema.
|
|
227
251
|
if (schemaResult.valid && submission.scenario_results) {
|
|
228
252
|
for (const scenario of submission.scenario_results) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
253
|
+
const stepsErrors = runValidator('steps', () => validateStepResults(scenario));
|
|
254
|
+
reasons.push(...stepsErrors.map(e => `steps[${scenario.scenario_id}]: ${e}`));
|
|
255
|
+
|
|
256
|
+
// F-3bfc2885: enforce success_criteria.required_steps when the caller
|
|
257
|
+
// loaded the scenario definition. This is the enforcement behind the
|
|
258
|
+
// `step-results-present` / `step-verdict-consistent` global reject rules
|
|
259
|
+
// that KNOWN_REJECT_RULE_IDS attributes to "verify() itself" — before
|
|
260
|
+
// this wiring, validateRequiredSteps had no callers and the rules were
|
|
261
|
+
// declared-but-unenforced. A scenario_result whose definition failed to
|
|
262
|
+
// load is NOT checked here; loadScenarios already reports that as a
|
|
263
|
+
// `scenario-load:` rejection at the ingest layer.
|
|
264
|
+
if (scenarios && typeof scenarios.get === 'function') {
|
|
265
|
+
const definition = scenarios.get(scenario.scenario_id);
|
|
266
|
+
const requiredSteps = definition?.success_criteria?.required_steps ?? [];
|
|
267
|
+
if (requiredSteps.length > 0) {
|
|
268
|
+
const requiredErrors = runValidator('steps', () => validateRequiredSteps(scenario, requiredSteps));
|
|
269
|
+
reasons.push(...requiredErrors.map(e => `steps[${scenario.scenario_id}]: ${e}`));
|
|
270
|
+
}
|
|
234
271
|
}
|
|
235
272
|
}
|
|
236
273
|
}
|
|
@@ -261,19 +298,16 @@ export async function verify(submission, options) {
|
|
|
261
298
|
reasons.push(`policy: repo policy unreadable — ${detail}`);
|
|
262
299
|
// policyValid stays false.
|
|
263
300
|
} else {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
} else {
|
|
275
|
-
reasons.push(policyRun.faultReason);
|
|
276
|
-
}
|
|
301
|
+
const policyResult = runValidator('policy', () => validatePolicy(submission, { globalPolicy, repoPolicy }));
|
|
302
|
+
policyValid = policyResult.valid;
|
|
303
|
+
reasons.push(...policyResult.errors.map(e => `policy: ${e}`));
|
|
304
|
+
// VERIFY-F1: a malformed REPO custom-rule predicate is a distinct
|
|
305
|
+
// `policy-config:` rejection (submission-bad — the repo authored the bad
|
|
306
|
+
// rule). A malformed GLOBAL predicate never reaches here; it throws and
|
|
307
|
+
// propagates as a classified VALIDATOR_FAULT_POLICY (operational,
|
|
308
|
+
// F-82429f90 — exit 2, nothing persisted) via runValidator.
|
|
309
|
+
reasons.push(...(policyResult.configErrors || []).map(e => `policy-config: ${e}`));
|
|
310
|
+
warnings.push(...(policyResult.warnings || []).map(w => `policy: ${w}`));
|
|
277
311
|
}
|
|
278
312
|
}
|
|
279
313
|
|
package/package.json
CHANGED
package/parse-rejection.js
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW:,
|
|
36
36
|
* CONTRACT_SCHEMA_TOO_OLD:
|
|
37
37
|
* - packages/ingest/run.js: scenario-load:
|
|
38
|
+
* - packages/ingest/load-context.js: scenario-fetch-fault: (V2-CROSS-BO-001,
|
|
39
|
+
* scenario-fetch outage/credential fault
|
|
40
|
+
* → operational)
|
|
38
41
|
*
|
|
39
42
|
* `VALIDATOR_FAULT_*` is matched by family, not by an exhaustive name list, so
|
|
40
43
|
* a future `VALIDATOR_FAULT_<NEW>:` (e.g. the runValidator seam adds a 5th
|
|
@@ -93,6 +96,13 @@ const LITERAL_PREFIXES = [
|
|
|
93
96
|
// (verify-B-003), not a submitter who sent a bad-but-shaped payload. Page ops;
|
|
94
97
|
// do NOT bounce it back to the submitter.
|
|
95
98
|
{ match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational' },
|
|
99
|
+
// operational — the scenario fetcher THREW after exhausting its retry
|
|
100
|
+
// budget (5xx/429 outage, transport reject) or hit a credential fault
|
|
101
|
+
// (401/403). V2-CROSS-BO-001: packages/ingest/load-context.js throws this
|
|
102
|
+
// classified error instead of returning `not_found`, so an outage never
|
|
103
|
+
// rejects a good submission. Ordered before `scenario-load:` to keep the
|
|
104
|
+
// most-specific-first invariant explicit (the tokens do not overlap).
|
|
105
|
+
{ match: 'scenario-fetch-fault:', prefix: 'scenario-fetch-fault:', class: 'operational' },
|
|
96
106
|
// ingest
|
|
97
107
|
{ match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest' },
|
|
98
108
|
];
|
|
@@ -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
|
+
});
|