mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
48f010475b
* 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.
463 lines
19 KiB
TypeScript
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');
|
|
});
|
|
});
|