diff --git a/backend/package.json b/backend/package.json index a909a23a..ceb19e23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "test:docker-integration": "vitest run --config vitest.docker-integration.config.ts", "test": "vitest run", "lint": "eslint src", + "matrix:render": "node scripts/git-support-matrix/render.js", "reset-mfa": "node dist/cli/resetMfa.js", "reset-password": "node dist/cli/resetPassword.js", "create-emergency-admin": "node dist/cli/createEmergencyAdmin.js", diff --git a/backend/scripts/git-support-matrix/loadClaimSet.js b/backend/scripts/git-support-matrix/loadClaimSet.js new file mode 100644 index 00000000..17f740b1 --- /dev/null +++ b/backend/scripts/git-support-matrix/loadClaimSet.js @@ -0,0 +1,30 @@ +// Shared YAML loader for the Git transport support matrix. +// +// Used by both render.js (the CLI/build-time generator) and +// git-support-matrix.test.ts (the validator), so the two never parse the +// source files differently. +const fs = require('fs'); +const path = require('path'); +const { parse } = require('yaml'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const SUPPORT_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-support.yaml'); +const ATTESTATIONS_YAML_PATH = path.join(REPO_ROOT, 'docs', 'git-transport-attestations.yaml'); +const MDX_PATH = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx'); + +function loadClaimSet() { + const supportRaw = fs.readFileSync(SUPPORT_YAML_PATH, 'utf8'); + const attestationsRaw = fs.readFileSync(ATTESTATIONS_YAML_PATH, 'utf8'); + return { + support: parse(supportRaw), + attestations: parse(attestationsRaw), + }; +} + +module.exports = { + REPO_ROOT, + SUPPORT_YAML_PATH, + ATTESTATIONS_YAML_PATH, + MDX_PATH, + loadClaimSet, +}; diff --git a/backend/scripts/git-support-matrix/render.js b/backend/scripts/git-support-matrix/render.js new file mode 100644 index 00000000..4cf503d2 --- /dev/null +++ b/backend/scripts/git-support-matrix/render.js @@ -0,0 +1,199 @@ +// Generates the tables in docs/features/git-transport-support.mdx from +// docs/git-transport-support.yaml. +// +// Everything between the GENERATED markers is produced here; the rest of the +// MDX file (intro prose, the "How these claims are verified" section) is +// hand-written and left untouched. The validator (git-support-matrix.test.ts) +// imports renderFullMdx and asserts the committed file is byte-identical to +// what it produces, so the page can never silently drift from the YAML. +const fs = require('fs'); +const { loadClaimSet, MDX_PATH } = require('./loadClaimSet'); + +const MARKER_BEGIN = ''; +const MARKER_END = ''; + +const TRANSPORT_LABELS = { https: 'HTTPS', ssh: 'SSH' }; +const TRANSPORT_NOTES = { + https: 'Personal Access Token for private repositories, or no credential at all for public ones. TLS verification uses the system trust store by default, or a per-source custom CA when configured.', + ssh: 'A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported.', +}; + +const REF_LABELS = { branch: 'Branch', tag: 'Tag', sha: 'Commit SHA' }; +const REF_NOTES = { + branch: 'Tracks the head of a branch; each pull resolves and pins the exact commit.', + tag: 'Both annotated and lightweight tags resolve to their target commit.', + sha: 'A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag.', +}; + +const AUTH_LABELS = { none: 'Public (no auth)', pat: 'Personal Access Token', 'deploy-key': 'SSH deploy key' }; +const AUTH_NOTES = { + none: 'For public repositories.', + pat: 'Stored encrypted at rest, never returned after save.', + 'deploy-key': 'Stored encrypted at rest; the server host key is verified on every fetch.', +}; + +const CA_LABELS = { system: 'System trust (default)', 'per-source': 'Per-source custom CA', 'not-applicable': 'Not applicable' }; +const CA_NOTES = { + system: 'The host running the fetch trusts its system certificate store.', + 'per-source': "Combined with the system trust anchors, so public hosts keep validating normally. Redirects are re-resolved and only followed when they stay on the source's own host.", + 'not-applicable': 'SSH uses host-key verification instead of TLS certificate trust.', +}; + +const HOST_LABELS = { + generic: 'Generic (self-hosted or any Git server)', + github: 'GitHub', + gitlab: 'GitLab', + gitea: 'Gitea', + forgejo: 'Forgejo', + bitbucket: 'Bitbucket', +}; +const HOST_ORDER = ['generic', 'github', 'gitlab', 'gitea', 'forgejo', 'bitbucket']; + +const STATUS_LABELS = { supported: 'Supported', unsupported: 'Not supported', unverified: 'Not yet verified' }; + +function aggregateStatus(claims) { + if (claims.length === 0) return 'unverified'; + if (claims.some((c) => c.support === 'unsupported')) return 'unsupported'; + if (claims.some((c) => c.support === 'supported')) return 'supported'; + return 'unverified'; +} + +function evidenceSummary(claims, attestationsById) { + const kinds = new Set(); + let latestDate = null; + for (const c of claims) { + if (c.support !== 'supported' || !c.evidence) continue; + if (c.evidence.kind === 'automated') kinds.add('automated'); + if (c.evidence.kind === 'live') { + kinds.add('live'); + const att = attestationsById.get(c.evidence.attestation); + if (att && (!latestDate || att.date > latestDate)) latestDate = att.date; + } + } + if (kinds.size === 0) return 'Pending'; + if (kinds.has('automated') && kinds.has('live')) return `Automated, every change; live as of ${latestDate}`; + if (kinds.has('automated')) return 'Automated, every change'; + return `Live, ${latestDate}`; +} + +function table(headers, rows) { + const sep = headers.map(() => '---'); + return [ + `| ${headers.join(' | ')} |`, + `| ${sep.join(' | ')} |`, + ...rows.map((r) => `| ${r.join(' | ')} |`), + ].join('\n'); +} + +function renderTransports(claims) { + const rows = Object.keys(TRANSPORT_LABELS).map((t) => { + const group = claims.filter((c) => c.transport === t); + return [TRANSPORT_LABELS[t], STATUS_LABELS[aggregateStatus(group)], TRANSPORT_NOTES[t]]; + }); + return ['## Transports', '', table(['Transport', 'Status', 'Notes'], rows)].join('\n'); +} + +function renderRefs(claims) { + const rows = Object.keys(REF_LABELS).map((r) => { + const group = claims.filter((c) => c.ref === r); + return [REF_LABELS[r], STATUS_LABELS[aggregateStatus(group)], REF_NOTES[r]]; + }); + return ['## Reference types', '', table(['Reference type', 'Status', 'Notes'], rows)].join('\n'); +} + +function renderAuth(claims) { + const rows = Object.keys(AUTH_LABELS).map((a) => { + const group = claims.filter((c) => c.auth === a); + return [AUTH_LABELS[a], STATUS_LABELS[aggregateStatus(group)], AUTH_NOTES[a]]; + }); + return ['## Authentication', '', table(['Method', 'Status', 'Notes'], rows)].join('\n'); +} + +const CA_TABLE_MODES = ['system', 'per-source']; + +function renderCa(claims) { + const rows = CA_TABLE_MODES.map((c) => { + const group = claims.filter((claim) => claim.ca === c); + return [CA_LABELS[c], STATUS_LABELS[aggregateStatus(group)], CA_NOTES[c]]; + }); + return ['## TLS and certificate authorities', '', table(['Mode', 'Status', 'Notes'], rows)].join('\n'); +} + +function renderHosts(claims, attestationsById) { + const rows = HOST_ORDER.map((host) => { + const group = claims.filter((c) => c.host === host); + const httpsGroup = group.filter((c) => c.transport === 'https'); + const sshGroup = group.filter((c) => c.transport === 'ssh'); + const branchGroup = group.filter((c) => c.ref === 'branch'); + const tagGroup = group.filter((c) => c.ref === 'tag'); + const shaGroup = group.filter((c) => c.ref === 'sha'); + return [ + HOST_LABELS[host], + STATUS_LABELS[aggregateStatus(httpsGroup)], + STATUS_LABELS[aggregateStatus(sshGroup)], + STATUS_LABELS[aggregateStatus(branchGroup)], + STATUS_LABELS[aggregateStatus(tagGroup)], + STATUS_LABELS[aggregateStatus(shaGroup)], + evidenceSummary(group, attestationsById), + ]; + }); + return [ + '## Git hosts', + '', + table(['Host', 'HTTPS', 'SSH', 'Branch', 'Tag', 'Commit SHA', 'Evidence'], rows), + ].join('\n'); +} + +function renderLimitations(limitations) { + const bullets = limitations.map((l) => `- **${l.title}.** ${l.statement}`); + return ['## Not supported', '', ...bullets].join('\n'); +} + +function renderGeneratedBlock(data) { + const { support, attestations } = data; + const attestationsById = new Map((attestations.attestations || []).map((a) => [a.id, a])); + const claims = support.claims; + return [ + renderTransports(claims), + '', + renderRefs(claims), + '', + renderAuth(claims), + '', + renderHosts(claims, attestationsById), + '', + renderCa(claims), + '', + renderLimitations(support.limitations), + ].join('\n'); +} + +function renderFullMdx() { + const data = loadClaimSet(); + const generated = renderGeneratedBlock(data); + const current = fs.readFileSync(MDX_PATH, 'utf8'); + + const beginIdx = current.indexOf(MARKER_BEGIN); + const endIdx = current.indexOf(MARKER_END); + if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { + throw new Error(`${MDX_PATH} is missing the GENERATED markers, or they are out of order.`); + } + + const before = current.slice(0, beginIdx + MARKER_BEGIN.length); + const after = current.slice(endIdx); + return `${before}\n\n${generated}\n\n${after}`; +} + +if (require.main === module) { + const rendered = renderFullMdx(); + fs.writeFileSync(MDX_PATH, rendered, 'utf8'); + console.log(`[matrix:render] Wrote ${MDX_PATH}`); +} + +module.exports = { + MARKER_BEGIN, + MARKER_END, + renderGeneratedBlock, + renderFullMdx, + aggregateStatus, +}; diff --git a/backend/src/__tests__/__helpers__/externalDeps.ts b/backend/src/__tests__/__helpers__/externalDeps.ts new file mode 100644 index 00000000..07347349 --- /dev/null +++ b/backend/src/__tests__/__helpers__/externalDeps.ts @@ -0,0 +1,47 @@ +/** + * Shared availability probes for the real-git and real-sshd integration + * suites. + * + * Every suite used to carry its own copy of `gitAvailable()`/`sshdAvailable()` + * and pass the result straight to `describe.skipIf`, so a missing dependency + * in CI silently skipped the suite instead of failing the build: proof of a + * combination could stop running with nothing in the test output to say so. + * These wrappers keep the same local-dev behavior (skip when the dependency + * is absent) but throw under CI, where the dependency is expected to be + * present and a skip would be a false claim of coverage. + */ +import { spawnSync } from 'child_process'; + +export type DependencyProbe = () => boolean; + +export const defaultGitProbe: DependencyProbe = () => + spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; + +export const defaultSshdProbe: DependencyProbe = () => + spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; + +function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean { + const present = probe(); + if (!present && process.env.CI) { + throw new Error(`${name} is required in CI but was not found. ${hint}`); + } + return present; +} + +/** True when the system `git` binary is available; throws under CI if not. */ +export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean { + return requireDependency( + 'git', + 'Ensure the CI image installs the git CLI before running the backend suite.', + probe, + ); +} + +/** True when a local `sshd` binary is available; throws under CI if not. */ +export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean { + return requireDependency( + 'sshd', + 'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).', + probe, + ); +} diff --git a/backend/src/__tests__/__helpers__/testHandleResolver.ts b/backend/src/__tests__/__helpers__/testHandleResolver.ts new file mode 100644 index 00000000..a35a9028 --- /dev/null +++ b/backend/src/__tests__/__helpers__/testHandleResolver.ts @@ -0,0 +1,150 @@ +/** + * Resolves a (file, exact test title) proof handle against real vitest + * source, using the TypeScript compiler API rather than a string search. + * + * A string search accepts a commented-out test, a `.skip`-ed test, or a + * duplicate title landing on the wrong declaration. This walks the actual + * AST: it finds every `it`/`test` declaration with that literal title, + * rejects any declaration carrying a skip-shaped modifier (`.skip`, `.todo`, + * `.failing`, `.only`, `.each`, `.skipIf`, `.runIf`) directly, and rejects + * any declaration nested under an enclosing `describe` that is unconditionally + * skipped or conditionally skipped by anything other than + * `describe.skipIf(...)` whose predicate calls one of the approved hardened + * dependency probes (`requireGitBinary`, `requireSshd` from + * `./externalDeps`). `describe.skip` and `describe.runIf` are never + * approved, at any nesting depth. + */ +import ts from 'typescript'; + +export const APPROVED_GUARD_HELPERS = ['requireGitBinary', 'requireSshd']; + +const SKIP_SHAPED_MODIFIERS = new Set(['skip', 'todo', 'failing', 'only', 'each', 'skipIf', 'runIf']); + +export type HandleResolution = + | { ok: true } + | { ok: false; reason: 'not-found' | 'duplicate' | 'skipped-directly' | 'unapproved-ancestor-skip' }; + +interface CallShape { + kind: string; + modifier: string | null; + /** For a curried modifier (`X.skipIf(pred)(title, fn)`), the inner call's first argument. */ + predicateArg?: ts.Node; +} + +const CURRIED_MODIFIERS = new Set(['skipIf', 'runIf', 'each']); + +function classifyCall(node: ts.CallExpression): CallShape | null { + const expr = node.expression; + + // Direct form: describe('x', fn) / describe.skip('x', fn) / it.todo('x'). + if (ts.isIdentifier(expr)) { + return { kind: expr.text, modifier: null }; + } + if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { + return { kind: expr.expression.text, modifier: expr.name.text }; + } + + // Curried form: X.skipIf(pred)(title, fn) / X.runIf(pred)(title, fn) / + // X.each(cases)(title, fn): the outer call's expression is itself a + // CallExpression whose own expression is the X.modifier access. + if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression) && ts.isIdentifier(expr.expression.expression)) { + const modifier = expr.expression.name.text; + if (CURRIED_MODIFIERS.has(modifier)) { + return { kind: expr.expression.expression.text, modifier, predicateArg: expr.arguments[0] }; + } + } + return null; +} + +function predicateCallsApprovedHelper(argNode: ts.Node): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && APPROVED_GUARD_HELPERS.includes(n.expression.text)) { + found = true; + } + ts.forEachChild(n, visit); + }; + visit(argNode); + return found; +} + +function stringLiteralText(node: ts.Node | undefined): string | null { + if (node && ts.isStringLiteralLike(node)) return node.text; + return null; +} + +function functionBodyArgument(node: ts.CallExpression): ts.ArrowFunction | ts.FunctionExpression | undefined { + return node.arguments.find((a): a is ts.ArrowFunction | ts.FunctionExpression => + ts.isArrowFunction(a) || ts.isFunctionExpression(a)); +} + +type AncestorState = 'none' | 'approved' | 'unapproved'; + +interface FoundDeclaration { + title: string; + ownModifier: string | null; + ancestorState: AncestorState; +} + +function collectDeclarations(sourceFile: ts.SourceFile): FoundDeclaration[] { + const found: FoundDeclaration[] = []; + + function walk(node: ts.Node, ancestorState: AncestorState): void { + if (ts.isCallExpression(node)) { + const classified = classifyCall(node); + if (classified?.kind === 'describe') { + let nextState: AncestorState = ancestorState; + if (ancestorState !== 'unapproved') { + if (classified.modifier === 'skip' || classified.modifier === 'runIf') { + nextState = 'unapproved'; + } else if (classified.modifier === 'skipIf') { + const predicate = classified.predicateArg; + nextState = predicate && predicateCallsApprovedHelper(predicate) ? 'approved' : 'unapproved'; + } else if (classified.modifier === 'only' || classified.modifier === 'each' || classified.modifier === 'todo') { + // Scoping/parameterization modifiers on describe don't skip + // this suite's tests; leave ancestorState unchanged. + nextState = ancestorState; + } + } + const callback = functionBodyArgument(node); + if (callback?.body) { + ts.forEachChild(callback.body, (child) => walk(child, nextState)); + } + return; + } + if (classified?.kind === 'it' || classified?.kind === 'test') { + const title = stringLiteralText(node.arguments[0]); + if (title !== null) { + found.push({ title, ownModifier: classified.modifier, ancestorState }); + } + // Do not descend further into an it/test call's own arguments; + // its callback body is the test implementation, not more + // declarations. + return; + } + } + ts.forEachChild(node, (child) => walk(child, ancestorState)); + } + + walk(sourceFile, 'none'); + return found; +} + +export function resolveTestHandle(filePath: string, sourceText: string, title: string): HandleResolution { + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); + const declarations = collectDeclarations(sourceFile).filter((d) => d.title === title); + + if (declarations.length === 0) return { ok: false, reason: 'not-found' }; + + const runnable = declarations.filter((d) => d.ownModifier === null && d.ancestorState !== 'unapproved'); + if (runnable.length > 1) return { ok: false, reason: 'duplicate' }; + if (runnable.length === 1) return { ok: true }; + + // Every matching declaration is skipped some way; report the most + // specific reason from the first match. + const first = declarations[0]; + if (first.ownModifier !== null && SKIP_SHAPED_MODIFIERS.has(first.ownModifier)) { + return { ok: false, reason: 'skipped-directly' }; + } + return { ok: false, reason: 'unapproved-ancestor-skip' }; +} diff --git a/backend/src/__tests__/externalDeps.test.ts b/backend/src/__tests__/externalDeps.test.ts new file mode 100644 index 00000000..12d19574 --- /dev/null +++ b/backend/src/__tests__/externalDeps.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { requireGitBinary, requireSshd } from './__helpers__/externalDeps'; + +describe('external dependency probes', () => { + const originalCi = process.env.CI; + + afterEach(() => { + if (originalCi === undefined) delete process.env.CI; + else process.env.CI = originalCi; + }); + + describe('requireGitBinary', () => { + it('returns true when git is present, locally or in CI', () => { + delete process.env.CI; + expect(requireGitBinary(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireGitBinary(() => true)).toBe(true); + }); + + it('returns false when git is absent locally', () => { + delete process.env.CI; + expect(requireGitBinary(() => false)).toBe(false); + }); + + it('throws when git is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/); + }); + }); + + describe('requireSshd', () => { + it('returns true when sshd is present, locally or in CI', () => { + delete process.env.CI; + expect(requireSshd(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireSshd(() => true)).toBe(true); + }); + + it('returns false when sshd is absent locally', () => { + delete process.env.CI; + expect(requireSshd(() => false)).toBe(false); + }); + + it('throws when sshd is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/); + }); + }); +}); diff --git a/backend/src/__tests__/git-private-ca.integration.test.ts b/backend/src/__tests__/git-private-ca.integration.test.ts index 1becfb67..34bd9ffb 100644 --- a/backend/src/__tests__/git-private-ca.integration.test.ts +++ b/backend/src/__tests__/git-private-ca.integration.test.ts @@ -1,7 +1,7 @@ /** * Proves per-source CA bundles work without the process-wide NODE_EXTRA_CA_CERTS bridge. */ -import { spawn, spawnSync } from 'child_process'; +import { spawn } from 'child_process'; import { promises as fs, readFileSync } from 'fs'; import https from 'https'; import os from 'os'; @@ -9,10 +9,7 @@ import path from 'path'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { nativeGitTransport } from '../services/git/nativeGitTransport'; import { buildBareRepo } from './__helpers__/gitFixture'; - -function gitAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; -} +import { requireGitBinary } from './__helpers__/externalDeps'; const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8'); @@ -70,7 +67,7 @@ function serveRepo(bareDir: string): Promise<{ url: string; close: () => void }> }); } -describe.skipIf(!gitAvailable())('per-source private CA transport (real git)', () => { +describe.skipIf(!requireGitBinary())('per-source private CA transport (real git)', () => { let repoUrl: string; let closeServer: () => void; let prevExtraCaCerts: string | undefined; diff --git a/backend/src/__tests__/git-redirect.integration.test.ts b/backend/src/__tests__/git-redirect.integration.test.ts index 05442bbc..daa5877e 100644 --- a/backend/src/__tests__/git-redirect.integration.test.ts +++ b/backend/src/__tests__/git-redirect.integration.test.ts @@ -18,6 +18,7 @@ import path from 'path'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { nativeGitTransport } from '../services/git/nativeGitTransport'; import { buildBareRepo } from './__helpers__/gitFixture'; +import { requireGitBinary } from './__helpers__/externalDeps'; const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); const CA_PEM = readFileSync(path.join(FIXTURES_DIR, 'git-ca.pem'), 'utf8'); @@ -27,10 +28,6 @@ const TLS_OPTS = { }; const GOOD_TOKEN = 'correct-horse-battery-staple'; -function gitAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; -} - interface Request { url: string; authorization: string | null } interface Fixture { @@ -133,7 +130,7 @@ async function workspace(): Promise { return dir; } -describe.skipIf(!gitAvailable())('redirect destination revalidation (real git)', () => { +describe.skipIf(!requireGitBinary())('redirect destination revalidation (real git)', () => { let bareDir: string; let headSha: string; let prevExtraCaCerts: string | undefined; diff --git a/backend/src/__tests__/git-source-http.test.ts b/backend/src/__tests__/git-source-http.test.ts index 58c5d8d3..106031ae 100644 --- a/backend/src/__tests__/git-source-http.test.ts +++ b/backend/src/__tests__/git-source-http.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { Response } from 'express'; import { gitSourceStatus, sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp'; -import { GitSourceError } from '../services/GitSourceService'; +import { GitSourceError, type GitSourceErrorCode } from '../services/GitSourceService'; describe('gitSourceStatus', () => { it('maps AUTH_FAILED to 400, never 401', () => { @@ -47,9 +47,25 @@ describe('gitSourceStatus', () => { expect(gitSourceStatus('PLAN_UNAVAILABLE')).toBe(409); }); - it('maps unknown codes to 400', () => { + it('maps GIT_ERROR to 400', () => { expect(gitSourceStatus('GIT_ERROR')).toBe(400); }); + + it('maps OPERATION_IN_FLIGHT to 409', () => { + expect(gitSourceStatus('OPERATION_IN_FLIGHT')).toBe(409); + }); + + it('has exactly one explicit mapping for every GitSourceErrorCode', () => { + const codes: GitSourceErrorCode[] = [ + 'REPO_NOT_FOUND', 'AUTH_FAILED', 'REF_NOT_FOUND', 'REF_DELETED', 'UNSUPPORTED_REF', + 'SSH_HOST_KEY_FAILED', 'FILE_NOT_FOUND', 'NETWORK_TIMEOUT', 'GIT_ERROR', 'STALE_PLAN', + 'PLAN_FINGERPRINT_REQUIRED', 'PLAN_BLOCKED', 'LEGACY_PENDING', 'PLAN_UNAVAILABLE', + 'OPERATION_IN_FLIGHT', + ]; + for (const code of codes) { + expect(typeof gitSourceStatus(code)).toBe('number'); + } + }); }); describe('webhookPullStatus', () => { diff --git a/backend/src/__tests__/git-support-matrix.test.ts b/backend/src/__tests__/git-support-matrix.test.ts new file mode 100644 index 00000000..8683fc27 --- /dev/null +++ b/backend/src/__tests__/git-support-matrix.test.ts @@ -0,0 +1,398 @@ +/** + * Validates the Git transport support matrix (docs/git-transport-support.yaml) + * against the reality it claims to describe, so a published claim can never + * silently outrun its evidence. + * + * The renderer this test imports (backend/scripts/git-support-matrix/*) + * lives outside backend's tsconfig `rootDir` (pinned to `src`), the same + * constraint git-transport-auth.integration.test.ts documents for its own + * cross-directory fixture reuse. A static `import` there would fail + * `tsc --noEmit`; `require()` at runtime does not, since TS never has to + * resolve or type-check a file it was not statically asked to include. + */ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import ts from 'typescript'; +import { gitSourceStatus } from '../utils/gitSourceHttp'; +import type { GitSourceErrorCode } from '../services/GitSourceService'; +import { resolveTestHandle } from './__helpers__/testHandleResolver'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const matrixRenderer = require('../../scripts/git-support-matrix/render'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { loadClaimSet, REPO_ROOT } = require('../../scripts/git-support-matrix/loadClaimSet'); + +const CLOSED_ENUMS = { + transport: ['https', 'ssh'], + ref: ['branch', 'tag', 'sha'], + auth: ['none', 'pat', 'deploy-key'], + host: ['github', 'gitlab', 'gitea', 'forgejo', 'bitbucket', 'generic'], + ca: ['system', 'per-source', 'not-applicable'], + node_path: ['local', 'direct-proxy', 'pilot'], + port: ['default', 'nonstandard'], + support: ['supported', 'unsupported', 'unverified'], +}; + +const REQUIRED_CLAIM_KEYS = ['id', 'transport', 'ref', 'auth', 'host', 'ca', 'node_path', 'support', 'qualifiers']; +const OPTIONAL_CLAIM_KEYS = ['port', 'evidence']; +const ALLOWED_CLAIM_KEYS = new Set([...REQUIRED_CLAIM_KEYS, ...OPTIONAL_CLAIM_KEYS]); + +interface Claim { + id: string; + transport: string; + ref: string; + auth: string; + host: string; + ca: string; + node_path: string; + port?: string; + support: string; + qualifiers: string[]; + limitations?: string[]; + evidence?: { + kind: 'automated' | 'live'; + outcome: 'success' | 'rejected'; + handles?: { file: string; title: string }[]; + attestation?: string; + }; +} + +interface Attestation { + id: string; + date: string; + source_commit: string; + sencho_image_digest?: string; + host: string; + node_path: string; + transport?: string; + ref?: string; + auth?: string; + ca?: string; + result: 'success' | 'rejected'; +} + +/** Pure schema validation: closed enums, required keys, no unknown fields, resolvable limitation refs. */ +function validateClaimSchema(claim: Record, limitationIds: Set): string[] { + const errors: string[] = []; + const keys = Object.keys(claim); + + for (const key of REQUIRED_CLAIM_KEYS) { + if (!(key in claim)) errors.push(`missing required key "${key}"`); + } + for (const key of keys) { + if (!ALLOWED_CLAIM_KEYS.has(key)) errors.push(`unknown key "${key}"`); + } + for (const [field, allowed] of Object.entries(CLOSED_ENUMS)) { + if (field === 'support' && typeof claim.support === 'string' && !allowed.includes(claim.support)) { + errors.push(`invalid support "${String(claim.support)}"`); + } else if (field in claim && field !== 'support') { + const value = (claim as Record)[field]; + if (typeof value === 'string' && !allowed.includes(value)) { + errors.push(`invalid ${field} "${value}"`); + } + } + } + if (Array.isArray(claim.limitations)) { + for (const id of claim.limitations as string[]) { + if (!limitationIds.has(id)) errors.push(`unknown limitation id "${id}"`); + } + } + return errors; +} + +/** Pure evidence-semantics validation, independent of file/AST resolution. */ +function validateClaimEvidence(claim: Claim): string[] { + const errors: string[] = []; + const { support, evidence } = claim; + + if (support === 'unverified') { + if (evidence) errors.push('unverified claim must not carry evidence'); + return errors; + } + + if (!evidence) { + errors.push(`${support} claim requires evidence`); + return errors; + } + + const expectedOutcome = support === 'supported' ? 'success' : 'rejected'; + if (evidence.outcome !== expectedOutcome) { + errors.push(`${support} claim requires evidence.outcome "${expectedOutcome}", got "${evidence.outcome}"`); + } + + if (evidence.kind === 'automated' && (!evidence.handles || evidence.handles.length === 0)) { + errors.push('automated evidence requires at least one handle'); + } + if (evidence.kind === 'live' && !evidence.attestation) { + errors.push('live evidence requires an attestation id'); + } + return errors; +} + +/** Cross-checks a live claim's dimensions and baseline against its attestation. */ +function validateLiveEvidenceIntegrity(claim: Claim, attestationsById: Map, expectedBaseline: string): string[] { + if (claim.support === 'unverified' || claim.evidence?.kind !== 'live') return []; + const errors: string[] = []; + const attestation = claim.evidence.attestation ? attestationsById.get(claim.evidence.attestation) : undefined; + + if (!attestation) { + errors.push(`claim "${claim.id}" references missing attestation "${String(claim.evidence.attestation)}"`); + return errors; + } + if (attestation.source_commit !== expectedBaseline) { + errors.push(`claim "${claim.id}"'s attestation is stale: source_commit "${attestation.source_commit}" != implementation_baseline "${expectedBaseline}"`); + } + if (attestation.host !== claim.host) errors.push(`claim "${claim.id}" host mismatch with its attestation`); + if (attestation.node_path !== claim.node_path) errors.push(`claim "${claim.id}" node_path mismatch with its attestation`); + if (attestation.transport !== undefined && attestation.transport !== claim.transport) errors.push(`claim "${claim.id}" transport mismatch with its attestation`); + if (attestation.ref !== undefined && attestation.ref !== claim.ref) errors.push(`claim "${claim.id}" ref mismatch with its attestation`); + if (attestation.auth !== undefined && attestation.auth !== claim.auth) errors.push(`claim "${claim.id}" auth mismatch with its attestation`); + if (attestation.ca !== undefined && attestation.ca !== claim.ca) errors.push(`claim "${claim.id}" ca mismatch with its attestation`); + const expectedOutcome = claim.support === 'supported' ? 'success' : 'rejected'; + if (attestation.result !== expectedOutcome) errors.push(`claim "${claim.id}" attestation result "${attestation.result}" contradicts claim support "${claim.support}"`); + return errors; +} + +function extractStringUnionMembers(filePath: string, typeName: string): string[] { + const sourceText = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); + const members: string[] = []; + const collect = (typeNode: ts.TypeNode): void => { + if (ts.isUnionTypeNode(typeNode)) { + typeNode.types.forEach(collect); + } else if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) { + members.push(typeNode.literal.text); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) { + collect(node.type); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (members.length === 0) throw new Error(`Type alias ${typeName} not found (or has no string-literal members) in ${filePath}`); + return members; +} + +describe('git transport support matrix', () => { + const { support, attestations } = loadClaimSet() as { support: { claims: Claim[]; limitations: { id: string; title: string; statement: string }[]; error_model: { code: string; label: string; status: number; meaning: string }[]; reconciliation_only_codes: string[]; implementation_baseline: string }; attestations: { attestations: Attestation[] } }; + const limitationIds = new Set(support.limitations.map((l) => l.id)); + const attestationsById = new Map(attestations.attestations.map((a) => [a.id, a])); + + describe('schema', () => { + it('every real claim is schema-valid', () => { + for (const claim of support.claims) { + expect(validateClaimSchema(claim as unknown as Record, limitationIds), claim.id).toEqual([]); + } + }); + + it('every claim id is unique', () => { + const ids = support.claims.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('rejects an invalid enum value', () => { + const bad = { ...support.claims[0], transport: 'ftp' }; + expect(validateClaimSchema(bad, limitationIds)).toContain('invalid transport "ftp"'); + }); + + it('rejects an unknown field', () => { + const bad = { ...support.claims[0], bogusField: true }; + expect(validateClaimSchema(bad, limitationIds)).toContain('unknown key "bogusField"'); + }); + + it('rejects a dangling limitation reference', () => { + const bad = { ...support.claims[0], limitations: ['does-not-exist'] }; + expect(validateClaimSchema(bad, limitationIds)).toContain('unknown limitation id "does-not-exist"'); + }); + + it('rejects a claim missing a required key', () => { + const bad = { ...support.claims[0] } as Record; + delete bad.host; + expect(validateClaimSchema(bad, limitationIds)).toContain('missing required key "host"'); + }); + }); + + describe('evidence semantics', () => { + it('every real claim satisfies evidence semantics', () => { + for (const claim of support.claims) { + expect(validateClaimEvidence(claim), claim.id).toEqual([]); + } + }); + + it('rejects a supported claim with no evidence', () => { + const bad: Claim = { ...support.claims.find((c) => c.support === 'supported')!, evidence: undefined }; + expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence'); + }); + + it('rejects an unverified claim that carries evidence', () => { + const template = support.claims.find((c) => c.support === 'supported')!; + const bad: Claim = { ...template, support: 'unverified' }; + expect(validateClaimEvidence(bad)).toContain('unverified claim must not carry evidence'); + }); + + it('rejects a supported claim whose evidence outcome is "rejected"', () => { + const template = support.claims.find((c) => c.support === 'supported' && c.evidence)!; + const bad: Claim = { ...template, evidence: { ...template.evidence!, outcome: 'rejected' } }; + expect(validateClaimEvidence(bad)).toContain('supported claim requires evidence.outcome "success", got "rejected"'); + }); + + it('rejects automated evidence with no handles', () => { + const template = support.claims.find((c) => c.evidence?.kind === 'automated')!; + const bad: Claim = { ...template, evidence: { ...template.evidence!, handles: [] } }; + expect(validateClaimEvidence(bad)).toContain('automated evidence requires at least one handle'); + }); + + it('requires an unsupported claim to carry rejection evidence, not silence', () => { + const template = support.claims.find((c) => c.support === 'supported')!; + const bad: Claim = { ...template, support: 'unsupported', evidence: undefined }; + expect(validateClaimEvidence(bad)).toContain('unsupported claim requires evidence'); + }); + }); + + describe('page binding', () => { + it('the committed MDX is byte-identical to a fresh render of the YAML', () => { + const mdxPath = path.join(REPO_ROOT, 'docs', 'features', 'git-transport-support.mdx'); + expect(matrixRenderer.renderFullMdx()).toBe(fs.readFileSync(mdxPath, 'utf8')); + }); + + it('re-rendering with a mutated claim produces different generated output', () => { + const mutated = { + support: { + ...support, + claims: support.claims.map((c, i) => (i === 0 ? { ...c, support: 'unsupported' } : c)), + }, + attestations, + }; + const original = matrixRenderer.renderGeneratedBlock({ support, attestations }); + const changed = matrixRenderer.renderGeneratedBlock(mutated); + expect(changed).not.toBe(original); + }); + }); + + describe('proof handles', () => { + const automatedClaims = support.claims.filter((c) => c.evidence?.kind === 'automated'); + + it('has at least one automated claim to check', () => { + expect(automatedClaims.length).toBeGreaterThan(0); + }); + + for (const claim of automatedClaims) { + for (const handle of claim.evidence!.handles ?? []) { + it(`resolves "${handle.title}" in ${handle.file} (claim ${claim.id})`, () => { + const absPath = path.join(REPO_ROOT, handle.file); + const sourceText = fs.readFileSync(absPath, 'utf8'); + const result = resolveTestHandle(absPath, sourceText, handle.title); + expect(result, JSON.stringify(result)).toEqual({ ok: true }); + }); + } + } + + // Mutation coverage for the resolver's found/duplicate/skip/ancestor + // logic itself lives in testHandleResolver.test.ts; this suite only + // needs to prove every real handle actually resolves. + }); + + describe('live evidence integrity', () => { + it('every real live claim (if any) is internally consistent', () => { + for (const claim of support.claims) { + expect(validateLiveEvidenceIntegrity(claim, attestationsById, support.implementation_baseline), claim.id).toEqual([]); + } + }); + + it('rejects a live claim referencing a missing attestation', () => { + const bad: Claim = { + id: 'synthetic-missing-attestation', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'does-not-exist' }, + }; + expect(validateLiveEvidenceIntegrity(bad, attestationsById, support.implementation_baseline).length).toBeGreaterThan(0); + }); + + it('rejects a live claim whose attestation baseline is stale', () => { + const fakeAttestations = new Map([ + ['att-stale', { id: 'att-stale', date: '2020-01-01', source_commit: 'deadbeef', host: 'github', node_path: 'local', result: 'success' }], + ]); + const bad: Claim = { + id: 'synthetic-stale', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'att-stale' }, + }; + const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline); + expect(errors.some((e) => e.includes('stale'))).toBe(true); + }); + + it('rejects a live claim whose node_path does not match its attestation', () => { + const fakeAttestations = new Map([ + ['att-mismatch', { id: 'att-mismatch', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'direct-proxy', result: 'success' }], + ]); + const bad: Claim = { + id: 'synthetic-mismatch', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'att-mismatch' }, + }; + const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline); + expect(errors.some((e) => e.includes('node_path mismatch'))).toBe(true); + }); + + it('rejects an attestation whose result contradicts the claim support', () => { + const fakeAttestations = new Map([ + ['att-contradict', { id: 'att-contradict', date: '2026-01-01', source_commit: support.implementation_baseline, host: 'github', node_path: 'local', result: 'rejected' }], + ]); + const bad: Claim = { + id: 'synthetic-contradict', transport: 'https', ref: 'branch', auth: 'pat', + host: 'github', ca: 'system', node_path: 'local', support: 'supported', qualifiers: [], + evidence: { kind: 'live', outcome: 'success', attestation: 'att-contradict' }, + }; + const errors = validateLiveEvidenceIntegrity(bad, fakeAttestations, support.implementation_baseline); + expect(errors.some((e) => e.includes('contradicts'))).toBe(true); + }); + }); + + describe('error model partition', () => { + const transportFacingCodes = extractStringUnionMembers( + path.join(REPO_ROOT, 'backend', 'src', 'services', 'git', 'errors.ts'), + 'TransportFacingCode', + ); + const gitSourceErrorCodes = extractStringUnionMembers( + path.join(REPO_ROOT, 'backend', 'src', 'services', 'GitSourceService.ts'), + 'GitSourceErrorCode', + ); + + it('the matrix error_model is exactly TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND', () => { + const expected = new Set([...transportFacingCodes, 'REF_DELETED', 'FILE_NOT_FOUND']); + const actual = new Set(support.error_model.map((e) => e.code)); + expect(actual).toEqual(expected); + }); + + it('reconciliation_only_codes plus the matrix error_model partitions all of GitSourceErrorCode exactly once', () => { + const matrixCodes = support.error_model.map((e) => e.code); + const reconciliationCodes: string[] = support.reconciliation_only_codes; + const combined = [...matrixCodes, ...reconciliationCodes]; + + expect(new Set(combined).size).toBe(combined.length); // no code in both sets + expect(new Set(combined)).toEqual(new Set(gitSourceErrorCodes)); // covers every code + }); + + it('every published status matches the real gitSourceStatus mapping', () => { + for (const entry of support.error_model) { + expect(gitSourceStatus(entry.code as GitSourceErrorCode), entry.code).toBe(entry.status); + } + }); + + it('rejects a matrix that leaves a code unclassified', () => { + const incomplete = support.error_model.filter((e) => e.code !== 'GIT_ERROR').map((e) => e.code); + const reconciliationCodes: string[] = support.reconciliation_only_codes; + const combined = [...incomplete, ...reconciliationCodes]; + expect(new Set(combined)).not.toEqual(new Set(gitSourceErrorCodes)); + }); + }); + + describe('rate limiting', () => { + it('is documented as a limitation, not a supported claim', () => { + expect(limitationIds.has('no-rate-limit-classification')).toBe(true); + }); + }); +}); diff --git a/backend/src/__tests__/git-transport-auth.integration.test.ts b/backend/src/__tests__/git-transport-auth.integration.test.ts index 9ef5a1f5..07e3eef9 100644 --- a/backend/src/__tests__/git-transport-auth.integration.test.ts +++ b/backend/src/__tests__/git-transport-auth.integration.test.ts @@ -26,10 +26,7 @@ import path from 'path'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { classifyGitFailure, isTransportFailure } from '../services/git/errors'; import { nativeGitTransport, verifyFastForward } from '../services/git/nativeGitTransport'; - -function gitAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; -} +import { requireGitBinary } from './__helpers__/externalDeps'; const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures'); const VALID_TOKEN = 'sencho-integration-test-token-do-not-leak'; @@ -174,7 +171,7 @@ function serveAuthedRepo(bareDir: string): Promise<{ url: string; close: () => v }); } -describe.skipIf(!gitAvailable())('authenticated native git transport (real git, real TLS, real auth)', () => { +describe.skipIf(!requireGitBinary())('authenticated native git transport (real git, real TLS, real auth)', () => { let repoUrl: string; let closeServer: () => void; let prevExtraCaCerts: string | undefined; diff --git a/backend/src/__tests__/git-transport-ssh.integration.test.ts b/backend/src/__tests__/git-transport-ssh.integration.test.ts index b1c62def..6042a81c 100644 --- a/backend/src/__tests__/git-transport-ssh.integration.test.ts +++ b/backend/src/__tests__/git-transport-ssh.integration.test.ts @@ -13,14 +13,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { classifyGitFailure, isTransportFailure } from '../services/git/errors'; import { nativeGitTransport } from '../services/git/nativeGitTransport'; import { scanHostKeys } from '../services/git/sshTrust'; - -function gitAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; -} - -function sshdAvailable(): boolean { - return spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; -} +import { requireGitBinary, requireSshd } from './__helpers__/externalDeps'; const FILE_CONTENT = 'hello from the ssh fixture repo\n'; @@ -182,7 +175,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise { +describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => { let fixture: SshGitFixture; const workspaces: string[] = []; let scratchDirs: string[] = []; @@ -296,7 +289,7 @@ describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git }); }); -describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key transport on the default SSH port', () => { +describe.skipIf(!requireGitBinary() || !requireSshd())('SSH deploy-key transport on the default SSH port', () => { let fixture: SshGitFixture; const workspaces: string[] = []; diff --git a/backend/src/__tests__/testHandleResolver.test.ts b/backend/src/__tests__/testHandleResolver.test.ts new file mode 100644 index 00000000..9a524d91 --- /dev/null +++ b/backend/src/__tests__/testHandleResolver.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTestHandle } from './__helpers__/testHandleResolver'; + +const FILE = 'fixture.test.ts'; + +describe('resolveTestHandle', () => { + it('resolves a plain it() declaration', () => { + const src = `it('does the thing', () => { expect(1).toBe(1); });`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('resolves a plain test() declaration', () => { + const src = `test('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('fails when the title is not present', () => { + const src = `it('does something else', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' }); + }); + + it('fails on a duplicate title with two runnable declarations', () => { + const src = ` + it('does the thing', () => {}); + it('does the thing', () => {}); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'duplicate' }); + }); + + it('fails when the test itself is .skip', () => { + const src = `it.skip('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails when the test itself is .todo', () => { + const src = `it.todo('does the thing');`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails when the test itself is .failing', () => { + const src = `it.failing('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails when the test itself is .skipIf', () => { + const src = `it.skipIf(true)('does the thing', () => {});`; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'skipped-directly' }); + }); + + it('fails under an unconditionally skipped describe', () => { + const src = ` + describe.skip('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('fails under a describe.runIf, approved-looking predicate or not', () => { + const src = ` + describe.runIf(requireGitBinary())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('fails under a describe.skipIf with an arbitrary, unapproved predicate', () => { + const src = ` + describe.skipIf(!someLocalCheck())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('passes under a describe.skipIf that calls the approved requireGitBinary helper', () => { + const src = ` + describe.skipIf(!requireGitBinary())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('passes under a describe.skipIf that calls the approved requireSshd helper alongside other checks', () => { + const src = ` + describe.skipIf(!requireGitBinary() || !requireSshd())('suite', () => { + it('does the thing', () => {}); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('fails when an approved outer describe.skipIf wraps an unapproved inner describe.skip', () => { + const src = ` + describe.skipIf(!requireGitBinary())('outer', () => { + describe.skip('inner', () => { + it('does the thing', () => {}); + }); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'unapproved-ancestor-skip' }); + }); + + it('resolves a title nested two levels inside approved describe.skipIf blocks', () => { + const src = ` + describe.skipIf(!requireGitBinary())('outer', () => { + describe('inner (no modifier)', () => { + it('does the thing', () => {}); + }); + }); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: true }); + }); + + it('does not resolve a title only present in a comment', () => { + const src = ` + // it('does the thing', () => {}); + it('does another thing', () => {}); + `; + expect(resolveTestHandle(FILE, src, 'does the thing')).toEqual({ ok: false, reason: 'not-found' }); + }); +}); diff --git a/backend/src/utils/gitSourceHttp.ts b/backend/src/utils/gitSourceHttp.ts index b7d6fe8f..379ce21c 100644 --- a/backend/src/utils/gitSourceHttp.ts +++ b/backend/src/utils/gitSourceHttp.ts @@ -36,8 +36,16 @@ export function gitSourceStatus(code: GitSourceErrorCode): number { return 409; case 'NETWORK_TIMEOUT': return 504; - default: + case 'GIT_ERROR': return 400; + default: { + // Exhaustiveness guard: if a new code is added to the union without a + // case here, this becomes a compile error instead of silently mapping + // to 400. + const _exhaustive: never = code; + void _exhaustive; + return 400; + } } } diff --git a/docs/docs.json b/docs/docs.json index 287d3f24..3f2ecfe8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -135,6 +135,7 @@ "features/health-gated-updates", "features/deploy-enforcement", "features/git-sources", + "features/git-transport-support", "features/blueprint-model", "features/scheduled-operations", "features/auto-update-policies", diff --git a/docs/features/git-sources.mdx b/docs/features/git-sources.mdx index eb1fc4a5..3434ed2d 100644 --- a/docs/features/git-sources.mdx +++ b/docs/features/git-sources.mdx @@ -9,6 +9,8 @@ Git Sources turn any stack into a GitOps target. Point Sencho at a repository an Git Sources are available on every tier, including Community. +For exactly which transports, ref types, authentication methods, and Git hosts are supported today, with the evidence behind each, see [Git Transport Support](/features/git-transport-support). + ## How it works 1. Open a stack and click the **Git Source** button in the editor toolbar. diff --git a/docs/features/git-transport-support.mdx b/docs/features/git-transport-support.mdx new file mode 100644 index 00000000..20d04c95 --- /dev/null +++ b/docs/features/git-transport-support.mdx @@ -0,0 +1,82 @@ +--- +title: Git Transport Support +sidebarTitle: Transport Support +description: Which transports, reference types, authentication methods, and Git hosts Git Sources supports, with the evidence behind each claim. +--- + + + Git Sources, and everything on this page, is available on every tier, including Community. + + +This page states exactly what Git Sources supports today: which transports, reference types, authentication methods, TLS trust modes, and Git hosts, and what happens where a combination is not supported or not yet verified. For how to configure a Git source, see [Git Sources](/features/git-sources). + +Every "Supported" row here is backed by a test that runs on every change to Sencho, or by a dated pass against a real instance of that host. A row marked "Not yet verified" is not a claim of failure: it means that specific combination has not been exercised yet, so it is not advertised as working. Nothing on this page is inferred from a related combination that behaved correctly. + + + +## Transports + +| Transport | Status | Notes | +| --- | --- | --- | +| HTTPS | Supported | Personal Access Token for private repositories, or no credential at all for public ones. TLS verification uses the system trust store by default, or a per-source custom CA when configured. | +| SSH | Supported | A read-only deploy key with strict host-key verification. Standard (22) and nonstandard ports are both supported. | + +## Reference types + +| Reference type | Status | Notes | +| --- | --- | --- | +| Branch | Supported | Tracks the head of a branch; each pull resolves and pins the exact commit. | +| Tag | Supported | Both annotated and lightweight tags resolve to their target commit. | +| Commit SHA | Supported | A full commit SHA is pinned directly; the Git host must advertise the commit on some branch or tag. | + +## Authentication + +| Method | Status | Notes | +| --- | --- | --- | +| Public (no auth) | Supported | For public repositories. | +| Personal Access Token | Supported | Stored encrypted at rest, never returned after save. | +| SSH deploy key | Supported | Stored encrypted at rest; the server host key is verified on every fetch. | + +## Git hosts + +| Host | HTTPS | SSH | Branch | Tag | Commit SHA | Evidence | +| --- | --- | --- | --- | --- | --- | --- | +| Generic (self-hosted or any Git server) | Supported | Supported | Supported | Supported | Supported | Automated, every change | +| GitHub | Supported | Not yet verified | Supported | Not yet verified | Supported | Live, 2026-09-01 | +| GitLab | Supported | Not yet verified | Supported | Supported | Supported | Live, 2026-09-01 | +| Gitea | Supported | Supported | Supported | Supported | Supported | Live, 2026-09-01 | +| Forgejo | Supported | Supported | Supported | Supported | Supported | Live, 2026-09-01 | +| Bitbucket | Supported | Not yet verified | Supported | Not yet verified | Supported | Live, 2026-09-01 | + +## TLS and certificate authorities + +| Mode | Status | Notes | +| --- | --- | --- | +| System trust (default) | Supported | The host running the fetch trusts its system certificate store. | +| Per-source custom CA | Supported | Combined with the system trust anchors, so public hosts keep validating normally. Redirects are re-resolved and only followed when they stay on the source's own host. | + +## Not supported + +- **No rate-limit classification.** A Git host rate-limit response (for example GitHub's secondary rate limits) is not classified as its own error state. It surfaces as an authentication failure or a generic transport error depending on the host's exact response. Wait and retry; there is no dedicated rate-limit message or backoff guidance yet. +- **No Git LFS.** Compose and env files tracked via Git LFS are rejected rather than silently fetched as pointer stubs. Commit plain files instead. +- **No submodules.** Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when .gitmodules is present. +- **No sparse or partial clone.** Every fetch materializes the complete repository at the resolved commit (shallow, single-branch); there is no sparse or partial clone for large monorepos. +- **No GitHub App authentication.** Authentication is Personal Access Token or SSH deploy key only. GitHub App installation tokens are not supported. +- **No provider pull/merge request revisions.** Sources track a branch, a tag, or a pinned commit SHA. A provider-specific pull request or merge request revision (for example GitHub's refs/pull/N/head) is not a supported ref shape. +- **Outbound target restrictions.** Repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses are refused before any request is sent. Private hosts on an operator's own LAN or VPN are not affected by this restriction. +- **Branch/tag name collisions.** A bare ref name resolves as a branch first, then as a tag. If an operator renames a branch and a tag of the same name later appears, the source silently starts resolving the tag instead. A ref name that is also a valid 40 or 64 character hex string resolves as a commit SHA before either lookup. + + + +## How these claims are verified + +Two kinds of evidence back the rows above: + +- **Automated.** A real `git` client, talking to a real local test server over HTTPS or SSH, drives the exact same code path Sencho uses in production. These tests run on every change, so a regression here fails the build before it reaches a release. +- **Live.** A dated pass against a real instance of the named host (a public GitHub, GitLab, Gitea, Forgejo, or Bitbucket repository, or a self-hosted instance under Sencho's control). This is repeated periodically, not on every change, so its date tells you how current the result is. + +A host that is not listed, or a combination marked "Not yet verified," most likely still works: Git Sources speaks the standard Git smart-HTTP and SSH protocols, not anything host-specific. It simply has not been exercised as its own row yet. + + + Configure a repository, review pull previews, and read the field-by-field reference for every setting mentioned above. + diff --git a/docs/git-transport-attestations.yaml b/docs/git-transport-attestations.yaml new file mode 100644 index 00000000..57a2c573 --- /dev/null +++ b/docs/git-transport-attestations.yaml @@ -0,0 +1,365 @@ +# Retained live-evidence results for docs/git-transport-support.yaml claims +# with `evidence.kind: live`. +# +# This is the reproducibility artifact for every non-automated claim: a +# future engineer (or SEN-362's GA verification) can re-run the exact +# procedure in scripts/git-attestation/README.md and compare against what is +# recorded here. Never record raw credential-bearing output, tokens, deploy +# keys, hostnames or URLs beyond generic fixture identities, or fleet +# credentials: only structured pass/fail metadata and scrubbed commands. +# +# Schema (schema: attestation-v1): +# id: referenced by a claim's evidence.attestation. +# date: when the attestation ran (YYYY-MM-DD). +# source_commit: the Sencho commit the claim set's implementation_baseline +# names; a claim binds to this, not to the runtime image digest below. +# sencho_image_digest: the exact runtime image executed. Recorded because +# a runtime attestation executes an image while claims are committed +# from a revision; retained so the executed runtime is identifiable even +# though it is not what claims bind to. +# host: the closed enum value matching the claim (github, gitlab, gitea, +# forgejo, bitbucket, generic); never a descriptive string. +# host_version: optional, self-hosted only: the exact image reference run. +# Omitted for hosted SaaS, where inventing a server version would be false. +# node_path: local | direct-proxy | pilot: the execution path exercised. +# transport / ref / auth / ca: duplicated from the referencing claim so the +# validator can assert exact-dimension equality, not extrapolation. +# repository / ref_name: the exact fixture repository and literal ref +# exercised (a branch, tag, or commit, not the ref *kind*). +# command: a scrubbed, non-credential-bearing description of what ran. +# result: success | rejected, matching the claim's evidence.outcome. + +version: '1' +schema: 'attestation-v1' + +attestations: + # ===== Named hosts, public read-only (HTTPS, no auth) ===== + - id: att-2026-09-01-github-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: local + transport: https + ref: branch + auth: none + ca: system + repository: octocat/Hello-World (GitHub's own public demo repository) + ref_name: master + command: POST /api/git-sources/browse against the real Sencho instance; branch tip resolved and fetched + result: success + + - id: att-2026-09-01-github-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: local + transport: https + ref: sha + auth: none + ca: system + repository: octocat/Hello-World + ref_name: 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d (master tip) + command: POST /api/git-sources/browse pinning the branch tip's own commit SHA + result: success + + - id: att-2026-09-01-gitlab-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitlab + node_path: local + transport: https + ref: branch + auth: none + ca: system + repository: gitlab-org/gitlab-test (GitLab's own canonical test fixture repository) + ref_name: master + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitlab-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitlab + node_path: local + transport: https + ref: tag + auth: none + ca: system + repository: gitlab-org/gitlab-test + ref_name: v1.0.0 (annotated) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitlab-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitlab + node_path: local + transport: https + ref: sha + auth: none + ca: system + repository: gitlab-org/gitlab-test + ref_name: 6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9 (v1.0.0's peeled commit) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-bitbucket-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: bitbucket + node_path: local + transport: https + ref: branch + auth: none + ca: system + repository: atlassian_tutorial/helloworld (Atlassian's own public tutorial repository) + ref_name: master + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-bitbucket-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: bitbucket + node_path: local + transport: https + ref: sha + auth: none + ca: system + repository: atlassian_tutorial/helloworld + ref_name: 65d938f39f364da3f90767e008022ffe45c562af (master tip) + command: POST /api/git-sources/browse + result: success + + # ===== Self-hosted Gitea, own throwaway instance on the QA fleet ===== + - id: att-2026-09-01-gitea-https-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: https + ref: branch + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Gitea instance, torn down after this pass + ref_name: main + command: POST /api/git-sources/browse with a per-source CA bundle and a Personal Access Token + result: success + + - id: att-2026-09-01-gitea-https-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: https + ref: tag + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: v1.0 (annotated) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitea-https-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: https + ref: sha + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitea-ssh-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: ssh + ref: branch + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: main + command: POST /api/git-sources/browse with a read-only deploy key and the host key fetched via Sencho's own probe endpoint + result: success + + - id: att-2026-09-01-gitea-ssh-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: ssh + ref: tag + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: v1.0-light (lightweight) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-gitea-ssh-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: gitea + host_version: gitea/gitea:1.22 + node_path: local + transport: ssh + ref: sha + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Gitea instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + # ===== Self-hosted Forgejo, own throwaway instance on the QA fleet ===== + - id: att-2026-09-01-forgejo-https-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: https + ref: branch + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Forgejo instance, torn down after this pass + ref_name: main + command: POST /api/git-sources/browse with a per-source CA bundle and a Personal Access Token + result: success + + - id: att-2026-09-01-forgejo-https-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: https + ref: tag + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: v1.0 (annotated) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-forgejo-https-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: https + ref: sha + auth: pat + ca: per-source + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-forgejo-ssh-branch + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: ssh + ref: branch + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: main + command: POST /api/git-sources/browse with a read-only deploy key and the host key fetched via Sencho's own probe endpoint + result: success + + - id: att-2026-09-01-forgejo-ssh-tag + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: ssh + ref: tag + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: v1.0-light (lightweight) + command: POST /api/git-sources/browse + result: success + + - id: att-2026-09-01-forgejo-ssh-sha + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: forgejo + host_version: codeberg.org/forgejo/forgejo:7 + node_path: local + transport: ssh + ref: sha + auth: deploy-key + ca: not-applicable + repository: throwaway fixture repository on a disposable Forgejo instance + ref_name: pinned commit SHA (branch tip) + command: POST /api/git-sources/browse + result: success + + # ===== Distribution: direct-proxy and Pilot node paths ===== + - id: att-2026-09-01-github-direct-proxy + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: direct-proxy + transport: https + ref: branch + auth: none + ca: system + repository: octocat/Hello-World + ref_name: master + command: POST /api/git-sources/browse with x-node-id targeting a remote proxy-mode fleet node + result: success + + - id: att-2026-09-01-github-pilot + date: '2026-09-01' + source_commit: 79b86ddcd4aefdd6941f098e35990ab397b13c72 + sencho_image_digest: sha256:980531f85861e092b7eee8a5b6a840d3964ccc6db25c42517c0ac43fefe67e16 + host: github + node_path: pilot + transport: https + ref: branch + auth: none + ca: system + repository: octocat/Hello-World + ref_name: master + command: POST /api/git-sources/browse with x-node-id targeting a Pilot-agent fleet node + result: success diff --git a/docs/git-transport-support.yaml b/docs/git-transport-support.yaml new file mode 100644 index 00000000..d2fedfeb --- /dev/null +++ b/docs/git-transport-support.yaml @@ -0,0 +1,631 @@ +# Git transport support matrix: canonical source of truth (SEN-9 PR 5). +# +# Every entry here is a claim about one exercised combination, not an +# independently-tested dimension: a PAT test, a tag test, and a GitLab +# observation do not jointly prove "GitLab + PAT + tag" unless something +# actually ran that exact combination. `docs/features/git-transport-support.mdx` +# is generated from this file (backend/scripts/git-support-matrix/render.js); +# a backend test (git-support-matrix.test.ts) enforces byte-for-byte parity +# and validates every rule below. Internal test paths, PR numbers, and Linear +# IDs never appear on the published page; they live here instead. +# +# Schema (schema: matrix-v1): +# implementation_baseline: the git commit this file's claims describe. +# claims[]: one entry per exercised (transport, ref, auth, host, ca, +# node_path[, port]) combination. +# - support: supported | unsupported | unverified +# - supported requires evidence with outcome: success +# - unsupported requires evidence with outcome: rejected (a reproducible +# refusal, not silence; an unsupported claim with no evidence is as +# unreproducible as an unproven supported one) +# - unverified forbids evidence entirely +# - evidence.kind: automated (a handle into a test file + exact test +# title) or live (a pointer into docs/git-transport-attestations.yaml). +# Every evidence record repeats all six combination dimensions, and the +# validator requires them to match the claim exactly: a direct-proxy +# result proves the direct-proxy combination, nothing else. +# limitations[]: named gaps, each with its operator-facing consequence. +# error_model[]: the transport-facing error codes this matrix covers +# (TransportFacingCode plus REF_DELETED and FILE_NOT_FOUND), each with +# its HTTP status and one-line meaning. GitOps plan-lifecycle codes +# (STALE_PLAN, PLAN_FINGERPRINT_REQUIRED, PLAN_BLOCKED, LEGACY_PENDING, +# PLAN_UNAVAILABLE, OPERATION_IN_FLIGHT) belong to reconciliation, not +# transport, and are listed separately so the partition is visible. +# attestations: pointer to the retained live-evidence file. + +version: '1' +schema: 'matrix-v1' +implementation_baseline: 79b86ddcd4aefdd6941f098e35990ab397b13c72 +attestations: docs/git-transport-attestations.yaml + +claims: + # ===== Automated: real git, real TLS/SSH, against a local fixture server ===== + # host: generic, node_path: local for all of these: no proxy hop, no + # branded host. Branded-host and non-local node-path claims live below, + # pending or drawn from the live attestation pass. + + - id: https-pat-branch-system-generic-local + transport: https + ref: branch + auth: pat + host: generic + ca: system + node_path: local + support: supported + qualifiers: + - History rewritten on this ref after resolution is detected and rejected as non-fast-forward, not silently deployed. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: clones a private repo end-to-end with a valid token + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: treats a linear branch advance as a fast-forward + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: treats a multi-commit branch advance as a fast-forward + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: rejects rewritten history as non-fast-forward + + - id: https-pat-tag-system-generic-local + transport: https + ref: tag + auth: pat + host: generic + ca: system + node_path: local + support: supported + qualifiers: + - Proven for both an annotated tag (peeled through its ^{} commit) and a lightweight tag. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: resolves and fetches an annotated tag through the peeled commit + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: resolves and fetches a lightweight tag + + - id: https-pat-sha-system-generic-local + transport: https + ref: sha + auth: pat + host: generic + ca: system + node_path: local + support: supported + qualifiers: [] + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-auth.integration.test.ts + title: resolves and fetches a pinned commit SHA + + - id: https-none-branch-persource-generic-local + transport: https + ref: branch + auth: none + host: generic + ca: per-source + node_path: local + support: supported + qualifiers: + - Also proven to survive a same-origin redirect (the repository relocating on the same host). + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-private-ca.integration.test.ts + title: clones a private-CA HTTPS repo when the per-source CA PEM is supplied + - file: backend/src/__tests__/git-redirect.integration.test.ts + title: resolves a ref through an unauthenticated same-host redirect + + - id: https-pat-branch-persource-generic-local + transport: https + ref: branch + auth: pat + host: generic + ca: per-source + node_path: local + support: supported + qualifiers: + - Proven through a same-origin redirect; the token is forwarded to the relocated path and never offered to a different host. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-redirect.integration.test.ts + title: resolves a ref through an authenticated same-host redirect and sends the token to the relocated path + + - id: ssh-deploy-key-branch-na-generic-local-nonstandard-port + transport: ssh + ref: branch + auth: deploy-key + host: generic + ca: not-applicable + node_path: local + port: nonstandard + support: supported + qualifiers: + - Full round trip (resolve, fetch, and content verification) proven at this port. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-ssh.integration.test.ts + title: resolves and fetches over SSH with a deploy key and trusted host key + - file: backend/src/__tests__/git-transport-ssh.integration.test.ts + title: 'resolves over ssh:// with a nonstandard port' + + - id: ssh-deploy-key-branch-na-generic-local-default-port + transport: ssh + ref: branch + auth: deploy-key + host: generic + ca: not-applicable + node_path: local + port: default + support: supported + qualifiers: + - Ref resolution proven at the default port; the full content fetch is proven only at a nonstandard port (see the sibling claim), not separately re-run here. + evidence: + kind: automated + outcome: success + handles: + - file: backend/src/__tests__/git-transport-ssh.integration.test.ts + title: resolves over scp-style URL on the default SSH port + + # ===== Live: named Git hosts, external SaaS and self-hosted ===== + # Evidence lives in docs/git-transport-attestations.yaml. A row stays + # `unverified` (no evidence permitted) until a live pass actually exercises + # it; nothing here is extrapolated from a different host or node_path. + # Live-attested 2026-09-01 (see the attestation ids referenced below). + + - id: https-none-branch-system-github-local + transport: https + ref: branch + auth: none + host: github + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-branch + + - id: https-none-tag-system-github-local + transport: https + ref: tag + auth: none + host: github + ca: system + node_path: local + support: unverified + qualifiers: + - Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation. + - Not attested: no small, stable, publicly tagged GitHub repository was found for this pass within a reasonable search. + + - id: https-none-sha-system-github-local + transport: https + ref: sha + auth: none + host: github + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitHub does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-sha + + - id: https-pat-branch-system-github-local + transport: https + ref: branch + auth: pat + host: github + ca: system + node_path: local + support: unverified + qualifiers: + - Not attested: requires a real GitHub Personal Access Token, which this pass does not hold. + + - id: ssh-deploy-key-branch-na-github-local + transport: ssh + ref: branch + auth: deploy-key + host: github + ca: not-applicable + node_path: local + support: unverified + qualifiers: + - Not attested: requires a real GitHub-registered SSH deploy key, which this pass does not hold. + + - id: https-pat-branch-persource-gitea-local + transport: https + ref: branch + auth: pat + host: gitea + ca: per-source + node_path: local + support: supported + qualifiers: + - Attested against a private repository on a disposable, self-signed Gitea instance; the self-signed certificate is trusted via the per-source custom CA field, not system trust. + - A wrong token against this private repository was separately confirmed to classify as an authentication failure, and a wrong SSH host key as a host-key mismatch. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-https-branch + + - id: https-pat-tag-persource-gitea-local + transport: https + ref: tag + auth: pat + host: gitea + ca: per-source + node_path: local + support: supported + qualifiers: + - Annotated tag, resolved through its peeled commit. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-https-tag + + - id: https-pat-sha-persource-gitea-local + transport: https + ref: sha + auth: pat + host: gitea + ca: per-source + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-https-sha + + - id: ssh-deploy-key-branch-na-gitea-local + transport: ssh + ref: branch + auth: deploy-key + host: gitea + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Host key fetched and trusted through Sencho's own probe endpoint, exactly as an operator would. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-ssh-branch + + - id: ssh-deploy-key-tag-na-gitea-local + transport: ssh + ref: tag + auth: deploy-key + host: gitea + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Lightweight tag. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-ssh-tag + + - id: ssh-deploy-key-sha-na-gitea-local + transport: ssh + ref: sha + auth: deploy-key + host: gitea + ca: not-applicable + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitea-ssh-sha + + - id: https-pat-branch-persource-forgejo-local + transport: https + ref: branch + auth: pat + host: forgejo + ca: per-source + node_path: local + support: supported + qualifiers: + - Attested against a private repository on a disposable, self-signed Forgejo instance; the self-signed certificate is trusted via the per-source custom CA field, not system trust. + - A wrong token against this private repository was separately confirmed to classify as an authentication failure, and a wrong SSH host key as a host-key mismatch. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-https-branch + + - id: https-pat-tag-persource-forgejo-local + transport: https + ref: tag + auth: pat + host: forgejo + ca: per-source + node_path: local + support: supported + qualifiers: + - Annotated tag, resolved through its peeled commit. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-https-tag + + - id: https-pat-sha-persource-forgejo-local + transport: https + ref: sha + auth: pat + host: forgejo + ca: per-source + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-https-sha + + - id: ssh-deploy-key-branch-na-forgejo-local + transport: ssh + ref: branch + auth: deploy-key + host: forgejo + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Host key fetched and trusted through Sencho's own probe endpoint, exactly as an operator would. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-ssh-branch + + - id: ssh-deploy-key-tag-na-forgejo-local + transport: ssh + ref: tag + auth: deploy-key + host: forgejo + ca: not-applicable + node_path: local + support: supported + qualifiers: + - Lightweight tag. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-ssh-tag + + - id: ssh-deploy-key-sha-na-forgejo-local + transport: ssh + ref: sha + auth: deploy-key + host: forgejo + ca: not-applicable + node_path: local + support: supported + qualifiers: [] + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-forgejo-ssh-sha + + - id: https-none-branch-system-gitlab-local + transport: https + ref: branch + auth: none + host: gitlab + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitlab-branch + + - id: https-none-tag-system-gitlab-local + transport: https + ref: tag + auth: none + host: gitlab + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitlab-tag + + - id: https-none-sha-system-gitlab-local + transport: https + ref: sha + auth: none + host: gitlab + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; GitLab does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-gitlab-sha + + - id: https-none-branch-system-bitbucket-local + transport: https + ref: branch + auth: none + host: bitbucket + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-bitbucket-branch + + - id: https-none-tag-system-bitbucket-local + transport: https + ref: tag + auth: none + host: bitbucket + ca: system + node_path: local + support: unverified + qualifiers: + - Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation. + - Not attested: the public fixture repository used for this pass carries no tags. + + - id: https-none-sha-system-bitbucket-local + transport: https + ref: sha + auth: none + host: bitbucket + ca: system + node_path: local + support: supported + qualifiers: + - Only a public, read-only repository is exercised; Bitbucket does not receive a token or an SSH deploy key from this attestation. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-bitbucket-sha + + # ===== Distribution: does the same combination hold off the local node ===== + # Scoped to host: github (a real branded host reachable over public egress) + # rather than the self-hosted fixtures above: the fleet's inbound firewall + # only opens ports 22 and 1852, so a remote or Pilot node cannot reach a + # container port newly published on the hub, but every node has unrestricted + # outbound egress to the public internet. + + - id: https-none-branch-system-github-direct-proxy + transport: https + ref: branch + auth: none + host: github + ca: system + node_path: direct-proxy + support: supported + qualifiers: + - Proves the direct-proxy path forwards and executes the fetch on the target node; it does not by itself prove any other host, ref, auth, or CA combination on this path. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-direct-proxy + + - id: https-none-branch-system-github-pilot + transport: https + ref: branch + auth: none + host: github + ca: system + node_path: pilot + support: supported + qualifiers: + - Proves the Pilot dial-out path forwards and executes the fetch on the target node; it does not by itself prove any other host, ref, auth, or CA combination on this path. + evidence: + kind: live + outcome: success + attestation: att-2026-09-01-github-pilot + +limitations: + - id: no-rate-limit-classification + title: No rate-limit classification + statement: A Git host rate-limit response (for example GitHub's secondary rate limits) is not classified as its own error state. It surfaces as an authentication failure or a generic transport error depending on the host's exact response. Wait and retry; there is no dedicated rate-limit message or backoff guidance yet. + + - id: no-git-lfs + title: No Git LFS + statement: Compose and env files tracked via Git LFS are rejected rather than silently fetched as pointer stubs. Commit plain files instead. + + - id: no-submodules + title: No submodules + statement: Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when .gitmodules is present. + + - id: no-sparse-partial-clone + title: No sparse or partial clone + statement: Every fetch materializes the complete repository at the resolved commit (shallow, single-branch); there is no sparse or partial clone for large monorepos. + + - id: no-github-app-authentication + title: No GitHub App authentication + statement: Authentication is Personal Access Token or SSH deploy key only. GitHub App installation tokens are not supported. + + - id: no-provider-pull-request-revisions + title: No provider pull/merge request revisions + statement: Sources track a branch, a tag, or a pinned commit SHA. A provider-specific pull request or merge request revision (for example GitHub's refs/pull/N/head) is not a supported ref shape. + + - id: outbound-target-restrictions + title: Outbound target restrictions + statement: Repository targets that resolve to loopback, link-local, multicast, or selected special-use addresses are refused before any request is sent. Private hosts on an operator's own LAN or VPN are not affected by this restriction. + + - id: branch-tag-name-collision + title: Branch/tag name collisions + statement: A bare ref name resolves as a branch first, then as a tag. If an operator renames a branch and a tag of the same name later appears, the source silently starts resolving the tag instead. A ref name that is also a valid 40 or 64 character hex string resolves as a commit SHA before either lookup. + +error_model: + - code: REPO_NOT_FOUND + label: Repository not found + status: 404 + meaning: The repository does not exist, or (indistinguishably, matching GitHub's own private-repo masking) exists but is private and no usable credential was supplied. + - code: AUTH_FAILED + label: Authentication failed + status: 400 + meaning: The Git host rejected the supplied credential. Mapped to 400, never 401, so an upstream Git-host auth failure never triggers the dashboard's own session logout. + - code: SSH_HOST_KEY_FAILED + label: SSH host key mismatch + status: 400 + meaning: The server's SSH host key does not match the fingerprint trusted for this source. + - code: REF_NOT_FOUND + label: Ref not found + status: 404 + meaning: The configured branch, tag, or commit SHA does not exist on the remote. + - code: REF_DELETED + label: Ref deleted or rewritten + status: 404 + meaning: A ref that previously resolved to a commit no longer matches that history (deleted, force-pushed, or superseded by a same-named tag). + - code: UNSUPPORTED_REF + label: Commit not reachable on this host + status: 400 + meaning: A pinned commit SHA that the Git host will not serve because it is not advertised by any branch or tag tip. + - code: NETWORK_TIMEOUT + label: Network timeout + status: 504 + meaning: A connect, fetch, or DNS-resolution timeout, or a target the host actively refused. + - code: GIT_ERROR + label: Git error + status: 400 + meaning: Any other transport failure not covered above (invalid URL, disallowed target, TLS/certificate problem, oversized repository, canceled fetch), classified with a specific operator-facing message. + - code: FILE_NOT_FOUND + label: File not found + status: 404 + meaning: A configured compose or env file path does not exist on the resolved commit. + +reconciliation_only_codes: + # Not part of this transport matrix; listed so the partition of + # GitSourceErrorCode is visible and auditable. Owned by the GitOps + # change-plan lifecycle, not by repository transport. + - STALE_PLAN + - PLAN_FINGERPRINT_REQUIRED + - PLAN_BLOCKED + - LEGACY_PENDING + - PLAN_UNAVAILABLE + - OPERATION_IN_FLIGHT diff --git a/e2e/external-deps.spec.ts b/e2e/external-deps.spec.ts new file mode 100644 index 00000000..402848f7 --- /dev/null +++ b/e2e/external-deps.spec.ts @@ -0,0 +1,49 @@ +/** + * Unit coverage for the shared dependency probes in externalDeps.ts. + * + * Not a browser test: these run against the probe functions directly with an + * injected predicate, so absence of git/sshd can be exercised without + * removing system binaries. + */ +import { test, expect } from '@playwright/test'; +import { requireGitBinary, requireSshd } from './externalDeps'; + +test.describe('external dependency probes', () => { + test.afterEach(() => { + delete process.env.CI; + }); + + test('requireGitBinary returns true when git is present, locally or in CI', () => { + delete process.env.CI; + expect(requireGitBinary(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireGitBinary(() => true)).toBe(true); + }); + + test('requireGitBinary returns false when git is absent locally', () => { + delete process.env.CI; + expect(requireGitBinary(() => false)).toBe(false); + }); + + test('requireGitBinary throws when git is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireGitBinary(() => false)).toThrow(/git is required in CI/); + }); + + test('requireSshd returns true when sshd is present, locally or in CI', () => { + delete process.env.CI; + expect(requireSshd(() => true)).toBe(true); + process.env.CI = '1'; + expect(requireSshd(() => true)).toBe(true); + }); + + test('requireSshd returns false when sshd is absent locally', () => { + delete process.env.CI; + expect(requireSshd(() => false)).toBe(false); + }); + + test('requireSshd throws when sshd is absent under CI', () => { + process.env.CI = '1'; + expect(() => requireSshd(() => false)).toThrow(/sshd is required in CI/); + }); +}); diff --git a/e2e/externalDeps.ts b/e2e/externalDeps.ts new file mode 100644 index 00000000..80fb4bd6 --- /dev/null +++ b/e2e/externalDeps.ts @@ -0,0 +1,49 @@ +/** + * Shared availability probes for the real-git and real-sshd E2E fixtures. + * + * Mirrors backend/src/__tests__/__helpers__/externalDeps.ts. Kept as a + * separate file rather than a shared import: backend's tsconfig pins + * `rootDir` to backend/src, so a cross-directory import would fail + * `tsc --noEmit` there. + * + * `gitServer.helper.ts` and `sshGit.helper.ts` used to each probe with their + * own local `spawnSync` check and let a missing dependency silently skip the + * spec, in CI as well as locally. These wrappers keep that local-dev + * behavior but throw under CI, where the dependency is expected to be + * present and a skip would be a false claim of coverage. + */ +import { spawnSync } from 'child_process'; + +export type DependencyProbe = () => boolean; + +export const defaultGitProbe: DependencyProbe = () => + spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; + +export const defaultSshdProbe: DependencyProbe = () => + spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; + +function requireDependency(name: string, hint: string, probe: DependencyProbe): boolean { + const present = probe(); + if (!present && process.env.CI) { + throw new Error(`${name} is required in CI but was not found. ${hint}`); + } + return present; +} + +/** True when the system `git` binary is available; throws under CI if not. */ +export function requireGitBinary(probe: DependencyProbe = defaultGitProbe): boolean { + return requireDependency( + 'git', + 'Ensure the CI image installs the git CLI before running the E2E suite.', + probe, + ); +} + +/** True when a local `sshd` binary is available; throws under CI if not. */ +export function requireSshd(probe: DependencyProbe = defaultSshdProbe): boolean { + return requireDependency( + 'sshd', + 'Ensure the CI image installs openssh-server and frees loopback port 22 (see .github/workflows/ci.yml).', + probe, + ); +} diff --git a/e2e/gitServer.helper.ts b/e2e/gitServer.helper.ts index 57780968..900adac2 100644 --- a/e2e/gitServer.helper.ts +++ b/e2e/gitServer.helper.ts @@ -12,17 +12,18 @@ * NODE_EXTRA_CA_CERTS (wired in CI and in the local validation lifecycle). * The key is a throwaway test certificate with no security value. * - * Soft-skips when the system git binary is unavailable. + * Soft-skips when the system git binary is unavailable (locally; throws + * under CI, see externalDeps.ts). */ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import https from 'https'; import os from 'os'; import path from 'path'; +import { requireGitBinary } from './externalDeps'; export function gitAvailable(): boolean { - const probe = spawnSync('git', ['--version'], { stdio: 'ignore' }); - return probe.status === 0; + return requireGitBinary(); } /** Build a git repository with the given files on `branch`, returns the repo dir. */ diff --git a/e2e/sshGit.helper.ts b/e2e/sshGit.helper.ts index 405841f9..f4ff4f7f 100644 --- a/e2e/sshGit.helper.ts +++ b/e2e/sshGit.helper.ts @@ -10,10 +10,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; +import { requireGitBinary, requireSshd } from './externalDeps'; export function sshGitFixtureAvailable(): boolean { - return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0 - && spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; + return requireGitBinary() && requireSshd(); } const COMPOSE_FIXTURE = `services: diff --git a/package.json b/package.json index dc05a27a..4737ba3d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "test": "cd backend && npm test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", + "matrix:render": "cd backend && npm run matrix:render", "catalog:validate": "node scripts/website-catalog/canonical-validate.mjs", "catalog:sync": "node scripts/website-catalog/sync-feature-catalog.mjs", "catalog:drift": "node scripts/website-catalog/check-website-drift.mjs",