mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 22:25:30 +00:00
feat(git): swap isomorphic-git for native git transport behind clone seam (#1849)
* feat(git): swap isomorphic-git for native git transport behind clone seam Replace the isomorphic-git engine (HTTP-only, single importer) with the native git CLI behind the existing withClonedRepo seam, so SSH deploy keys, ref semantics, and private CAs become reachable in later PRs. - resolve-before-fetch: ls-remote pins the branch to an immutable SHA, then rev-parse verifies the checkout against it; tip races refuse - hardened spawns: argv arrays only, protocol allowlist (https only), neutralized hooks, isolated HOME and all config channels, no prompts - token reaches git only via a credential helper reading SENCHO_GIT_TOKEN from the child env; never argv or URL - size cap becomes a workspace watchdog (on-disk measure) keeping the same knob and breach message; deterministic final gate added - Windows: pin http.sslBackend=openssl (schannel ignores sslCAInfo) and anchor to Git's bundled CA; NODE_EXTRA_CA_CERTS combines with platform defaults instead of replacing them - error classification retargets to exit code + stderr while preserving the contractual mappings (AUTH_FAILED maps to 400, never 401; unauthenticated refusals mask as REPO_NOT_FOUND) - runtime image installs git; tests re-pointed at the transport boundary plus a new engine suite (classifier corpus, argv hardening, watchdog) Zero externally visible behavior change except two edge cases: an empty branch now surfaces BRANCH_NOT_FOUND, and a mid-fetch force push refuses instead of materializing the moved tip. * fix(git): unblock CI on linux kill-path test and codeql log warning Two CI-only findings from the first pipeline run: - The scripted spawn child in the transport tests lacked the kill method that killTree's POSIX fallback reaches when a fake process group does not exist; Linux runs crashed inside the timeout tests while Windows (taskkill branch) could not reproduce it. Give the fixture the method the real ChildProcess always has. - CodeQL flagged the workspace-removal warning that interpolated the NODE_EXTRA_CA_CERTS path (environment-sourced values are treated as sensitive at log sinks). Reword the warning to name the variable instead of its value; operators know their own environment. * fix(git): collapse remaining duplicated test setup so the shared helper is used * fix(git): close watchdog, size-gate, ref-validator, and kill-ordering gaps in native transport Resolves the release-blocking findings from an independent pre-merge audit of the native git transport swap: - A watchdog-triggered kill mid-clone was misclassified as a generic exit failure instead of a size breach, because runGit resolves (not rejects) when the child is killed via SIGKILL. - The final on-disk size measurement failed open when it could not be read (workspace removed mid-walk, permissions), letting an unmeasured clone through as a success. Now fails closed and logs the real cause. - The ref-name validator was an overly restrictive allow-list that rejected valid branch names (leading underscore, non-ASCII, '#'). Replaced with a deny-list matching real `git check-ref-format --branch` semantics, verified against the git binary, including a per-path-segment `.lock` check the first pass missed. - runGit's timeout handler settled as soon as a kill was issued rather than confirmed, racing workspace cleanup against a still-alive child tree. It now waits for the child's close event, with a bounded fallback if termination is never confirmed, and preserves the timeout classification if 'error' fires after the kill. - Windows killTree now also falls back to child.kill() when taskkill itself exits non-zero, not just when it fails to spawn. - Added a real, non-mocked integration test that drives the credential helper through the actual git binary against a local HTTPS server with Basic Auth checking. It caught a genuine bug the mocked suite could not see: the credential.helper config value was quoted in a way that broke git's own absolute-path helper detection, failing every authenticated clone. Fixed by removing the quotes. - Migrated a separately developed test file's mocks off the deleted isomorphic-git module onto the native transport seam, matching the pattern already used elsewhere, after merging with main pulled in that feature. Also updates two stale comments left over from the isomorphic-git era and adds a git version check to the Docker runtime image smoke tests. * fix(git): make credential-helper path safe, unify ref length, and fix Windows kill ordering Addresses three PR 1 correction items from pre-merge audit: - credential.helper is a shell string, not argv: interpolating the helper's workspace-relative path broke authenticated fetches whenever the workspace sat under a directory with a space in its name. The config value is now a fixed string that names an environment variable instead, so no workspace path character can affect how git's shell parses it. - The transport rejected branch names over 200 characters while the route accepted up to 256 and real git has no comparable limit. REF_MAX_LEN is now a single exported constant shared by the transport and both routes. - On Windows, taskkill runs as a separate process and could still be walking a killed process tree after the direct git child reported closed, letting the caller delete the workspace early. Kill operations are now awaited to completion (bounded by a timeout) before a timed-out or size-breached run settles, on both the close and error event paths. Verified against a real authenticated git server inside the built runtime image: public HTTPS, private HTTPS with a valid PAT, invalid PAT, a deleted branch, an oversized repository, and the awkward workspace-path case, including from a workspace path containing spaces and shell metacharacters. * fix(git): reap killed helpers and classify curl refusals
This commit is contained in:
@@ -18,6 +18,7 @@ import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
|
||||
@@ -244,6 +245,21 @@ describe('PUT /api/stacks/:stackName/git-source — max-length caps', () => {
|
||||
expect(res.body.error).toMatch(/branch/i);
|
||||
});
|
||||
|
||||
it('does not reject a branch at the transport limit as too long', async () => {
|
||||
// The route and the transport share one bound, so a branch the route
|
||||
// stores is always one the transport will still fetch. This asserts
|
||||
// the shared side of that: at the limit, length is not the objection.
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
...baseBody,
|
||||
repo_url: 'https://github.com/example/repo.git',
|
||||
branch: 'b'.repeat(REF_MAX_LEN),
|
||||
});
|
||||
expect(String(res.body?.error ?? '')).not.toMatch(/too long/i);
|
||||
});
|
||||
|
||||
it('rejects oversized compose_path', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/existing-stack/git-source')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - validateCompose YAML pre-check (empty / non-object / syntax error)
|
||||
* - Token round-trip via upsert: encryption, has_token projection, undefined/null/empty/non-empty semantics
|
||||
* - Apply-matrix rejection (auto_deploy requires auto_apply)
|
||||
* - Error code mapping from isomorphic-git failures (REPO_NOT_FOUND, AUTH_FAILED, BRANCH_NOT_FOUND, NETWORK_TIMEOUT)
|
||||
* - Error code mapping from native-git transport failures (REPO_NOT_FOUND, AUTH_FAILED, BRANCH_NOT_FOUND, NETWORK_TIMEOUT)
|
||||
* - Credential scrubbing in surfaced error messages
|
||||
* - Pending state lifecycle (setPending -> apply clears -> dismissPending clears)
|
||||
* - Webhook debounce enforcement
|
||||
@@ -17,6 +17,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import type { TransportFailure } from '../services/git/errors';
|
||||
import { GitOpsStore } from '../services/gitops/store';
|
||||
import { GitOpsTransitions } from '../services/gitops/transitions';
|
||||
import {
|
||||
@@ -28,17 +29,22 @@ import {
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockGitClone, mockGitLog } = vi.hoisted(() => ({
|
||||
const { mockResolveRef, mockFetchAtCommit, mockGitClone, mockGitLog } = vi.hoisted(() => ({
|
||||
mockResolveRef: vi.fn(),
|
||||
mockFetchAtCommit: vi.fn(),
|
||||
mockGitClone: vi.fn(),
|
||||
mockGitLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('isomorphic-git', () => {
|
||||
const api = { clone: mockGitClone, log: mockGitLog };
|
||||
return { default: api, clone: mockGitClone, log: mockGitLog };
|
||||
});
|
||||
|
||||
vi.mock('isomorphic-git/http/node', () => ({ default: {} }));
|
||||
// The transport boundary is what gets mocked. mockGitClone/mockGitLog remain
|
||||
// as the fixture layer so every per-test override keeps its meaning: clone
|
||||
// writes files into the checkout dir, log yields the deterministic sha.
|
||||
vi.mock('../services/git/nativeGitTransport', () => ({
|
||||
nativeGitTransport: {
|
||||
resolveRef: mockResolveRef,
|
||||
fetchAtCommit: mockFetchAtCommit,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const {
|
||||
@@ -94,8 +100,11 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolveRef.mockReset();
|
||||
mockFetchAtCommit.mockReset();
|
||||
mockGitClone.mockReset();
|
||||
mockGitLog.mockReset();
|
||||
wireTransportDefaults();
|
||||
mockCaptureCandidate.mockReset();
|
||||
mockCaptureCandidate.mockImplementation(async () => ({ id: 'rec-test-1' }));
|
||||
mockRecoveryAbandon.mockReset();
|
||||
@@ -121,9 +130,46 @@ beforeEach(() => {
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stub out isomorphic-git so that `clone` writes a minimal compose file into
|
||||
* the caller's temp dir and `log` returns a deterministic commit sha. Returns
|
||||
* the sha so tests can compare.
|
||||
* Default transport wiring: resolveRef defers to the log stub so per-test
|
||||
* overrides of mockGitLog keep controlling the final SHA, and fetchAtCommit
|
||||
* delegates to the clone/log fixture fns, handing clone a `dir` that points
|
||||
* at the workspace checkout.
|
||||
*/
|
||||
function wireTransportDefaults(): void {
|
||||
mockResolveRef.mockImplementation(async () => {
|
||||
const log = await mockGitLog({});
|
||||
const oid = Array.isArray(log) ? log[0]?.oid : undefined;
|
||||
return { commitSha: oid ?? '' };
|
||||
});
|
||||
mockFetchAtCommit.mockImplementation(async (req: { workspaceRoot: string; commitSha: string }) => {
|
||||
const path = await import('path');
|
||||
const { promises: fsp } = await import('fs');
|
||||
const dir = path.join(req.workspaceRoot, 'repo');
|
||||
// The real clone creates the checkout dir; fixture impls may not.
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
await mockGitClone({ ...req, dir });
|
||||
const log = await mockGitLog({ dir });
|
||||
if (!Array.isArray(log) || !log.length) {
|
||||
// An empty branch produces no remote ref; mirror the structured
|
||||
// failure the real transport raises for that case.
|
||||
throw { transportFailure: true as const, reason: 'ref-not-found', host: 'unknown', hasToken: false };
|
||||
}
|
||||
return { commitSha: log[0].oid, dir };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured transport failure carrying a real-world git stderr sample, for
|
||||
* exercising the service's classification of native-git failures.
|
||||
*/
|
||||
function gitFailure(stderr: string, hasToken: boolean): TransportFailure {
|
||||
return { transportFailure: true as const, reason: 'exit', stderr, exitCode: 128, host: 'github.com', hasToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub out the clone/log fixtures so that `clone` writes a minimal compose
|
||||
* file into the checkout dir and `log` returns a deterministic commit sha.
|
||||
* Returns the sha so tests can compare.
|
||||
*/
|
||||
function mockSuccessfulClone(options: {
|
||||
compose?: string;
|
||||
@@ -445,7 +491,10 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
|
||||
});
|
||||
|
||||
it('does not persist when dry-run fetch fails', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('404 not found'), { code: 'NotFoundError' }));
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: repository 'https://github.com/example/nope.git/' not found",
|
||||
false,
|
||||
));
|
||||
const svc = GitSourceService.getInstance();
|
||||
await expect(svc.upsert({
|
||||
stackName: 'unreachable',
|
||||
@@ -598,123 +647,159 @@ describe('GitSourceService error mapping', () => {
|
||||
composePaths: ['compose.yaml'],
|
||||
};
|
||||
|
||||
it('maps 401 with supplied token to AUTH_FAILED', async () => {
|
||||
// A 401 only means "your token is wrong" when the caller actually sent one.
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 401 Unauthorized'), {
|
||||
code: 'HttpError',
|
||||
data: { statusCode: 401 },
|
||||
}));
|
||||
it('maps an authentication refusal with supplied token to AUTH_FAILED', async () => {
|
||||
// Auth failure only means "your token is wrong" when the caller actually sent one.
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: Authentication failed for 'https://github.com/example/repo.git/'",
|
||||
true,
|
||||
));
|
||||
await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' }))
|
||||
.rejects.toMatchObject({ code: 'AUTH_FAILED' });
|
||||
});
|
||||
|
||||
it('maps 401 without a token to REPO_NOT_FOUND with a private-repo hint', async () => {
|
||||
// GitHub returns 404 for genuinely missing public repos but 401/403 can
|
||||
// also reach us for private repos that the caller did not authenticate
|
||||
// to. Without a supplied token, "check your token" is misleading, so we
|
||||
// surface it as "not found or private" and suggest adding a PAT.
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 401 Unauthorized'), {
|
||||
code: 'HttpError',
|
||||
data: { statusCode: 401 },
|
||||
}));
|
||||
it('maps a credential prompt without a token to REPO_NOT_FOUND with a private-repo hint', async () => {
|
||||
// Private repos demand credentials; without a supplied token,
|
||||
// "check your token" is misleading, so we surface it as "not found or
|
||||
// private" and suggest adding a PAT (GitHub masks private repos too).
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: could not read Username for 'https://github.com/example/repo.git': terminal prompts disabled",
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams))
|
||||
.rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/private/i) });
|
||||
});
|
||||
|
||||
it('maps 404 HttpError to REPO_NOT_FOUND (not AUTH_FAILED)', async () => {
|
||||
// Regression: isomorphic-git throws HttpError for every non-2xx, so a
|
||||
// 404 on info/refs was previously misclassified as auth failure.
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 404 Not Found'), {
|
||||
code: 'HttpError',
|
||||
data: { statusCode: 404 },
|
||||
}));
|
||||
it('maps repository-not-found to REPO_NOT_FOUND (not AUTH_FAILED)', async () => {
|
||||
// Regression guard: a missing repo must never read as an auth problem.
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: repository 'https://github.com/example/repo.git/' not found",
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams))
|
||||
.rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/private/i) });
|
||||
});
|
||||
|
||||
it('maps 404 with a supplied token to REPO_NOT_FOUND with a token-scope hint', async () => {
|
||||
// GitHub returns 404 for both "missing repo" and "token lacks access",
|
||||
// so when the caller did supply a token we point them at URL + scopes
|
||||
// instead of "add a PAT" (which they already did).
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 404 Not Found'), {
|
||||
code: 'HttpError',
|
||||
data: { statusCode: 404 },
|
||||
}));
|
||||
it('maps repository-not-found with a supplied token to REPO_NOT_FOUND with a token-scope hint', async () => {
|
||||
// GitHub returns not-found for both "missing repo" and "token lacks
|
||||
// access", so when the caller did supply a token we point them at URL
|
||||
// + scopes instead of "add a PAT" (which they already did).
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: repository 'https://github.com/example/repo.git/' not found",
|
||||
true,
|
||||
));
|
||||
await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' }))
|
||||
.rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/token has read access/i) });
|
||||
});
|
||||
|
||||
it('maps 404/not-found errors to REPO_NOT_FOUND', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('Repository not found'), { code: 'NotFoundError' }));
|
||||
it('classifies resolve-phase failures too (ls-remote runs before clone)', async () => {
|
||||
// The first real-world failure point is resolution; if the service
|
||||
// ever stops translating its failures this goes generic GIT_ERROR.
|
||||
mockResolveRef.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: Authentication failed for 'https://github.com/example/repo.git/'",
|
||||
true,
|
||||
));
|
||||
await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' }))
|
||||
.rejects.toMatchObject({ code: 'AUTH_FAILED' });
|
||||
});
|
||||
|
||||
it('threads the resolved commit, ref, and token into the pinned fetch', async () => {
|
||||
const sha = mockSuccessfulClone();
|
||||
await svc().fetchFromGit({ ...fetchParams, token: 'tok-abc' });
|
||||
expect(mockFetchAtCommit.mock.calls[0][0]).toMatchObject({
|
||||
commitSha: sha,
|
||||
ref: 'main',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
token: 'tok-abc',
|
||||
workspaceRoot: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the transport workspace after success and after failure', async () => {
|
||||
const fsMod = await import('fs');
|
||||
mockSuccessfulClone();
|
||||
await svc().fetchFromGit(fetchParams);
|
||||
const successRoot = mockFetchAtCommit.mock.calls[0][0].workspaceRoot;
|
||||
expect(fsMod.existsSync(successRoot)).toBe(false);
|
||||
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure('fatal: repository not found', false));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
|
||||
const failureRoot = mockFetchAtCommit.mock.calls[1][0].workspaceRoot;
|
||||
expect(fsMod.existsSync(failureRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports BRANCH_NOT_FOUND for a branch with no commits', async () => {
|
||||
// Resolve-first turns an empty branch into a missing remote head.
|
||||
mockGitLog.mockResolvedValue([]);
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({
|
||||
code: 'BRANCH_NOT_FOUND',
|
||||
message: expect.stringMatching(/Branch not found/),
|
||||
});
|
||||
});
|
||||
|
||||
it('maps short not-found phrasing to REPO_NOT_FOUND', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure('fatal: repository not found', false));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('maps resolve-ref errors to BRANCH_NOT_FOUND', async () => {
|
||||
// Message phrased to miss the REPO_NOT_FOUND regex ("could not resolve")
|
||||
// so the BRANCH_NOT_FOUND branch is exercised.
|
||||
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('unknown ref nonexistent'), { code: 'ResolveRefError' }));
|
||||
it('maps remote-branch-not-found to BRANCH_NOT_FOUND', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
'fatal: Remote branch nonexistent not found in upstream origin',
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'BRANCH_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('maps timeout errors to NETWORK_TIMEOUT', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(new Error('ETIMEDOUT connecting to host'));
|
||||
it('maps connection timeouts to NETWORK_TIMEOUT', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: unable to access 'https://github.com/example/repo.git/': Failed to connect to github.com port 443 after 21005 ms: Connection timed out",
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
||||
});
|
||||
|
||||
it('maps a bare "fetch failed" TypeError with an ENOTFOUND cause to NETWORK_TIMEOUT', async () => {
|
||||
// Node's global fetch() reports DNS failure as TypeError('fetch failed')
|
||||
// with the real reason on err.cause. Without cause-unwrapping this fell
|
||||
// through to a useless GIT_ERROR: "fetch failed".
|
||||
mockGitClone.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('getaddrinfo ENOTFOUND github.com'), { code: 'ENOTFOUND' }),
|
||||
}),
|
||||
);
|
||||
it('maps DNS failure stderr to NETWORK_TIMEOUT', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: unable to access 'https://github.com/example/repo.git/': Could not resolve host: github.com",
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
||||
});
|
||||
|
||||
it('maps a "fetch failed" TypeError with an ECONNREFUSED cause to NETWORK_TIMEOUT', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { code: 'ECONNREFUSED' }),
|
||||
}),
|
||||
);
|
||||
it('maps connection-refused stderr to NETWORK_TIMEOUT', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: unable to access 'https://github.com/example/repo.git/': Failed to connect to github.com port 443: Connection refused",
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
||||
});
|
||||
|
||||
it('surfaces the host instead of bare "fetch failed" in transport errors', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
|
||||
}),
|
||||
);
|
||||
it('surfaces the host in DNS transport errors', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: unable to access 'https://github.com/example/repo.git/': Could not resolve host: github.com",
|
||||
false,
|
||||
));
|
||||
try {
|
||||
await svc().fetchFromGit(fetchParams);
|
||||
expect.fail('should have thrown');
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
expect(err.message).not.toMatch(/^fetch failed$/i);
|
||||
expect(err.message).toContain('github.com');
|
||||
}
|
||||
});
|
||||
|
||||
it('unwraps a nested fetch cause chain to find the transport code', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: new TypeError('terminated', {
|
||||
cause: Object.assign(new Error('reset'), { code: 'ECONNRESET' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
it('maps a reset connection to NETWORK_TIMEOUT', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
'fatal: the remote end hung up unexpectedly',
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
||||
});
|
||||
|
||||
it('maps a TLS certificate "fetch failed" cause to a certificate GIT_ERROR', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('self-signed certificate'), { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' }),
|
||||
}),
|
||||
);
|
||||
it('maps a TLS certificate failure to a certificate GIT_ERROR', async () => {
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: unable to access 'https://github.com/example/repo.git/': SSL certificate problem: self-signed certificate",
|
||||
false,
|
||||
));
|
||||
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({
|
||||
code: 'GIT_ERROR',
|
||||
message: expect.stringMatching(/certificate/i),
|
||||
@@ -731,7 +816,10 @@ describe('GitSourceService error mapping', () => {
|
||||
});
|
||||
|
||||
it('scrubs inline credentials from surfaced error messages', async () => {
|
||||
mockGitClone.mockRejectedValueOnce(new Error('Failed: https://user:supersecret@github.com/example/repo.git 500'));
|
||||
mockFetchAtCommit.mockRejectedValueOnce(gitFailure(
|
||||
"fatal: unable to access 'https://user:supersecret@github.com/example/repo.git/': The requested URL returned error: 500",
|
||||
false,
|
||||
));
|
||||
try {
|
||||
await svc().fetchFromGit(fetchParams);
|
||||
expect.fail('should have thrown');
|
||||
@@ -743,42 +831,6 @@ describe('GitSourceService error mapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('countingBodyIterator (clone size cap)', () => {
|
||||
function chunkStream(...sizes: number[]): AsyncIterableIterator<Uint8Array> {
|
||||
async function* gen(): AsyncIterableIterator<Uint8Array> {
|
||||
for (const s of sizes) yield new Uint8Array(s);
|
||||
}
|
||||
return gen();
|
||||
}
|
||||
|
||||
it('passes chunks through unchanged while under the cap', async () => {
|
||||
const { countingBodyIterator } = await import('../services/GitSourceService');
|
||||
const controller = new AbortController();
|
||||
const state = { exceeded: false, received: 0 };
|
||||
const out: number[] = [];
|
||||
for await (const c of countingBodyIterator(chunkStream(10, 20, 30), controller, 1000, state)) {
|
||||
out.push(c.byteLength);
|
||||
}
|
||||
expect(out).toEqual([10, 20, 30]);
|
||||
expect(state.exceeded).toBe(false);
|
||||
expect(state.received).toBe(60);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the transport and throws once the cumulative size exceeds the cap', async () => {
|
||||
const { countingBodyIterator } = await import('../services/GitSourceService');
|
||||
const controller = new AbortController();
|
||||
const state = { exceeded: false, received: 0 };
|
||||
await expect((async () => {
|
||||
for await (const _c of countingBodyIterator(chunkStream(60, 60), controller, 100, state)) {
|
||||
void _c;
|
||||
}
|
||||
})()).rejects.toThrow(/maximum allowed size/i);
|
||||
expect(state.exceeded).toBe(true);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitSourceService.fetchFromGit (size limits)', () => {
|
||||
const svc = () => GitSourceService.getInstance();
|
||||
const fetchParams = {
|
||||
@@ -788,8 +840,8 @@ describe('GitSourceService.fetchFromGit (size limits)', () => {
|
||||
};
|
||||
|
||||
it('rejects a compose file larger than the per-file read cap', async () => {
|
||||
// The download cap bounds the compressed pack, not a single decompressed
|
||||
// file, so readRepoFile guards the in-memory read by file size.
|
||||
// The workspace cap bounds the on-disk clone, not a single file, so
|
||||
// readRepoFile guards the in-memory read by file size.
|
||||
mockSuccessfulClone();
|
||||
const { promises: fsp } = await import('fs');
|
||||
const lstatSpy = vi.spyOn(fsp, 'lstat').mockResolvedValue({
|
||||
@@ -805,19 +857,14 @@ describe('GitSourceService.fetchFromGit (size limits)', () => {
|
||||
lstatSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('surfaces a clone-size error when the download exceeds the cap', async () => {
|
||||
// Drive the real size-counting transport the service injected into
|
||||
// git.clone, with a tiny cap, and confirm fetchFromGit reports it as a
|
||||
// clone-size error rather than a generic transport failure.
|
||||
it('surfaces a clone-size error and forwards the configured cap to the transport', async () => {
|
||||
// The transport enforces the cap with its size watchdog (covered in the
|
||||
// transport unit tests); here we pin the plumbing: the env knob reaches
|
||||
// the transport as maxBytes, and a structured size failure translates
|
||||
// into the clone-size message rather than a generic transport error.
|
||||
process.env.GITSOURCE_MAX_CLONE_BYTES = '8';
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array(64), { status: 200 }),
|
||||
);
|
||||
mockGitClone.mockImplementation(async (args: {
|
||||
http: { request: (r: { url: string; method: string; headers: Record<string, string> }) => Promise<{ body: AsyncIterableIterator<Uint8Array> }> };
|
||||
}) => {
|
||||
const resp = await args.http.request({ url: 'https://example.test/info/refs', method: 'GET', headers: {} });
|
||||
for await (const chunk of resp.body) { void chunk; }
|
||||
mockFetchAtCommit.mockImplementationOnce(async () => {
|
||||
throw { transportFailure: true as const, reason: 'size', maxBytes: 8, host: 'github.com', hasToken: false };
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -825,9 +872,9 @@ describe('GitSourceService.fetchFromGit (size limits)', () => {
|
||||
code: 'GIT_ERROR',
|
||||
message: expect.stringMatching(/exceeds the maximum clone size/i),
|
||||
});
|
||||
expect(mockFetchAtCommit.mock.calls[0][0]).toMatchObject({ maxBytes: 8 });
|
||||
} finally {
|
||||
delete process.env.GITSOURCE_MAX_CLONE_BYTES;
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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 } 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';
|
||||
|
||||
/**
|
||||
* Build a bare repo with one committed file. Mirrors e2e/gitServer.helper.ts's
|
||||
* fixture builder. Returns both the served bare dir and every scratch
|
||||
* directory created along the way, so the caller can remove them all.
|
||||
*/
|
||||
function buildBareFixtureRepo(): { bareDir: string; scratchDirs: string[] } {
|
||||
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}`);
|
||||
};
|
||||
run(['init', '-b', 'main']);
|
||||
run(['config', 'user.email', 'integration-test@sencho.test']);
|
||||
run(['config', 'user.name', 'Sencho Integration Test']);
|
||||
run(['add', '-A']);
|
||||
// Explicitly off: a developer machine or CI runner with commit.gpgsign=true
|
||||
// in its global gitconfig would otherwise fail this fixture commit.
|
||||
run(['-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']);
|
||||
|
||||
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}`);
|
||||
return { bareDir, scratchDirs: [srcDir, bareRoot] };
|
||||
}
|
||||
|
||||
/** 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[] = [];
|
||||
const workspaces: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const { bareDir, scratchDirs } = buildBareFixtureRepo();
|
||||
fixtureScratchDirs = scratchDirs;
|
||||
const served = await serveAuthedRepo(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,
|
||||
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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,18 +21,25 @@ import path from 'path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
const { mockGitClone, mockGitLog, compose } = vi.hoisted(() => ({
|
||||
const { mockResolveRef, mockFetchAtCommit, mockGitClone, mockGitLog, compose } = vi.hoisted(() => ({
|
||||
mockResolveRef: vi.fn(),
|
||||
mockFetchAtCommit: vi.fn(),
|
||||
mockGitClone: vi.fn(),
|
||||
mockGitLog: vi.fn(),
|
||||
/** Exit code the next daemon-dependent compose command reports. */
|
||||
compose: { exitCode: 1 },
|
||||
}));
|
||||
|
||||
vi.mock('isomorphic-git', () => {
|
||||
const api = { clone: mockGitClone, log: mockGitLog };
|
||||
return { default: api, clone: mockGitClone, log: mockGitLog };
|
||||
});
|
||||
vi.mock('isomorphic-git/http/node', () => ({ default: {} }));
|
||||
// The transport boundary is what gets mocked (see git-source-service.test.ts,
|
||||
// which established this seam). mockGitClone/mockGitLog remain as the
|
||||
// fixture layer so stageRepo() keeps its meaning: clone writes files into
|
||||
// the checkout dir, log yields the deterministic sha.
|
||||
vi.mock('../services/git/nativeGitTransport', () => ({
|
||||
nativeGitTransport: {
|
||||
resolveRef: mockResolveRef,
|
||||
fetchAtCommit: mockFetchAtCommit,
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Compose verbs that need a running daemon and are issued through `spawn`.
|
||||
@@ -132,6 +139,33 @@ let GitOpsStore: typeof import('../services/gitops/store').GitOpsStore;
|
||||
let GitOpsTransitions: typeof import('../services/gitops/transitions').GitOpsTransitions;
|
||||
let projectApplication: typeof import('../services/gitops/derive').projectApplication;
|
||||
|
||||
/**
|
||||
* Default transport wiring: resolveRef defers to the log stub so per-test
|
||||
* overrides of mockGitLog keep controlling the final SHA, and fetchAtCommit
|
||||
* delegates to the clone/log fixture fns, handing clone a `dir` that points
|
||||
* at the workspace checkout. Mirrors git-source-service.test.ts's
|
||||
* wireTransportDefaults.
|
||||
*/
|
||||
function wireTransportDefaults(): void {
|
||||
mockResolveRef.mockImplementation(async () => {
|
||||
const log = await mockGitLog({});
|
||||
const oid = Array.isArray(log) ? log[0]?.oid : undefined;
|
||||
return { commitSha: oid ?? '' };
|
||||
});
|
||||
mockFetchAtCommit.mockImplementation(async (req: { workspaceRoot: string; commitSha: string }) => {
|
||||
const dir = path.join(req.workspaceRoot, 'repo');
|
||||
await fsPromises.mkdir(dir, { recursive: true });
|
||||
await mockGitClone({ ...req, dir });
|
||||
const log = await mockGitLog({ dir });
|
||||
if (!Array.isArray(log) || !log.length) {
|
||||
// An empty branch produces no remote ref; mirror the structured
|
||||
// failure the real transport raises for that case.
|
||||
throw { transportFailure: true as const, reason: 'ref-not-found', host: 'unknown', hasToken: false };
|
||||
}
|
||||
return { commitSha: log[0].oid, dir };
|
||||
});
|
||||
}
|
||||
|
||||
/** Make the next clone produce a project containing this compose content. */
|
||||
function stageRepo(content: string, sha: string, extraFiles: Record<string, string> = {}): void {
|
||||
mockGitClone.mockImplementation(async ({ dir }: { dir: string }) => {
|
||||
@@ -166,8 +200,11 @@ describe('Direct Git producers drive the revision state', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolveRef.mockReset();
|
||||
mockFetchAtCommit.mockReset();
|
||||
mockGitClone.mockReset();
|
||||
mockGitLog.mockReset();
|
||||
wireTransportDefaults();
|
||||
compose.exitCode = 1;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user