Skip to content

Commit d0d4ac3

Browse files
committed
test: add GitHub REST utils and respec size handler tests
1 parent 5916d39 commit d0d4ac3

2 files changed

Lines changed: 567 additions & 74 deletions

File tree

Lines changed: 249 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,268 @@
1-
import { requestData } from "../../../../../build/routes/github/lib/utils/rest.js";
2-
3-
describe("routes/github/lib/utils/rest - requestData", () => {
4-
it("throws for a non-GitHub API endpoint", async () => {
5-
const gen = requestData("https://evil.example.com/steal");
6-
await expectAsync(gen.next()).toBeRejectedWithError(
7-
/expected https:\/\/api\.github\.com/,
8-
);
9-
});
1+
// Set env before importing the module (tokens.ts reads GH_TOKEN at import time)
2+
process.env.GH_TOKEN ??= "test-token-for-rest-tests";
103

11-
it("throws for an http (non-https) GitHub URL", async () => {
12-
const gen = requestData("http://api.github.com/repos/foo/bar");
13-
await expectAsync(gen.next()).toBeRejectedWithError(
14-
/expected https:\/\/api\.github\.com/,
15-
);
16-
});
4+
const { requestData } = await import(
5+
"../../../../../build/routes/github/lib/utils/rest.js"
6+
);
177

18-
it("throws for a GitHub URL that isn't the API subdomain", async () => {
19-
const gen = requestData("https://github.com/speced/respec");
20-
await expectAsync(gen.next()).toBeRejectedWithError(
21-
/expected https:\/\/api\.github\.com/,
22-
);
23-
});
8+
describe("github/lib/utils/rest - requestData", () => {
9+
let originalFetch;
2410

25-
it("throws for an empty string endpoint", async () => {
26-
const gen = requestData("");
27-
await expectAsync(gen.next()).toBeRejectedWithError(
28-
/expected https:\/\/api\.github\.com/,
29-
);
11+
beforeEach(() => {
12+
originalFetch = globalThis.fetch;
3013
});
3114

32-
it("throws for a URL with github.com as username (URL confusion)", async () => {
33-
const gen = requestData("https://api.github.com@evil.com/path");
34-
await expectAsync(gen.next()).toBeRejectedWithError(
35-
/expected https:\/\/api\.github\.com/,
36-
);
15+
afterEach(() => {
16+
globalThis.fetch = originalFetch;
3717
});
3818

39-
it("throws for a blob: URL with matching origin", async () => {
40-
const gen = requestData("blob:https://api.github.com/some-uuid");
41-
await expectAsync(gen.next()).toBeRejectedWithError(
42-
/expected https:\/\/api\.github\.com/,
43-
);
19+
/**
20+
* Creates a mock fetch that returns a single page of JSON data.
21+
* @param {object} [options]
22+
* @param {object} [options.json] - JSON body to return
23+
* @param {number} [options.status] - HTTP status code
24+
* @param {string} [options.statusText] - HTTP status text
25+
* @param {string} [options.linkHeader] - Link header value
26+
* @param {Record<string, string>} [options.extraHeaders] - Additional headers
27+
*/
28+
function mockFetch({
29+
json = { ok: true },
30+
status = 200,
31+
statusText = "OK",
32+
linkHeader = "",
33+
extraHeaders = {},
34+
} = {}) {
35+
const headers = new Headers({
36+
"x-ratelimit-remaining": "4999",
37+
"x-ratelimit-reset": "1700000000",
38+
"x-ratelimit-limit": "5000",
39+
...extraHeaders,
40+
});
41+
if (linkHeader) {
42+
headers.set("link", linkHeader);
43+
}
44+
45+
globalThis.fetch = jasmine
46+
.createSpy("fetch")
47+
.and.resolveTo(
48+
new Response(JSON.stringify(json), { status, statusText, headers }),
49+
);
50+
}
51+
52+
// -- URL validation / SSRF guard --
53+
54+
describe("endpoint URL validation (SSRF guard)", () => {
55+
it("rejects a non-GitHub URL", async () => {
56+
const gen = requestData("https://evil.example.com/repos");
57+
await expectAsync(gen.next()).toBeRejectedWithError(
58+
/expected https:\/\/api\.github\.com/,
59+
);
60+
});
61+
62+
it("rejects an HTTP (non-HTTPS) GitHub URL", async () => {
63+
const gen = requestData("http://api.github.com/repos");
64+
await expectAsync(gen.next()).toBeRejectedWithError(
65+
/expected https:\/\/api\.github\.com/,
66+
);
67+
});
68+
69+
it("rejects a URL that contains the prefix but doesn't start with it", async () => {
70+
const gen = requestData(
71+
"https://evil.com/?redirect=https://api.github.com/repos",
72+
);
73+
await expectAsync(gen.next()).toBeRejectedWithError(
74+
/expected https:\/\/api\.github\.com/,
75+
);
76+
});
77+
78+
it("rejects an empty string", async () => {
79+
const gen = requestData("");
80+
await expectAsync(gen.next()).toBeRejectedWithError(
81+
/expected https:\/\/api\.github\.com/,
82+
);
83+
});
84+
85+
it("accepts a valid GitHub API URL", async () => {
86+
mockFetch({ json: [{ id: 1 }] });
87+
88+
const gen = requestData("https://api.github.com/repos/user/repo");
89+
const { value, done } = await gen.next();
90+
expect(done).toBeFalse();
91+
expect(value.result).toEqual([{ id: 1 }]);
92+
});
93+
94+
it("accepts a GitHub API URL with path and query params", async () => {
95+
mockFetch({ json: { total: 42 } });
96+
97+
const gen = requestData(
98+
"https://api.github.com/search/repositories?q=respec&per_page=100",
99+
);
100+
const { value } = await gen.next();
101+
expect(value.result).toEqual({ total: 42 });
102+
});
44103
});
45104

46-
it("throws for a data: URL", async () => {
47-
const gen = requestData("data:text/html,<h1>hi</h1>");
48-
await expectAsync(gen.next()).toBeRejectedWithError(
49-
/expected https:\/\/api\.github\.com/,
50-
);
105+
// -- Pagination URL validation --
106+
107+
describe("pagination link validation", () => {
108+
it("rejects a pagination link pointing to a non-GitHub domain", async () => {
109+
mockFetch({
110+
json: { page: 1 },
111+
linkHeader: '<https://evil.com/next>; rel="next"',
112+
});
113+
114+
const gen = requestData(
115+
"https://api.github.com/repos/w3c/respec/issues",
116+
);
117+
// First yield succeeds (the initial fetch is valid)
118+
const first = await gen.next();
119+
expect(first.value.result).toEqual({ page: 1 });
120+
121+
// The generator throws when it processes the malicious Link header,
122+
// which surfaces on the next .next() call after the yield.
123+
await expectAsync(gen.next()).toBeRejectedWithError(
124+
/expected https:\/\/api\.github\.com/,
125+
);
126+
});
127+
128+
it("follows valid GitHub pagination links", async () => {
129+
const page1Headers = new Headers({
130+
"x-ratelimit-remaining": "4999",
131+
"x-ratelimit-reset": "1700000000",
132+
"x-ratelimit-limit": "5000",
133+
link: '<https://api.github.com/repos/w3c/respec/issues?page=2>; rel="next"',
134+
});
135+
const page2Headers = new Headers({
136+
"x-ratelimit-remaining": "4998",
137+
"x-ratelimit-reset": "1700000000",
138+
"x-ratelimit-limit": "5000",
139+
});
140+
141+
let callCount = 0;
142+
globalThis.fetch = jasmine.createSpy("fetch").and.callFake(() => {
143+
callCount++;
144+
if (callCount === 1) {
145+
return Promise.resolve(
146+
new Response(JSON.stringify({ page: 1 }), {
147+
status: 200,
148+
headers: page1Headers,
149+
}),
150+
);
151+
}
152+
return Promise.resolve(
153+
new Response(JSON.stringify({ page: 2 }), {
154+
status: 200,
155+
headers: page2Headers,
156+
}),
157+
);
158+
});
159+
160+
const gen = requestData(
161+
"https://api.github.com/repos/w3c/respec/issues",
162+
);
163+
const first = await gen.next();
164+
expect(first.value.result).toEqual({ page: 1 });
165+
166+
const second = await gen.next();
167+
expect(second.value.result).toEqual({ page: 2 });
168+
169+
// No more pages
170+
const third = await gen.next();
171+
expect(third.done).toBeTrue();
172+
});
173+
174+
it("stops when there is no next page link", async () => {
175+
mockFetch({ json: { only: "page" } });
176+
177+
const gen = requestData("https://api.github.com/repos/w3c/respec");
178+
const first = await gen.next();
179+
expect(first.value.result).toEqual({ only: "page" });
180+
181+
const second = await gen.next();
182+
expect(second.done).toBeTrue();
183+
});
51184
});
52185

53-
it("accepts a valid GitHub API URL", async () => {
54-
const original = globalThis.fetch;
55-
globalThis.fetch = async () => new Response(JSON.stringify([]), {
56-
status: 200,
57-
headers: {
58-
"x-ratelimit-remaining": "10",
59-
"x-ratelimit-reset": "9999999999",
60-
"x-ratelimit-limit": "60",
61-
},
186+
// -- HTTP error handling --
187+
188+
describe("HTTP error handling", () => {
189+
it("throws on non-OK response", async () => {
190+
mockFetch({ status: 404, statusText: "Not Found", json: {} });
191+
192+
const gen = requestData("https://api.github.com/repos/nonexistent");
193+
await expectAsync(gen.next()).toBeRejectedWithError(
194+
/Failed to fetch.*404 Not Found/,
195+
);
196+
});
197+
198+
it("throws on 500 server error", async () => {
199+
mockFetch({
200+
status: 500,
201+
statusText: "Internal Server Error",
202+
json: {},
203+
});
204+
205+
const gen = requestData("https://api.github.com/repos/w3c/respec");
206+
await expectAsync(gen.next()).toBeRejectedWithError(
207+
/Failed to fetch.*500 Internal Server Error/,
208+
);
62209
});
63-
try {
64-
const gen = requestData("https://api.github.com/repos/w3c/respec/issues");
65-
const { value } = await gen.next();
66-
expect(value.url).toBe("https://api.github.com/repos/w3c/respec/issues");
67-
} finally {
68-
globalThis.fetch = original;
69-
}
70210
});
71211

72-
it("rejects a malicious pagination URL in Link header", async () => {
73-
const original = globalThis.fetch;
74-
globalThis.fetch = async () => new Response(JSON.stringify([]), {
75-
status: 200,
76-
headers: {
77-
link: '<https://evil.com/page2>; rel="next"',
78-
"x-ratelimit-remaining": "10",
79-
"x-ratelimit-reset": "9999999999",
80-
"x-ratelimit-limit": "60",
81-
},
212+
// -- Page limit --
213+
214+
describe("page limit", () => {
215+
it("respects the pages argument", async () => {
216+
// Create a fetch that always returns a next page link
217+
let callCount = 0;
218+
globalThis.fetch = jasmine.createSpy("fetch").and.callFake(() => {
219+
callCount++;
220+
const headers = new Headers({
221+
"x-ratelimit-remaining": String(5000 - callCount),
222+
"x-ratelimit-reset": "1700000000",
223+
"x-ratelimit-limit": "5000",
224+
link: `<https://api.github.com/repos/w3c/respec/issues?page=${callCount + 1}>; rel="next"`,
225+
});
226+
return Promise.resolve(
227+
new Response(JSON.stringify({ page: callCount }), {
228+
status: 200,
229+
headers,
230+
}),
231+
);
232+
});
233+
234+
// Request only 2 pages
235+
const gen = requestData(
236+
"https://api.github.com/repos/w3c/respec/issues",
237+
2,
238+
);
239+
const results = [];
240+
for await (const item of gen) {
241+
results.push(item.result);
242+
}
243+
expect(results).toEqual([{ page: 1 }, { page: 2 }]);
244+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
82245
});
83-
try {
84-
const gen = requestData("https://api.github.com/repos/w3c/respec/issues");
246+
});
247+
248+
// -- Request headers --
249+
250+
describe("request headers", () => {
251+
it("sends Accept and Authorization headers", async () => {
252+
mockFetch({ json: {} });
253+
254+
const gen = requestData("https://api.github.com/repos/w3c/respec");
85255
await gen.next();
86-
await expectAsync(gen.next()).toBeRejectedWithError(
87-
/expected https:\/\/api\.github\.com/,
256+
257+
expect(globalThis.fetch).toHaveBeenCalledOnceWith(
258+
"https://api.github.com/repos/w3c/respec",
259+
jasmine.objectContaining({
260+
headers: jasmine.objectContaining({
261+
Accept: "application/vnd.github.v3+json",
262+
Authorization: jasmine.stringMatching(/^token /),
263+
}),
264+
}),
88265
);
89-
} finally {
90-
globalThis.fetch = original;
91-
}
266+
});
92267
});
93268
});

0 commit comments

Comments
 (0)