@dogfood-lab/verify 1.4.0 → 1.6.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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * parse-rejection.js — F1-CONTRACTS-003 (Wave 4, MED)
3
+ *
4
+ * The verifier emits `verification.rejection_reasons` as an array of STRINGS
5
+ * carrying stable prefixes (see verify/README.md → "Prefix taxonomy"). Until
6
+ * now every operator discriminated failure class with hand-rolled
7
+ * `.startsWith()` chains, re-implementing the same taxonomy at each call site —
8
+ * a fresh drift source the moment a prefix is added.
9
+ *
10
+ * `parseRejectionReason(reason)` is the single exported classifier. It maps a
11
+ * raw rejection string to `{ class, prefix, detail }`:
12
+ *
13
+ * - class — the routing decision (who fixes this):
14
+ * 'submission-bad' → the submitter fixes the payload and resubmits
15
+ * 'operational' → the verifier/tooling faulted; page ops, do NOT
16
+ * bounce back to the submitter
17
+ * 'ingest' → an ingest-side load fault (scenario fetch)
18
+ * 'unknown' → unrecognized prefix; log + surface raw
19
+ * - prefix — the canonical prefix token that matched (e.g. `'schema:'`,
20
+ * `'steps[<id>]:'`), or `null` for the 'unknown' class.
21
+ * - detail — the human-readable remainder with the matched prefix stripped
22
+ * (for 'unknown', the whole string verbatim).
23
+ *
24
+ * The prefix vocabulary below is enumerated from the ACTUAL emitters — it is
25
+ * NOT invented:
26
+ * - verify/index.js: schema:, policy:, steps[<id>]:,
27
+ * provenance: (run absent → submission-bad),
28
+ * provenance-fault: (provider 429/5xx/401/403
29
+ * → operational), repo:,
30
+ * submission-contains-verifier-field:,
31
+ * submission-malformed:,
32
+ * VALIDATOR_FAULT_<NAME>:
33
+ * - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW:,
34
+ * CONTRACT_SCHEMA_TOO_OLD:
35
+ * - packages/ingest/run.js: scenario-load:
36
+ *
37
+ * `VALIDATOR_FAULT_*` is matched by family, not by an exhaustive name list, so
38
+ * a future `VALIDATOR_FAULT_<NEW>:` (e.g. the runValidator seam adds a 5th
39
+ * validator) is classified 'operational' without a code change here. The
40
+ * `steps[<id>]:` prefix uses bracket syntax (`steps[step-7]:`), so it is matched
41
+ * by a small regex rather than a literal `.startsWith`.
42
+ */
43
+
44
+ /**
45
+ * @typedef {'submission-bad' | 'operational' | 'ingest' | 'unknown'} RejectionClass
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} ParsedRejection
50
+ * @property {RejectionClass} class Routing decision (who must act).
51
+ * @property {string | null} prefix Canonical prefix token, or null when unknown.
52
+ * @property {string} detail Human-readable remainder (prefix stripped).
53
+ */
54
+
55
+ /**
56
+ * Literal-prefix matchers, ordered most-specific-first. Each carries the
57
+ * canonical prefix token and the routing class. `match` is the exact string the
58
+ * reason must start with; `detail` is whatever follows it (trimmed of one
59
+ * leading space). `repo:` matches the `repo:mismatch: …` family — the canonical
60
+ * token reported is the stable `repo:` head.
61
+ */
62
+ const LITERAL_PREFIXES = [
63
+ // submission-bad
64
+ { match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
65
+ { match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
66
+ // operational — a provider-side provenance FAULT (429/5xx/401/403). The
67
+ // adapter THROWS these (verify-A-002); index.js catches the throw and emits
68
+ // this distinct `provenance-fault:` prefix so the incident pages ops. Ordered
69
+ // before the bare `provenance:` literal — it is a distinct token, but keeping
70
+ // the longer, more-specific match first preserves the most-specific-first
71
+ // invariant the array is sorted by. The genuine not-confirmed case
72
+ // (`provenance: source run could not be confirmed`) stays submission-bad below.
73
+ { match: 'provenance-fault:', prefix: 'provenance-fault:', class: 'operational' },
74
+ { match: 'provenance:', prefix: 'provenance:', class: 'submission-bad' },
75
+ { match: 'repo:', prefix: 'repo:', class: 'submission-bad' },
76
+ {
77
+ match: 'submission-contains-verifier-field:',
78
+ prefix: 'submission-contains-verifier-field:',
79
+ class: 'submission-bad',
80
+ },
81
+ { match: 'CONTRACT_SCHEMA_TOO_NEW:', prefix: 'CONTRACT_SCHEMA_TOO_NEW:', class: 'submission-bad' },
82
+ { match: 'CONTRACT_SCHEMA_TOO_OLD:', prefix: 'CONTRACT_SCHEMA_TOO_OLD:', class: 'submission-bad' },
83
+ // operational — a null/non-object submission is a malfunctioning dispatcher
84
+ // (verify-B-003), not a submitter who sent a bad-but-shaped payload. Page ops;
85
+ // do NOT bounce it back to the submitter.
86
+ { match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational' },
87
+ // ingest
88
+ { match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest' },
89
+ ];
90
+
91
+ /** Strip a matched literal prefix and one optional leading space from a reason. */
92
+ function stripLiteral(reason, match) {
93
+ return reason.slice(match.length).replace(/^\s+/, '');
94
+ }
95
+
96
+ /**
97
+ * Classify a single verifier/ingest rejection-reason string.
98
+ *
99
+ * @param {unknown} reason A rejection string (typically from a persisted
100
+ * record's `verification.rejection_reasons[]`).
101
+ * @returns {ParsedRejection}
102
+ */
103
+ export function parseRejectionReason(reason) {
104
+ if (typeof reason !== 'string') {
105
+ return { class: 'unknown', prefix: null, detail: '' };
106
+ }
107
+
108
+ // 1. Operational faults — matched by FAMILY so future VALIDATOR_FAULT_<NEW>
109
+ // classes need no edit here. (runValidator emits `VALIDATOR_FAULT_<CLS>: …`.)
110
+ const faultMatch = reason.match(/^(VALIDATOR_FAULT_[A-Z0-9_]+):\s*/);
111
+ if (faultMatch) {
112
+ return {
113
+ class: 'operational',
114
+ prefix: `${faultMatch[1]}:`,
115
+ detail: reason.slice(faultMatch[0].length),
116
+ };
117
+ }
118
+
119
+ // 2. steps[<id>]: — bracketed id, so matched by regex rather than a literal.
120
+ const stepsMatch = reason.match(/^steps\[[^\]]*\]:\s*/);
121
+ if (stepsMatch) {
122
+ return {
123
+ class: 'submission-bad',
124
+ prefix: 'steps[<id>]:',
125
+ detail: reason.slice(stepsMatch[0].length),
126
+ };
127
+ }
128
+
129
+ // 3. Literal prefixes (most-specific-first; the array order guards against a
130
+ // shorter prefix shadowing a longer one — none currently overlap, but the
131
+ // ordering keeps that invariant explicit).
132
+ for (const entry of LITERAL_PREFIXES) {
133
+ if (reason.startsWith(entry.match)) {
134
+ return {
135
+ class: entry.class,
136
+ prefix: entry.prefix,
137
+ detail: stripLiteral(reason, entry.match),
138
+ };
139
+ }
140
+ }
141
+
142
+ // 4. Unrecognized — log + surface raw. (The null/non-object submission reason
143
+ // now carries the typed `submission-malformed:` prefix → 'operational', so it
144
+ // no longer falls through here; see verify-B-003.)
145
+ return { class: 'unknown', prefix: null, detail: reason };
146
+ }
@@ -29,6 +29,31 @@ function deepMerge(target, source) {
29
29
  return result;
30
30
  }
31
31
 
32
+ /**
33
+ * Global reject-rule ids the build can account for. Two groups:
34
+ * - HANDLED HERE — enforced by the switch below.
35
+ * - ENFORCED ELSEWHERE — owned by another validator or by verify() itself
36
+ * (the switch's `default` arm intentionally no-ops them).
37
+ *
38
+ * PROACT-VERIFY-002: any `severity: reject` rule whose id is OUTSIDE this set is
39
+ * an operator-added gate the build does not enforce. Silently no-op'ing it (the
40
+ * old `default: break`) meant the rule looked active in global-policy.yaml but
41
+ * never ran. We now surface an actionable diagnostic instead. Keep this set in
42
+ * sync when a new reject rule gains real enforcement.
43
+ */
44
+ const KNOWN_REJECT_RULE_IDS = new Set([
45
+ // handled by the switch in validatePolicy
46
+ 'scenario-minimum',
47
+ 'attested-if-human',
48
+ 'blocked-needs-reason',
49
+ // enforced by other validators or by verify() itself (default-arm no-op is correct)
50
+ 'schema-valid',
51
+ 'provenance-confirmed',
52
+ 'step-results-present',
53
+ 'step-verdict-consistent',
54
+ 'no-verdict-upgrade',
55
+ ]);
56
+
32
57
  /**
33
58
  * Resolve the effective surface policy for a given product surface.
34
59
  * Repo policy overrides global defaults per surface.
@@ -55,16 +80,32 @@ function resolveSurfacePolicy(surface, globalPolicy, repoPolicy) {
55
80
  * @param {object} options
56
81
  * @param {object} options.globalPolicy
57
82
  * @param {object|null} options.repoPolicy
58
- * @returns {{ valid: boolean, errors: string[] }}
83
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
59
84
  */
60
85
  export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
61
86
  const errors = [];
87
+ // VERIFY-F4: warn-severity rules record an accepted-with-warning note here
88
+ // instead of being silently dropped. A populated warnings[] never affects
89
+ // `valid` — the caller (index.js) routes it to verification.warnings.
90
+ const warnings = [];
62
91
 
63
92
  // --- Global rules (non-overridable) ---
64
93
 
65
94
  const globalRules = globalPolicy.global_rules || [];
66
95
 
67
96
  for (const rule of globalRules) {
97
+ // VERIFY-F4: a global warn-rule is accepted-with-warning; an info-rule logs
98
+ // only. Neither may reject. Mirrors the PROACT-VERIFY-002 discipline of not
99
+ // dropping operator-authored rules on the floor: a declared rule that the
100
+ // build does nothing with is invisible to the operator who wrote it.
101
+ if (rule.severity === 'warn') {
102
+ warnings.push(`${rule.id}: ${rule.description || 'policy warning'}`);
103
+ continue;
104
+ }
105
+ if (rule.severity === 'info') {
106
+ // Logged only — info rules are intentionally non-surfacing in the record.
107
+ continue;
108
+ }
68
109
  if (rule.severity !== 'reject') continue;
69
110
 
70
111
  switch (rule.id) {
@@ -95,8 +136,20 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
95
136
  break;
96
137
 
97
138
  // schema-valid, provenance-confirmed, step-results-present, step-verdict-consistent,
98
- // no-verdict-upgrade are enforced by other validators or the main verify() function
139
+ // no-verdict-upgrade are enforced by other validators or the main verify() function.
140
+ // PROACT-VERIFY-002: a reject rule the build neither handles here NOR enforces
141
+ // elsewhere would otherwise pass SILENTLY — the operator's new gate never runs.
142
+ // Reject the submission with a diagnostic naming the unenforced rule so the gap
143
+ // is visible instead of failing open.
99
144
  default:
145
+ if (!KNOWN_REJECT_RULE_IDS.has(rule.id)) {
146
+ // No `policy:` prefix here — index.js prepends it to every policy
147
+ // error (mirrors the `[rule.id]` / `surface[...]` messages above).
148
+ errors.push(
149
+ `rule "${rule.id}" is declared severity:reject but has no enforcement in this build — ` +
150
+ `add an enforcement arm in validators/policy.js or register it in KNOWN_REJECT_RULE_IDS`
151
+ );
152
+ }
100
153
  break;
101
154
  }
102
155
  }
@@ -134,6 +187,33 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
134
187
  }
135
188
  }
136
189
  }
190
+
191
+ // VERIFY-F2: tag gating. forbidden_tags rejects a scenario_result carrying
192
+ // any listed tag; required_tags rejects one missing any listed tag (a
193
+ // tagless scenario fails every required_tags rule). Tags are optional on
194
+ // the scenario_result, so an absent `tags` array trips required_tags but
195
+ // never forbidden_tags.
196
+ const tags = new Set(sr.tags || []);
197
+
198
+ if (evidenceReqs.forbidden_tags) {
199
+ for (const tag of evidenceReqs.forbidden_tags) {
200
+ if (tags.has(tag)) {
201
+ errors.push(
202
+ `surface[${surface}]: scenario "${sr.scenario_id}" carries forbidden tag "${tag}"`
203
+ );
204
+ }
205
+ }
206
+ }
207
+
208
+ if (evidenceReqs.required_tags) {
209
+ for (const tag of evidenceReqs.required_tags) {
210
+ if (!tags.has(tag)) {
211
+ errors.push(
212
+ `surface[${surface}]: scenario "${sr.scenario_id}" is missing required tag "${tag}"`
213
+ );
214
+ }
215
+ }
216
+ }
137
217
  }
138
218
  }
139
219
 
@@ -176,5 +256,5 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
176
256
  }
177
257
  }
178
258
 
179
- return { valid: errors.length === 0, errors };
259
+ return { valid: errors.length === 0, errors, warnings };
180
260
  }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * provenance-gitlab.test.js — GitLab CI provenance adapter (peer of githubProvenance)
3
+ *
4
+ * GitLab is the SECOND provenance provider. The adapter mirrors githubProvenance's
5
+ * hardening exactly:
6
+ * - per-request AbortController timeout → throws `provenance: GitLab API timeout`
7
+ * - returns false ONLY on HTTP 404 (pipeline/job genuinely absent)
8
+ * - THROWS on 401/403/429/5xx as OPERATIONAL (so parseRejectionReason routes the
9
+ * fault to ops, not back to the submitter) — same `provenance:` prefix as GitHub
10
+ * - asserts the pipeline/job reached a FINISHED/SUCCESS state
11
+ * - binds the confirmed commit sha to the PERSISTED commit (expected.refCommitSha,
12
+ * the verify-A-001 anti-forgery guard) mandatorily, and binds the project path
13
+ * to source.repo
14
+ * - injectable fetch (opts.fetchImpl) so tests need no network
15
+ *
16
+ * Anti-forgery test id mirrored from GitHub: verify-A-001 (ref.commit_sha binding).
17
+ */
18
+
19
+ import { describe, it } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+
22
+ import { gitlabProvenance } from './provenance.js';
23
+
24
+ // A schema-shaped GitLab source. `provider_run_id` is the pipeline (or job) id;
25
+ // `run_url` is a GitLab pipeline/job URL the repo-binding layer can decode.
26
+ function pipelineSource(overrides = {}) {
27
+ return {
28
+ provider: 'gitlab',
29
+ workflow: '.gitlab-ci.yml',
30
+ provider_run_id: '424242',
31
+ run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242',
32
+ repo: 'acme/widget',
33
+ ...overrides
34
+ };
35
+ }
36
+
37
+ function jobSource(overrides = {}) {
38
+ return {
39
+ provider: 'gitlab',
40
+ workflow: '.gitlab-ci.yml',
41
+ provider_run_id: '987654',
42
+ run_url: 'https://gitlab.com/acme/widget/-/jobs/987654',
43
+ repo: 'acme/widget',
44
+ ...overrides
45
+ };
46
+ }
47
+
48
+ const RUN_HEAD = 'c5d6c4e0000000000000000000000000deadbeef';
49
+ const FORGED = 'f0f0f0f0000000000000000000000000baddecaf';
50
+
51
+ // GitLab pipeline payload (GET /projects/:id/pipelines/:pipeline_id).
52
+ function mockPipeline(overrides = {}) {
53
+ return {
54
+ id: 424242,
55
+ status: 'success',
56
+ sha: RUN_HEAD,
57
+ ...overrides
58
+ };
59
+ }
60
+
61
+ // GitLab job payload (GET /projects/:id/jobs/:job_id). The commit lives under
62
+ // `commit.id` rather than a top-level `sha`.
63
+ function mockJob(overrides = {}) {
64
+ return {
65
+ id: 987654,
66
+ status: 'success',
67
+ commit: { id: RUN_HEAD },
68
+ ...overrides
69
+ };
70
+ }
71
+
72
+ function fetchReturning(body) {
73
+ return async () => ({ ok: true, status: 200, json: async () => body });
74
+ }
75
+
76
+ function fetchWithStatus(status) {
77
+ return async () => ({ ok: false, status, json: async () => ({}) });
78
+ }
79
+
80
+ // ── Happy path: well-formed finished pipeline/job is confirmed ────
81
+
82
+ describe('gitlabProvenance confirms a finished pipeline/job', () => {
83
+ it('confirms a successful pipeline whose sha === refCommitSha and project === source.repo', async () => {
84
+ const adapter = gitlabProvenance('token', {
85
+ timeoutMs: 1000,
86
+ fetchImpl: fetchReturning(mockPipeline())
87
+ });
88
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
89
+ assert.equal(ok, true);
90
+ });
91
+
92
+ it('confirms a successful JOB whose commit.id === refCommitSha', async () => {
93
+ const adapter = gitlabProvenance('token', {
94
+ timeoutMs: 1000,
95
+ fetchImpl: fetchReturning(mockJob())
96
+ });
97
+ const ok = await adapter.confirm(jobSource(), { refCommitSha: RUN_HEAD });
98
+ assert.equal(ok, true);
99
+ });
100
+
101
+ it('binds the project path to source.repo (rejects a mismatched project)', async () => {
102
+ const adapter = gitlabProvenance('token', {
103
+ timeoutMs: 1000,
104
+ fetchImpl: fetchReturning(mockPipeline())
105
+ });
106
+ const ok = await adapter.confirm(
107
+ pipelineSource({ repo: 'victim/repo', run_url: 'https://gitlab.com/acme/widget/-/pipelines/424242' }),
108
+ { refCommitSha: RUN_HEAD }
109
+ );
110
+ assert.equal(ok, false, 'source.repo not matching the run_url project must not confirm');
111
+ });
112
+ });
113
+
114
+ // ── verify-A-001: anti-forgery ref.commit_sha binding (mirrors GitHub) ──
115
+ //
116
+ // HIGH/security: the commit a record attests to is submission.ref.commit_sha
117
+ // (index.js persists submission.ref verbatim). A submitter who owns a real
118
+ // finished pipeline could otherwise point ref.commit_sha at any 40-hex sha and
119
+ // earn a provenance_confirmed 'pass' for a commit the pipeline never executed.
120
+ // gitlabProvenance binds the pipeline sha to the persisted commit and rejects
121
+ // any mismatch — identical guard to githubProvenance.
122
+
123
+ describe('gitlabProvenance binds ref.commit_sha to the run (verify-A-001)', () => {
124
+ it('rejects when the persisted ref.commit_sha differs from the pipeline sha', async () => {
125
+ const adapter = gitlabProvenance('token', {
126
+ timeoutMs: 1000,
127
+ fetchImpl: fetchReturning(mockPipeline())
128
+ });
129
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: FORGED });
130
+ assert.equal(ok, false,
131
+ 'a ref.commit_sha that does not match the confirmed pipeline sha must NOT be confirmed');
132
+ });
133
+
134
+ it('confirms when the persisted ref.commit_sha matches the pipeline sha', async () => {
135
+ const adapter = gitlabProvenance('token', {
136
+ timeoutMs: 1000,
137
+ fetchImpl: fetchReturning(mockPipeline())
138
+ });
139
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
140
+ assert.equal(ok, true,
141
+ 'a ref.commit_sha equal to the confirmed pipeline sha must be confirmed');
142
+ });
143
+
144
+ it('rejects a forged commit on the JOB path too (commit.id binding)', async () => {
145
+ const adapter = gitlabProvenance('token', {
146
+ timeoutMs: 1000,
147
+ fetchImpl: fetchReturning(mockJob())
148
+ });
149
+ const ok = await adapter.confirm(jobSource(), { refCommitSha: FORGED });
150
+ assert.equal(ok, false);
151
+ });
152
+ });
153
+
154
+ // ── Finished-state guard (mirrors githubProvenance run.status === 'completed') ──
155
+
156
+ describe('gitlabProvenance requires a finished/success state', () => {
157
+ for (const status of ['running', 'pending', 'created', 'preparing', 'waiting_for_resource', 'scheduled', 'manual']) {
158
+ it(`rejects a pipeline with non-finished status: ${status}`, async () => {
159
+ const adapter = gitlabProvenance('token', {
160
+ timeoutMs: 1000,
161
+ fetchImpl: fetchReturning(mockPipeline({ status }))
162
+ });
163
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
164
+ assert.equal(ok, false, `${status} pipeline must not be confirmed`);
165
+ });
166
+ }
167
+
168
+ it('confirms a successful pipeline (status: success)', async () => {
169
+ const adapter = gitlabProvenance('token', {
170
+ timeoutMs: 1000,
171
+ fetchImpl: fetchReturning(mockPipeline({ status: 'success' }))
172
+ });
173
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
174
+ assert.equal(ok, true);
175
+ });
176
+
177
+ it('confirms a finished pipeline even when it FAILED (provenance = ran, not passed)', async () => {
178
+ // Mirror githubProvenance: provenance confirms the pipeline EXECUTED to a
179
+ // terminal state. Pass/fail is a separate signal (ci_checks / scenario
180
+ // verdicts). 'failed' is terminal, so it confirms.
181
+ const adapter = gitlabProvenance('token', {
182
+ timeoutMs: 1000,
183
+ fetchImpl: fetchReturning(mockPipeline({ status: 'failed' }))
184
+ });
185
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
186
+ assert.equal(ok, true, 'verifier confirms the pipeline reached a terminal state');
187
+ });
188
+
189
+ it('confirms a canceled pipeline (terminal state)', async () => {
190
+ const adapter = gitlabProvenance('token', {
191
+ timeoutMs: 1000,
192
+ fetchImpl: fetchReturning(mockPipeline({ status: 'canceled' }))
193
+ });
194
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
195
+ assert.equal(ok, true);
196
+ });
197
+ });
198
+
199
+ // ── 404 vs operational (mirrors verify-A-002) ──────────────────────
200
+
201
+ describe('gitlabProvenance distinguishes ops failures from missing runs', () => {
202
+ it('returns false (run genuinely absent) on HTTP 404', async () => {
203
+ const adapter = gitlabProvenance('token', {
204
+ timeoutMs: 1000,
205
+ fetchImpl: fetchWithStatus(404)
206
+ });
207
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
208
+ assert.equal(ok, false, '404 means the pipeline does not exist — a real rejection, not an outage');
209
+ });
210
+
211
+ for (const status of [401, 403, 429, 500, 503]) {
212
+ it(`throws an operational error on HTTP ${status} (not a submission-bad false)`, async () => {
213
+ const adapter = gitlabProvenance('token', {
214
+ timeoutMs: 1000,
215
+ retries: 0,
216
+ fetchImpl: fetchWithStatus(status)
217
+ });
218
+ await assert.rejects(
219
+ adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
220
+ err => {
221
+ assert.match(err.message, /provenance: GitLab API returned/);
222
+ assert.match(err.message, new RegExp(String(status)));
223
+ return true;
224
+ }
225
+ );
226
+ });
227
+ }
228
+ });
229
+
230
+ // ── Timeout (mirrors F-246817-014) ─────────────────────────────────
231
+
232
+ describe('gitlabProvenance fetch timeout', () => {
233
+ function makeHangingFetch() {
234
+ return function hangingFetch(_url, opts) {
235
+ return new Promise((_resolve, reject) => {
236
+ if (opts && opts.signal) {
237
+ opts.signal.addEventListener('abort', () => {
238
+ const err = new Error('aborted');
239
+ err.name = 'AbortError';
240
+ reject(err);
241
+ });
242
+ }
243
+ });
244
+ };
245
+ }
246
+
247
+ it('throws timeout error when fetch hangs longer than timeoutMs', async () => {
248
+ const adapter = gitlabProvenance('token', {
249
+ timeoutMs: 50,
250
+ fetchImpl: makeHangingFetch()
251
+ });
252
+ const start = Date.now();
253
+ await assert.rejects(
254
+ adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
255
+ err => {
256
+ assert.match(err.message, /provenance: GitLab API timeout/);
257
+ assert.match(err.message, /50ms/);
258
+ return true;
259
+ }
260
+ );
261
+ assert.ok(Date.now() - start < 5000, 'expected fast abort');
262
+ });
263
+
264
+ it('returns false (not throws) on non-AbortError transport failures', async () => {
265
+ const failingFetch = async () => { throw new Error('connection refused'); };
266
+ const adapter = gitlabProvenance('token', {
267
+ timeoutMs: 1000,
268
+ fetchImpl: failingFetch
269
+ });
270
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
271
+ assert.equal(ok, false);
272
+ });
273
+ });
274
+
275
+ // ── Provider guard + malformed input ───────────────────────────────
276
+
277
+ describe('gitlabProvenance input guards', () => {
278
+ it('throws on a non-gitlab provider', async () => {
279
+ const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
280
+ await assert.rejects(
281
+ adapter.confirm({ provider: 'github', provider_run_id: '1', run_url: 'https://github.com/a/b/actions/runs/1' }),
282
+ /unsupported provider: github/
283
+ );
284
+ });
285
+
286
+ it('returns false when run_url is missing', async () => {
287
+ const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
288
+ const ok = await adapter.confirm({ provider: 'gitlab', provider_run_id: '1' });
289
+ assert.equal(ok, false);
290
+ });
291
+
292
+ it('returns false when run_url does not match the GitLab pipeline/job shape', async () => {
293
+ const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
294
+ const ok = await adapter.confirm({
295
+ provider: 'gitlab',
296
+ provider_run_id: '1',
297
+ run_url: 'https://gitlab.com/acme/widget/-/merge_requests/1'
298
+ });
299
+ assert.equal(ok, false);
300
+ });
301
+ });
302
+
303
+ // ── Bounded retry over transient faults (PROACT-VERIFY-001, mirrors GitHub) ──
304
+ //
305
+ // MEDIUM/resilience: a single 429/5xx blip used to fail the submission as an
306
+ // operational incident. The adapter now retries 429/5xx within a bounded budget
307
+ // (opts.retries, default 2) with exponential backoff (honoring Retry-After), and
308
+ // still THROWS on exhaustion so a genuinely-down provider surfaces as
309
+ // operational — never a false 'confirmed'. 404 is NOT retried.
310
+
311
+ describe('gitlabProvenance bounded retry (PROACT-VERIFY-001)', () => {
312
+ it('retries a 429 then confirms on the following 200 (retry worked)', async () => {
313
+ let calls = 0;
314
+ const fetchImpl = async () => {
315
+ calls++;
316
+ if (calls === 1) {
317
+ return { ok: false, status: 429, headers: { get: () => null }, json: async () => ({}) };
318
+ }
319
+ return { ok: true, status: 200, json: async () => mockPipeline() };
320
+ };
321
+ const adapter = gitlabProvenance('token', { timeoutMs: 1000, backoffMs: 1, fetchImpl });
322
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
323
+ assert.equal(ok, true, '429-then-200 must confirm — the retry succeeded');
324
+ assert.equal(calls, 2, 'expected exactly one retry (2 total requests)');
325
+ });
326
+
327
+ it('honors Retry-After (numeric seconds) before the retry', async () => {
328
+ let calls = 0;
329
+ const waited = [];
330
+ const fetchImpl = async () => {
331
+ calls++;
332
+ if (calls === 1) {
333
+ return { ok: false, status: 503, headers: { get: h => (h === 'retry-after' ? '2' : null) }, json: async () => ({}) };
334
+ }
335
+ return { ok: true, status: 200, json: async () => mockPipeline() };
336
+ };
337
+ const adapter = gitlabProvenance('token', {
338
+ timeoutMs: 1000,
339
+ sleepImpl: ms => { waited.push(ms); return Promise.resolve(); },
340
+ fetchImpl
341
+ });
342
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
343
+ assert.equal(ok, true);
344
+ assert.deepEqual(waited, [2000], 'Retry-After: 2 must drive a 2000ms wait');
345
+ });
346
+
347
+ it('THROWS on exhausted 5xx retries (a genuinely-down provider still surfaces)', async () => {
348
+ let calls = 0;
349
+ const fetchImpl = async () => {
350
+ calls++;
351
+ return { ok: false, status: 500, headers: { get: () => null }, json: async () => ({}) };
352
+ };
353
+ const adapter = gitlabProvenance('token', { timeoutMs: 1000, retries: 2, backoffMs: 1, fetchImpl });
354
+ await assert.rejects(
355
+ adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
356
+ err => {
357
+ assert.match(err.message, /provenance: GitLab API returned 500/);
358
+ return true;
359
+ }
360
+ );
361
+ assert.equal(calls, 3, 'expected 1 initial + 2 retries = 3 requests before throwing');
362
+ });
363
+
364
+ it('does NOT retry a 404 (run genuinely absent → single immediate false)', async () => {
365
+ let calls = 0;
366
+ const fetchImpl = async () => {
367
+ calls++;
368
+ return { ok: false, status: 404, headers: { get: () => null }, json: async () => ({}) };
369
+ };
370
+ const adapter = gitlabProvenance('token', { timeoutMs: 1000, retries: 2, backoffMs: 1, fetchImpl });
371
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
372
+ assert.equal(ok, false);
373
+ assert.equal(calls, 1, '404 must not be retried');
374
+ });
375
+ });