Files
sencho/backend/src/__tests__/git-transport-auth.integration.test.ts
T
Anso 48f010475b feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch (#1864)
* feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch

The ref model now resolves a configured branch, tag, or full commit SHA to
an immutable commit before any content is downloaded, and records both the
configured and the resolved identity where revision state persists.

- RefKind (branch | tag | sha) is a resolved property, not caller-asserted.
  A bare string resolves branch-first, then tag; a full 40/64-hex SHA
  self-resolves with no remote round-trip. Branch and tag both fetch via a
  bare --branch name; a SHA uses init + shallow fetch + detached checkout.
- A single ls-remote with narrow heads/tags refspecs pins the configured ref
  to an immutable SHA; rev-parse HEAD must equal the resolved SHA or the
  fetch refuses (tip-changed) instead of materializing unreviewed content.
- Error union grew: REF_NOT_FOUND (ref-neutral, replaces BRANCH_NOT_FOUND),
  UNSUPPORTED_REF (a pinned SHA the host will not serve), and a service-level
  REF_DELETED upgrade that fires when a classified REF_NOT_FOUND occurs for a
  source with prior fetch history (a vanished ref reads as delete/force-push,
  not a fresh typo). Status mapping: REF_NOT_FOUND/REF_DELETED to 404,
  UNSUPPORTED_REF to 400.
- Configured-vs-resolved identity is recorded via a nullable resolved_ref_kind
  column on gitops_generations (added to CREATE TABLE and re-added for legacy
  installs through maybeAddCol). The kind is deliberately NOT in the plan
  fingerprint: two sources naming the same commit differently are the same plan.

Docs updated (git-sources feature page, connect-a-git-source tutorial, and the
native-git-transport internal deep-dive) to the ref-neutral naming.

* fix(gitops): harden ref resolution after pre-merge audit

Request peeled annotated-tag refs from ls-remote, detect force-pushes and
ref-kind changes against prior fetch identity, persist resolved kind on
application rows, and add real-git tag/SHA integration coverage plus
ref-neutral UI and operator docs.

* test(gitops): mock verifyFastForward in direct producer suite

The producer tests stub the transport seam but were missing resolved kind
on resolveRef and a verifyFastForward stub, so second pulls tripped the new
ref-continuity checks as REF_DELETED.

* test(git): remove unused buildBareFixtureRepo helper

Fixes backend lint failure after the integration fixture was refactored
to buildRichFixtureRepo without dropping the old wrapper.

* fix(gitops): correct fast-forward ancestry verification under size bounds

Replace the dual shallow-fetch ancestry probe with a single-tip deepen
strategy, keep verifier Git work inside the transport watchdog, and add
real-Git regression coverage for linear advances and rewritten history.

* fix(gitops): bound fast-forward verification with exponential deepen

Replace per-commit deepen loops with exponential steps, cap remote fetch
rounds, and share one deadline across verifier Git calls. Budget exhaustion
now surfaces as a classified timeout instead of REF_DELETED.

* fix(gitops): classify fast-forward probe failures accurately

Normalize verifier probe timeouts and unexpected exit codes into transport
failures, interpret merge-base status 1 as proven non-ancestry only, and
treat shallow stagnation as timeout instead of REF_DELETED.

* fix(gitops): satisfy tsc on probeFailure never returns

* fix(gitops): address Phase E QA findings on ref verification

Remove the fast-forward scratch repo after verification so pull size
caps are not inflated, classify GitHub not-our-ref as UNSUPPORTED_REF,
persist fetched_resolved_ref_kind on create-from-git, and broaden
REF_DELETED copy for retagged tags.
2026-08-28 16:15:37 -04:00

463 lines
19 KiB
TypeScript

/**
* Real end-to-end coverage for authenticated native-git transport.
*
* Every other transport test mocks `child_process` or bypasses the
* transport module entirely, so nothing proves the credential helper, the
* `x-access-token` username convention, argv quoting, and env-var handoff
* actually work against a real git binary talking to a server that checks
* Basic Auth. This file does: a local HTTPS smart-HTTP server that requires
* a token and rejects everything else, driven through the real
* `nativeGitTransport` with nothing mocked.
*
* Reuses the committed dev-only TLS fixture from the Git Sources E2E specs
* (e2e/fixtures/git-ca.pem / git-server.pem|key) via direct file reads
* rather than importing e2e/gitServer.helper.ts: backend's tsconfig pins
* rootDir to backend/src, so a cross-directory import would fail `tsc
* --noEmit`.
*
* Soft-skips when the system git binary is unavailable, mirroring the E2E
* fixture server's own skip.
*/
import { spawn, spawnSync } from 'child_process';
import { promises as fs, mkdtempSync, readFileSync, writeFileSync } from 'fs';
import https from 'https';
import os from 'os';
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;
}
const FIXTURES_DIR = path.resolve(__dirname, '..', '..', '..', 'e2e', 'fixtures');
const VALID_TOKEN = 'sencho-integration-test-token-do-not-leak';
const FILE_CONTENT = 'hello from the authenticated fixture repo\n';
interface RichFixtureRepo {
bare: { bareDir: string; scratchDirs: string[] };
mainSha: string;
annotatedTagSha: string;
lightweightTagSha: string;
pinnedSha: string;
chainTipSha: string;
rewrittenSha: string;
}
function buildRichFixtureRepo(): RichFixtureRepo {
const srcDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-git-auth-src-'));
writeFileSync(path.join(srcDir, 'hello.txt'), FILE_CONTENT);
const run = (args: string[]) => {
const r = spawnSync('git', args, { cwd: srcDir, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${r.stderr}`);
return r.stdout.trim();
};
run(['init', '-b', 'main']);
run(['config', 'user.email', 'integration-test@sencho.test']);
run(['config', 'user.name', 'Sencho Integration Test']);
run(['add', '-A']);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']);
const mainSha = run(['rev-parse', 'HEAD']);
run(['tag', '-a', 'v-annotated', '-m', 'annotated release']);
const annotatedTagSha = run(['rev-parse', 'v-annotated^{commit}']);
run(['tag', 'v-light']);
const lightweightTagSha = run(['rev-parse', 'v-light']);
writeFileSync(path.join(srcDir, 'second.txt'), 'second fixture file\n');
run(['add', 'second.txt']);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'second']);
const pinnedSha = run(['rev-parse', 'HEAD']);
run(['checkout', 'main']);
for (let i = 3; i <= 5; i += 1) {
writeFileSync(path.join(srcDir, `chain-${i}.txt`), `chain file ${i}\n`);
run(['add', `chain-${i}.txt`]);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', `chain-${i}`]);
}
const chainTipSha = run(['rev-parse', 'HEAD']);
run(['checkout', '--orphan', 'rewritten']);
writeFileSync(path.join(srcDir, 'rewritten.txt'), 'rewritten history\n');
run(['add', 'rewritten.txt']);
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'rewritten']);
const rewrittenSha = run(['rev-parse', 'HEAD']);
const bareRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-git-auth-bare-'));
const bareDir = path.join(bareRoot, 'repo.git');
const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' });
if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`);
const pushRewritten = spawnSync(
'git',
['push', bareDir, 'rewritten:refs/heads/rewritten'],
{ cwd: srcDir, encoding: 'utf8' },
);
if (pushRewritten.status !== 0) {
throw new Error(`git push rewritten main failed: ${pushRewritten.stderr}`);
}
return {
bare: { bareDir, scratchDirs: [srcDir, bareRoot] },
mainSha,
annotatedTagSha,
lightweightTagSha,
pinnedSha,
chainTipSha,
rewrittenSha,
};
}
/** Serve one bare repo over HTTPS smart-HTTP, rejecting any request without a valid Basic Auth token. */
function serveAuthedRepo(bareDir: string): Promise<{ url: string; close: () => void }> {
return new Promise((resolve, reject) => {
const expectedAuth = `Basic ${Buffer.from(`x-access-token:${VALID_TOKEN}`).toString('base64')}`;
const server = https.createServer(
{
cert: readFileSync(path.join(FIXTURES_DIR, 'git-server.pem')),
key: readFileSync(path.join(FIXTURES_DIR, 'git-server.key')),
},
(req, res) => {
if (req.headers.authorization !== expectedAuth) {
res.statusCode = 401;
res.setHeader('WWW-Authenticate', 'Basic realm="sencho-integration-test"');
res.end('authentication required');
return;
}
const url = req.url ?? '/';
if (!url.startsWith('/repo.git/')) {
res.statusCode = 404;
res.end('unknown repo');
return;
}
const pathname = url.slice('/repo.git'.length).split('?')[0];
if (pathname === '/info/refs' && (req.method === 'GET' || req.method === 'POST')) {
const ps = spawn('git', ['upload-pack', '--stateless-rpc', '--advertise-refs', bareDir]);
let out = Buffer.alloc(0);
ps.stdout.on('data', (d: Buffer) => {
out = Buffer.concat([out, d]);
});
ps.on('close', (code) => {
if (code !== 0) {
res.statusCode = 500;
res.end('git upload-pack failed');
return;
}
res.setHeader('content-type', 'application/x-git-upload-pack-advertisement');
res.end(Buffer.concat([Buffer.from('001e# service=git-upload-pack\n0000'), out]));
});
return;
}
if (pathname === '/git-upload-pack' && req.method === 'POST') {
const ps = spawn('git', ['upload-pack', '--stateless-rpc', bareDir]);
res.setHeader('content-type', 'application/x-git-upload-pack-result');
ps.stdout.pipe(res);
ps.stdin.on('error', (err) => {
// EPIPE/ECONNRESET: the client aborted mid-stream.
// Anything else is a real bug in this fixture server.
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'EPIPE' && code !== 'ECONNRESET') throw err;
});
req.pipe(ps.stdin);
return;
}
res.statusCode = 404;
res.end('unsupported git endpoint');
},
);
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('server did not bind'));
return;
}
resolve({ url: `https://127.0.0.1:${address.port}/repo.git`, close: () => server.close() });
});
});
}
describe.skipIf(!gitAvailable())('authenticated native git transport (real git, real TLS, real auth)', () => {
let repoUrl: string;
let closeServer: () => void;
let prevExtraCaCerts: string | undefined;
let fixtureScratchDirs: string[] = [];
let fixture: RichFixtureRepo;
const workspaces: string[] = [];
beforeAll(async () => {
fixture = buildRichFixtureRepo();
fixtureScratchDirs = fixture.bare.scratchDirs;
const served = await serveAuthedRepo(fixture.bare.bareDir);
repoUrl = served.url;
closeServer = served.close;
prevExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS;
process.env.NODE_EXTRA_CA_CERTS = path.join(FIXTURES_DIR, 'git-ca.pem');
});
afterAll(async () => {
closeServer?.();
if (prevExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
else process.env.NODE_EXTRA_CA_CERTS = prevExtraCaCerts;
await Promise.all(fixtureScratchDirs.map((d) => fs.rm(d, { recursive: true, force: true })));
});
afterEach(async () => {
await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true })));
});
async function makeWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-auth-ws-'));
workspaces.push(dir);
return dir;
}
/**
* A workspace nested under a directory whose name contains a space, plus
* the other characters git's shell treats specially. Git reads
* `credential.helper` as a shell string, so a transport that interpolates
* the helper's path into it breaks here (and on any host whose temp dir
* sits under something like `C:/Users/Ada Lovelace/...`) while passing
* every normal-path test.
*/
async function makeAwkwardWorkspace(): Promise<string> {
const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-git-auth-odd-'));
workspaces.push(parent);
const dir = path.join(parent, "a dir with spaces & 'quotes' $dollar");
await fs.mkdir(dir);
return dir;
}
it('clones a private repo end-to-end with a valid token', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'main',
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved.commitSha).toMatch(/^[0-9a-f]{40}$/);
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'main',
token: VALID_TOKEN,
refKind: 'branch',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(resolved.commitSha);
const content = await fs.readFile(path.join(fetched.dir, 'hello.txt'), 'utf8');
expect(content).toBe(FILE_CONTENT);
});
it('clones a private repo end-to-end from a workspace path containing spaces and shell metacharacters', async () => {
const workspaceRoot = await makeAwkwardWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'main',
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved.commitSha).toMatch(/^[0-9a-f]{40}$/);
const fetchWorkspace = await makeAwkwardWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'main',
token: VALID_TOKEN,
refKind: 'branch',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(resolved.commitSha);
expect(await fs.readFile(path.join(fetched.dir, 'hello.txt'), 'utf8')).toBe(FILE_CONTENT);
});
it('still classifies a wrong token as AUTH_FAILED from an awkward workspace path', async () => {
// Guards the subtler half of the same defect: when the helper cannot
// execute, git sends no credentials at all and the server's 401 reads
// like an anonymous request, so the failure silently downgrades to the
// private-repo masking classification instead of AUTH_FAILED.
const workspaceRoot = await makeAwkwardWorkspace();
const failure = await nativeGitTransport
.resolveRef({ repoUrl, ref: 'main', token: 'wrong-token', timeoutMs: 15_000, workspaceRoot })
.then(() => null, (e: unknown) => e);
expect(isTransportFailure(failure)).toBe(true);
if (!isTransportFailure(failure)) throw new Error('unreachable');
expect(failure.hasToken).toBe(true);
expect(classifyGitFailure(failure).code).toBe('AUTH_FAILED');
});
it('fails with the private-repo masking classification when no token is supplied', async () => {
const workspaceRoot = await makeWorkspace();
const failure = await nativeGitTransport
.resolveRef({ repoUrl, ref: 'main', timeoutMs: 15_000, workspaceRoot })
.then(() => null, (e: unknown) => e);
expect(isTransportFailure(failure)).toBe(true);
if (!isTransportFailure(failure)) throw new Error('unreachable');
expect(failure.hasToken).toBe(false);
expect(classifyGitFailure(failure).code).toBe('REPO_NOT_FOUND');
});
it('fails with AUTH_FAILED when an invalid token is supplied, and never leaks it', async () => {
const wrongToken = 'this-token-is-wrong-and-must-never-appear-in-output';
const workspaceRoot = await makeWorkspace();
const failure = await nativeGitTransport
.resolveRef({ repoUrl, ref: 'main', token: wrongToken, timeoutMs: 15_000, workspaceRoot })
.then(() => null, (e: unknown) => e);
expect(isTransportFailure(failure)).toBe(true);
if (!isTransportFailure(failure)) throw new Error('unreachable');
expect(failure.hasToken).toBe(true);
const classified = classifyGitFailure(failure);
expect(classified.code).toBe('AUTH_FAILED');
const serialized = JSON.stringify(failure) + classified.message;
expect(serialized).not.toContain(wrongToken);
expect(serialized).not.toContain(VALID_TOKEN);
});
it('resolves and fetches an annotated tag through the peeled commit', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'v-annotated',
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved).toMatchObject({ commitSha: fixture.annotatedTagSha, kind: 'tag' });
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'v-annotated',
token: VALID_TOKEN,
refKind: 'tag',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(fixture.annotatedTagSha);
expect(await fs.readFile(path.join(fetched.dir, 'hello.txt'), 'utf8')).toBe(FILE_CONTENT);
});
it('resolves and fetches a lightweight tag', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: 'v-light',
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved).toMatchObject({ commitSha: fixture.lightweightTagSha, kind: 'tag' });
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: 'v-light',
token: VALID_TOKEN,
refKind: 'tag',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(fixture.lightweightTagSha);
});
it('resolves and fetches a pinned commit SHA', async () => {
const workspaceRoot = await makeWorkspace();
const resolved = await nativeGitTransport.resolveRef({
repoUrl,
ref: fixture.chainTipSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
});
expect(resolved).toMatchObject({ commitSha: fixture.chainTipSha, kind: 'sha' });
const fetchWorkspace = await makeWorkspace();
const fetched = await nativeGitTransport.fetchAtCommit({
repoUrl,
ref: fixture.chainTipSha,
token: VALID_TOKEN,
refKind: 'sha',
commitSha: resolved.commitSha,
timeoutMs: 15_000,
workspaceRoot: fetchWorkspace,
maxBytes: 10 * 1024 * 1024,
});
expect(fetched.commitSha).toBe(fixture.chainTipSha);
expect(await fs.readFile(path.join(fetched.dir, 'chain-5.txt'), 'utf8')).toBe('chain file 5\n');
});
it('treats a linear branch advance as a fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const fastForward = await verifyFastForward({
repoUrl,
ancestorSha: fixture.pinnedSha,
descendantSha: fixture.chainTipSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
});
expect(fastForward).toBe(true);
});
it('treats a multi-commit branch advance as a fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const fastForward = await verifyFastForward({
repoUrl,
ancestorSha: fixture.mainSha,
descendantSha: fixture.chainTipSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
});
expect(fastForward).toBe(true);
});
it('rejects rewritten history as non-fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const fastForward = await verifyFastForward({
repoUrl,
ancestorSha: fixture.mainSha,
descendantSha: fixture.rewrittenSha,
token: VALID_TOKEN,
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
});
expect(fastForward).toBe(false);
});
it('classifies verifyFastForward auth failures without collapsing to non-fast-forward', async () => {
const workspaceRoot = await makeWorkspace();
const failure = await verifyFastForward({
repoUrl,
ancestorSha: fixture.mainSha,
descendantSha: fixture.chainTipSha,
token: 'wrong-token',
timeoutMs: 15_000,
workspaceRoot,
maxBytes: 10 * 1024 * 1024,
}).then(() => null, (e: unknown) => e);
expect(isTransportFailure(failure)).toBe(true);
if (!isTransportFailure(failure)) throw new Error('unreachable');
expect(classifyGitFailure(failure).code).toBe('AUTH_FAILED');
});
});