Skip to content

Commit 96d3336

Browse files
Gavin Williamsclaude
andcommitted
fix(web): harden getFileSourceForRepo error boundary
Three fixes to getFileSourceForRepo, which was extracted outside sew() in v4.16.14 and lost its error boundary: - Wrap the entire function body in a top-level try/catch so exceptions from prisma, getRepoPath, simpleGit().cwd(), language helpers, and URL builders are converted to unexpectedError rather than propagating as fatal Next.js task-runner exceptions. - Add unresolvedGitRef() to serviceError.ts (errorCode: INVALID_GIT_REF, distinct message) and use it for "unknown revision"/"bad revision"/ "invalid object name" git errors, replacing the syntactic invalidGitRef message that was misleading for unfetched head_sha refs. - Fix the simple-git vi.mock() factory in the test file to map both the default and named exports to the same hoisted mock fn, ensuring the SUT and the test body reference identical mocks. Add a test for the outer catch (DB throws) and tighten INVALID_GIT_REF assertions to distinguish syntactic from unresolved-ref errors by message content. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 41f978f commit 96d3336

3 files changed

Lines changed: 114 additions & 73 deletions

File tree

packages/web/src/features/git/getFileSourceApi.test.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
22

3-
// Mocks must be declared before imports
4-
vi.mock('simple-git');
3+
// Hoist the mock function so it can be referenced in both the vi.mock factory
4+
// and the test body. The SUT imports simpleGit as a default export; the factory
5+
// maps both default and named exports to the same fn so both resolve identically.
6+
const mockSimpleGit = vi.hoisted(() => vi.fn());
7+
8+
vi.mock('simple-git', () => ({
9+
default: mockSimpleGit,
10+
simpleGit: mockSimpleGit,
11+
}));
512
vi.mock('@sourcebot/shared', () => ({
613
getRepoPath: (repo: { id: number }) => ({
714
path: `/mock/repos/${repo.id}`,
@@ -48,7 +55,6 @@ vi.mock('@/ee/features/audit/factory', () => ({
4855
}),
4956
}));
5057

51-
import { simpleGit } from 'simple-git';
5258
import { getFileSourceForRepo } from './getFileSourceApi';
5359

5460
const MOCK_ORG = { id: 1, name: 'test-org' } as Parameters<typeof getFileSourceForRepo>[1]['org'];
@@ -66,7 +72,6 @@ const MOCK_REPO = {
6672
describe('getFileSourceForRepo', () => {
6773
const mockGitRaw = vi.fn();
6874
const mockCwd = vi.fn();
69-
const mockSimpleGit = simpleGit as unknown as Mock;
7075
const mockFindFirst = vi.fn();
7176

7277
const mockPrisma = {
@@ -111,6 +116,17 @@ describe('getFileSourceForRepo', () => {
111116
where: { name: 'github.com/owner/repo', orgId: 1 },
112117
});
113118
});
119+
120+
it('returns UNEXPECTED_ERROR when the database throws (outer catch)', async () => {
121+
mockFindFirst.mockRejectedValue(new Error('DB connection refused'));
122+
123+
const result = await getFileSourceForRepo(
124+
{ path: 'src/index.ts', repo: 'github.com/owner/repo' },
125+
{ org: MOCK_ORG, prisma: mockPrisma },
126+
);
127+
128+
expect(result).toMatchObject({ errorCode: 'UNEXPECTED_ERROR' });
129+
});
114130
});
115131

116132
describe('input validation', () => {
@@ -132,13 +148,16 @@ describe('getFileSourceForRepo', () => {
132148
expect(result).toMatchObject({ errorCode: 'FILE_NOT_FOUND' });
133149
});
134150

135-
it('returns INVALID_GIT_REF for refs starting with "-" (flag injection guard)', async () => {
151+
it('returns INVALID_GIT_REF with a syntactic message for refs starting with "-" (flag injection guard)', async () => {
136152
const result = await getFileSourceForRepo(
137153
{ path: 'src/index.ts', repo: 'github.com/owner/repo', ref: '--upload-pack=evil' },
138154
{ org: MOCK_ORG, prisma: mockPrisma },
139155
);
140156

141-
expect(result).toMatchObject({ errorCode: 'INVALID_GIT_REF' });
157+
expect(result).toMatchObject({
158+
errorCode: 'INVALID_GIT_REF',
159+
message: expect.stringContaining("cannot start with '-'"),
160+
});
142161
});
143162
});
144163

@@ -167,7 +186,7 @@ describe('getFileSourceForRepo', () => {
167186
expect(result).toMatchObject({ errorCode: 'FILE_NOT_FOUND' });
168187
});
169188

170-
it('returns INVALID_GIT_REF when head_sha has not been fetched on the local clone ("unknown revision")', async () => {
189+
it('returns INVALID_GIT_REF with an unresolved-ref message when head_sha has not been fetched ("unknown revision")', async () => {
171190
// This is the scenario from the v4.16.14 regression: the review agent passes
172191
// pr_payload.head_sha as ref, but the bare clone hasn't fetched it yet.
173192
mockGitRaw.mockRejectedValueOnce(
@@ -179,29 +198,38 @@ describe('getFileSourceForRepo', () => {
179198
{ org: MOCK_ORG, prisma: mockPrisma },
180199
);
181200

182-
expect(result).toMatchObject({ errorCode: 'INVALID_GIT_REF' });
201+
expect(result).toMatchObject({
202+
errorCode: 'INVALID_GIT_REF',
203+
message: expect.stringContaining('could not be resolved'),
204+
});
183205
});
184206

185-
it('returns INVALID_GIT_REF for "bad revision" errors', async () => {
207+
it('returns INVALID_GIT_REF with an unresolved-ref message for "bad revision" errors', async () => {
186208
mockGitRaw.mockRejectedValueOnce(new Error('fatal: bad revision'));
187209

188210
const result = await getFileSourceForRepo(
189211
{ path: 'src/index.ts', repo: 'github.com/owner/repo', ref: 'nonexistent' },
190212
{ org: MOCK_ORG, prisma: mockPrisma },
191213
);
192214

193-
expect(result).toMatchObject({ errorCode: 'INVALID_GIT_REF' });
215+
expect(result).toMatchObject({
216+
errorCode: 'INVALID_GIT_REF',
217+
message: expect.stringContaining('could not be resolved'),
218+
});
194219
});
195220

196-
it('returns INVALID_GIT_REF for "invalid object name" errors', async () => {
221+
it('returns INVALID_GIT_REF with an unresolved-ref message for "invalid object name" errors', async () => {
197222
mockGitRaw.mockRejectedValueOnce(new Error('fatal: invalid object name HEAD'));
198223

199224
const result = await getFileSourceForRepo(
200225
{ path: 'src/index.ts', repo: 'github.com/owner/repo' },
201226
{ org: MOCK_ORG, prisma: mockPrisma },
202227
);
203228

204-
expect(result).toMatchObject({ errorCode: 'INVALID_GIT_REF' });
229+
expect(result).toMatchObject({
230+
errorCode: 'INVALID_GIT_REF',
231+
message: expect.stringContaining('could not be resolved'),
232+
});
205233
});
206234

207235
it('returns UNEXPECTED_ERROR — not throw — for unrecognised git errors (regression: v4.16.14 fatal exception)', async () => {

packages/web/src/features/git/getFileSourceApi.ts

Lines changed: 66 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getBrowsePath } from '@/app/(app)/browse/hooks/utils';
33
import { getAuditService } from '@/ee/features/audit/factory';
44
import { parseGitAttributes, resolveLanguageFromGitAttributes } from '@/lib/gitattributes';
55
import { detectLanguageFromFilename } from '@/lib/languageDetection';
6-
import { ServiceError, notFound, fileNotFound, invalidGitRef, unexpectedError } from '@/lib/serviceError';
6+
import { ServiceError, notFound, fileNotFound, invalidGitRef, unresolvedGitRef, unexpectedError } from '@/lib/serviceError';
77
import { getCodeHostBrowseFileAtBranchUrl } from '@/lib/utils';
88
import { withOptionalAuth } from '@/middleware/withAuth';
99
import { env, getRepoPath } from '@sourcebot/shared';
@@ -27,77 +27,82 @@ export const getFileSourceForRepo = async (
2727
{ path: filePath, repo: repoName, ref }: FileSourceRequest,
2828
{ org, prisma }: { org: Org; prisma: PrismaClient },
2929
): Promise<FileSourceResponse | ServiceError> => {
30-
const repo = await prisma.repo.findFirst({
31-
where: { name: repoName, orgId: org.id },
32-
});
33-
if (!repo) {
34-
return notFound(`Repository "${repoName}" not found.`);
35-
}
30+
try {
31+
const repo = await prisma.repo.findFirst({
32+
where: { name: repoName, orgId: org.id },
33+
});
34+
if (!repo) {
35+
return notFound(`Repository "${repoName}" not found.`);
36+
}
3637

37-
if (!isPathValid(filePath)) {
38-
return fileNotFound(filePath, repoName);
39-
}
38+
if (!isPathValid(filePath)) {
39+
return fileNotFound(filePath, repoName);
40+
}
4041

41-
if (ref !== undefined && !isGitRefValid(ref)) {
42-
return invalidGitRef(ref);
43-
}
42+
if (ref !== undefined && !isGitRefValid(ref)) {
43+
return invalidGitRef(ref);
44+
}
4445

45-
const { path: repoPath } = getRepoPath(repo);
46-
const git = simpleGit().cwd(repoPath);
46+
const { path: repoPath } = getRepoPath(repo);
47+
const git = simpleGit().cwd(repoPath);
4748

48-
const gitRef = ref ?? repo.defaultBranch ?? 'HEAD';
49+
const gitRef = ref ?? repo.defaultBranch ?? 'HEAD';
4950

50-
let fileContent: string;
51-
try {
52-
fileContent = await git.raw(['show', `${gitRef}:${filePath}`]);
53-
} catch (error: unknown) {
54-
const errorMessage = error instanceof Error ? error.message : String(error);
55-
if (errorMessage.includes('does not exist') || errorMessage.includes('fatal: path')) {
56-
return fileNotFound(filePath, repoName);
57-
}
58-
if (errorMessage.includes('unknown revision') || errorMessage.includes('bad revision') || errorMessage.includes('invalid object name')) {
59-
return invalidGitRef(gitRef);
51+
let fileContent: string;
52+
try {
53+
fileContent = await git.raw(['show', `${gitRef}:${filePath}`]);
54+
} catch (error: unknown) {
55+
const errorMessage = error instanceof Error ? error.message : String(error);
56+
if (errorMessage.includes('does not exist') || errorMessage.includes('fatal: path')) {
57+
return fileNotFound(filePath, repoName);
58+
}
59+
if (errorMessage.includes('unknown revision') || errorMessage.includes('bad revision') || errorMessage.includes('invalid object name')) {
60+
return unresolvedGitRef(gitRef);
61+
}
62+
return unexpectedError(errorMessage);
6063
}
61-
return unexpectedError(errorMessage);
62-
}
6364

64-
let gitattributesContent: string | undefined;
65-
try {
66-
gitattributesContent = await git.raw(['show', `${gitRef}:.gitattributes`]);
67-
} catch {
68-
// No .gitattributes in this repo/ref, that's fine
69-
}
65+
let gitattributesContent: string | undefined;
66+
try {
67+
gitattributesContent = await git.raw(['show', `${gitRef}:.gitattributes`]);
68+
} catch {
69+
// No .gitattributes in this repo/ref, that's fine
70+
}
7071

71-
const language = gitattributesContent
72-
? (resolveLanguageFromGitAttributes(filePath, parseGitAttributes(gitattributesContent)) ?? detectLanguageFromFilename(filePath))
73-
: detectLanguageFromFilename(filePath);
72+
const language = gitattributesContent
73+
? (resolveLanguageFromGitAttributes(filePath, parseGitAttributes(gitattributesContent)) ?? detectLanguageFromFilename(filePath))
74+
: detectLanguageFromFilename(filePath);
7475

75-
const externalWebUrl = getCodeHostBrowseFileAtBranchUrl({
76-
webUrl: repo.webUrl,
77-
codeHostType: repo.external_codeHostType,
78-
branchName: gitRef,
79-
filePath,
80-
});
76+
const externalWebUrl = getCodeHostBrowseFileAtBranchUrl({
77+
webUrl: repo.webUrl,
78+
codeHostType: repo.external_codeHostType,
79+
branchName: gitRef,
80+
filePath,
81+
});
8182

82-
const baseUrl = env.AUTH_URL;
83-
const webUrl = `${baseUrl}${getBrowsePath({
84-
repoName: repo.name,
85-
revisionName: ref,
86-
path: filePath,
87-
pathType: 'blob',
88-
})}`;
83+
const baseUrl = env.AUTH_URL;
84+
const webUrl = `${baseUrl}${getBrowsePath({
85+
repoName: repo.name,
86+
revisionName: ref,
87+
path: filePath,
88+
pathType: 'blob',
89+
})}`;
8990

90-
return {
91-
source: fileContent,
92-
language,
93-
path: filePath,
94-
repo: repoName,
95-
repoCodeHostType: repo.external_codeHostType,
96-
repoDisplayName: repo.displayName ?? undefined,
97-
repoExternalWebUrl: repo.webUrl ?? undefined,
98-
webUrl,
99-
externalWebUrl,
100-
} satisfies FileSourceResponse;
91+
return {
92+
source: fileContent,
93+
language,
94+
path: filePath,
95+
repo: repoName,
96+
repoCodeHostType: repo.external_codeHostType,
97+
repoDisplayName: repo.displayName ?? undefined,
98+
repoExternalWebUrl: repo.webUrl ?? undefined,
99+
webUrl,
100+
externalWebUrl,
101+
} satisfies FileSourceResponse;
102+
} catch (error: unknown) {
103+
const errorMessage = error instanceof Error ? error.message : String(error);
104+
return unexpectedError(errorMessage);
105+
}
101106
};
102107

103108
export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest, { source }: { source?: string } = {}): Promise<FileSourceResponse | ServiceError> => sew(() => withOptionalAuth(async ({ org, prisma, user }) => {

packages/web/src/lib/serviceError.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,11 @@ export const invalidGitRef = (ref: string): ServiceError => {
109109
};
110110
}
111111

112+
export const unresolvedGitRef = (ref: string): ServiceError => {
113+
return {
114+
statusCode: StatusCodes.BAD_REQUEST,
115+
errorCode: ErrorCode.INVALID_GIT_REF,
116+
message: `Git reference "${ref}" could not be resolved.`,
117+
};
118+
}
119+

0 commit comments

Comments
 (0)