Skip to content

Commit e3aca80

Browse files
committed
Add tests
1 parent 71259a9 commit e3aca80

File tree

3 files changed

+304
-3
lines changed

3 files changed

+304
-3
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 = (url: string) => ({});
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 = (url: string) => ({
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 = (url: string) => ({
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 = (url: string) => ({
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+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { createGitHubAuthHeaders, isGitHubUrl } from './git-auth-helpers';
3+
import { oAuthState } from './state';
4+
5+
describe('isGitHubUrl', () => {
6+
describe('direct GitHub URLs', () => {
7+
it('returns true for github.com URLs', () => {
8+
expect(isGitHubUrl('https://github.com/user/repo')).toBe(true);
9+
});
10+
11+
it('returns true for api.github.com URLs', () => {
12+
expect(isGitHubUrl('https://api.github.com/repos')).toBe(true);
13+
});
14+
15+
it('returns false for non-GitHub URLs', () => {
16+
expect(isGitHubUrl('https://gitlab.com/user/repo')).toBe(false);
17+
expect(isGitHubUrl('https://bitbucket.org/user/repo')).toBe(false);
18+
});
19+
});
20+
21+
describe('security: rejects URLs with github.com in wrong places', () => {
22+
it('returns false when github.com is in the path', () => {
23+
expect(isGitHubUrl('https://evil.com/github.com/fake')).toBe(false);
24+
});
25+
26+
it('returns false when github.com is in a query parameter', () => {
27+
expect(isGitHubUrl('https://evil.com?redirect=github.com')).toBe(
28+
false
29+
);
30+
});
31+
32+
it('returns false for look-alike domains', () => {
33+
expect(isGitHubUrl('https://github.com.evil.com')).toBe(false);
34+
expect(isGitHubUrl('https://mygithub.com')).toBe(false);
35+
expect(isGitHubUrl('https://fakegithub.com')).toBe(false);
36+
});
37+
});
38+
39+
describe('CORS proxy URLs', () => {
40+
it('returns true for GitHub URLs through CORS proxy', () => {
41+
expect(
42+
isGitHubUrl(
43+
'https://playground.wordpress.net/cors-proxy.php?https://github.com/user/repo'
44+
)
45+
).toBe(true);
46+
expect(
47+
isGitHubUrl(
48+
'http://127.0.0.1:5263/cors-proxy.php?https://github.com/user/repo'
49+
)
50+
).toBe(true);
51+
});
52+
53+
it('returns false for non-GitHub URLs through CORS proxy', () => {
54+
expect(
55+
isGitHubUrl(
56+
'https://playground.wordpress.net/cors-proxy.php?https://gitlab.com/user/repo'
57+
)
58+
).toBe(false);
59+
});
60+
61+
it('returns false for malicious URLs through CORS proxy', () => {
62+
expect(
63+
isGitHubUrl(
64+
'https://playground.wordpress.net/cors-proxy.php?https://evil.com/github.com/fake'
65+
)
66+
).toBe(false);
67+
expect(
68+
isGitHubUrl(
69+
'http://127.0.0.1:5263/cors-proxy.php?https://github.com.evil.com'
70+
)
71+
).toBe(false);
72+
});
73+
});
74+
75+
describe('edge cases', () => {
76+
it('returns false for invalid URLs', () => {
77+
expect(isGitHubUrl('not-a-url')).toBe(false);
78+
expect(isGitHubUrl('')).toBe(false);
79+
expect(isGitHubUrl('github.com')).toBe(false);
80+
});
81+
});
82+
});
83+
84+
describe('createGitHubAuthHeaders integration', () => {
85+
beforeEach(() => {
86+
oAuthState.value = { token: '', isAuthorizing: false };
87+
});
88+
89+
describe('with GitHub token present', () => {
90+
beforeEach(() => {
91+
oAuthState.value = {
92+
token: 'gho_TestToken123',
93+
isAuthorizing: false,
94+
};
95+
});
96+
97+
it('includes Authorization header for github.com URLs', () => {
98+
const getHeaders = createGitHubAuthHeaders();
99+
const headers = getHeaders('https://github.com/user/repo');
100+
101+
expect(headers).toHaveProperty('Authorization');
102+
expect(headers.Authorization).toMatch(/^Basic /);
103+
expect(headers).toHaveProperty(
104+
'X-Cors-Proxy-Allowed-Request-Headers',
105+
'Authorization'
106+
);
107+
});
108+
109+
it('includes Authorization header for api.github.com URLs', () => {
110+
const getHeaders = createGitHubAuthHeaders();
111+
const headers = getHeaders('https://api.github.com/repos');
112+
113+
expect(headers).toHaveProperty('Authorization');
114+
});
115+
116+
it('includes Authorization header for GitHub URLs through CORS proxy', () => {
117+
const getHeaders = createGitHubAuthHeaders();
118+
const headers = getHeaders(
119+
'https://playground.wordpress.net/cors-proxy.php?https://github.com/user/repo'
120+
);
121+
122+
expect(headers).toHaveProperty('Authorization');
123+
expect(headers).toHaveProperty(
124+
'X-Cors-Proxy-Allowed-Request-Headers'
125+
);
126+
});
127+
128+
it('does NOT include Authorization header for non-GitHub URLs', () => {
129+
const getHeaders = createGitHubAuthHeaders();
130+
131+
expect(getHeaders('https://gitlab.com/user/repo')).toEqual({});
132+
expect(getHeaders('https://bitbucket.org/user/repo')).toEqual({});
133+
expect(getHeaders('https://evil.com/github.com/fake')).toEqual({});
134+
});
135+
136+
it('does NOT include Authorization header for non-GitHub URLs through CORS proxy', () => {
137+
const getHeaders = createGitHubAuthHeaders();
138+
const headers = getHeaders(
139+
'https://playground.wordpress.net/cors-proxy.php?https://gitlab.com/user/repo'
140+
);
141+
142+
expect(headers).toEqual({});
143+
});
144+
});
145+
146+
describe('without GitHub token', () => {
147+
beforeEach(() => {
148+
oAuthState.value = { token: '', isAuthorizing: false };
149+
});
150+
151+
it('returns empty headers even for GitHub URLs', () => {
152+
const getHeaders = createGitHubAuthHeaders();
153+
154+
expect(getHeaders('https://github.com/user/repo')).toEqual({});
155+
});
156+
});
157+
158+
describe('token encoding', () => {
159+
it('encodes token correctly as Basic auth', () => {
160+
oAuthState.value = { token: 'test-token', isAuthorizing: false };
161+
const getHeaders = createGitHubAuthHeaders();
162+
const headers = getHeaders('https://github.com/user/repo');
163+
164+
const decoded = atob(headers.Authorization.replace('Basic ', ''));
165+
expect(decoded).toBe('test-token:');
166+
});
167+
});
168+
});

packages/playground/website/src/github/git-auth-helpers.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function isGitHubUrl(url: string): boolean {
1717
try {
1818
const urlObj = new URL(url);
1919
const hostname = urlObj.hostname;
20-
return hostname === 'github.com';
20+
return hostname === 'github.com' || hostname === 'api.github.com';
2121
} catch {
2222
return false;
2323
}
@@ -33,11 +33,10 @@ export function createGitHubAuthHeaders(): (
3333
return {};
3434
}
3535

36-
const headers = {
36+
return {
3737
Authorization: `Basic ${btoa(`${token}:`)}`,
3838
// Tell the CORS proxy to forward the Authorization header
3939
'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization',
4040
};
41-
return headers;
4241
};
4342
}

0 commit comments

Comments
 (0)