@dogfood-lab/verify 1.3.2 → 1.5.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
@@ -9,7 +9,28 @@
9
9
  import { validateSubmissionSchema as _defaultValidateSubmissionSchema } from './validators/schema.js';
10
10
  import { validatePolicy as _defaultValidatePolicy } from './validators/policy.js';
11
11
  import { validateStepResults as _defaultValidateStepResults } from './validators/steps.js';
12
+ import { validateSchemaVersion as _defaultValidateSchemaVersion } from './validators/schema-version.js';
12
13
  import { computeVerdict } from './validators/verdict.js';
14
+ import { parseRunUrlRepo } from './validators/repo-binding.js';
15
+ import { SUPPORTED_SCHEMA_VERSIONS } from '@dogfood-lab/schemas';
16
+
17
+ // F1-CONTRACTS-003: re-export the rejection-reason classifier from the package
18
+ // root so consumers `import { parseRejectionReason } from '@dogfood-lab/verify'`
19
+ // instead of hand-rolling .startsWith() chains over the prefix taxonomy.
20
+ export { parseRejectionReason } from './parse-rejection.js';
21
+
22
+ // Provider-keyed provenance selection. A submission's `source.provider` decides
23
+ // which provider API confirms its run; `provenanceForProvider(provider)` returns
24
+ // the matching adapter factory (or null for an unknown provider). Re-exported
25
+ // from the package root so the wiring layer selects by provider here rather than
26
+ // hand-rolling an if-chain over provider literals. The registry stays in lockstep
27
+ // with the source.provider enum via validators/provenance-registry.test.js.
28
+ export { provenanceForProvider, PROVENANCE_ADAPTERS } from './validators/provenance.js';
29
+
30
+ // F1-CONTRACTS-001: the persisted record's `schema_version` is the SINGLE
31
+ // source of truth from the contract package — not a hardcoded literal that
32
+ // can drift from `SUPPORTED_SCHEMA_VERSIONS.record.current`.
33
+ const RECORD_SCHEMA_VERSION = SUPPORTED_SCHEMA_VERSIONS.record.current;
13
34
 
14
35
  /**
15
36
  * D1B-003 (Stage C humanization): the SOLE catch wrapper for synchronous
@@ -75,7 +96,7 @@ export async function verify(submission, options) {
75
96
  // timing.finished_at). Mark _skipPersist so the ingest layer surfaces the
76
97
  // rejection without crashing the persist layer with `invalid repo format: undefined`.
77
98
  return {
78
- schema_version: '1.0.0',
99
+ schema_version: RECORD_SCHEMA_VERSION,
79
100
  _skipPersist: true,
80
101
  verification: {
81
102
  status: 'rejected',
@@ -83,7 +104,12 @@ export async function verify(submission, options) {
83
104
  provenance_confirmed: false,
84
105
  schema_valid: false,
85
106
  policy_valid: false,
86
- rejection_reasons: ['submission is null or not an object']
107
+ // verify-B-003: a null/non-object submission is a malfunctioning
108
+ // DISPATCHER (the caller handed us garbage), not a submitter who sent a
109
+ // bad-but-shaped payload. Carry a typed `submission-malformed:` prefix so
110
+ // parseRejectionReason classifies it 'operational' (page the runner) instead
111
+ // of bouncing an ops incident back to the submitter as 'unknown'.
112
+ rejection_reasons: ['submission-malformed: submission is null or not an object']
87
113
  }
88
114
  };
89
115
  }
@@ -95,6 +121,7 @@ export async function verify(submission, options) {
95
121
  const validateSubmissionSchema = validatorOverrides.validateSubmissionSchema || _defaultValidateSubmissionSchema;
96
122
  const validateStepResults = validatorOverrides.validateStepResults || _defaultValidateStepResults;
97
123
  const validatePolicy = validatorOverrides.validatePolicy || _defaultValidatePolicy;
124
+ const validateSchemaVersion = validatorOverrides.validateSchemaVersion || _defaultValidateSchemaVersion;
98
125
  const now = new Date().toISOString();
99
126
  const reasons = [];
100
127
 
@@ -104,13 +131,17 @@ export async function verify(submission, options) {
104
131
  // real, legitimate run from their own repo. Provenance would confirm (the run
105
132
  // exists), and the persist layer would file the record under victim-org's path
106
133
  // — a forged "pass" verdict for a repo the submitter does not control.
107
- // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
134
+ //
135
+ // verify-B-001: the run_url shape is PROVIDER-specific, so the decode is keyed
136
+ // by `source.provider` in validators/repo-binding.js. RUN_URL_PARSERS there MUST
137
+ // stay in lockstep with the source.provider enum in the submission schema — a
138
+ // coverage test (validators/repo-binding.test.js) fails CI if a provider is
139
+ // added to the schema without a parser, so this binding can never silently
140
+ // no-op for a new provider (which would reopen the verify-A-001 forgery vector).
108
141
  if (submission.repo && submission.source?.run_url) {
109
- const m = submission.source.run_url.match(
110
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/\d+$/
111
- );
112
- if (m) {
113
- const sourceRepo = `${m[1]}/${m[2]}`;
142
+ const bound = parseRunUrlRepo(submission.source.provider, submission.source.run_url);
143
+ if (bound) {
144
+ const sourceRepo = `${bound.owner}/${bound.repo}`;
114
145
  if (sourceRepo !== submission.repo) {
115
146
  reasons.push(
116
147
  `repo:mismatch: submission.repo (${submission.repo}) does not match source.run_url repo (${sourceRepo})`
@@ -134,6 +165,26 @@ export async function verify(submission, options) {
134
165
  reasons.push(schemaRun.faultReason);
135
166
  }
136
167
 
168
+ // 1b. schema_version VALUE gate (F1-CONTRACTS-001)
169
+ // The schema check above gates `schema_version` by PATTERN only. This gate
170
+ // compares the declared MAJOR against `SUPPORTED_SCHEMA_VERSIONS` (the
171
+ // single source of truth in @dogfood-lab/schemas) and refuses an
172
+ // incompatible major instead of silently mis-validating a future contract
173
+ // against the live 1.x schema. It runs INDEPENDENT of `schemaResult.valid`:
174
+ // a future-major payload may also fail shape, but the version refusal is the
175
+ // operator-actionable signal and must land regardless. The validator emits a
176
+ // 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);
186
+ }
187
+
137
188
  // 2. Reject if submission includes verifier-owned fields
138
189
  const verifierFields = ['policy_version', 'verification'];
139
190
  for (const field of verifierFields) {
@@ -149,7 +200,14 @@ export async function verify(submission, options) {
149
200
  let provenanceConfirmed = false;
150
201
  if (schemaResult.valid && submission.source) {
151
202
  try {
152
- provenanceConfirmed = await provenance.confirm(submission.source);
203
+ // verify-A-001: bind the PERSISTED commit (submission.ref.commit_sha) to the
204
+ // verified run. The record attests to this commit, so the provenance adapter
205
+ // must reject a run whose head differs from it — otherwise a real run can be
206
+ // pointed at an arbitrary persisted sha. Adapters that ignore the binding
207
+ // (stub/rejecting) accept the extra arg harmlessly.
208
+ provenanceConfirmed = await provenance.confirm(submission.source, {
209
+ refCommitSha: submission.ref?.commit_sha
210
+ });
153
211
  } catch (err) {
154
212
  reasons.push(`provenance: verification failed: ${err.message}`);
155
213
  }
@@ -219,7 +277,7 @@ export async function verify(submission, options) {
219
277
 
220
278
  // 7. Assemble persisted record
221
279
  const persisted = {
222
- schema_version: '1.0.0',
280
+ schema_version: RECORD_SCHEMA_VERSION,
223
281
  policy_version: policyVersion,
224
282
  run_id: submission.run_id,
225
283
  repo: submission.repo,
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@dogfood-lab/verify",
3
- "version": "1.3.2",
3
+ "version": "1.5.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",
7
+ "bin": {
8
+ "dogfood-verify": "cli.js"
9
+ },
7
10
  "exports": {
8
11
  ".": "./index.js",
12
+ "./cli.js": "./cli.js",
13
+ "./parse-rejection.js": "./parse-rejection.js",
9
14
  "./validators/*": "./validators/*",
10
15
  "./validators/*.js": "./validators/*.js"
11
16
  },
@@ -15,6 +20,8 @@
15
20
  },
16
21
  "files": [
17
22
  "index.js",
23
+ "cli.js",
24
+ "parse-rejection.js",
18
25
  "validators/",
19
26
  "README.md",
20
27
  "LICENSE"
@@ -0,0 +1,136 @@
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:, repo:,
28
+ * submission-contains-verifier-field:,
29
+ * submission-malformed:,
30
+ * VALIDATOR_FAULT_<NAME>:
31
+ * - validators/schema-version.js: CONTRACT_SCHEMA_TOO_NEW:,
32
+ * CONTRACT_SCHEMA_TOO_OLD:
33
+ * - packages/ingest/run.js: scenario-load:
34
+ *
35
+ * `VALIDATOR_FAULT_*` is matched by family, not by an exhaustive name list, so
36
+ * a future `VALIDATOR_FAULT_<NEW>:` (e.g. the runValidator seam adds a 5th
37
+ * validator) is classified 'operational' without a code change here. The
38
+ * `steps[<id>]:` prefix uses bracket syntax (`steps[step-7]:`), so it is matched
39
+ * by a small regex rather than a literal `.startsWith`.
40
+ */
41
+
42
+ /**
43
+ * @typedef {'submission-bad' | 'operational' | 'ingest' | 'unknown'} RejectionClass
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} ParsedRejection
48
+ * @property {RejectionClass} class Routing decision (who must act).
49
+ * @property {string | null} prefix Canonical prefix token, or null when unknown.
50
+ * @property {string} detail Human-readable remainder (prefix stripped).
51
+ */
52
+
53
+ /**
54
+ * Literal-prefix matchers, ordered most-specific-first. Each carries the
55
+ * canonical prefix token and the routing class. `match` is the exact string the
56
+ * reason must start with; `detail` is whatever follows it (trimmed of one
57
+ * leading space). `repo:` matches the `repo:mismatch: …` family — the canonical
58
+ * token reported is the stable `repo:` head.
59
+ */
60
+ const LITERAL_PREFIXES = [
61
+ // submission-bad
62
+ { match: 'schema:', prefix: 'schema:', class: 'submission-bad' },
63
+ { match: 'policy:', prefix: 'policy:', class: 'submission-bad' },
64
+ { match: 'provenance:', prefix: 'provenance:', class: 'submission-bad' },
65
+ { match: 'repo:', prefix: 'repo:', class: 'submission-bad' },
66
+ {
67
+ match: 'submission-contains-verifier-field:',
68
+ prefix: 'submission-contains-verifier-field:',
69
+ class: 'submission-bad',
70
+ },
71
+ { match: 'CONTRACT_SCHEMA_TOO_NEW:', prefix: 'CONTRACT_SCHEMA_TOO_NEW:', class: 'submission-bad' },
72
+ { match: 'CONTRACT_SCHEMA_TOO_OLD:', prefix: 'CONTRACT_SCHEMA_TOO_OLD:', class: 'submission-bad' },
73
+ // operational — a null/non-object submission is a malfunctioning dispatcher
74
+ // (verify-B-003), not a submitter who sent a bad-but-shaped payload. Page ops;
75
+ // do NOT bounce it back to the submitter.
76
+ { match: 'submission-malformed:', prefix: 'submission-malformed:', class: 'operational' },
77
+ // ingest
78
+ { match: 'scenario-load:', prefix: 'scenario-load:', class: 'ingest' },
79
+ ];
80
+
81
+ /** Strip a matched literal prefix and one optional leading space from a reason. */
82
+ function stripLiteral(reason, match) {
83
+ return reason.slice(match.length).replace(/^\s+/, '');
84
+ }
85
+
86
+ /**
87
+ * Classify a single verifier/ingest rejection-reason string.
88
+ *
89
+ * @param {unknown} reason A rejection string (typically from a persisted
90
+ * record's `verification.rejection_reasons[]`).
91
+ * @returns {ParsedRejection}
92
+ */
93
+ export function parseRejectionReason(reason) {
94
+ if (typeof reason !== 'string') {
95
+ return { class: 'unknown', prefix: null, detail: '' };
96
+ }
97
+
98
+ // 1. Operational faults — matched by FAMILY so future VALIDATOR_FAULT_<NEW>
99
+ // classes need no edit here. (runValidator emits `VALIDATOR_FAULT_<CLS>: …`.)
100
+ const faultMatch = reason.match(/^(VALIDATOR_FAULT_[A-Z0-9_]+):\s*/);
101
+ if (faultMatch) {
102
+ return {
103
+ class: 'operational',
104
+ prefix: `${faultMatch[1]}:`,
105
+ detail: reason.slice(faultMatch[0].length),
106
+ };
107
+ }
108
+
109
+ // 2. steps[<id>]: — bracketed id, so matched by regex rather than a literal.
110
+ const stepsMatch = reason.match(/^steps\[[^\]]*\]:\s*/);
111
+ if (stepsMatch) {
112
+ return {
113
+ class: 'submission-bad',
114
+ prefix: 'steps[<id>]:',
115
+ detail: reason.slice(stepsMatch[0].length),
116
+ };
117
+ }
118
+
119
+ // 3. Literal prefixes (most-specific-first; the array order guards against a
120
+ // shorter prefix shadowing a longer one — none currently overlap, but the
121
+ // ordering keeps that invariant explicit).
122
+ for (const entry of LITERAL_PREFIXES) {
123
+ if (reason.startsWith(entry.match)) {
124
+ return {
125
+ class: entry.class,
126
+ prefix: entry.prefix,
127
+ detail: stripLiteral(reason, entry.match),
128
+ };
129
+ }
130
+ }
131
+
132
+ // 4. Unrecognized — log + surface raw. (The null/non-object submission reason
133
+ // now carries the typed `submission-malformed:` prefix → 'operational', so it
134
+ // no longer falls through here; see verify-B-003.)
135
+ return { class: 'unknown', prefix: null, detail: reason };
136
+ }
@@ -156,7 +156,15 @@ export function validatePolicy(submission, { globalPolicy, repoPolicy }) {
156
156
 
157
157
  if (ciReqs.coverage_min != null) {
158
158
  const coverageCheck = submission.ci_checks?.find(c => c.kind === 'coverage');
159
- if (!coverageCheck) {
159
+ // ci_checks[].value is OPTIONAL in the submission schema (only id/kind/status
160
+ // are required). A value-less coverage check must be treated the SAME as a
161
+ // missing one: at the trust boundary the verifier rejects incomplete proof.
162
+ // Without this guard, `undefined < coverage_min` is false (and a coerced
163
+ // null/NaN is likewise not a real measurement), so the gate silently passed
164
+ // with no measured coverage.
165
+ const measured = coverageCheck && typeof coverageCheck.value === 'number'
166
+ && Number.isFinite(coverageCheck.value);
167
+ if (!coverageCheck || !measured) {
160
168
  errors.push(
161
169
  `surface[${surface}]: coverage_min is ${ciReqs.coverage_min}% but no coverage data provided`
162
170
  );
@@ -0,0 +1,300 @@
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
+ fetchImpl: fetchWithStatus(status)
216
+ });
217
+ await assert.rejects(
218
+ adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
219
+ err => {
220
+ assert.match(err.message, /provenance: GitLab API returned/);
221
+ assert.match(err.message, new RegExp(String(status)));
222
+ return true;
223
+ }
224
+ );
225
+ });
226
+ }
227
+ });
228
+
229
+ // ── Timeout (mirrors F-246817-014) ─────────────────────────────────
230
+
231
+ describe('gitlabProvenance fetch timeout', () => {
232
+ function makeHangingFetch() {
233
+ return function hangingFetch(_url, opts) {
234
+ return new Promise((_resolve, reject) => {
235
+ if (opts && opts.signal) {
236
+ opts.signal.addEventListener('abort', () => {
237
+ const err = new Error('aborted');
238
+ err.name = 'AbortError';
239
+ reject(err);
240
+ });
241
+ }
242
+ });
243
+ };
244
+ }
245
+
246
+ it('throws timeout error when fetch hangs longer than timeoutMs', async () => {
247
+ const adapter = gitlabProvenance('token', {
248
+ timeoutMs: 50,
249
+ fetchImpl: makeHangingFetch()
250
+ });
251
+ const start = Date.now();
252
+ await assert.rejects(
253
+ adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD }),
254
+ err => {
255
+ assert.match(err.message, /provenance: GitLab API timeout/);
256
+ assert.match(err.message, /50ms/);
257
+ return true;
258
+ }
259
+ );
260
+ assert.ok(Date.now() - start < 5000, 'expected fast abort');
261
+ });
262
+
263
+ it('returns false (not throws) on non-AbortError transport failures', async () => {
264
+ const failingFetch = async () => { throw new Error('connection refused'); };
265
+ const adapter = gitlabProvenance('token', {
266
+ timeoutMs: 1000,
267
+ fetchImpl: failingFetch
268
+ });
269
+ const ok = await adapter.confirm(pipelineSource(), { refCommitSha: RUN_HEAD });
270
+ assert.equal(ok, false);
271
+ });
272
+ });
273
+
274
+ // ── Provider guard + malformed input ───────────────────────────────
275
+
276
+ describe('gitlabProvenance input guards', () => {
277
+ it('throws on a non-gitlab provider', async () => {
278
+ const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
279
+ await assert.rejects(
280
+ adapter.confirm({ provider: 'github', provider_run_id: '1', run_url: 'https://github.com/a/b/actions/runs/1' }),
281
+ /unsupported provider: github/
282
+ );
283
+ });
284
+
285
+ it('returns false when run_url is missing', async () => {
286
+ const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
287
+ const ok = await adapter.confirm({ provider: 'gitlab', provider_run_id: '1' });
288
+ assert.equal(ok, false);
289
+ });
290
+
291
+ it('returns false when run_url does not match the GitLab pipeline/job shape', async () => {
292
+ const adapter = gitlabProvenance('token', { fetchImpl: fetchReturning(mockPipeline()) });
293
+ const ok = await adapter.confirm({
294
+ provider: 'gitlab',
295
+ provider_run_id: '1',
296
+ run_url: 'https://gitlab.com/acme/widget/-/merge_requests/1'
297
+ });
298
+ assert.equal(ok, false);
299
+ });
300
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * provenance-registry.test.js — provenance adapter coverage (peer of repo-binding.test.js)
3
+ *
4
+ * The forgery-vector tripwire in repo-binding.test.js asserts every
5
+ * `source.provider` enum member has a run_url PARSER. This file closes the other
6
+ * half of the same discipline: every provider must ALSO have a provenance
7
+ * ADAPTER in PROVENANCE_ADAPTERS. A provider with a parser but no adapter would
8
+ * be selectable at the binding layer yet un-confirmable at the provenance layer
9
+ * — submissions for it would fail closed with an opaque "no adapter" path. The
10
+ * coverage test reads the ACTUAL provider enum from the canonical submission
11
+ * schema, so adding a provider without an adapter goes RED in CI.
12
+ */
13
+
14
+ import { describe, it } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { readFileSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+
19
+ import { PROVENANCE_ADAPTERS, provenanceForProvider, githubProvenance, gitlabProvenance } from './provenance.js';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const schemaPath = require.resolve(
23
+ '@dogfood-lab/schemas/json/dogfood-record-submission.schema.json'
24
+ );
25
+ const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
26
+ const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
27
+
28
+ describe('provenance adapter coverage', () => {
29
+ it('exposes the provider enum it is meant to cover (sanity)', () => {
30
+ assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
31
+ assert.ok(PROVIDER_ENUM.includes('github'));
32
+ assert.ok(PROVIDER_ENUM.includes('gitlab'));
33
+ });
34
+
35
+ it('has a provenance adapter factory for EVERY registered source.provider', () => {
36
+ const missing = PROVIDER_ENUM.filter(p => typeof PROVENANCE_ADAPTERS[p] !== 'function');
37
+ assert.deepEqual(
38
+ missing,
39
+ [],
40
+ `source.provider enum members without a PROVENANCE_ADAPTERS factory: ${missing.join(', ')}. ` +
41
+ 'Add an adapter in validators/provenance.js or provenance falls closed for these providers ' +
42
+ 'with no confirmation path.'
43
+ );
44
+ });
45
+
46
+ it('provenanceForProvider returns the matching factory', () => {
47
+ assert.equal(PROVENANCE_ADAPTERS.github, githubProvenance);
48
+ assert.equal(PROVENANCE_ADAPTERS.gitlab, gitlabProvenance);
49
+ assert.equal(provenanceForProvider('github'), githubProvenance);
50
+ assert.equal(provenanceForProvider('gitlab'), gitlabProvenance);
51
+ });
52
+
53
+ it('provenanceForProvider returns null for an unknown provider (no throw)', () => {
54
+ assert.equal(provenanceForProvider('bitbucket'), null);
55
+ });
56
+ });