Files
orca/src/main/github/client-test-mocks.ts
T
Brennan Benson 8e9b5c908c fix(github): fail closed instead of running client git against a remote repoPath when the SSH provider is unregistered (#14945)
* fix(github): fail closed when the SSH git provider is gone

getCurrentHeadOid and probeTrackedUpstreamBranches only routed through the
SSH provider when one was registered. With connectionId set but the provider
unregistered (dropped connection, not yet reattached) they fell through to
client-side git with cwd pointing at the remote repoPath — on a machine with
a same-named local path that silently answers for the wrong repository.

getCurrentHeadOid feeds shouldHideMergedImplicitPR, so a wrong OID changes
which PR the UI attributes to a worktree.

Both now take their existing unknown path (null / probeFailed) instead,
matching repo-default-branch.ts. Local and WSL routing is unchanged.

* fix(github): preserve PR state when SSH probes fail

* fix(github): keep failed SSH discovery unverifiable

* fix(github): propagate SSH identity failures

* fix(github): scope verified SSH identity probes

* test(github): preserve tolerant resolver calls

* fix(github): preserve indeterminate auth discovery

* fix(github): isolate SSH repository probe generations

* test(github): expose SSH probe generation in mocks
2026-08-17 00:12:14 -07:00

242 lines
8.8 KiB
TypeScript

import { vi, type Mock } from 'vitest'
import type { ClassifiedError } from '../../shared/classified-error'
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type * as GithubApiRepositoryModule from './github-api-repository'
import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository'
import type { ghRepoExecOptions, githubRepoContext } from './github-repository-identity'
export type RateLimitGuardResult =
| { blocked: false }
| { blocked: true; remaining: number; limit: number; resetAt: number }
export type GitHubClientMocks = {
execFileAsyncMock: Mock
ghExecFileAsyncMock: Mock
getOwnerRepoMock: Mock
getIssueOwnerRepoMock: Mock
getOwnerRepoForRemoteMock: Mock
resolvePRRepositoryCandidatesMock: Mock
getRemoteUrlForRepoMock: Mock
gitExecFileAsyncMock: Mock
getRateLimitMock: Mock
rateLimitGuardMock: Mock<(bucket?: string) => RateLimitGuardResult>
noteRateLimitSpendMock: Mock
ghRepoExecOptionsMock: Mock<typeof ghRepoExecOptions>
githubRepoContextMock: Mock<typeof githubRepoContext>
getSshGitProviderMock: Mock
readLocalGitConfigSignatureMock: Mock
acquireMock: Mock
releaseMock: Mock
}
// Why: every client suite registers its own `vi.mock` factories (hoisting is
// per-file), so the mock set and the factory bodies live here and the test file
// only wires them together.
export function createGitHubClientMocks(): GitHubClientMocks {
return {
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getIssueOwnerRepoMock: vi.fn(),
getOwnerRepoForRemoteMock: vi.fn(),
resolvePRRepositoryCandidatesMock: vi.fn(),
getRemoteUrlForRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getRateLimitMock: vi.fn(),
rateLimitGuardMock: vi.fn<(bucket?: string) => RateLimitGuardResult>(() => ({
blocked: false
})),
noteRateLimitSpendMock: vi.fn(),
ghRepoExecOptionsMock: vi.fn<typeof ghRepoExecOptions>((context) =>
context.connectionId
? {}
: {
cwd: context.repoPath,
...(context.wslDistro ? { wslDistro: context.wslDistro } : {})
}
),
githubRepoContextMock: vi.fn<typeof githubRepoContext>(
(repoPath, connectionId, localGitOptions) => ({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})
),
getSshGitProviderMock: vi.fn(),
readLocalGitConfigSignatureMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}
}
export type GhUtilsModuleMock = {
execFileAsync: Mock
ghExecFileAsync: Mock
getOwnerRepo: Mock
getIssueOwnerRepo: Mock
getOwnerRepoForRemote: Mock
resolvePRRepositoryCandidates: Mock
getRemoteUrlForRepo: Mock
gitExecFileAsync: Mock
ghRepoExecOptions: Mock<typeof ghRepoExecOptions>
githubRepoContext: Mock<typeof githubRepoContext>
classifyGhError: (stderr: string) => ClassifiedError
parseGitHubOwnerRepo: (remoteUrl: string) => GitHubOwnerRepo | null
acquire: Mock
release: Mock
_resetOwnerRepoCache: Mock
}
export function ghUtilsModuleMock(mocks: GitHubClientMocks): GhUtilsModuleMock {
return {
execFileAsync: mocks.execFileAsyncMock,
ghExecFileAsync: mocks.ghExecFileAsyncMock,
getOwnerRepo: mocks.getOwnerRepoMock,
getIssueOwnerRepo: mocks.getIssueOwnerRepoMock,
getOwnerRepoForRemote: mocks.getOwnerRepoForRemoteMock,
resolvePRRepositoryCandidates: mocks.resolvePRRepositoryCandidatesMock,
getRemoteUrlForRepo: mocks.getRemoteUrlForRepoMock,
gitExecFileAsync: mocks.gitExecFileAsyncMock,
ghRepoExecOptions: mocks.ghRepoExecOptionsMock,
githubRepoContext: mocks.githubRepoContextMock,
classifyGhError: (stderr: string) => {
const lower = stderr.toLowerCase()
if (lower.includes('not found') || stderr.includes('HTTP 404')) {
return { type: 'not_found', message: stderr }
}
if (lower.includes('rate limit')) {
return { type: 'rate_limited', message: stderr }
}
if (lower.includes('resource not accessible')) {
return { type: 'permission_denied', message: stderr }
}
return { type: 'unknown', message: stderr }
},
parseGitHubOwnerRepo: (remoteUrl: string) => {
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
return match ? { owner: match[1], repo: match[2] } : null
},
acquire: mocks.acquireMock,
release: mocks.releaseMock,
_resetOwnerRepoCache: vi.fn()
}
}
export type GitRunnerModuleMock = { gitExecFileAsync: Mock; ghExecFileAsync: Mock }
export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMock {
return {
gitExecFileAsync: mocks.gitExecFileAsyncMock,
ghExecFileAsync: mocks.ghExecFileAsyncMock
}
}
export type SshGitDispatchModuleMock = {
getSshGitProviderGeneration: Mock
getSshGitProvider: Mock
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string
}
export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock {
return {
getSshGitProviderGeneration: vi.fn(() => 0),
getSshGitProvider: mocks.getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
}
}
export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock }
export function localGitConfigSignatureModuleMock(
mocks: GitHubClientMocks
): LocalGitConfigSignatureModuleMock {
return { readLocalGitConfigSignature: mocks.readLocalGitConfigSignatureMock }
}
export function githubEnterpriseRepositoryModuleMock(
actual: typeof GitHubEnterpriseRepositoryModule
): typeof GitHubEnterpriseRepositoryModule {
return {
...actual,
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
}
}
export type RateLimitModuleMock = {
getRateLimit: Mock
rateLimitGuard: Mock<(bucket?: string) => RateLimitGuardResult>
noteRateLimitSpend: Mock
repositoryRateLimitGuard: (repository: unknown, bucket: string) => RateLimitGuardResult
noteRepositoryRateLimitSpend: (repository: unknown, bucket: string) => void
spendsSharedGitHubComQuota: () => boolean
}
export function rateLimitModuleMock(mocks: GitHubClientMocks): RateLimitModuleMock {
return {
getRateLimit: mocks.getRateLimitMock,
rateLimitGuard: mocks.rateLimitGuardMock,
noteRateLimitSpend: mocks.noteRateLimitSpendMock,
// Why: the repository-scoped guards share the same bucket-keyed budget as the
// legacy ones, so delegate to the existing mocks to keep per-bucket blocking
// and spend assertions working unchanged.
repositoryRateLimitGuard: (_repository: unknown, bucket: string) =>
mocks.rateLimitGuardMock(bucket),
noteRepositoryRateLimitSpend: (_repository: unknown, bucket: string) =>
mocks.noteRateLimitSpendMock(bucket),
spendsSharedGitHubComQuota: () => true
}
}
export function githubApiRepositoryModuleMock(
mocks: GitHubClientMocks,
actual: typeof GithubApiRepositoryModule
): typeof GithubApiRepositoryModule {
return {
...actual,
// Why: these suites inject repo identities through the legacy gh-utils
// mocks; bridge the hosted seams onto the same mocks so per-test setups
// keep driving resolution without real enterprise probes.
resolveGitHubApiRepositoryCandidates: (
repoPath: string,
connectionId?: string | null,
localGitOptions?: unknown
) => mocks.resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions),
getGitHubApiRepositoryForRemote: (
repoPath: string,
remoteName: string,
connectionId?: string | null
) => mocks.getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId),
getOriginGitHubApiRepository: async (
repoPath: string,
connectionId?: string | null,
localGitOptions?: unknown
) => {
// Prefer the remote-specific mock (production origin path); fall back for
// suites that only configure getOwnerRepo.
const fromRemote = await mocks.getOwnerRepoForRemoteMock(
repoPath,
'origin',
connectionId,
localGitOptions
)
const slug =
fromRemote ?? (await mocks.getOwnerRepoMock(repoPath, connectionId, localGitOptions))
// Mirror production: dotcom origin slugs come back pinned to github.com.
return slug ? { host: 'github.com', ...slug } : slug
},
getIssueGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => {
const slug = await mocks.getIssueOwnerRepoMock(repoPath, connectionId)
// Mirror production: issue slugs come back host-qualified to github.com.
return slug ? { host: 'github.com', ...slug } : slug
},
resolveIssueGitHubApiRepositorySource: async (
repoPath: string,
_preference: unknown,
connectionId?: string | null
) => {
const slug = await mocks.getIssueOwnerRepoMock(repoPath, connectionId)
return { source: slug ? { host: 'github.com', ...slug } : slug, fellBack: false }
}
}
}