@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.
@@ -2,9 +2,18 @@
2
2
  * Provenance adapters
3
3
  *
4
4
  * The verifier checks that a source run actually exists and matches claims.
5
- * Two adapters:
6
- * - stub: always confirms (for tests and local development)
7
- * - github: confirms via GitHub Actions API (for production)
5
+ * Adapters:
6
+ * - stub: always confirms (for tests and local development)
7
+ * - github: confirms via the GitHub Actions API (production)
8
+ * - gitlab: confirms via the GitLab CI API (production)
9
+ *
10
+ * The two real adapters are PEERS — same hardening discipline (per-request
11
+ * timeout, 404-only-false, throw-on-operational, finished-state assertion, and
12
+ * the verify-A-001 commit-binding anti-forgery guard). Production code selects
13
+ * the adapter for a submission via {@link provenanceForProvider}, keyed on
14
+ * `source.provider`, so the provider enum, the run_url parsers
15
+ * (validators/repo-binding.js), and the adapters here stay in lockstep —
16
+ * coverage tests fail CI if any of the three drifts.
8
17
  */
9
18
 
10
19
  /**
@@ -16,6 +25,12 @@
16
25
  */
17
26
  export const GITHUB_PROVENANCE_TIMEOUT_MS = 30000;
18
27
 
28
+ /**
29
+ * Default per-request timeout for the GitLab provenance fetch. Same rationale
30
+ * as the GitHub guard — a hung gitlab.com/api call must not block ingest.
31
+ */
32
+ export const GITLAB_PROVENANCE_TIMEOUT_MS = 30000;
33
+
19
34
  /**
20
35
  * Stub provenance adapter. Always confirms.
21
36
  * Use in tests and local development.
@@ -48,7 +63,16 @@ export function githubProvenance(token, opts = {}) {
48
63
  const timeoutMs = opts.timeoutMs ?? GITHUB_PROVENANCE_TIMEOUT_MS;
49
64
  const fetchImpl = opts.fetchImpl ?? fetch;
50
65
  return {
51
- async confirm(source) {
66
+ /**
67
+ * @param {object} source - submission.source (provider-scoped run claim)
68
+ * @param {{ refCommitSha?: string }} [expected] - cross-field invariants the
69
+ * verifier binds at the call site. `refCommitSha` is the PERSISTED
70
+ * `submission.ref.commit_sha` — the commit the record will attest to. When
71
+ * present it is checked MANDATORILY against the confirmed run head, so a
72
+ * submitter cannot point a real run at an arbitrary persisted commit
73
+ * (verify-A-001).
74
+ */
75
+ async confirm(source, expected = {}) {
52
76
  if (source.provider !== 'github') {
53
77
  throw new Error(`unsupported provider: ${source.provider}`);
54
78
  }
@@ -91,13 +115,32 @@ export function githubProvenance(token, opts = {}) {
91
115
  signal: controller.signal
92
116
  });
93
117
 
94
- if (!resp.ok) return false;
118
+ if (!resp.ok) {
119
+ // verify-A-002: only 404 means the run is genuinely absent — a real
120
+ // submission-bad rejection. Every other non-2xx is an OPERATIONAL
121
+ // signal (401/403 expired/insufficient token, 429 rate limit, 5xx
122
+ // GitHub outage). Collapsing those into `return false` made the
123
+ // verifier record 'source run could not be confirmed' and routing
124
+ // bounced an ops incident to submitters. Throw so the existing catch
125
+ // in index.js records it as a provenance verification failure
126
+ // (operational), mirroring the timeout fix above.
127
+ if (resp.status === 404) return false;
128
+ throw new Error(`provenance: GitHub API returned ${resp.status}`);
129
+ }
95
130
 
96
131
  run = await resp.json();
97
132
  } catch (err) {
98
133
  if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
99
134
  throw new Error(`provenance: GitHub API timeout after ${timeoutMs}ms`);
100
135
  }
136
+ // verify-A-002: operational signals we raised ourselves (non-2xx HTTP)
137
+ // already carry the 'provenance:' prefix — re-throw them so they reach
138
+ // index.js as a verification failure rather than being swallowed into
139
+ // 'return false'. Reserve `return false` for genuine transport errors
140
+ // (DNS, connection refused) where there is no run to confirm.
141
+ if (err && typeof err.message === 'string' && err.message.startsWith('provenance:')) {
142
+ throw err;
143
+ }
101
144
  return false;
102
145
  } finally {
103
146
  clearTimeout(timer);
@@ -112,6 +155,14 @@ export function githubProvenance(token, opts = {}) {
112
155
  // underlying CI evidence exists. Rejects 'queued' / 'in_progress' / 'waiting'.
113
156
  if (run.status !== 'completed') return false;
114
157
 
158
+ // verify-A-001: bind the persisted commit to the confirmed run. The record
159
+ // attests to submission.ref.commit_sha (index.js persists submission.ref
160
+ // verbatim), so the run head MUST equal it. Without this, a submitter who
161
+ // owns a real completed run could set ref.commit_sha to any 40-hex sha and
162
+ // earn a provenance_confirmed 'pass' for a commit the run never executed.
163
+ // Mandatory whenever the binding is supplied — not gated on the optional
164
+ // source.commit_sha.
165
+ if (expected.refCommitSha && run.head_sha !== expected.refCommitSha) return false;
115
166
  if (source.commit_sha && run.head_sha !== source.commit_sha) return false;
116
167
  if (source.repo && run.repository?.full_name !== source.repo) return false;
117
168
 
@@ -119,3 +170,179 @@ export function githubProvenance(token, opts = {}) {
119
170
  }
120
171
  };
121
172
  }
173
+
174
+ /**
175
+ * Terminal GitLab pipeline/job states. Provenance confirms the pipeline RAN to a
176
+ * terminal state — it is NOT a pass/fail gate (pass/fail is carried by ci_checks
177
+ * and scenario verdicts, exactly as in githubProvenance, which accepts a
178
+ * `completed` run regardless of conclusion). 'success', 'failed', 'canceled',
179
+ * and 'skipped' are terminal; 'running'/'pending'/'created'/'preparing'/
180
+ * 'waiting_for_resource'/'scheduled'/'manual' are not.
181
+ */
182
+ const GITLAB_FINISHED_STATES = new Set(['success', 'failed', 'canceled', 'cancelled', 'skipped']);
183
+
184
+ /**
185
+ * GitLab CI provenance adapter — peer of {@link githubProvenance}.
186
+ *
187
+ * Confirms a GitLab pipeline (or job) exists and matches the claimed project,
188
+ * commit, and finished state. Mirrors githubProvenance's hardening exactly:
189
+ * - per-request AbortController timeout (throws `provenance: GitLab API timeout`)
190
+ * - returns false ONLY on HTTP 404 (run genuinely absent)
191
+ * - THROWS on 401/403/429/5xx as OPERATIONAL (`provenance: GitLab API returned N`)
192
+ * so parseRejectionReason classifies it operational, not submission-bad
193
+ * - asserts a finished/terminal pipeline state ({@link GITLAB_FINISHED_STATES})
194
+ * - binds the pipeline sha (or job commit.id) to the PERSISTED commit
195
+ * (expected.refCommitSha — the verify-A-001 anti-forgery guard) MANDATORILY,
196
+ * and binds the run_url project path to source.repo
197
+ *
198
+ * The run_url shape decides which endpoint is queried:
199
+ * PIPELINE → GET /api/v4/projects/:id/pipelines/:pipeline_id (commit at .sha)
200
+ * JOB → GET /api/v4/projects/:id/jobs/:job_id (commit at .commit.id)
201
+ * `:id` is the URL-encoded `group/project` (or nested `group/subgroup/project`)
202
+ * path — GitLab accepts the URL-encoded path in place of a numeric project id.
203
+ *
204
+ * @param {string} token - GitLab token with `read_api` scope (sent as PRIVATE-TOKEN).
205
+ * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch, apiBase?: string }} [opts]
206
+ * `apiBase` overrides the API origin for self-hosted GitLab (default
207
+ * 'https://gitlab.com'); tests inject `fetchImpl` so no network is hit.
208
+ * @returns {object} Provenance adapter
209
+ */
210
+ export function gitlabProvenance(token, opts = {}) {
211
+ const timeoutMs = opts.timeoutMs ?? GITLAB_PROVENANCE_TIMEOUT_MS;
212
+ const fetchImpl = opts.fetchImpl ?? fetch;
213
+ const apiBase = (opts.apiBase ?? 'https://gitlab.com').replace(/\/+$/, '');
214
+ return {
215
+ /**
216
+ * @param {object} source - submission.source (provider-scoped run claim)
217
+ * @param {{ refCommitSha?: string }} [expected] - see githubProvenance.confirm.
218
+ * `refCommitSha` (the persisted submission.ref.commit_sha) is checked
219
+ * MANDATORILY against the confirmed pipeline/job commit (verify-A-001).
220
+ */
221
+ async confirm(source, expected = {}) {
222
+ if (source.provider !== 'gitlab') {
223
+ throw new Error(`unsupported provider: ${source.provider}`);
224
+ }
225
+
226
+ const { provider_run_id, run_url } = source;
227
+ if (!provider_run_id || !run_url) {
228
+ return false;
229
+ }
230
+
231
+ // Decode the project path + run kind + id from the run_url.
232
+ // JOB: https://<host>/<project-path>/-/jobs/<id>
233
+ // PIPELINE: https://<host>/<project-path>/-/pipelines/<id>
234
+ // <project-path> may be a nested namespace (group/subgroup/project).
235
+ const match = run_url.match(
236
+ /^https:\/\/[^/]+\/(.+?)\/-\/(jobs|pipelines)\/(\d+)(?:[/?#].*)?$/
237
+ );
238
+ if (!match) return false;
239
+
240
+ const [, projectPath, runKind, urlRunId] = match;
241
+
242
+ // run id in URL must match the claimed provider_run_id.
243
+ if (urlRunId !== String(provider_run_id)) return false;
244
+
245
+ // Bind the project path to source.repo BEFORE the network call — a forged
246
+ // project claim is a cheap, offline rejection. For GitLab, submission.repo
247
+ // is the full project path (which may contain nested-subgroup slashes).
248
+ if (source.repo && projectPath !== source.repo) return false;
249
+
250
+ const projectId = encodeURIComponent(projectPath);
251
+ const endpoint = runKind === 'jobs'
252
+ ? `${apiBase}/api/v4/projects/${projectId}/jobs/${provider_run_id}`
253
+ : `${apiBase}/api/v4/projects/${projectId}/pipelines/${provider_run_id}`;
254
+
255
+ // Per-request timeout — identical discipline to githubProvenance. Without
256
+ // it a hung gitlab.com/api call blocks ingest until the surrounding runner
257
+ // times out. AbortError → re-thrown with a clear 'provenance:' message so
258
+ // the verifier records it instead of silently returning false.
259
+ const controller = new AbortController();
260
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
261
+
262
+ let run;
263
+ try {
264
+ const resp = await fetchImpl(endpoint, {
265
+ headers: {
266
+ 'PRIVATE-TOKEN': token,
267
+ Accept: 'application/json'
268
+ },
269
+ signal: controller.signal
270
+ });
271
+
272
+ if (!resp.ok) {
273
+ // Mirror verify-A-002: only 404 means the pipeline/job is genuinely
274
+ // absent (submission-bad). 401/403/429/5xx are OPERATIONAL — throw so
275
+ // the catch in index.js records a 'provenance:' verification failure
276
+ // (paged to ops) instead of bouncing an outage to the submitter.
277
+ if (resp.status === 404) return false;
278
+ throw new Error(`provenance: GitLab API returned ${resp.status}`);
279
+ }
280
+
281
+ run = await resp.json();
282
+ } catch (err) {
283
+ if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
284
+ throw new Error(`provenance: GitLab API timeout after ${timeoutMs}ms`);
285
+ }
286
+ // Operational signals we raised ourselves already carry the
287
+ // 'provenance:' prefix — re-throw. Reserve `return false` for genuine
288
+ // transport errors (DNS, connection refused) where there is no run.
289
+ if (err && typeof err.message === 'string' && err.message.startsWith('provenance:')) {
290
+ throw err;
291
+ }
292
+ return false;
293
+ } finally {
294
+ clearTimeout(timer);
295
+ }
296
+
297
+ // id in the response must match the claimed run id.
298
+ if (run.id !== Number(provider_run_id)) return false;
299
+
300
+ // Contract: confirm the pipeline/job reached a TERMINAL state. Like
301
+ // githubProvenance's `status === 'completed'`, this is not a pass/fail
302
+ // gate — a terminal 'failed'/'canceled' still confirms the run executed.
303
+ if (!GITLAB_FINISHED_STATES.has(run.status)) return false;
304
+
305
+ // The confirmed commit: a pipeline carries it at `.sha`; a job at
306
+ // `.commit.id`. Either must be present for a binding to be possible.
307
+ const runCommit = runKind === 'jobs' ? run.commit?.id : run.sha;
308
+ if (!runCommit) return false;
309
+
310
+ // verify-A-001: bind the PERSISTED commit to the confirmed run. Mandatory
311
+ // whenever the binding is supplied — a submitter who owns a real finished
312
+ // pipeline must not be able to attest an arbitrary commit.
313
+ if (expected.refCommitSha && runCommit !== expected.refCommitSha) return false;
314
+ if (source.commit_sha && runCommit !== source.commit_sha) return false;
315
+
316
+ return true;
317
+ }
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Provider → provenance-adapter-factory registry, keyed on `source.provider`.
323
+ *
324
+ * Each factory has the signature `(token, opts?) => { confirm(source, expected?) }`.
325
+ * This registry is the lockstep partner of `RUN_URL_PARSERS` in
326
+ * validators/repo-binding.js: a provider in the `source.provider` schema enum
327
+ * MUST have BOTH a run_url parser (so the anti-forgery repo-binding decodes it)
328
+ * AND a provenance adapter (so its runs are confirmable). Two coverage tests —
329
+ * validators/repo-binding.test.js and validators/provenance-registry.test.js —
330
+ * read the actual enum and fail CI if either side is missing for a provider.
331
+ *
332
+ * @type {Record<string, (token: string, opts?: object) => { confirm: Function }>}
333
+ */
334
+ export const PROVENANCE_ADAPTERS = {
335
+ github: githubProvenance,
336
+ gitlab: gitlabProvenance,
337
+ };
338
+
339
+ /**
340
+ * Resolve the provenance-adapter factory for a `source.provider`.
341
+ *
342
+ * @param {string} provider The `source.provider` token (e.g. 'github', 'gitlab').
343
+ * @returns {((token: string, opts?: object) => { confirm: Function }) | null}
344
+ * The adapter factory, or `null` when the provider has no registered adapter.
345
+ */
346
+ export function provenanceForProvider(provider) {
347
+ return PROVENANCE_ADAPTERS[provider] ?? null;
348
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Cross-field repo-binding parsers (verify-B-001).
3
+ *
4
+ * The anti-forgery guard in verify/index.js binds `submission.repo` to the
5
+ * owner/repo encoded in `submission.source.run_url`, so a submitter cannot
6
+ * claim `repo='victim/repo'` while supplying a real run_url from their own
7
+ * repo (the verify-A-001 vector). That guard only works if it can decode the
8
+ * run_url for the submission's PROVIDER.
9
+ *
10
+ * Pre-fix the GitHub run_url regex was inlined in index.js behind `if (m)`.
11
+ * A second provider in the `source.provider` enum would fall through `m ===
12
+ * null` and the binding would SILENTLY no-op — reopening the forgery vector
13
+ * for the new provider with no error and no test failure.
14
+ *
15
+ * The fix keys the parsers by provider. `RUN_URL_PARSERS` MUST stay in lockstep
16
+ * with the `source.provider` enum in dogfood-record-submission.schema.json —
17
+ * a guard-coverage test (repo-binding.test.js) asserts exactly that, so adding
18
+ * a provider to the schema without adding a parser here fails CI instead of
19
+ * silently disabling the binding for the new provider.
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} RunUrlRepo
24
+ * @property {string} owner Repository owner/org.
25
+ * @property {string} repo Repository name.
26
+ */
27
+
28
+ /**
29
+ * Per-provider run_url → { owner, repo } parser.
30
+ *
31
+ * Each parser returns `null` when the URL does not match the provider's run_url
32
+ * shape (malformed/foreign URL). A malformed URL is NOT a binding failure —
33
+ * the schema validator already rejects a malformed source.run_url — so the
34
+ * guard treats a `null` parse as "nothing to bind against" and lets the schema
35
+ * layer own the rejection.
36
+ *
37
+ * @type {Record<string, (runUrl: string) => RunUrlRepo | null>}
38
+ */
39
+ export const RUN_URL_PARSERS = {
40
+ // Format: https://github.com/{owner}/{repo}/actions/runs/{id}
41
+ github(runUrl) {
42
+ const m = runUrl.match(
43
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/\d+$/
44
+ );
45
+ return m ? { owner: m[1], repo: m[2] } : null;
46
+ },
47
+
48
+ // GitLab run_url shapes (gitlab.com AND self-hosted hosts):
49
+ // JOB: https://<host>/<namespace>/<project>/-/jobs/<id>
50
+ // PIPELINE: https://<host>/<namespace>/<project>/-/pipelines/<id>
51
+ // The project path is everything between the host and the `/-/` run segment.
52
+ //
53
+ // NESTED-SUBGROUP MAPPING (load-bearing decision, kept consistent with how
54
+ // submission.repo is expressed for GitLab): GitLab namespaces can nest —
55
+ // `group/subgroup/project`. We map owner = the FULL namespace (everything
56
+ // before the last path segment, slashes preserved) and repo = the LAST segment
57
+ // (the project). So `${owner}/${repo}` reconstructs the full project path. For
58
+ // a flat `group/project` this degenerates to owner='group', repo='project'
59
+ // (same shape as GitHub). The repo-binding guard then compares
60
+ // `${owner}/${repo}` against submission.repo, so for GitLab submission.repo is
61
+ // the FULL project path — which may contain more than one slash for nested
62
+ // subgroups, unlike GitHub's strict two-segment org/repo.
63
+ //
64
+ // A single-segment path (no namespace + project, just `<project>/-/jobs/<id>`)
65
+ // returns null — it cannot be split into owner + repo and is not a valid
66
+ // GitLab project URL.
67
+ gitlab(runUrl) {
68
+ const m = runUrl.match(
69
+ /^https:\/\/[^/]+\/(.+?)\/-\/(?:jobs|pipelines)\/\d+(?:[/?#].*)?$/
70
+ );
71
+ if (!m) return null;
72
+ const segments = m[1].split('/');
73
+ if (segments.length < 2) return null; // need at least namespace + project
74
+ const repo = segments.pop();
75
+ const owner = segments.join('/');
76
+ return { owner, repo };
77
+ },
78
+ };
79
+
80
+ /**
81
+ * Decode the owner/repo a run_url attests to, for the given provider.
82
+ *
83
+ * @param {string} provider The `source.provider` token (e.g. 'github').
84
+ * @param {string} runUrl The `source.run_url`.
85
+ * @returns {RunUrlRepo | null} `{ owner, repo }`, or `null` when the provider
86
+ * has no parser or the URL does not match the provider's shape.
87
+ */
88
+ export function parseRunUrlRepo(provider, runUrl) {
89
+ const parser = RUN_URL_PARSERS[provider];
90
+ if (!parser) return null;
91
+ return parser(runUrl);
92
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * repo-binding.test.js — verify-B-001 (Stage C, MED)
3
+ *
4
+ * The cross-field anti-forgery guard in verify/index.js binds submission.repo
5
+ * to the owner/repo decoded from source.run_url. That decode is provider-keyed
6
+ * (RUN_URL_PARSERS in repo-binding.js). The forgery vector (verify-A-001)
7
+ * reopens silently if a provider is added to the submission schema's
8
+ * `source.provider` enum without a corresponding parser branch: the guard would
9
+ * see `parseRunUrlRepo(provider, url) === null`, skip the comparison, and accept
10
+ * a forged repo with no error and no other test failure.
11
+ *
12
+ * The coverage test below is the tripwire: it reads the ACTUAL `source.provider`
13
+ * enum from the canonical submission schema and asserts every member has a
14
+ * parser. Add a provider to the schema and forget the parser → this test goes
15
+ * red in CI before the binding can ship disabled.
16
+ */
17
+
18
+ import { describe, it } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { readFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+
23
+ import { RUN_URL_PARSERS, parseRunUrlRepo } from './repo-binding.js';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const schemaPath = require.resolve(
27
+ '@dogfood-lab/schemas/json/dogfood-record-submission.schema.json'
28
+ );
29
+ const submissionSchema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
30
+ const PROVIDER_ENUM = submissionSchema.properties.source.properties.provider.enum;
31
+
32
+ describe('repo-binding guard coverage (verify-B-001)', () => {
33
+ it('exposes the provider enum it is meant to cover (sanity)', () => {
34
+ assert.ok(Array.isArray(PROVIDER_ENUM) && PROVIDER_ENUM.length > 0);
35
+ assert.ok(PROVIDER_ENUM.includes('github'));
36
+ });
37
+
38
+ it('has a run_url parser for EVERY registered source.provider', () => {
39
+ const missing = PROVIDER_ENUM.filter(p => typeof RUN_URL_PARSERS[p] !== 'function');
40
+ assert.deepEqual(
41
+ missing,
42
+ [],
43
+ `source.provider enum members without a RUN_URL_PARSERS branch: ${missing.join(', ')}. ` +
44
+ 'Add a parser in validators/repo-binding.js or the anti-forgery repo binding ' +
45
+ 'silently no-ops for these providers (verify-A-001 / verify-B-001).'
46
+ );
47
+ });
48
+
49
+ describe('github parser', () => {
50
+ it('decodes owner/repo from a well-formed run_url', () => {
51
+ const bound = parseRunUrlRepo(
52
+ 'github',
53
+ 'https://github.com/acme/widget/actions/runs/12345'
54
+ );
55
+ assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
56
+ });
57
+
58
+ it('returns null for a foreign/malformed run_url', () => {
59
+ assert.equal(parseRunUrlRepo('github', 'https://example.com/x'), null);
60
+ });
61
+ });
62
+
63
+ describe('gitlab parser', () => {
64
+ it('decodes owner/repo from a JOB run_url', () => {
65
+ const bound = parseRunUrlRepo(
66
+ 'gitlab',
67
+ 'https://gitlab.com/acme/widget/-/jobs/987654'
68
+ );
69
+ assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
70
+ });
71
+
72
+ it('decodes owner/repo from a PIPELINE run_url', () => {
73
+ const bound = parseRunUrlRepo(
74
+ 'gitlab',
75
+ 'https://gitlab.com/acme/widget/-/pipelines/424242'
76
+ );
77
+ assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
78
+ });
79
+
80
+ it('maps nested subgroups: owner = full namespace, repo = last segment', () => {
81
+ // GitLab supports nested subgroups (group/subgroup/project). The mapping
82
+ // decision (documented in repo-binding.js): owner = everything before the
83
+ // LAST path segment (the full namespace, slashes preserved), repo = the
84
+ // last segment. Reconstructing `${owner}/${repo}` yields the full project
85
+ // path, which is what submission.repo carries for GitLab.
86
+ const bound = parseRunUrlRepo(
87
+ 'gitlab',
88
+ 'https://gitlab.com/acme/team-a/widget/-/pipelines/1'
89
+ );
90
+ assert.deepEqual(bound, { owner: 'acme/team-a', repo: 'widget' });
91
+ assert.equal(`${bound.owner}/${bound.repo}`, 'acme/team-a/widget');
92
+ });
93
+
94
+ it('parses self-hosted GitLab hosts (owner/repo = path before /-/)', () => {
95
+ const bound = parseRunUrlRepo(
96
+ 'gitlab',
97
+ 'https://gitlab.example.com/acme/widget/-/jobs/55'
98
+ );
99
+ assert.deepEqual(bound, { owner: 'acme', repo: 'widget' });
100
+ });
101
+
102
+ it('returns null for a foreign/malformed run_url', () => {
103
+ assert.equal(parseRunUrlRepo('gitlab', 'https://example.com/x'), null);
104
+ });
105
+
106
+ it('returns null when the URL has no /-/ run segment', () => {
107
+ assert.equal(parseRunUrlRepo('gitlab', 'https://gitlab.com/acme/widget'), null);
108
+ });
109
+
110
+ it('returns null for a single-segment path (no namespace + project)', () => {
111
+ assert.equal(parseRunUrlRepo('gitlab', 'https://gitlab.com/widget/-/jobs/1'), null);
112
+ });
113
+ });
114
+
115
+ it('returns null for a provider with no parser (no throw)', () => {
116
+ assert.equal(parseRunUrlRepo('bitbucket', 'https://bitbucket.org/a/b/pipelines/1'), null);
117
+ });
118
+ });
@@ -27,23 +27,14 @@ import { validatePayload } from '@dogfood-lab/schemas';
27
27
  * @returns {{ valid: boolean, errors: string[] }}
28
28
  */
29
29
  export function validateSubmissionSchema(submission) {
30
- let result;
31
- try {
32
- result = validatePayload('recordSubmission', submission);
33
- } catch (e) {
34
- // The canonical compile path can throw at compileSchema time if the
35
- // schema file is unreadable or malformed (Ajv compile fault). Pre-H3
36
- // the corresponding fault was surfaced via a `{ __loadError }` sentinel
37
- // that the runValidator helper in verify/index.js never actually
38
- // routed through `VALIDATOR_FAULT_*` — `getValidator` returned a plain
39
- // object and the call site special-cased its `__loadError` key. The
40
- // post-H3 path is cleaner: a thrown error here propagates up to
41
- // runValidator which already wraps thrown validators in
42
- // `VALIDATOR_FAULT_SCHEMA` (D1B-003 humanization). Same operator-
43
- // facing prefix, just routed through the lawful seam instead of a
44
- // sentinel. No catch — propagate.
45
- throw e;
46
- }
30
+ // The canonical compile path can throw at compileSchema time if the schema
31
+ // file is unreadable or malformed (Ajv compile fault). We deliberately do
32
+ // NOT catch it here: a thrown error propagates up to runValidator in
33
+ // verify/index.js, which wraps thrown validators in `VALIDATOR_FAULT_SCHEMA`
34
+ // (D1B-003 humanization). Pre-H3 this fault was surfaced via a `{ __loadError }`
35
+ // sentinel the call site special-cased; routing the throw through the lawful
36
+ // runValidator seam yields the same operator-facing prefix without the sentinel.
37
+ const result = validatePayload('recordSubmission', submission);
47
38
 
48
39
  if (result.valid) {
49
40
  return { valid: true, errors: [] };
@@ -52,8 +52,12 @@ export function validateStepResults(scenarioResult) {
52
52
 
53
53
  // A scenario cannot be "pass" if any step is "fail" or "blocked"
54
54
  if (verdict === 'pass') {
55
+ // Guard `s != null` to match the two sibling loops above: a null element is
56
+ // already reported as malformed there, and dereferencing `s.status` here
57
+ // would throw a TypeError that runValidator misclassifies as an operational
58
+ // VALIDATOR_FAULT_STEPS instead of a submission-bad signal (verify-B-004).
55
59
  const failingSteps = step_results.filter(
56
- s => s.status === 'fail' || s.status === 'blocked'
60
+ s => s != null && (s.status === 'fail' || s.status === 'blocked')
57
61
  );
58
62
  if (failingSteps.length > 0) {
59
63
  const ids = failingSteps.map(s => s.step_id).join(', ');