|
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"; |
10 | 3 |
|
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 | +); |
17 | 7 |
|
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; |
24 | 10 |
|
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; |
30 | 13 | }); |
31 | 14 |
|
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; |
37 | 17 | }); |
38 | 18 |
|
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 | + /endpoint must start with 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 | + /endpoint must start with 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 | + /endpoint must start with 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 | + /endpoint must start with 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 | + }); |
44 | 103 | }); |
45 | 104 |
|
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 | + /pagination URL must start with 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 | + }); |
51 | 184 | }); |
52 | 185 |
|
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 | + ); |
62 | 209 | }); |
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 | | - } |
70 | 210 | }); |
71 | 211 |
|
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); |
82 | 245 | }); |
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"); |
85 | 255 | 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 | + }), |
88 | 265 | ); |
89 | | - } finally { |
90 | | - globalThis.fetch = original; |
91 | | - } |
| 266 | + }); |
92 | 267 | }); |
93 | 268 | }); |
0 commit comments