@dogfood-lab/verify 1.7.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/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 { validateStepResults as _defaultValidateStepResults } from './validators/steps.js';
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 wrapper for synchronous
37
- * validator calls. Distinguishes operator-actionable signals:
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 synthesizes a STABLE coded
44
- * prefix `'VALIDATOR_FAULT_<NAME>: <details>'` for the caller to push.
45
- * The prefix is greppable across runner logs:
46
- * `grep -E '"VALIDATOR_FAULT_' …` → every operational incident
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). Returns:
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 {{ ok: true, result: T } | { ok: false, faultReason: string }}
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 { ok: true, result: fn() };
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
- return { ok: false, faultReason: `VALIDATOR_FAULT_${cls}: ${detail}` };
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: route both happy + fault paths through `runValidator` so
155
- // submission-bad (`'schema: …'`) and operational incidents
156
- // (`'VALIDATOR_FAULT_SCHEMA: …'`) emit DISTINCT, greppable prefixes.
157
- let schemaResult = { valid: false, errors: [] };
158
- const schemaRun = runValidator('schema', () => validateSubmissionSchema(submission));
159
- if (schemaRun.ok) {
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 surfaces as
178
- // `VALIDATOR_FAULT_CONTRACT_SCHEMA_VERSION` via the same runValidator seam.
179
- const versionRun = runValidator('contract_schema_version', () => validateSchemaVersion(submission, 'recordSubmission'));
180
- if (versionRun.ok) {
181
- if (!versionRun.result.valid) {
182
- reasons.push(...versionRun.result.errors);
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 faults
213
- // (429 rate-limit, 5xx outage, 401/403 token) and returns false only for
214
- // a genuinely-absent run (404/transport). Emit a DISTINCT `provenance-fault:`
215
- // prefix here so parseRejectionReason routes the incident to ops instead of
216
- // bouncing an outage back to the submitter as submission-bad. The not-confirmed
217
- // case below keeps the bare `provenance:` prefix (still submission-bad).
218
- reasons.push(`provenance-fault: verification failed: ${err.message}`);
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 && !reasons.some(r => r.startsWith('provenance'))) {
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 stepsRun = runValidator('steps', () => validateStepResults(scenario));
230
- if (stepsRun.ok) {
231
- reasons.push(...stepsRun.result.map(e => `steps[${scenario.scenario_id}]: ${e}`));
232
- } else {
233
- reasons.push(stepsRun.faultReason);
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 policyRun = runValidator('policy', () => validatePolicy(submission, { globalPolicy, repoPolicy }));
265
- if (policyRun.ok) {
266
- policyValid = policyRun.result.valid;
267
- reasons.push(...policyRun.result.errors.map(e => `policy: ${e}`));
268
- // VERIFY-F1: a malformed REPO custom-rule predicate is a distinct
269
- // `policy-config:` rejection (submission-bad the repo authored the bad
270
- // rule). A malformed GLOBAL predicate never reaches here; it throws and
271
- // surfaces as VALIDATOR_FAULT_POLICY (operational) via the else branch.
272
- reasons.push(...(policyRun.result.configErrors || []).map(e => `policy-config: ${e}`));
273
- warnings.push(...(policyRun.result.warnings || []).map(w => `policy: ${w}`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Central verifier for testing-os. Validates submissions against schema and policy, produces persisted records.",
6
6
  "main": "index.js",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./index.js",
12
12
  "./cli.js": "./cli.js",
13
+ "./cli-lint.js": "./cli-lint.js",
13
14
  "./parse-rejection.js": "./parse-rejection.js",
14
15
  "./validators/*": "./validators/*",
15
16
  "./validators/*.js": "./validators/*.js"
@@ -21,6 +22,7 @@
21
22
  "files": [
22
23
  "index.js",
23
24
  "cli.js",
25
+ "cli-lint.js",
24
26
  "parse-rejection.js",
25
27
  "validators/",
26
28
  "README.md",
@@ -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
+ });