@framers/agentos-ext-github 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +6 -6
  2. package/dist/documents/GitHubDocumentsConnector.d.ts +37 -11
  3. package/dist/documents/GitHubDocumentsConnector.d.ts.map +1 -1
  4. package/dist/documents/GitHubDocumentsConnector.js +67 -22
  5. package/dist/documents/GitHubDocumentsConnector.js.map +1 -1
  6. package/dist/documents/links.d.ts +19 -13
  7. package/dist/documents/links.d.ts.map +1 -1
  8. package/dist/documents/links.js +43 -24
  9. package/dist/documents/links.js.map +1 -1
  10. package/dist/documents/tree.d.ts +29 -10
  11. package/dist/documents/tree.d.ts.map +1 -1
  12. package/dist/documents/tree.js +70 -11
  13. package/dist/documents/tree.js.map +1 -1
  14. package/manifest.json +1 -1
  15. package/package.json +7 -5
  16. package/dist/__tests__/app.test.d.ts +0 -7
  17. package/dist/__tests__/app.test.d.ts.map +0 -1
  18. package/dist/__tests__/app.test.js +0 -1219
  19. package/dist/__tests__/app.test.js.map +0 -1
  20. package/dist/__tests__/documents.test.d.ts +0 -9
  21. package/dist/__tests__/documents.test.d.ts.map +0 -1
  22. package/dist/__tests__/documents.test.js +0 -644
  23. package/dist/__tests__/documents.test.js.map +0 -1
  24. package/dist/__tests__/entry.test.d.ts +0 -7
  25. package/dist/__tests__/entry.test.d.ts.map +0 -1
  26. package/dist/__tests__/entry.test.js +0 -34
  27. package/dist/__tests__/entry.test.js.map +0 -1
  28. package/dist/__tests__/events.test.d.ts +0 -8
  29. package/dist/__tests__/events.test.d.ts.map +0 -1
  30. package/dist/__tests__/events.test.js +0 -321
  31. package/dist/__tests__/events.test.js.map +0 -1
  32. package/dist/__tests__/fileAndBranchTools.test.d.ts +0 -6
  33. package/dist/__tests__/fileAndBranchTools.test.d.ts.map +0 -1
  34. package/dist/__tests__/fileAndBranchTools.test.js +0 -310
  35. package/dist/__tests__/fileAndBranchTools.test.js.map +0 -1
  36. package/dist/__tests__/indexer.test.d.ts +0 -6
  37. package/dist/__tests__/indexer.test.d.ts.map +0 -1
  38. package/dist/__tests__/indexer.test.js +0 -253
  39. package/dist/__tests__/indexer.test.js.map +0 -1
  40. package/dist/__tests__/issueTools.test.d.ts +0 -5
  41. package/dist/__tests__/issueTools.test.d.ts.map +0 -1
  42. package/dist/__tests__/issueTools.test.js +0 -183
  43. package/dist/__tests__/issueTools.test.js.map +0 -1
  44. package/dist/__tests__/links.test.d.ts +0 -10
  45. package/dist/__tests__/links.test.d.ts.map +0 -1
  46. package/dist/__tests__/links.test.js +0 -255
  47. package/dist/__tests__/links.test.js.map +0 -1
  48. package/dist/__tests__/prTools.test.d.ts +0 -5
  49. package/dist/__tests__/prTools.test.d.ts.map +0 -1
  50. package/dist/__tests__/prTools.test.js +0 -318
  51. package/dist/__tests__/prTools.test.js.map +0 -1
  52. package/dist/__tests__/refs.test.d.ts +0 -8
  53. package/dist/__tests__/refs.test.d.ts.map +0 -1
  54. package/dist/__tests__/refs.test.js +0 -702
  55. package/dist/__tests__/refs.test.js.map +0 -1
  56. package/dist/__tests__/releaseAndActionsTools.test.d.ts +0 -6
  57. package/dist/__tests__/releaseAndActionsTools.test.d.ts.map +0 -1
  58. package/dist/__tests__/releaseAndActionsTools.test.js +0 -315
  59. package/dist/__tests__/releaseAndActionsTools.test.js.map +0 -1
  60. package/dist/__tests__/repoTools.test.d.ts +0 -6
  61. package/dist/__tests__/repoTools.test.d.ts.map +0 -1
  62. package/dist/__tests__/repoTools.test.js +0 -308
  63. package/dist/__tests__/repoTools.test.js.map +0 -1
  64. package/dist/__tests__/service.test.d.ts +0 -6
  65. package/dist/__tests__/service.test.d.ts.map +0 -1
  66. package/dist/__tests__/service.test.js +0 -173
  67. package/dist/__tests__/service.test.js.map +0 -1
@@ -1,1219 +0,0 @@
1
- /**
2
- * @fileoverview The GitHub App half of the `./documents` entry, driven over a fetch stand-in that records each
3
- * request: the app's JWT, installation tokens, the user's web flow, a user's installations and repositories, the
4
- * webhook signature, the daily reconcile and the uninstall, and the REST calls under them.
5
- */
6
- import { createHash, createHmac, createVerify, generateKeyPairSync } from 'node:crypto';
7
- import { afterEach, describe, expect, it, vi } from 'vitest';
8
- import { githubFetch } from '../app/http.js';
9
- import { appJwt, GITHUB_OAUTH_ENDPOINTS, GitHubApiError, GitHubAppCredential, GitHubAppUserFlow, GitHubListingChanged, GitHubNoAnswer, getInstallation, installationsOfUser, listInstallations, reconcileInstallations, repositoriesLost, repositoriesOfInstallation, repositoriesOfUser, uninstallInstallation, userOfToken, verifyWebhookSignature, } from '../documents/index.js';
10
- /** A fetch that answers from `route` and records every request. */
11
- function standIn(route) {
12
- const seen = [];
13
- const fetchImpl = async (input, init) => {
14
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
15
- const request = {
16
- method: init?.method ?? 'GET',
17
- url,
18
- headers: new Headers(init?.headers),
19
- body: typeof init?.body === 'string' ? init.body : null,
20
- };
21
- seen.push(request);
22
- return route(request);
23
- };
24
- return { fetchImpl, seen };
25
- }
26
- /** A JSON answer. */
27
- function json(body, status = 200, headers = {}) {
28
- return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
29
- }
30
- /** A fetch that never answers, rejecting with its signal's reason when the signal aborts, as fetch does. */
31
- const neverAnswers = async (_input, init) => {
32
- const signal = init?.signal ?? undefined;
33
- return new Promise((_resolve, reject) => {
34
- if (signal === undefined)
35
- return;
36
- if (signal.aborted)
37
- reject(signal.reason);
38
- else
39
- signal.addEventListener('abort', () => reject(signal.reason), { once: true });
40
- });
41
- };
42
- /**
43
- * A fetch that answers 200 with the start of a body and then stalls, erroring the body when its signal aborts, as
44
- * fetch does to a body still streaming.
45
- */
46
- const stallsInBody = async (_input, init) => {
47
- const signal = init?.signal ?? undefined;
48
- const body = new ReadableStream({
49
- start(controller) {
50
- controller.enqueue(new TextEncoder().encode('{"login":'));
51
- if (signal !== undefined)
52
- signal.addEventListener('abort', () => controller.error(signal.reason), { once: true });
53
- },
54
- });
55
- return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
56
- };
57
- /**
58
- * A fetch that answers 403 with no rate limit header and the start of a body that would name a secondary rate limit,
59
- * then stalls, erroring the body when its signal aborts.
60
- */
61
- const refusalStallsInBody = async (_input, init) => {
62
- const signal = init?.signal ?? undefined;
63
- const body = new ReadableStream({
64
- start(controller) {
65
- controller.enqueue(new TextEncoder().encode('{"message":"You have exceeded a secondary rate'));
66
- if (signal !== undefined)
67
- signal.addEventListener('abort', () => controller.error(signal.reason), { once: true });
68
- },
69
- });
70
- return new Response(body, { status: 403, headers: { 'content-type': 'application/json' } });
71
- };
72
- /** A fetch that never answers and never looks at the signal it is handed, as a wrapper that drops the signal would. */
73
- const ignoresSignal = async () => new Promise(() => undefined);
74
- /**
75
- * A fetch that answers `status` with the start of a body and then stalls, leaving the body open when its signal aborts,
76
- * as a fetch that does not tie its answer's body to its signal would.
77
- */
78
- function bodyIgnoresSignal(status, start) {
79
- return async () => new Response(new ReadableStream({
80
- start(controller) {
81
- controller.enqueue(new TextEncoder().encode(start));
82
- },
83
- }), { status, headers: { 'content-type': 'application/json' } });
84
- }
85
- /**
86
- * A fetch that answers `status` with `headers` and the start of a body, then stalls, leaving the body open when its
87
- * signal aborts, as a fetch that does not tie its answer's body to its signal would. It counts the body's cancels; with
88
- * `settles` false a cancel never settles, as with a stream whose source never answers one.
89
- */
90
- function heldBody(status, start, options = {}) {
91
- let cancels = 0;
92
- let waiting = () => undefined;
93
- const stalled = new Promise((resolve) => {
94
- waiting = () => resolve();
95
- });
96
- const fetchImpl = async () => {
97
- let pulls = 0;
98
- return new Response(new ReadableStream({
99
- pull(controller) {
100
- pulls += 1;
101
- if (pulls === 1)
102
- controller.enqueue(new TextEncoder().encode(start));
103
- else
104
- waiting();
105
- },
106
- cancel() {
107
- cancels += 1;
108
- return options.settles === false ? new Promise(() => undefined) : undefined;
109
- },
110
- }, { highWaterMark: 0 }), { status, headers: { 'content-type': 'application/json', ...options.headers } });
111
- };
112
- return { fetchImpl, cancels: () => cancels, stalled };
113
- }
114
- /** A 202 whose body never settles a cancel, counting the cancels it gets. */
115
- function acceptedHolding() {
116
- let cancels = 0;
117
- const answer = () => new Response(new ReadableStream({
118
- cancel() {
119
- cancels += 1;
120
- return new Promise(() => undefined);
121
- },
122
- }), { status: 202 });
123
- return { answer, cancels: () => cancels };
124
- }
125
- const BASE = 'https://stand-in.test';
126
- const CLIENT_ID = 'Iv23liTESTCLIENT';
127
- const keys = generateKeyPairSync('rsa', {
128
- modulusLength: 2048,
129
- publicKeyEncoding: { type: 'spki', format: 'pem' },
130
- privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
131
- });
132
- /** The claims of a bearer that verifies as the app's JWT under the test key pair, or null. */
133
- function appClaims(authorization) {
134
- const match = /^Bearer ([\w-]+)\.([\w-]+)\.([\w-]+)$/.exec(authorization ?? '');
135
- if (match === null)
136
- return null;
137
- const [, head, body, signature] = match;
138
- const verified = createVerify('RSA-SHA256').update(`${head}.${body}`).verify(keys.publicKey, Buffer.from(signature, 'base64url'));
139
- return verified ? JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) : null;
140
- }
141
- const HEAD_BEFORE = 'a'.repeat(40);
142
- const HEAD_NOW = 'b'.repeat(40);
143
- /** Two links: installation 21, whose repository 7 moved and whose repository 9 left it, and installation 22, gone. */
144
- const RECONCILE_LINKS = [
145
- {
146
- installationId: 21,
147
- repositories: [
148
- { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE },
149
- { id: 9, fullName: 'first-team/removed', defaultBranch: 'main', headCommit: HEAD_BEFORE },
150
- ],
151
- },
152
- { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
153
- ];
154
- /** What a reconcile of {@link RECONCILE_LINKS} finds before its deliveries step. */
155
- const RECONCILED = {
156
- gone: [22],
157
- suspended: [],
158
- moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
159
- unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
160
- };
161
- /**
162
- * A stand-in for a reconcile of {@link RECONCILE_LINKS}: GitHub lists installation 21 alone, mints a token for
163
- * repository 7 and refuses one for repository 9 with 422, answers repository 7's head as {@link HEAD_NOW}, and leaves
164
- * the app's hook routes to `hooks`.
165
- */
166
- function reconcileStandIn(hooks) {
167
- return standIn((request) => {
168
- const url = new URL(request.url);
169
- if (url.pathname === '/app/installations')
170
- return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
171
- if (url.pathname === '/app/installations/21/access_tokens') {
172
- const ids = JSON.parse(request.body ?? '{}').repository_ids;
173
- if (ids.includes(9))
174
- return json({ message: 'There is at least one repository that does not exist' }, 422);
175
- return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
176
- }
177
- if (url.pathname === '/repositories/7/branches/main')
178
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
179
- if (url.pathname.startsWith('/app/hook/'))
180
- return hooks(request, url);
181
- return json({ message: 'Not Found' }, 404);
182
- });
183
- }
184
- afterEach(() => {
185
- vi.useRealTimers();
186
- });
187
- describe('the GitHub App', () => {
188
- it('signs its JWT with RS256, issued 60 seconds back, valid nine minutes, the client id as issuer', () => {
189
- const now = 1_760_000_000;
190
- const token = appJwt({ clientId: CLIENT_ID, privateKey: keys.privateKey }, now);
191
- const [head, body, signature] = token.split('.');
192
- expect(JSON.parse(Buffer.from(head, 'base64url').toString('utf8'))).toEqual({ alg: 'RS256', typ: 'JWT' });
193
- expect(JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))).toEqual({ iat: now - 60, exp: now + 540, iss: CLIENT_ID });
194
- expect(createVerify('RSA-SHA256').update(`${head}.${body}`).verify(keys.publicKey, Buffer.from(signature, 'base64url'))).toBe(true);
195
- });
196
- it('mints an installation token narrowed to the repositories asked for and holds it until five minutes before it ends', async () => {
197
- vi.useFakeTimers({ toFake: ['Date'] });
198
- vi.setSystemTime(new Date('2026-10-14T12:00:00Z'));
199
- let minted = 0;
200
- const { fetchImpl, seen } = standIn(() => {
201
- minted += 1;
202
- return json({ token: `ghs_test${minted}`, expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
203
- });
204
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
205
- expect(await credential.installationToken(42, [7, 3])).toBe('ghs_test1');
206
- expect(seen).toHaveLength(1);
207
- expect(seen[0].method).toBe('POST');
208
- expect(seen[0].url).toBe(`${BASE}/app/installations/42/access_tokens`);
209
- expect(appClaims(seen[0].headers.get('authorization'))).toMatchObject({ iss: CLIENT_ID });
210
- expect(seen[0].headers.get('x-github-api-version')).toBe('2026-03-10');
211
- expect(seen[0].headers.get('accept')).toBe('application/vnd.github+json');
212
- expect(JSON.parse(seen[0].body ?? '')).toEqual({ repository_ids: [7, 3] });
213
- vi.setSystemTime(new Date('2026-10-14T12:54:00Z'));
214
- expect(await credential.installationToken(42, [3, 7])).toBe('ghs_test1');
215
- expect(seen).toHaveLength(1);
216
- vi.setSystemTime(new Date('2026-10-14T12:56:00Z'));
217
- expect(await credential.installationToken(42, [3, 7])).toBe('ghs_test2');
218
- expect(seen).toHaveLength(2);
219
- });
220
- it('refuses an empty repository list before any request, since GitHub would answer a token for every repository', async () => {
221
- const { fetchImpl, seen } = standIn(() => json({ token: 'ghs_every', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201));
222
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
223
- await expect(credential.installationToken(42, [])).rejects.toThrow(TypeError);
224
- expect(seen).toHaveLength(0);
225
- });
226
- it("lists every repository an installation reaches across pages, with a token that reads their metadata alone and is held", async () => {
227
- const { fetchImpl, seen } = standIn((request) => {
228
- const url = new URL(request.url);
229
- if (url.pathname === '/app/installations/21/access_tokens') {
230
- return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
231
- }
232
- if (url.pathname === '/installation/repositories' && url.searchParams.get('page') === '2') {
233
- return json({ total_count: 2, repository_selection: 'selected', repositories: [{ id: 9, full_name: 'first-team/plans', default_branch: 'trunk', private: true }] });
234
- }
235
- if (url.pathname === '/installation/repositories') {
236
- const next = `${BASE}/installation/repositories?per_page=100&page=2`;
237
- return json({ total_count: 2, repository_selection: 'selected', repositories: [{ id: 7, full_name: 'first-team/handbook', default_branch: 'main', private: true }] }, 200, { link: `<${next}>; rel="next", <${next}>; rel="last"` });
238
- }
239
- return json({ message: 'Not Found' }, 404);
240
- });
241
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
242
- const at = { fetchImpl, apiBase: BASE };
243
- expect(await repositoriesOfInstallation(credential, 21, at)).toEqual([
244
- { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main' },
245
- { id: 9, fullName: 'first-team/plans', defaultBranch: 'trunk' },
246
- ]);
247
- expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
248
- 'POST /app/installations/21/access_tokens',
249
- 'GET /installation/repositories',
250
- 'GET /installation/repositories',
251
- ]);
252
- // No repository named, so the token reaches every repository of the installation, and their metadata alone.
253
- expect(JSON.parse(seen[0].body ?? '')).toEqual({ permissions: { metadata: 'read' } });
254
- expect(appClaims(seen[0].headers.get('authorization'))).toMatchObject({ iss: CLIENT_ID });
255
- expect(seen.slice(1).every((request) => request.headers.get('authorization') === 'Bearer ghs_metadata')).toBe(true);
256
- expect(seen.slice(1).every((request) => request.headers.get('x-github-api-version') === '2026-03-10')).toBe(true);
257
- // The token is held as an installation token is, apart from the tokens narrowed to repositories.
258
- await repositoriesOfInstallation(credential, 21, at);
259
- expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
260
- await credential.installationToken(21, [7]);
261
- expect(seen.filter((request) => request.method === 'POST').map((request) => JSON.parse(request.body ?? ''))).toEqual([
262
- { permissions: { metadata: 'read' } },
263
- { repository_ids: [7] },
264
- ]);
265
- });
266
- it('mints the metadata token again when GitHub refuses the held one, so an installation removed since reads as gone (404), not as a refused token', async () => {
267
- let removed = false;
268
- const { fetchImpl, seen } = standIn((request) => {
269
- const url = new URL(request.url);
270
- if (url.pathname === '/app/installations/21/access_tokens') {
271
- if (removed)
272
- return json({ message: 'Not Found' }, 404);
273
- return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
274
- }
275
- if (url.pathname === '/installation/repositories') {
276
- if (removed)
277
- return json({ message: 'Bad credentials' }, 401);
278
- return json({ total_count: 1, repository_selection: 'selected', repositories: [{ id: 7, full_name: 'first-team/handbook', default_branch: 'main' }] });
279
- }
280
- return json({ message: 'Not Found' }, 404);
281
- });
282
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
283
- const at = { fetchImpl, apiBase: BASE };
284
- expect(await repositoriesOfInstallation(credential, 21, at)).toHaveLength(1);
285
- // GitHub removes the installation: the held token is refused, and a new one cannot be minted.
286
- removed = true;
287
- const failure = await repositoriesOfInstallation(credential, 21, at).catch((error) => error);
288
- expect(failure).toBeInstanceOf(GitHubApiError);
289
- expect(failure.status).toBe(404);
290
- expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual([
291
- 'POST /app/installations/21/access_tokens',
292
- 'GET /installation/repositories',
293
- 'GET /installation/repositories',
294
- 'POST /app/installations/21/access_tokens',
295
- ]);
296
- });
297
- it('throws a listing whose metadata token GitHub refuses, so a lost installation never reads as one that reaches no repository', async () => {
298
- const { fetchImpl, seen } = standIn(() => json({ message: 'Not Found' }, 404));
299
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
300
- const failure = await repositoriesOfInstallation(credential, 21, { fetchImpl, apiBase: BASE }).catch((error) => error);
301
- expect(failure).toBeInstanceOf(GitHubApiError);
302
- expect(failure.status).toBe(404);
303
- expect(seen.map((request) => new URL(request.url).pathname)).toEqual(['/app/installations/21/access_tokens']);
304
- });
305
- it('reads a listing again while its pages disagree on the count, and throws one that keeps changing', async () => {
306
- // GitHub's pages of two: while a read is between its pages, a repository is taken off the installation, so the count
307
- // the second page gives is one less and another repository has moved onto the first page, already read.
308
- let reached = [5, 7, 9, 11, 13];
309
- let firstPages = 0;
310
- let removals = 1;
311
- const { fetchImpl, seen } = standIn((request) => {
312
- const url = new URL(request.url);
313
- if (url.pathname === '/app/installations/21/access_tokens') {
314
- return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
315
- }
316
- if (url.pathname === '/installation/repositories') {
317
- const page = Number(url.searchParams.get('page') ?? '1');
318
- if (page === 1)
319
- firstPages += 1;
320
- if (page === 2 && removals > 0) {
321
- removals -= 1;
322
- reached = reached.slice(1);
323
- }
324
- const pages = Math.ceil(reached.length / 2);
325
- const next = `${BASE}/installation/repositories?per_page=100&page=${page + 1}`;
326
- return json({
327
- total_count: reached.length,
328
- repository_selection: 'selected',
329
- repositories: reached.slice((page - 1) * 2, page * 2).map((id) => ({ id, full_name: `first-team/repository-${id}`, default_branch: 'main' })),
330
- }, 200, page < pages ? { link: `<${next}>; rel="next"` } : {});
331
- }
332
- return json({ message: 'Not Found' }, 404);
333
- });
334
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
335
- const at = { fetchImpl, apiBase: BASE };
336
- // The first read lost 9 to the move and gave counts of 5 and 4; the second agrees with itself and is answered.
337
- expect((await repositoriesOfInstallation(credential, 21, at)).map((repository) => repository.id)).toEqual([7, 9, 11, 13]);
338
- expect(firstPages).toBe(2);
339
- // A listing that changes during each of three reads is thrown, answering no repository.
340
- reached = [5, 7, 9, 11, 13, 15, 17];
341
- firstPages = 0;
342
- removals = 3;
343
- const failure = await repositoriesOfInstallation(credential, 21, at).catch((error) => error);
344
- expect(failure).toBeInstanceOf(GitHubListingChanged);
345
- expect(failure.message).not.toMatch(/\d/);
346
- expect(firstPages).toBe(3);
347
- expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
348
- });
349
- it("answers a held repository lost only when two listings miss it, so one a change moved past a page's end is kept", async () => {
350
- // GitHub's pages of two: while the first listing is between its pages, repository 5 is taken off the installation
351
- // and 13 added to it, which leaves the count as it was, so 9 moves onto the first page, already read, and that
352
- // listing misses it.
353
- let reached = [5, 7, 9, 11];
354
- let firstPages = 0;
355
- const { fetchImpl, seen } = standIn((request) => {
356
- const url = new URL(request.url);
357
- if (url.pathname === '/app/installations/21/access_tokens') {
358
- return json({ token: 'ghs_metadata', expires_at: new Date(Date.now() + 3_600_000).toISOString() }, 201);
359
- }
360
- if (url.pathname === '/installation/repositories') {
361
- const page = Number(url.searchParams.get('page') ?? '1');
362
- if (page === 1)
363
- firstPages += 1;
364
- if (page === 2 && firstPages === 1)
365
- reached = [...reached.filter((id) => id !== 5), 13];
366
- const pages = Math.ceil(reached.length / 2);
367
- const next = `${BASE}/installation/repositories?per_page=100&page=${page + 1}`;
368
- return json({
369
- total_count: reached.length,
370
- repository_selection: 'selected',
371
- repositories: reached.slice((page - 1) * 2, page * 2).map((id) => ({ id, full_name: `first-team/repository-${id}`, default_branch: 'main' })),
372
- }, 200, page < pages ? { link: `<${next}>; rel="next"` } : {});
373
- }
374
- return json({ message: 'Not Found' }, 404);
375
- });
376
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
377
- const at = { fetchImpl, apiBase: BASE };
378
- const listings = () => seen.filter((request) => new URL(request.url).pathname === '/installation/repositories').length;
379
- // 9 is missing from the first listing alone and kept; 12 is missing from both and lost. 5, which the first listing
380
- // still held, waits for the next run.
381
- expect(await repositoriesLost(credential, 21, [5, 7, 9, 12, 12], at)).toEqual([12]);
382
- expect(listings()).toBe(4);
383
- expect(seen.filter((request) => request.method === 'POST')).toHaveLength(1);
384
- // A listing that holds every held repository is read once, and an empty list asks GitHub for nothing.
385
- const before = listings();
386
- expect(await repositoriesLost(credential, 21, [7, 9, 11], at)).toEqual([]);
387
- expect(listings() - before).toBe(2);
388
- expect(await repositoriesLost(credential, 21, [], at)).toEqual([]);
389
- expect(listings() - before).toBe(2);
390
- // A refused listing throws, answering nothing lost.
391
- const refusing = standIn(() => json({ message: 'Not Found' }, 404));
392
- const refused = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, refusing.fetchImpl, BASE);
393
- const failure = await repositoriesLost(refused, 21, [7], { fetchImpl: refusing.fetchImpl, apiBase: BASE }).catch((error) => error);
394
- expect(failure).toBeInstanceOf(GitHubApiError);
395
- expect(failure.status).toBe(404);
396
- });
397
- it("begins the user's web flow with state and PKCE and no scope, and completes it into a token it never stores", async () => {
398
- const { fetchImpl, seen } = standIn(() => json({ access_token: 'ghu_testuser', expires_in: 28_800, refresh_token: 'ghr_testuser', refresh_token_expires_in: 15_897_600, scope: '', token_type: 'bearer' }));
399
- const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, fetchImpl);
400
- const redirectUri = 'https://app.example/sources/github/callback';
401
- const begun = flow.begin({ redirectUri });
402
- const address = new URL(begun.url);
403
- expect(`${address.origin}${address.pathname}`).toBe('https://github.com/login/oauth/authorize');
404
- expect(address.searchParams.get('client_id')).toBe(CLIENT_ID);
405
- expect(address.searchParams.get('redirect_uri')).toBe(redirectUri);
406
- expect(address.searchParams.get('state')).toBe(begun.state);
407
- expect(address.searchParams.get('code_challenge')).toBe(createHash('sha256').update(begun.codeVerifier).digest('base64url'));
408
- expect(address.searchParams.get('code_challenge_method')).toBe('S256');
409
- expect(address.searchParams.has('scope')).toBe(false);
410
- const tokens = await flow.complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri });
411
- expect(tokens.accessToken).toBe('ghu_testuser');
412
- expect(seen).toHaveLength(1);
413
- expect(seen[0].method).toBe('POST');
414
- expect(seen[0].url).toBe('https://github.com/login/oauth/access_token');
415
- expect(seen[0].headers.get('accept')).toBe('application/json');
416
- const form = new URLSearchParams(seen[0].body ?? '');
417
- expect(form.get('code')).toBe('test-code');
418
- expect(form.get('code_verifier')).toBe(begun.codeVerifier);
419
- expect(form.get('client_id')).toBe(CLIENT_ID);
420
- expect(form.get('client_secret')).toBe('test-client-secret');
421
- expect(form.get('redirect_uri')).toBe(redirectUri);
422
- await expect(flow.keep('person', tokens)).rejects.toThrow('this flow has no store');
423
- });
424
- it("ends the user's code exchange at its deadline, and names a token address that cannot be reached", async () => {
425
- const redirectUri = 'https://app.example/sources/github/callback';
426
- const late = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, neverAnswers, GITHUB_OAUTH_ENDPOINTS, { timeoutMs: 50 });
427
- const begun = late.begin({ redirectUri });
428
- const timedOut = await late
429
- .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
430
- .catch((error) => error);
431
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
432
- expect(timedOut.reason).toBe('timeout');
433
- const unreachable = async () => {
434
- throw new TypeError('fetch failed');
435
- };
436
- const offline = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, unreachable);
437
- const second = offline.begin({ redirectUri });
438
- const failed = await offline
439
- .complete({ code: 'test-code', state: second.state, expectedState: second.state, codeVerifier: second.codeVerifier, redirectUri })
440
- .catch((error) => error);
441
- expect(failed).toBeInstanceOf(GitHubNoAnswer);
442
- expect(failed.reason).toBe('network');
443
- });
444
- it("ends the user's code exchange at its deadline when the fetch leaves the request or the token answer's body open past its signal", async () => {
445
- const redirectUri = 'https://app.example/sources/github/callback';
446
- const client = { clientId: CLIENT_ID, clientSecret: 'test-client-secret' };
447
- for (const fetchImpl of [ignoresSignal, bodyIgnoresSignal(200, '{"access_token":')]) {
448
- const flow = new GitHubAppUserFlow(client, fetchImpl, GITHUB_OAUTH_ENDPOINTS, { timeoutMs: 50 });
449
- const begun = flow.begin({ redirectUri });
450
- const timedOut = await flow
451
- .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
452
- .catch((error) => error);
453
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
454
- expect(timedOut.reason).toBe('timeout');
455
- }
456
- });
457
- it("cancels the token answer's body when the code exchange ends at its deadline, so nothing more of it is read", async () => {
458
- const redirectUri = 'https://app.example/sources/github/callback';
459
- const held = heldBody(200, '{"access_token":');
460
- const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, held.fetchImpl, GITHUB_OAUTH_ENDPOINTS, { timeoutMs: 50 });
461
- const begun = flow.begin({ redirectUri });
462
- const timedOut = await flow
463
- .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
464
- .catch((error) => error);
465
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
466
- expect(timedOut.reason).toBe('timeout');
467
- expect(held.cancels()).toBe(1);
468
- });
469
- it('refuses a redirect from the token address, so the code and the client secret are never sent anywhere else', async () => {
470
- const elsewhere = 'https://elsewhere.test/collect';
471
- const seen = [];
472
- // Redirects as fetch does: a 307 is followed with the same method and body unless the call asks for `manual`,
473
- // which answers the redirect itself, or `error`, which fails the call.
474
- const redirecting = async function follow(input, init) {
475
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
476
- seen.push({ url, body: typeof init?.body === 'string' ? init.body : null });
477
- if (url !== GITHUB_OAUTH_ENDPOINTS.token)
478
- return json({ access_token: 'ghu_elsewhere', token_type: 'bearer' });
479
- const moved = new Response(null, { status: 307, headers: { location: elsewhere } });
480
- if (init?.redirect === 'manual')
481
- return moved;
482
- if (init?.redirect === 'error')
483
- throw new TypeError('fetch failed');
484
- return follow(elsewhere, init);
485
- };
486
- const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, redirecting);
487
- const redirectUri = 'https://app.example/sources/github/callback';
488
- const begun = flow.begin({ redirectUri });
489
- const refused = await flow
490
- .complete({ code: 'test-code', state: begun.state, expectedState: begun.state, codeVerifier: begun.codeVerifier, redirectUri })
491
- .catch((error) => error);
492
- expect(refused).toMatchObject({ name: 'OAuthGrantRefused', reason: 'exchange', status: 307 });
493
- expect(seen.map((request) => request.url)).toEqual([GITHUB_OAUTH_ENDPOINTS.token]);
494
- });
495
- it("revokes a user token at GitHub as the app, and refuses any other answer without its body or a redirect's target", async () => {
496
- // GitHub's "Delete an app token": Basic authentication with the client id and secret, the token in a JSON body, 204.
497
- const { fetchImpl, seen } = standIn((request) => {
498
- const body = JSON.parse(request.body ?? '{}');
499
- if (body.access_token === 'ghu_moved')
500
- return new Response(null, { status: 307, headers: { location: 'https://elsewhere.test/collect' } });
501
- if (body.access_token === 'ghu_testuser')
502
- return new Response(null, { status: 204 });
503
- return json({ message: 'Validation Failed: ghu_unknown' }, 422);
504
- });
505
- const client = { clientId: CLIENT_ID, clientSecret: 'test-client-secret' };
506
- const flow = new GitHubAppUserFlow(client, fetchImpl, GITHUB_OAUTH_ENDPOINTS, { apiBase: BASE });
507
- await flow.revoke('ghu_testuser');
508
- expect(seen).toHaveLength(1);
509
- expect(seen[0].method).toBe('DELETE');
510
- expect(seen[0].url).toBe(`${BASE}/applications/${CLIENT_ID}/token`);
511
- expect(seen[0].headers.get('authorization')).toBe(`Basic ${Buffer.from(`${CLIENT_ID}:test-client-secret`).toString('base64')}`);
512
- expect(seen[0].headers.get('accept')).toBe('application/vnd.github+json');
513
- expect(seen[0].headers.get('content-type')).toBe('application/json');
514
- expect(seen[0].headers.get('x-github-api-version')).toBe('2026-03-10');
515
- expect(JSON.parse(seen[0].body ?? '{}')).toEqual({ access_token: 'ghu_testuser' });
516
- const unknown = await flow.revoke('ghu_unknown').catch((error) => error);
517
- expect(unknown).toMatchObject({ name: 'OAuthGrantRefused', reason: 'revoke', status: 422 });
518
- expect(String(unknown.message)).not.toContain('ghu_unknown');
519
- const moved = await flow.revoke('ghu_moved').catch((error) => error);
520
- expect(moved).toMatchObject({ name: 'OAuthGrantRefused', reason: 'revoke', status: 307 });
521
- expect(seen).toHaveLength(3);
522
- // github.com's own API when no base is given.
523
- await new GitHubAppUserFlow(client, fetchImpl).revoke('ghu_testuser');
524
- expect(seen[3].url).toBe(`https://api.github.com/applications/${CLIENT_ID}/token`);
525
- const late = new GitHubAppUserFlow(client, neverAnswers, GITHUB_OAUTH_ENDPOINTS, { apiBase: BASE, timeoutMs: 50 });
526
- const timedOut = await late.revoke('ghu_testuser').catch((error) => error);
527
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
528
- expect(timedOut.reason).toBe('timeout');
529
- });
530
- it("takes GitHub's 204 alone as a revoked token, refusing any other success with its status", async () => {
531
- const { fetchImpl } = standIn((request) => {
532
- const body = JSON.parse(request.body ?? '{}');
533
- if (body.access_token === 'ghu_ok')
534
- return json({}, 200);
535
- return new Response(null, { status: 202 });
536
- });
537
- const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, fetchImpl, GITHUB_OAUTH_ENDPOINTS, { apiBase: BASE });
538
- const ok = await flow.revoke('ghu_ok').catch((error) => error);
539
- expect(ok).toMatchObject({ name: 'OAuthGrantRefused', reason: 'revoke', status: 200 });
540
- const accepted = await flow.revoke('ghu_accepted').catch((error) => error);
541
- expect(accepted).toMatchObject({ name: 'OAuthGrantRefused', reason: 'revoke', status: 202 });
542
- });
543
- it('tells a refused revocation by its status alone, never reading a body past the cap', async () => {
544
- let pulls = 0;
545
- const large = async () => new Response(new ReadableStream({
546
- pull(controller) {
547
- pulls += 1;
548
- controller.enqueue(new Uint8Array(16_384));
549
- },
550
- }, { highWaterMark: 0 }), { status: 422, headers: { 'content-type': 'application/json' } });
551
- const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, large, GITHUB_OAUTH_ENDPOINTS, { apiBase: BASE });
552
- const refused = await flow.revoke('ghu_testuser').catch((error) => error);
553
- expect(refused).toMatchObject({ name: 'OAuthGrantRefused', reason: 'revoke', status: 422 });
554
- expect(pulls).toBe(0);
555
- });
556
- it('tells a refused revocation by its status at once, never waiting on a body the fetch leaves open', async () => {
557
- // A deadline past the test's own: a revocation that waited on the body would end the test, not answer.
558
- const flow = new GitHubAppUserFlow({ clientId: CLIENT_ID, clientSecret: 'test-client-secret' }, bodyIgnoresSignal(422, '{"message":"Validation'), GITHUB_OAUTH_ENDPOINTS, {
559
- apiBase: BASE,
560
- timeoutMs: 60_000,
561
- });
562
- const refused = await flow.revoke('ghu_testuser').catch((error) => error);
563
- expect(refused).toMatchObject({ name: 'OAuthGrantRefused', reason: 'revoke', status: 422 });
564
- });
565
- it("reads a user token's person, its installations across pages and an installation's repositories at the API base given", async () => {
566
- const { fetchImpl, seen } = standIn((request) => {
567
- const url = new URL(request.url);
568
- if (url.pathname === '/user')
569
- return json({ login: 'test-person', id: 501, type: 'User' });
570
- if (url.pathname === '/user/installations' && url.searchParams.get('page') === '2') {
571
- return json({ total_count: 2, installations: [{ id: 12, account: { login: 'second-team', id: 902 } }] });
572
- }
573
- if (url.pathname === '/user/installations') {
574
- const next = `${BASE}/user/installations?per_page=100&page=2`;
575
- return json({ total_count: 2, installations: [{ id: 11, account: { login: 'first-team', id: 901 } }] }, 200, {
576
- link: `<${next}>; rel="next", <${next}>; rel="last"`,
577
- });
578
- }
579
- if (url.pathname === '/user/installations/11/repositories') {
580
- return json({
581
- total_count: 1,
582
- repository_selection: 'selected',
583
- repositories: [{ id: 7, full_name: 'first-team/handbook', default_branch: 'main', private: true }],
584
- });
585
- }
586
- return json({ message: 'Not Found' }, 404);
587
- });
588
- const at = { fetchImpl, apiBase: BASE };
589
- expect(await userOfToken('ghu_testuser', at)).toEqual({ id: 501, login: 'test-person' });
590
- expect(await installationsOfUser('ghu_testuser', at)).toEqual([
591
- { id: 11, account: { login: 'first-team', id: 901 } },
592
- { id: 12, account: { login: 'second-team', id: 902 } },
593
- ]);
594
- expect(await repositoriesOfUser('ghu_testuser', 11, at)).toEqual([{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main' }]);
595
- expect(seen.map((request) => new URL(request.url).pathname)).toEqual(['/user', '/user/installations', '/user/installations', '/user/installations/11/repositories']);
596
- expect(seen.every((request) => request.url.startsWith(`${BASE}/`))).toBe(true);
597
- expect(seen.every((request) => request.headers.get('authorization') === 'Bearer ghu_testuser')).toBe(true);
598
- expect(seen.every((request) => request.headers.get('x-github-api-version') === '2026-03-10')).toBe(true);
599
- });
600
- it("checks a delivery's X-Hub-Signature-256 against the raw body", () => {
601
- // The test vector of https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
602
- const secret = "It's a Secret to Everybody";
603
- const body = new TextEncoder().encode('Hello, World!');
604
- const right = 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17';
605
- expect(verifyWebhookSignature(body, right, secret)).toBe(true);
606
- expect(verifyWebhookSignature(body, `${right.slice(0, -1)}8`, secret)).toBe(false);
607
- expect(verifyWebhookSignature(new TextEncoder().encode('Hello, World?'), right, secret)).toBe(false);
608
- expect(verifyWebhookSignature(body, null, secret)).toBe(false);
609
- expect(verifyWebhookSignature(body, right.slice(0, -2), secret)).toBe(false);
610
- expect(verifyWebhookSignature(body, `${right}00`, secret)).toBe(false);
611
- expect(verifyWebhookSignature(body, `sha1=${createHmac('sha1', secret).update(body).digest('hex')}`, secret)).toBe(false);
612
- });
613
- it('refuses an empty or missing secret, under which anyone can sign a delivery', () => {
614
- const body = new TextEncoder().encode('Hello, World!');
615
- const signedWithoutKey = `sha256=${createHmac('sha256', '').update(body).digest('hex')}`;
616
- expect(() => verifyWebhookSignature(body, signedWithoutKey, '')).toThrow(TypeError);
617
- expect(() => verifyWebhookSignature(body, null, '')).toThrow(TypeError);
618
- expect(() => verifyWebhookSignature(body, signedWithoutKey, undefined)).toThrow(TypeError);
619
- });
620
- it("reconciles links: an installation gone, a repository's head moved, a day's failed deliveries asked for again", async () => {
621
- const now = Date.now();
622
- const hours = (count) => new Date(now - count * 3_600_000).toISOString();
623
- const headBefore = 'a'.repeat(40);
624
- const headNow = 'b'.repeat(40);
625
- const headSame = 'c'.repeat(40);
626
- const { fetchImpl, seen } = standIn((request) => {
627
- const url = new URL(request.url);
628
- if (url.pathname === '/app/installations')
629
- return json([{ id: 21, account: { login: 'first-team', id: 901 } }, { id: 23, account: null }]);
630
- const tokenFor = /^\/app\/installations\/(\d+)\/access_tokens$/.exec(url.pathname);
631
- if (tokenFor !== null) {
632
- const ids = JSON.parse(request.body ?? '{}').repository_ids;
633
- if (ids.includes(9))
634
- return json({ message: 'There is at least one repository that does not exist' }, 422);
635
- return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
636
- }
637
- if (url.pathname === '/repositories/7/branches/main')
638
- return json({ name: 'main', commit: { sha: headNow } });
639
- if (url.pathname === '/repositories/8/branches/trunk')
640
- return json({ name: 'trunk', commit: { sha: headSame } });
641
- if (url.pathname === '/app/hook/deliveries') {
642
- const cursor = url.searchParams.get('cursor');
643
- if (cursor === null) {
644
- return json([
645
- { id: 9003, guid: 'guid-2', delivered_at: hours(1), redelivery: true, status: 'OK', status_code: 200 },
646
- { id: 9001, guid: 'guid-1', delivered_at: hours(2), redelivery: false, status: 'Internal Server Error', status_code: 500 },
647
- { id: 9002, guid: 'guid-2', delivered_at: hours(3), redelivery: false, status: 'Bad Gateway', status_code: 502 },
648
- ], 200, { link: `<${BASE}/app/hook/deliveries?per_page=100&cursor=page2>; rel="next"` });
649
- }
650
- if (cursor === 'page2') {
651
- return json([
652
- { id: 9004, guid: 'guid-3', delivered_at: hours(5), redelivery: false, status: 'OK', status_code: 200 },
653
- { id: 9005, guid: 'guid-4', delivered_at: hours(30), redelivery: false, status: 'Internal Server Error', status_code: 500 },
654
- ], 200, { link: `<${BASE}/app/hook/deliveries?per_page=100&cursor=page3>; rel="next"` });
655
- }
656
- return json([{ id: 9006, guid: 'guid-5', delivered_at: hours(40), redelivery: false, status: 'Internal Server Error', status_code: 500 }]);
657
- }
658
- if (url.pathname === '/app/hook/deliveries/9001/attempts' && request.method === 'POST')
659
- return new Response(null, { status: 202 });
660
- return json({ message: 'Not Found' }, 404);
661
- });
662
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
663
- const outcome = await reconcileInstallations(credential, [
664
- {
665
- installationId: 21,
666
- repositories: [
667
- { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: headBefore },
668
- { id: 8, fullName: 'first-team/notes', defaultBranch: 'trunk', headCommit: headSame },
669
- { id: 9, fullName: 'first-team/removed', defaultBranch: 'main', headCommit: headBefore },
670
- ],
671
- },
672
- { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: headBefore }] },
673
- ], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
674
- expect(outcome).toEqual({
675
- gone: [22],
676
- suspended: [],
677
- moved: [{ installationId: 21, repositoryId: 7, head: headNow }],
678
- redelivered: [9001],
679
- unreadable: [{ installationId: 21, repositoryId: 9, status: 422 }],
680
- deliveries: { listingStatus: null, refused: [] },
681
- retryAt: null,
682
- });
683
- const branchRead = seen.find((request) => request.url === `${BASE}/repositories/7/branches/main`);
684
- expect(branchRead?.headers.get('authorization')).toBe('Bearer ghs_repo7');
685
- expect(seen.some((request) => request.url.includes('/app/installations/22/'))).toBe(false);
686
- expect(seen.some((request) => request.url.includes('cursor=page3'))).toBe(false);
687
- const appCalls = seen.filter((request) => request.url.includes('/app/installations?') || request.url.includes('/app/hook/'));
688
- expect(appCalls.length).toBeGreaterThan(0);
689
- expect(appCalls.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
690
- });
691
- it('keeps the lists it computed when GitHub answers a page of the deliveries 500, asking for no delivery again', async () => {
692
- const now = Date.now();
693
- const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
694
- if (url.pathname === '/app/hook/deliveries' && url.searchParams.get('cursor') === null) {
695
- return json([{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 3_600_000).toISOString(), status_code: 500 }], 200, {
696
- link: `<${BASE}/app/hook/deliveries?per_page=100&cursor=page2>; rel="next"`,
697
- });
698
- }
699
- if (url.pathname === '/app/hook/deliveries')
700
- return json({ message: 'Server Error' }, 500);
701
- return new Response(null, { status: 202 });
702
- });
703
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
704
- const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
705
- expect(outcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 500, refused: [] }, retryAt: null });
706
- expect(seen.some((request) => request.url.endsWith('/attempts'))).toBe(false);
707
- });
708
- it('goes on past a redelivery GitHub refuses with 403, reporting it beside the lists it computed', async () => {
709
- const now = Date.now();
710
- const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
711
- if (url.pathname === '/app/hook/deliveries') {
712
- return json([
713
- { id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 },
714
- { id: 9002, guid: 'guid-2', delivered_at: new Date(now - 1_200_000).toISOString(), status_code: 502 },
715
- ]);
716
- }
717
- if (url.pathname === '/app/hook/deliveries/9001/attempts')
718
- return json({ message: 'Forbidden' }, 403);
719
- return new Response(null, { status: 202 });
720
- });
721
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
722
- const outcome = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
723
- expect(outcome).toEqual({ ...RECONCILED, redelivered: [9002], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 403 }] }, retryAt: null });
724
- expect(seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
725
- '/app/hook/deliveries/9001/attempts',
726
- '/app/hook/deliveries/9002/attempts',
727
- ]);
728
- });
729
- it('asks again for a delivery its route answered 408, 429 or 5xx, never for one it refused with another 4xx', async () => {
730
- const now = Date.now();
731
- // Newest first, as GitHub lists them: guid-e failed with 404 and then 503, guid-f with 500 and then 422.
732
- const attempts = [
733
- [9101, 'guid-a', 10, 413],
734
- [9106, 'guid-e', 20, 503],
735
- [9107, 'guid-f', 30, 422],
736
- [9102, 'guid-b', 40, 400],
737
- [9109, 'guid-g', 50, 500],
738
- [9103, 'guid-c', 60, 408],
739
- [9104, 'guid-d', 70, 429],
740
- [9105, 'guid-e', 80, 404],
741
- [9108, 'guid-f', 90, 500],
742
- ];
743
- const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
744
- if (url.pathname === '/app/hook/deliveries') {
745
- return json(attempts.map(([id, guid, minutesAgo, statusCode]) => ({ id, guid, delivered_at: new Date(now - minutesAgo * 60_000).toISOString(), status_code: statusCode })));
746
- }
747
- return new Response(null, { status: 202 });
748
- });
749
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
750
- const outcome = await reconcileInstallations(credential, [], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
751
- expect(outcome.redelivered).toEqual([9106, 9109, 9103, 9104]);
752
- expect(seen.filter((request) => request.method === 'POST').map((request) => new URL(request.url).pathname)).toEqual([9106, 9109, 9103, 9104].map((id) => `/app/hook/deliveries/${id}/attempts`));
753
- });
754
- it("reads each repository's head by its id, never through a name another repository has taken since the link was made", async () => {
755
- const now = Date.now();
756
- const { fetchImpl, seen } = standIn((request) => {
757
- const url = new URL(request.url);
758
- if (url.pathname === '/app/installations')
759
- return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
760
- if (url.pathname === '/app/installations/21/access_tokens')
761
- return json({ token: 'ghs_repo7', expires_at: new Date(now + 3_600_000).toISOString() }, 201);
762
- if (url.pathname === '/repositories/7/branches/main')
763
- return json({ name: 'main', commit: { sha: HEAD_BEFORE } });
764
- // Another account's public repository holds the old name now, at another head.
765
- if (url.pathname === '/repos/first-team/handbook/branches/main')
766
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
767
- if (url.pathname === '/app/hook/deliveries')
768
- return json([]);
769
- return json({ message: 'Not Found' }, 404);
770
- });
771
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
772
- const outcome = await reconcileInstallations(credential, [{ installationId: 21, repositories: [{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE }] }], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
773
- expect(outcome.moved).toEqual([]);
774
- expect(outcome.unreadable).toEqual([]);
775
- expect(seen.map((request) => new URL(request.url).pathname)).toContain('/repositories/7/branches/main');
776
- expect(seen.some((request) => new URL(request.url).pathname.startsWith('/repos/'))).toBe(false);
777
- });
778
- it('stops at a rate limit, making no request after it, and keeps what it found beside the time to run again', async () => {
779
- const now = Date.now();
780
- const { fetchImpl, seen } = standIn((request) => {
781
- const url = new URL(request.url);
782
- if (url.pathname === '/app/installations')
783
- return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
784
- if (url.pathname === '/app/installations/21/access_tokens') {
785
- const ids = JSON.parse(request.body ?? '{}').repository_ids;
786
- return json({ token: `ghs_repo${ids.join('_')}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
787
- }
788
- if (url.pathname === '/repositories/7/branches/main')
789
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
790
- if (url.pathname === '/repositories/8/branches/main')
791
- return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '120' });
792
- if (url.pathname === '/repositories/9/branches/main')
793
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
794
- if (url.pathname === '/app/hook/deliveries') {
795
- return json([{ id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 }]);
796
- }
797
- if (url.pathname === '/app/installations/22')
798
- return json({ message: 'Not Found' }, 404);
799
- return new Response(null, { status: 202 });
800
- });
801
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
802
- const started = Date.now();
803
- const outcome = await reconcileInstallations(credential, [
804
- {
805
- installationId: 21,
806
- repositories: [
807
- { id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE },
808
- { id: 8, fullName: 'first-team/notes', defaultBranch: 'main', headCommit: HEAD_BEFORE },
809
- { id: 9, fullName: 'first-team/wiki', defaultBranch: 'main', headCommit: HEAD_BEFORE },
810
- ],
811
- },
812
- { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
813
- ], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
814
- const { retryAt, ...found } = outcome;
815
- // What it found before the refusal is kept, the gone installation among it; repository 8 was refused, not found unreadable.
816
- expect(found).toEqual({
817
- gone: [22],
818
- suspended: [],
819
- moved: [{ installationId: 21, repositoryId: 7, head: HEAD_NOW }],
820
- redelivered: [],
821
- unreadable: [],
822
- deliveries: { listingStatus: null, refused: [] },
823
- });
824
- expect(retryAt).toBeGreaterThanOrEqual(started + 120_000);
825
- expect(retryAt).toBeLessThanOrEqual(Date.now() + 120_000);
826
- // The refused read was the last request: not repository 9, not the deliveries, not a redelivery.
827
- expect(seen[seen.length - 1]?.url).toBe(`${BASE}/repositories/8/branches/main`);
828
- });
829
- it('ends the deliveries step at a rate limit, asking for no delivery after it, and answers the time to run again', async () => {
830
- const now = Date.now();
831
- const reset = Math.floor(now / 1000) + 900;
832
- const listing = reconcileStandIn((_request, url) => {
833
- if (url.pathname === '/app/hook/deliveries') {
834
- return json({ message: 'API rate limit exceeded' }, 403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
835
- }
836
- return new Response(null, { status: 202 });
837
- });
838
- const listingCredential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, listing.fetchImpl, BASE);
839
- const listingOutcome = await reconcileInstallations(listingCredential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl: listing.fetchImpl, apiBase: BASE });
840
- expect(listingOutcome).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: 403, refused: [] }, retryAt: reset * 1000 });
841
- const redelivery = reconcileStandIn((_request, url) => {
842
- if (url.pathname === '/app/hook/deliveries') {
843
- return json([
844
- { id: 9001, guid: 'guid-1', delivered_at: new Date(now - 600_000).toISOString(), status_code: 500 },
845
- { id: 9002, guid: 'guid-2', delivered_at: new Date(now - 1_200_000).toISOString(), status_code: 502 },
846
- ]);
847
- }
848
- if (url.pathname === '/app/hook/deliveries/9001/attempts')
849
- return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '60' });
850
- return new Response(null, { status: 202 });
851
- });
852
- const redeliveryCredential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, redelivery.fetchImpl, BASE);
853
- const started = Date.now();
854
- const redeliveryOutcome = await reconcileInstallations(redeliveryCredential, RECONCILE_LINKS, {
855
- since: new Date(now - 86_400_000),
856
- fetchImpl: redelivery.fetchImpl,
857
- apiBase: BASE,
858
- });
859
- const { retryAt, ...found } = redeliveryOutcome;
860
- expect(found).toEqual({ ...RECONCILED, redelivered: [], deliveries: { listingStatus: null, refused: [{ id: 9001, status: 429 }] } });
861
- expect(retryAt).toBeGreaterThanOrEqual(started + 60_000);
862
- expect(redelivery.seen.filter((request) => request.url.endsWith('/attempts')).map((request) => new URL(request.url).pathname)).toEqual([
863
- '/app/hook/deliveries/9001/attempts',
864
- ]);
865
- });
866
- it("ends a repository's installation token request at the reconcile's deadline", async () => {
867
- const { fetchImpl: answering } = reconcileStandIn(() => json([]));
868
- const fetchImpl = async (input, init) => {
869
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
870
- return url.endsWith('/access_tokens') ? neverAnswers(input, init) : answering(input, init);
871
- };
872
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
873
- const started = Date.now();
874
- const failure = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(started - 86_400_000), fetchImpl, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
875
- expect(failure).toBeInstanceOf(GitHubNoAnswer);
876
- expect(failure.reason).toBe('timeout');
877
- expect(Date.now() - started).toBeLessThan(5_000);
878
- });
879
- it('answers a linked installation gone only once its own address answers 404, and reads one the listing missed', async () => {
880
- const now = Date.now();
881
- const { fetchImpl, seen } = standIn((request) => {
882
- const url = new URL(request.url);
883
- // Installation 23 still exists: an installation removed while the listing was read moved it past a page's end.
884
- if (url.pathname === '/app/installations')
885
- return json([{ id: 21, account: { login: 'first-team', id: 901 }, suspended_at: null }]);
886
- if (url.pathname === '/app/installations/23')
887
- return json({ id: 23, account: { login: 'third-team', id: 903 }, suspended_at: null });
888
- const tokenFor = /^\/app\/installations\/(\d+)\/access_tokens$/.exec(url.pathname);
889
- if (tokenFor !== null)
890
- return json({ token: `ghs_installation${tokenFor[1]}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
891
- if (url.pathname === '/repositories/7/branches/main')
892
- return json({ name: 'main', commit: { sha: HEAD_BEFORE } });
893
- if (url.pathname === '/repositories/40/branches/main')
894
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
895
- if (url.pathname === '/app/hook/deliveries')
896
- return json([]);
897
- return json({ message: 'Not Found' }, 404);
898
- });
899
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
900
- const outcome = await reconcileInstallations(credential, [
901
- { installationId: 21, repositories: [{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
902
- { installationId: 22, repositories: [{ id: 30, fullName: 'gone-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
903
- { installationId: 23, repositories: [{ id: 40, fullName: 'third-team/notes', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
904
- { installationId: 22, repositories: [] },
905
- ], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE });
906
- expect(outcome).toEqual({
907
- gone: [22],
908
- suspended: [],
909
- moved: [{ installationId: 23, repositoryId: 40, head: HEAD_NOW }],
910
- redelivered: [],
911
- unreadable: [],
912
- deliveries: { listingStatus: null, refused: [] },
913
- retryAt: null,
914
- });
915
- // Each installation the listing missed was asked for once, at its own address with the app's JWT, and no token was
916
- // asked for the gone one.
917
- const ownAddresses = seen.filter((request) => /^\/app\/installations\/\d+$/.test(new URL(request.url).pathname));
918
- expect(ownAddresses.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual(['GET /app/installations/22', 'GET /app/installations/23']);
919
- expect(ownAddresses.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
920
- expect(seen.some((request) => request.url.includes('/app/installations/22/'))).toBe(false);
921
- });
922
- it('answers the linked installations GitHub suspended, reading none of their repositories, and their time in the listing', async () => {
923
- const now = Date.now();
924
- const { fetchImpl, seen } = standIn((request) => {
925
- const url = new URL(request.url);
926
- if (url.pathname === '/app/installations') {
927
- return json([
928
- { id: 21, account: { login: 'first-team', id: 901 }, suspended_at: '2026-10-20T10:00:00Z', suspended_by: { login: 'first-owner', id: 911 } },
929
- { id: 24, account: { login: 'fourth-team', id: 904 }, suspended_at: null, suspended_by: null },
930
- ]);
931
- }
932
- // Installation 25 was moved past the listing's page end, and is suspended too.
933
- if (url.pathname === '/app/installations/25')
934
- return json({ id: 25, account: { login: 'fifth-team', id: 905 }, suspended_at: '2026-10-21T08:00:00Z' });
935
- const tokenFor = /^\/app\/installations\/(\d+)\/access_tokens$/.exec(url.pathname);
936
- if (tokenFor !== null)
937
- return json({ token: `ghs_installation${tokenFor[1]}`, expires_at: new Date(now + 3_600_000).toISOString() }, 201);
938
- if (url.pathname === '/repositories/50/branches/main')
939
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
940
- if (url.pathname === '/app/hook/deliveries')
941
- return json([]);
942
- return json({ message: 'Not Found' }, 404);
943
- });
944
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
945
- const at = { fetchImpl, apiBase: BASE };
946
- const outcome = await reconcileInstallations(credential, [
947
- { installationId: 21, repositories: [{ id: 7, fullName: 'first-team/handbook', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
948
- { installationId: 24, repositories: [{ id: 50, fullName: 'fourth-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
949
- { installationId: 25, repositories: [{ id: 60, fullName: 'fifth-team/docs', defaultBranch: 'main', headCommit: HEAD_BEFORE }] },
950
- ], { since: new Date(now - 86_400_000), ...at });
951
- expect(outcome).toEqual({
952
- gone: [],
953
- suspended: [21, 25],
954
- moved: [{ installationId: 24, repositoryId: 50, head: HEAD_NOW }],
955
- redelivered: [],
956
- unreadable: [],
957
- deliveries: { listingStatus: null, refused: [] },
958
- retryAt: null,
959
- });
960
- // No token was asked for a suspended installation, and none of their repositories was read.
961
- expect(seen.filter((request) => request.url.endsWith('/access_tokens')).map((request) => new URL(request.url).pathname)).toEqual(['/app/installations/24/access_tokens']);
962
- expect(seen.some((request) => /\/repositories\/(7|60)\//.test(new URL(request.url).pathname))).toBe(false);
963
- // The app's own calls answer each installation with when it was suspended, and one GitHub no longer has as null.
964
- expect(await listInstallations(credential, at)).toEqual([
965
- { id: 21, account: { login: 'first-team', id: 901 }, suspendedAt: '2026-10-20T10:00:00Z' },
966
- { id: 24, account: { login: 'fourth-team', id: 904 }, suspendedAt: null },
967
- ]);
968
- expect(await getInstallation(credential, 25, at)).toEqual({ id: 25, account: { login: 'fifth-team', id: 905 }, suspendedAt: '2026-10-21T08:00:00Z' });
969
- expect(await getInstallation(credential, 26, at)).toBeNull();
970
- });
971
- it("throws, answering nothing gone, when GitHub refuses an installation's own address with a status other than 404", async () => {
972
- const now = Date.now();
973
- const refusals = [
974
- { status: 502, headers: {} },
975
- { status: 429, headers: { 'retry-after': '120' } },
976
- ];
977
- for (const { status, headers } of refusals) {
978
- const { fetchImpl, seen } = standIn((request) => {
979
- const url = new URL(request.url);
980
- if (url.pathname === '/app/installations')
981
- return json([{ id: 21, account: { login: 'first-team', id: 901 } }]);
982
- if (url.pathname === '/app/installations/22')
983
- return json({ message: 'refused' }, status, headers);
984
- return json({ name: 'main', commit: { sha: HEAD_NOW } });
985
- });
986
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
987
- const started = Date.now();
988
- const failure = await reconcileInstallations(credential, RECONCILE_LINKS, { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE }).catch((error) => error);
989
- expect(failure).toBeInstanceOf(GitHubApiError);
990
- expect(failure.status).toBe(status);
991
- if (status === 429) {
992
- expect(failure.rateLimited).toBe(true);
993
- expect(failure.retryAt).toBeGreaterThanOrEqual(started + 120_000);
994
- }
995
- // Nothing was read after the refusal: no token, no head, no deliveries.
996
- expect(seen.map((request) => new URL(request.url).pathname)).toEqual(['/app/installations', '/app/installations/22']);
997
- }
998
- });
999
- it('uninstalls with the JWT, taking a 2xx (202, 204) and 404 as done and throwing other answers without their body', async () => {
1000
- const { fetchImpl, seen } = standIn((request) => {
1001
- const id = new URL(request.url).pathname.split('/').pop();
1002
- if (id === '31')
1003
- return new Response(null, { status: 202 });
1004
- if (id === '32')
1005
- return new Response(null, { status: 204 });
1006
- if (id === '33')
1007
- return json({ message: 'Not Found' }, 404);
1008
- if (id === '35')
1009
- return json({}, 200);
1010
- return json({ message: 'internal detail of the failure' }, 500);
1011
- });
1012
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
1013
- const at = { fetchImpl, apiBase: BASE };
1014
- await expect(uninstallInstallation(credential, 31, at)).resolves.toBeUndefined();
1015
- await expect(uninstallInstallation(credential, 32, at)).resolves.toBeUndefined();
1016
- await expect(uninstallInstallation(credential, 33, at)).resolves.toBeUndefined();
1017
- const failure = await uninstallInstallation(credential, 34, at).catch((error) => error);
1018
- expect(failure).toBeInstanceOf(GitHubApiError);
1019
- expect(failure.status).toBe(500);
1020
- expect(failure.message).not.toContain('internal detail');
1021
- // Any 2xx is GitHub taking the removal, whatever the API version answers it with.
1022
- await expect(uninstallInstallation(credential, 35, at)).resolves.toBeUndefined();
1023
- expect(seen.map((request) => `${request.method} ${request.url}`)).toEqual([31, 32, 33, 34, 35].map((id) => `DELETE ${BASE}/app/installations/${id}`));
1024
- expect(seen.every((request) => appClaims(request.headers.get('authorization')) !== null)).toBe(true);
1025
- });
1026
- it("takes an uninstall and a redelivery as done at once, never waiting on an answer's body whose cancel does not settle", async () => {
1027
- // A deadline past the test's own: a call that waited on the cancel would end the test, not answer.
1028
- const uninstalled = acceptedHolding();
1029
- const { fetchImpl: uninstalling } = standIn(() => uninstalled.answer());
1030
- const credential = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, uninstalling, BASE);
1031
- await expect(uninstallInstallation(credential, 31, { fetchImpl: uninstalling, apiBase: BASE, timeoutMs: 60_000 })).resolves.toBeUndefined();
1032
- expect(uninstalled.cancels()).toBe(1);
1033
- const now = Date.now();
1034
- const redelivery = acceptedHolding();
1035
- const { fetchImpl, seen } = reconcileStandIn((_request, url) => {
1036
- if (url.pathname === '/app/hook/deliveries') {
1037
- return json([{ id: 9201, guid: 'guid-h', delivered_at: new Date(now - 600_000).toISOString(), status_code: 502 }]);
1038
- }
1039
- return redelivery.answer();
1040
- });
1041
- const reconciling = new GitHubAppCredential({ clientId: CLIENT_ID, privateKey: keys.privateKey }, fetchImpl, BASE);
1042
- const outcome = await reconcileInstallations(reconciling, [], { since: new Date(now - 86_400_000), fetchImpl, apiBase: BASE, timeoutMs: 60_000 });
1043
- expect(outcome.redelivered).toEqual([9201]);
1044
- expect(seen.filter((request) => request.method === 'POST').map((request) => new URL(request.url).pathname)).toEqual(['/app/hook/deliveries/9201/attempts']);
1045
- expect(redelivery.cancels()).toBe(1);
1046
- });
1047
- });
1048
- describe('the REST calls', () => {
1049
- it('refuses a next page on another origin, so the bearer never goes there', async () => {
1050
- const { fetchImpl, seen } = standIn(() => json({ total_count: 2, installations: [{ id: 11, account: { login: 'first-team', id: 901 } }] }, 200, {
1051
- link: '<https://elsewhere.example.com/user/installations?per_page=100&page=2>; rel="next"',
1052
- }));
1053
- await expect(installationsOfUser('ghu_testuser', { fetchImpl, apiBase: BASE })).rejects.toThrow('GitHub named a next page on another origin');
1054
- expect(seen.map((request) => request.url)).toEqual([`${BASE}/user/installations?per_page=100`]);
1055
- });
1056
- it('answers a body that is not JSON with an error that does not quote it', async () => {
1057
- const { fetchImpl } = standIn(() => new Response('<html>upstream detail</html>', { status: 200, headers: { 'content-type': 'text/html' } }));
1058
- const failure = await userOfToken('ghu_testuser', { fetchImpl, apiBase: BASE }).catch((error) => error);
1059
- expect(failure).toBeInstanceOf(Error);
1060
- expect(failure.message).toBe('GitHub answered a body that is not JSON');
1061
- });
1062
- it('answers undefined for a 202 or a 204 without a body', async () => {
1063
- const { fetchImpl, seen } = standIn((request) => new Response(null, { status: request.method === 'POST' ? 202 : 204 }));
1064
- await expect(githubFetch(fetchImpl, `${BASE}/app/hook/deliveries/9001/attempts`, { token: 'ghs_test', method: 'POST' })).resolves.toBeUndefined();
1065
- await expect(githubFetch(fetchImpl, `${BASE}/app/installations/31`, { token: 'ghs_test', method: 'DELETE' })).resolves.toBeUndefined();
1066
- expect(seen.map((request) => `${request.method} ${new URL(request.url).pathname}`)).toEqual(['POST /app/hook/deliveries/9001/attempts', 'DELETE /app/installations/31']);
1067
- });
1068
- it("sends the pack's User-Agent beside the bearer, the media type and the pinned API version", async () => {
1069
- const { fetchImpl, seen } = standIn(() => json({ login: 'test-person', id: 501 }));
1070
- await userOfToken('ghu_testuser', { fetchImpl, apiBase: BASE });
1071
- expect(seen).toHaveLength(1);
1072
- expect(seen[0].headers.get('user-agent')).toBe('agentos-ext-github');
1073
- expect(seen[0].headers.get('authorization')).toBe('Bearer ghu_testuser');
1074
- expect(seen[0].headers.get('accept')).toBe('application/vnd.github+json');
1075
- expect(seen[0].headers.get('x-github-api-version')).toBe('2026-03-10');
1076
- });
1077
- it('says when a rate-limited call may be made again: retry-after first, then the window reset, else a minute', async () => {
1078
- vi.useFakeTimers({ toFake: ['Date'] });
1079
- vi.setSystemTime(new Date('2026-10-15T12:00:00Z'));
1080
- const now = Date.now();
1081
- const reset = Math.floor(now / 1000) + 1800;
1082
- // The answers of https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit
1083
- const { fetchImpl } = standIn((request) => {
1084
- switch (request.headers.get('authorization')) {
1085
- case 'Bearer primary':
1086
- return json({ message: 'API rate limit exceeded for installation ID 5.' }, 403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
1087
- case 'Bearer primary-429':
1088
- return json({ message: 'API rate limit exceeded for installation ID 5.' }, 429, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
1089
- case 'Bearer secondary-after':
1090
- return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': '30', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(reset) });
1091
- case 'Bearer secondary-long':
1092
- return json({ message: 'You have exceeded a secondary rate limit.' }, 403, { 'retry-after': '7200' });
1093
- case 'Bearer secondary-date':
1094
- return json({ message: 'You have exceeded a secondary rate limit.' }, 429, { 'retry-after': new Date(now + 90_000).toUTCString() });
1095
- case 'Bearer secondary-bare':
1096
- return json({ message: 'You have exceeded a secondary rate limit. Please wait a few minutes before you try again.' }, 403);
1097
- case 'Bearer forbidden':
1098
- return json({ message: 'Resource not accessible by integration', documentation_url: 'https://docs.github.com/rest/users/users' }, 403);
1099
- case 'Bearer unavailable':
1100
- return json({ message: 'Unicorn detail of an outage' }, 503, { 'retry-after': '120' });
1101
- default:
1102
- return json({ message: 'Not Found' }, 404);
1103
- }
1104
- });
1105
- const refusalOf = async (token) => {
1106
- const failure = await userOfToken(token, { fetchImpl, apiBase: BASE }).catch((error) => error);
1107
- expect(failure).toBeInstanceOf(GitHubApiError);
1108
- return failure;
1109
- };
1110
- const timing = (failure) => [failure.status, failure.rateLimited, failure.retryAt];
1111
- expect(timing(await refusalOf('primary'))).toEqual([403, true, reset * 1000]);
1112
- expect(timing(await refusalOf('primary-429'))).toEqual([429, true, reset * 1000]);
1113
- expect(timing(await refusalOf('secondary-after'))).toEqual([429, true, now + 30_000]);
1114
- // A wait GitHub names is kept whole, however long, as delay-seconds or as an HTTP date.
1115
- expect(timing(await refusalOf('secondary-long'))).toEqual([403, true, now + 7_200_000]);
1116
- expect(timing(await refusalOf('secondary-date'))).toEqual([429, true, now + 90_000]);
1117
- expect(timing(await refusalOf('secondary-bare'))).toEqual([403, true, now + 60_000]);
1118
- expect(timing(await refusalOf('forbidden'))).toEqual([403, false, null]);
1119
- expect(timing(await refusalOf('unavailable'))).toEqual([503, false, now + 120_000]);
1120
- expect(timing(await refusalOf('missing'))).toEqual([404, false, null]);
1121
- const quoted = await refusalOf('secondary-bare');
1122
- expect(quoted.message).toBe('GitHub answered 403');
1123
- expect((await refusalOf('unavailable')).message).not.toContain('Unicorn');
1124
- });
1125
- it('ends a call at its deadline, its body included, and names a cancelled call and a failed connection', async () => {
1126
- const timedOut = await userOfToken('ghu_testuser', { fetchImpl: neverAnswers, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
1127
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
1128
- expect(timedOut.reason).toBe('timeout');
1129
- expect(timedOut.message).toBe('GitHub gave no answer: timeout');
1130
- const stalled = await userOfToken('ghu_testuser', { fetchImpl: stallsInBody, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
1131
- expect(stalled).toBeInstanceOf(GitHubNoAnswer);
1132
- expect(stalled.reason).toBe('timeout');
1133
- const controller = new AbortController();
1134
- const pending = githubFetch(neverAnswers, `${BASE}/user`, { token: 'ghu_testuser', signal: controller.signal }).catch((error) => error);
1135
- controller.abort();
1136
- const cancelled = await pending;
1137
- expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
1138
- expect(cancelled.reason).toBe('cancelled');
1139
- const refusing = async () => {
1140
- throw new TypeError('fetch failed');
1141
- };
1142
- const offline = await installationsOfUser('ghu_testuser', { fetchImpl: refusing, apiBase: BASE }).catch((error) => error);
1143
- expect(offline).toBeInstanceOf(GitHubNoAnswer);
1144
- expect(offline.reason).toBe('network');
1145
- });
1146
- it('ends a call at its deadline and at its cancel when the fetch leaves the request or the body open past its signal', async () => {
1147
- const leftOpen = [ignoresSignal, bodyIgnoresSignal(200, '{"login":'), bodyIgnoresSignal(403, '{"message":"You have exceeded a secondary rate')];
1148
- for (const fetchImpl of leftOpen) {
1149
- const timedOut = await userOfToken('ghu_testuser', { fetchImpl, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
1150
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
1151
- expect(timedOut.reason).toBe('timeout');
1152
- }
1153
- const controller = new AbortController();
1154
- const pending = githubFetch(ignoresSignal, `${BASE}/user`, { token: 'ghu_testuser', signal: controller.signal }).catch((error) => error);
1155
- controller.abort();
1156
- const cancelled = await pending;
1157
- expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
1158
- expect(cancelled.reason).toBe('cancelled');
1159
- });
1160
- it('cancels the body a call stops reading at its deadline or its cancel, so nothing more of it is read', async () => {
1161
- const answers = [
1162
- [200, '{"login":'],
1163
- [403, '{"message":"You have exceeded a secondary rate'],
1164
- ];
1165
- for (const [status, start] of answers) {
1166
- const open = heldBody(status, start);
1167
- const timedOut = await userOfToken('ghu_testuser', { fetchImpl: open.fetchImpl, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
1168
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
1169
- expect(timedOut.reason).toBe('timeout');
1170
- expect(open.cancels()).toBe(1);
1171
- }
1172
- const held = heldBody(200, '{"login":');
1173
- const controller = new AbortController();
1174
- const pending = githubFetch(held.fetchImpl, `${BASE}/user`, { token: 'ghu_testuser', signal: controller.signal }).catch((error) => error);
1175
- await held.stalled;
1176
- controller.abort();
1177
- const cancelled = await pending;
1178
- expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
1179
- expect(cancelled.reason).toBe('cancelled');
1180
- expect(held.cancels()).toBe(1);
1181
- });
1182
- it('tells a refusal by its status and headers at once, never waiting on a body whose cancel does not settle', async () => {
1183
- // A deadline past the test's own: a call that waited on the cancel would end the test, not answer.
1184
- const limited = heldBody(429, '{"message":"You have exceeded a secondary rate limit', { headers: { 'retry-after': '30' }, settles: false });
1185
- const refused = await userOfToken('ghu_testuser', { fetchImpl: limited.fetchImpl, apiBase: BASE, timeoutMs: 60_000 }).catch((error) => error);
1186
- expect(refused).toBeInstanceOf(GitHubApiError);
1187
- expect(refused).toMatchObject({ status: 429, rateLimited: true });
1188
- expect(limited.cancels()).toBe(1);
1189
- const unavailable = heldBody(503, '{"message":"Unicorn', { settles: false });
1190
- const failed = await userOfToken('ghu_testuser', { fetchImpl: unavailable.fetchImpl, apiBase: BASE, timeoutMs: 60_000 }).catch((error) => error);
1191
- expect(failed).toBeInstanceOf(GitHubApiError);
1192
- expect(failed).toMatchObject({ status: 503, rateLimited: false, retryAt: null });
1193
- expect(unavailable.cancels()).toBe(1);
1194
- });
1195
- it('reads a refusal whose body stops before its end as no answer, never as a refusal told by its status alone', async () => {
1196
- const timedOut = await userOfToken('ghu_testuser', { fetchImpl: refusalStallsInBody, apiBase: BASE, timeoutMs: 50 }).catch((error) => error);
1197
- expect(timedOut).toBeInstanceOf(GitHubNoAnswer);
1198
- expect(timedOut.reason).toBe('timeout');
1199
- const controller = new AbortController();
1200
- const pending = githubFetch(refusalStallsInBody, `${BASE}/user`, { token: 'ghu_testuser', signal: controller.signal }).catch((error) => error);
1201
- controller.abort();
1202
- const cancelled = await pending;
1203
- expect(cancelled).toBeInstanceOf(GitHubNoAnswer);
1204
- expect(cancelled.reason).toBe('cancelled');
1205
- });
1206
- it('refuses an answer whose body states more than the cap before reading it', async () => {
1207
- let pulled = false;
1208
- const oversized = async () => new Response(new ReadableStream({
1209
- pull() {
1210
- pulled = true;
1211
- },
1212
- }, { highWaterMark: 0 }), { status: 200, headers: { 'content-type': 'application/json', 'content-length': String(64 * 1024 * 1024) } });
1213
- const failure = await userOfToken('ghu_testuser', { fetchImpl: oversized, apiBase: BASE }).catch((error) => error);
1214
- expect(failure).toBeInstanceOf(Error);
1215
- expect(failure.message).toBe('GitHub answered a body past the size cap');
1216
- expect(pulled).toBe(false);
1217
- });
1218
- });
1219
- //# sourceMappingURL=app.test.js.map