@dogfood-lab/verify 1.4.0 → 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.
@@ -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
+ }
@@ -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
+ });