Skip to content

Commit 9b7ef83

Browse files
committed
Add tests and address review feedback
1 parent cb9755b commit 9b7ef83

File tree

5 files changed

+313
-33
lines changed

5 files changed

+313
-33
lines changed

packages/playground/storage/src/lib/git-sparse-checkout.spec.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
sparseCheckout,
44
listGitFiles,
55
resolveCommitHash,
6+
type GitAdditionalHeaders,
67
} from './git-sparse-checkout';
8+
import { vi } from 'vitest';
79

810
describe('listRefs', () => {
911
it('should return the latest commit hash for a given ref', async () => {
@@ -190,3 +192,135 @@ describe('listGitFiles', () => {
190192
);
191193
});
192194
});
195+
196+
describe('gitAdditionalHeaders callback', () => {
197+
const repoUrl = 'https://github.com/WordPress/wordpress-playground.git';
198+
199+
it('should invoke callback with the actual URL being fetched', async () => {
200+
const headerCallback = vi.fn<GitAdditionalHeaders>(() => ({}));
201+
202+
await listGitRefs(repoUrl, 'refs/heads/trunk', headerCallback);
203+
204+
expect(headerCallback).toHaveBeenCalledWith(repoUrl);
205+
});
206+
207+
it('should successfully fetch when callback returns empty object', async () => {
208+
const headerCallback: GitAdditionalHeaders = () => ({});
209+
210+
const refs = await listGitRefs(
211+
repoUrl,
212+
'refs/heads/trunk',
213+
headerCallback
214+
);
215+
216+
expect(refs).toHaveProperty('refs/heads/trunk');
217+
expect(refs['refs/heads/trunk']).toMatch(/^[a-f0-9]{40}$/);
218+
});
219+
220+
it('should pass callback through the full call chain', async () => {
221+
const headerCallback = vi.fn<GitAdditionalHeaders>(() => ({}));
222+
223+
await resolveCommitHash(
224+
repoUrl,
225+
{ value: 'trunk', type: 'branch' },
226+
headerCallback
227+
);
228+
229+
expect(headerCallback).toHaveBeenCalledWith(repoUrl);
230+
});
231+
});
232+
233+
describe('authentication error handling', () => {
234+
let originalFetch: typeof global.fetch;
235+
236+
beforeEach(() => {
237+
originalFetch = global.fetch;
238+
});
239+
240+
afterEach(() => {
241+
global.fetch = originalFetch;
242+
});
243+
244+
it('should throw GitAuthenticationError for 401 responses', async () => {
245+
global.fetch = vi.fn().mockResolvedValue({
246+
ok: false,
247+
status: 401,
248+
statusText: 'Unauthorized',
249+
});
250+
251+
const headerCallback: GitAdditionalHeaders = () => ({
252+
Authorization: 'Bearer token',
253+
});
254+
255+
await expect(
256+
listGitRefs(
257+
'https://github.com/user/private-repo',
258+
'refs/heads/main',
259+
headerCallback
260+
)
261+
).rejects.toThrow(
262+
'Authentication required to access private repository'
263+
);
264+
});
265+
266+
it('should throw GitAuthenticationError for 403 responses', async () => {
267+
global.fetch = vi.fn().mockResolvedValue({
268+
ok: false,
269+
status: 403,
270+
statusText: 'Forbidden',
271+
});
272+
273+
const headerCallback: GitAdditionalHeaders = () => ({
274+
Authorization: 'Bearer token',
275+
});
276+
277+
await expect(
278+
listGitRefs(
279+
'https://github.com/user/private-repo',
280+
'refs/heads/main',
281+
headerCallback
282+
)
283+
).rejects.toThrow(
284+
'Authentication required to access private repository'
285+
);
286+
});
287+
288+
it('should throw generic error for 404 even with auth token (ambiguous: repo not found OR no access)', async () => {
289+
global.fetch = vi.fn().mockResolvedValue({
290+
ok: false,
291+
status: 404,
292+
statusText: 'Not Found',
293+
});
294+
295+
const headerCallback: GitAdditionalHeaders = () => ({
296+
Authorization: 'Bearer token',
297+
});
298+
299+
await expect(
300+
listGitRefs(
301+
'https://github.com/user/repo-or-no-access',
302+
'refs/heads/main',
303+
headerCallback
304+
)
305+
).rejects.toThrow(
306+
'Failed to fetch git refs from https://github.com/user/repo-or-no-access: 404 Not Found'
307+
);
308+
});
309+
310+
it('should throw generic error for 404 without auth token', async () => {
311+
global.fetch = vi.fn().mockResolvedValue({
312+
ok: false,
313+
status: 404,
314+
statusText: 'Not Found',
315+
});
316+
317+
await expect(
318+
listGitRefs(
319+
'https://github.com/user/nonexistent-repo',
320+
'refs/heads/main'
321+
)
322+
).rejects.toThrow(
323+
'Failed to fetch git refs from https://github.com/user/nonexistent-repo: 404 Not Found'
324+
);
325+
});
326+
});

packages/playground/storage/src/lib/git-sparse-checkout.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,6 @@ export class GitAuthenticationError extends Error {
4444

4545
export type GitAdditionalHeaders = (url: string) => Record<string, string>;
4646

47-
function resolveGitHeaders(
48-
url: string,
49-
headers?: GitAdditionalHeaders
50-
): Record<string, string> {
51-
if (!headers || typeof headers !== 'function') {
52-
return {};
53-
}
54-
55-
return headers(url);
56-
}
57-
5847
/**
5948
* Downloads specific files from a git repository.
6049
* It uses the git protocol over HTTP to fetch the files. It only uses
@@ -98,7 +87,7 @@ export async function sparseCheckout(
9887
const treesPack = await fetchWithoutBlobs(
9988
repoUrl,
10089
commitHash,
101-
resolveGitHeaders(repoUrl, options?.additionalHeaders)
90+
options?.additionalHeaders?.(repoUrl) ?? {}
10291
);
10392
const objects = await resolveObjects(treesPack.idx, commitHash, filesPaths);
10493

@@ -108,7 +97,7 @@ export async function sparseCheckout(
10897
? await fetchObjects(
10998
repoUrl,
11099
blobOids,
111-
resolveGitHeaders(repoUrl, options?.additionalHeaders)
100+
options?.additionalHeaders?.(repoUrl) ?? {}
112101
)
113102
: null;
114103

@@ -219,7 +208,7 @@ export async function listGitFiles(
219208
const treesPack = await fetchWithoutBlobs(
220209
repoUrl,
221210
commitHash,
222-
resolveGitHeaders(repoUrl, additionalHeaders)
211+
additionalHeaders?.(repoUrl) ?? {}
223212
);
224213
const rootTree = await resolveAllObjects(treesPack.idx, commitHash);
225214
if (!rootTree?.object) {
@@ -306,7 +295,7 @@ export async function listGitRefs(
306295
'content-type': 'application/x-git-upload-pack-request',
307296
'Content-Length': `${packbuffer.length}`,
308297
'Git-Protocol': 'version=2',
309-
...resolveGitHeaders(repoUrl, additionalHeaders),
298+
...(additionalHeaders?.(repoUrl) ?? {}),
310299
},
311300
body: packbuffer as any,
312301
});

packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export async function acquireOAuthTokenIfNeeded() {
3434

3535
// Reload the page to retry the blueprint with the new token
3636
// This is necessary because the blueprint failed before we had the token
37-
window.location.href = url.toString();
37+
// Use replace() instead of href assignment to avoid Chrome Error 5
38+
window.location.replace(url.toString());
3839
} finally {
3940
oAuthState.value = {
4041
...oAuthState.value,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { createGitHubAuthHeaders } from './git-auth-helpers';
3+
import { oAuthState } from './state';
4+
5+
vi.mock('virtual:cors-proxy-url', () => ({
6+
corsProxyUrl: 'https://corsproxyurl/',
7+
}));
8+
9+
describe('createGitHubAuthHeaders', () => {
10+
beforeEach(() => {
11+
oAuthState.value = { token: '', isAuthorizing: false };
12+
});
13+
14+
describe('with GitHub token present', () => {
15+
beforeEach(() => {
16+
oAuthState.value = {
17+
token: 'gho_TestToken123',
18+
isAuthorizing: false,
19+
};
20+
});
21+
22+
it('includes Authorization header for github.com URLs', () => {
23+
const getHeaders = createGitHubAuthHeaders();
24+
const headers = getHeaders('https://github.com/user/repo');
25+
26+
expect(headers).toHaveProperty('Authorization');
27+
expect(headers.Authorization).toMatch(/^Basic /);
28+
expect(headers).toHaveProperty(
29+
'X-Cors-Proxy-Allowed-Request-Headers',
30+
'Authorization'
31+
);
32+
});
33+
34+
it('includes Authorization header for api.github.com URLs', () => {
35+
const getHeaders = createGitHubAuthHeaders();
36+
const headers = getHeaders('https://api.github.com/repos');
37+
38+
expect(headers).toHaveProperty('Authorization');
39+
});
40+
41+
it('includes Authorization header for GitHub URLs through CORS proxy', () => {
42+
const getHeaders = createGitHubAuthHeaders();
43+
const headers = getHeaders(
44+
'https://corsproxyurl/?https://github.com/user/repo'
45+
);
46+
47+
expect(headers).toHaveProperty('Authorization');
48+
expect(headers).toHaveProperty(
49+
'X-Cors-Proxy-Allowed-Request-Headers'
50+
);
51+
});
52+
53+
it('does NOT include Authorization header for non-GitHub URLs', () => {
54+
const getHeaders = createGitHubAuthHeaders();
55+
56+
expect(getHeaders('https://gitlab.com/user/repo')).toEqual({});
57+
expect(getHeaders('https://bitbucket.org/user/repo')).toEqual({});
58+
});
59+
60+
it('does NOT include Authorization header for malicious URLs (security)', () => {
61+
const getHeaders = createGitHubAuthHeaders();
62+
63+
// github.com in path
64+
expect(getHeaders('https://evil.com/github.com/fake')).toEqual({});
65+
66+
// github.com in query parameter
67+
expect(getHeaders('https://evil.com?redirect=github.com')).toEqual(
68+
{}
69+
);
70+
71+
// look-alike domains
72+
expect(getHeaders('https://github.com.evil.com')).toEqual({});
73+
expect(getHeaders('https://mygithub.com')).toEqual({});
74+
expect(getHeaders('https://fakegithub.com')).toEqual({});
75+
});
76+
77+
it('does NOT include Authorization header for non-GitHub URLs through CORS proxy', () => {
78+
const getHeaders = createGitHubAuthHeaders();
79+
const headers = getHeaders(
80+
'https://corsproxyurl/?https://gitlab.com/user/repo'
81+
);
82+
83+
expect(headers).toEqual({});
84+
});
85+
86+
it('does NOT include Authorization header for malicious URLs through CORS proxy (security)', () => {
87+
const getHeaders = createGitHubAuthHeaders();
88+
89+
expect(
90+
getHeaders(
91+
'https://corsproxyurl/?https://evil.com/github.com/fake'
92+
)
93+
).toEqual({});
94+
95+
expect(
96+
getHeaders('https://corsproxyurl/?https://github.com.evil.com')
97+
).toEqual({});
98+
});
99+
});
100+
101+
describe('without GitHub token', () => {
102+
beforeEach(() => {
103+
oAuthState.value = { token: '', isAuthorizing: false };
104+
});
105+
106+
it('returns empty headers even for GitHub URLs', () => {
107+
const getHeaders = createGitHubAuthHeaders();
108+
109+
expect(getHeaders('https://github.com/user/repo')).toEqual({});
110+
});
111+
});
112+
113+
describe('token encoding', () => {
114+
it('encodes token correctly as Basic auth', () => {
115+
oAuthState.value = { token: 'test-token', isAuthorizing: false };
116+
const getHeaders = createGitHubAuthHeaders();
117+
const headers = getHeaders('https://github.com/user/repo');
118+
119+
const decoded = atob(headers.Authorization.replace('Basic ', ''));
120+
expect(decoded).toBe('test-token:');
121+
});
122+
123+
it('handles tokens with non-ASCII characters (UTF-8)', () => {
124+
// This would fail with plain btoa(): "characters outside of the Latin1 range"
125+
oAuthState.value = {
126+
token: 'test-token-ąñ-emoji-🔑',
127+
isAuthorizing: false,
128+
};
129+
const getHeaders = createGitHubAuthHeaders();
130+
const headers = getHeaders('https://github.com/user/repo');
131+
132+
expect(headers).toHaveProperty('Authorization');
133+
expect(headers.Authorization).toMatch(/^Basic /);
134+
135+
// Verify the encoding is valid base64
136+
const base64Part = headers.Authorization.replace('Basic ', '');
137+
expect(() => atob(base64Part)).not.toThrow();
138+
});
139+
});
140+
});

0 commit comments

Comments
 (0)