@goodfellow-cms/github 0.2.0 → 0.3.0
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.
- package/dist/index.d.ts +70 -4
- package/dist/index.js +841 -197
- package/dist/testing.d.ts +51 -1
- package/dist/testing.js +468 -50
- package/package.json +2 -2
package/dist/testing.js
CHANGED
|
@@ -6,6 +6,28 @@ import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@
|
|
|
6
6
|
* an in-memory repository. For tests only: pass `fake.fetch` as the backend's
|
|
7
7
|
* `fetch`, or route browser requests to `fake.handle` in end-to-end tests.
|
|
8
8
|
*/
|
|
9
|
+
/** GitHub's number for an account, worked out from its login when a test doesn't give one. */
|
|
10
|
+
function accountId(login) {
|
|
11
|
+
let hash = 7;
|
|
12
|
+
for (const char of login.toLowerCase()) hash = (hash * 31 + char.charCodeAt(0)) % 9e6;
|
|
13
|
+
return 1e6 + hash;
|
|
14
|
+
}
|
|
15
|
+
function same(a, b) {
|
|
16
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
function covers(installation, fullName) {
|
|
19
|
+
const [owner = ""] = fullName.split("/");
|
|
20
|
+
if (!same(installation.account, owner)) return false;
|
|
21
|
+
return installation.repositories === "all" || installation.repositories.some((repo) => same(repo, fullName));
|
|
22
|
+
}
|
|
23
|
+
function decodeJwtPart(part) {
|
|
24
|
+
try {
|
|
25
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
26
|
+
return JSON.parse(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)));
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
9
31
|
function json(body, status = 200) {
|
|
10
32
|
return new Response(JSON.stringify(body), {
|
|
11
33
|
status,
|
|
@@ -18,10 +40,22 @@ function fakeGitHub(options) {
|
|
|
18
40
|
login: "editor",
|
|
19
41
|
name: "Test Editor"
|
|
20
42
|
} };
|
|
43
|
+
/** Every token that works: the test's own, and those apps' sign-ins make. */
|
|
44
|
+
const principals = new Map(Object.entries(tokens));
|
|
45
|
+
const apps = options.apps ?? [];
|
|
46
|
+
const issued = [];
|
|
47
|
+
/** OAuth codes waiting to be exchanged, and refresh tokens, by the app and person they're for. */
|
|
48
|
+
const codes = /* @__PURE__ */ new Map();
|
|
49
|
+
const refreshTokens = /* @__PURE__ */ new Map();
|
|
50
|
+
/** Who each commit made through the REST API names as its author. */
|
|
51
|
+
const commitAuthors = /* @__PURE__ */ new Map();
|
|
52
|
+
let nextToken = 1;
|
|
53
|
+
let nextRepoId = 100;
|
|
21
54
|
const deployments = [];
|
|
22
55
|
const runs = [];
|
|
23
56
|
const requests = [];
|
|
24
57
|
let nextDeployment = 1;
|
|
58
|
+
let graphqlReads = 0;
|
|
25
59
|
const created = /* @__PURE__ */ new Map();
|
|
26
60
|
const [owner = "", name = ""] = options.repo.split("/");
|
|
27
61
|
const pages = { current: options.pages ? {
|
|
@@ -60,6 +94,24 @@ function fakeGitHub(options) {
|
|
|
60
94
|
permissions: invitation.permission
|
|
61
95
|
})));
|
|
62
96
|
}
|
|
97
|
+
const permission = rest.match(/^\/collaborators\/([^/]+)\/permission$/);
|
|
98
|
+
if (permission && request.method === "GET") {
|
|
99
|
+
const login = decodeURIComponent(permission[1] ?? "");
|
|
100
|
+
if (!accounts.has(login)) return json({ message: "Not Found" }, 404);
|
|
101
|
+
const role = collaborators.get(login);
|
|
102
|
+
const level = role === "admin" ? "admin" : role === "push" ? "write" : role === "read" ? "read" : "none";
|
|
103
|
+
return json({
|
|
104
|
+
permission: level,
|
|
105
|
+
user: {
|
|
106
|
+
login,
|
|
107
|
+
permissions: {
|
|
108
|
+
admin: level === "admin",
|
|
109
|
+
push: level === "admin" || level === "write",
|
|
110
|
+
pull: level !== "none"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
63
115
|
const collaborator = rest.match(/^\/collaborators\/([^/]+)$/);
|
|
64
116
|
if (collaborator) {
|
|
65
117
|
if (!canManage) return forbidden();
|
|
@@ -141,10 +193,12 @@ function fakeGitHub(options) {
|
|
|
141
193
|
if (invitation) collaborators.set(login, invitation.permission === "admin" ? "admin" : "push");
|
|
142
194
|
}
|
|
143
195
|
/** Repository creation and the Git data API, for repositories created through the fake. */
|
|
144
|
-
async function handleCreated(request, path,
|
|
196
|
+
async function handleCreated(request, path, principal) {
|
|
197
|
+
const login = principal.login;
|
|
145
198
|
if (path === "/user/orgs") return json((options.orgs ?? []).map((org) => ({ login: org })));
|
|
146
199
|
const create = path === "/user/repos" ? login : path.match(/^\/orgs\/([^/]+)\/repos$/)?.[1];
|
|
147
200
|
if (create !== void 0 && request.method === "POST") {
|
|
201
|
+
if (principal.bot || principal.app && principal.permissions?.administration !== "write") return json({ message: "Resource not accessible by integration" }, 403);
|
|
148
202
|
if (path !== "/user/repos" && !options.orgs?.includes(create)) return json({ message: "Not Found" }, 404);
|
|
149
203
|
const body = await request.json();
|
|
150
204
|
if (!/^[\w.-]+$/.test(body.name)) return json({ message: "Repository creation failed." }, 422);
|
|
@@ -154,7 +208,9 @@ function fakeGitHub(options) {
|
|
|
154
208
|
errors: [{ message: "name already exists on this account" }]
|
|
155
209
|
}, 422);
|
|
156
210
|
const repo = new FakeRepo({ "README.md": `# ${body.name}\n` }, options.newRepoBranch ?? "main");
|
|
211
|
+
const id = nextRepoId++;
|
|
157
212
|
created.set(fullName, {
|
|
213
|
+
id,
|
|
158
214
|
fullName,
|
|
159
215
|
private: body.private === true,
|
|
160
216
|
description: body.description,
|
|
@@ -162,6 +218,7 @@ function fakeGitHub(options) {
|
|
|
162
218
|
rulesets: []
|
|
163
219
|
});
|
|
164
220
|
return json({
|
|
221
|
+
id,
|
|
165
222
|
full_name: fullName,
|
|
166
223
|
html_url: `https://github.com/${fullName}`,
|
|
167
224
|
default_branch: repo.defaultBranch
|
|
@@ -170,13 +227,18 @@ function fakeGitHub(options) {
|
|
|
170
227
|
const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
|
|
171
228
|
const site = created.get(decodeURIComponent(match?.[1] ?? ""));
|
|
172
229
|
if (!match || !site) return void 0;
|
|
230
|
+
if (!canSee(principal, site.fullName)) return json({ message: "Not Found" }, 404);
|
|
173
231
|
const { repo } = site;
|
|
174
232
|
const rest = match[2] ?? "";
|
|
175
233
|
const method = request.method;
|
|
176
234
|
if (rest === "" && method === "GET") return json({
|
|
235
|
+
id: site.id,
|
|
177
236
|
full_name: site.fullName,
|
|
178
237
|
default_branch: repo.defaultBranch,
|
|
179
|
-
permissions: {
|
|
238
|
+
permissions: {
|
|
239
|
+
push: true,
|
|
240
|
+
admin: true
|
|
241
|
+
}
|
|
180
242
|
});
|
|
181
243
|
if (rest === "" && method === "PATCH") {
|
|
182
244
|
const body = await request.json();
|
|
@@ -198,17 +260,6 @@ function fakeGitHub(options) {
|
|
|
198
260
|
repo.branches.delete(decodeURIComponent(ref[1] ?? ""));
|
|
199
261
|
return new Response(null, { status: 204 });
|
|
200
262
|
}
|
|
201
|
-
if (ref && method === "PATCH") {
|
|
202
|
-
const body = await request.json();
|
|
203
|
-
const branch = decodeURIComponent(ref[1] ?? "");
|
|
204
|
-
const commit = repo.commitAt(body.sha);
|
|
205
|
-
if (!commit || !repo.branches.has(branch)) return json({ message: "Reference does not exist" }, 422);
|
|
206
|
-
if (!body.force && commit.parent !== repo.branches.get(branch)) return json({ message: "Update is not a fast forward" }, 422);
|
|
207
|
-
if (site.rulesets.length > 0 && commit.parent !== repo.branches.get(branch)) return json({ message: "Cannot force-push to this branch" }, 422);
|
|
208
|
-
repo.branches.set(branch, body.sha);
|
|
209
|
-
deploy(body.sha);
|
|
210
|
-
return json({ object: { sha: body.sha } });
|
|
211
|
-
}
|
|
212
263
|
if (rest === "/git/refs" && method === "POST") {
|
|
213
264
|
const body = await request.json();
|
|
214
265
|
repo.branches.set(body.ref.replace(/^refs\/heads\//, ""), body.sha);
|
|
@@ -217,20 +268,68 @@ function fakeGitHub(options) {
|
|
|
217
268
|
object: { sha: body.sha }
|
|
218
269
|
}, 201);
|
|
219
270
|
}
|
|
271
|
+
const data = await handleGitData(request, rest, repo, principal, site.rulesets.length > 0);
|
|
272
|
+
if (data) return data;
|
|
273
|
+
if (rest === "/pages" && method === "POST") {
|
|
274
|
+
if (site.private && !options.paidPlan) return json({ message: "Your current plan does not support GitHub Pages for this repository." }, 422);
|
|
275
|
+
site.pages = await request.json();
|
|
276
|
+
const [owner, name] = site.fullName.split("/");
|
|
277
|
+
return json({
|
|
278
|
+
html_url: `https://${owner}.github.io/${name}/`,
|
|
279
|
+
build_type: site.pages.build_type
|
|
280
|
+
}, 201);
|
|
281
|
+
}
|
|
282
|
+
if (rest === "/rulesets" && method === "POST") {
|
|
283
|
+
if (site.private && !options.paidPlan) return json({ message: "Upgrade to GitHub Pro" }, 403);
|
|
284
|
+
site.rulesets.push(await request.json());
|
|
285
|
+
return json({ id: site.rulesets.length }, 201);
|
|
286
|
+
}
|
|
287
|
+
return json({ message: "Not Found" }, 404);
|
|
288
|
+
}
|
|
289
|
+
/** Whether a token may see a repository: an app's only where it's installed, and an installation's only its own. */
|
|
290
|
+
function canSee(principal, fullName) {
|
|
291
|
+
if (principal.repositories) return principal.repositories.some((repo) => same(repo, fullName));
|
|
292
|
+
if (principal.app) return principal.app.installations.some((installation) => covers(installation, fullName));
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
function treeOf(target, commitSha) {
|
|
296
|
+
const sha = target.blobSha(`tree-of:${commitSha}`);
|
|
297
|
+
if (!trees.has(sha)) trees.set(sha, new Map(target.files(commitSha)));
|
|
298
|
+
return sha;
|
|
299
|
+
}
|
|
300
|
+
/** Git's data API on a repository: blobs, trees, commits and moving a branch, as REST commits need. */
|
|
301
|
+
async function handleGitData(request, rest, target, principal, protectedHistory) {
|
|
302
|
+
const method = request.method;
|
|
303
|
+
const commitSha = rest.match(/^\/git\/commits\/([0-9a-f]{40})$/);
|
|
304
|
+
if (commitSha && method === "GET") {
|
|
305
|
+
const found = target.commitAt(commitSha[1] ?? "");
|
|
306
|
+
if (!found) return json({ message: "Not Found" }, 404);
|
|
307
|
+
return json({
|
|
308
|
+
sha: found.sha,
|
|
309
|
+
tree: { sha: treeOf(target, found.sha) },
|
|
310
|
+
parents: found.parent ? [{ sha: found.parent }] : []
|
|
311
|
+
});
|
|
312
|
+
}
|
|
220
313
|
if (rest === "/git/blobs" && method === "POST") {
|
|
314
|
+
if (principal.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
221
315
|
const body = await request.json();
|
|
222
|
-
return json({ sha:
|
|
316
|
+
return json({ sha: target.blobSha(body.encoding === "base64" ? fakeFileFromBytes(decodeBase64(body.content)) : body.content) }, 201);
|
|
223
317
|
}
|
|
224
318
|
if (rest === "/git/trees" && method === "POST") {
|
|
225
319
|
const body = await request.json();
|
|
226
|
-
|
|
320
|
+
if (body.base_tree && !trees.has(body.base_tree)) return json({ message: "Tree not found" }, 422);
|
|
321
|
+
const files = new Map(body.base_tree ? trees.get(body.base_tree) : void 0);
|
|
227
322
|
for (const entry of body.tree) {
|
|
228
|
-
if (entry.path.startsWith(".github/workflows/") &&
|
|
229
|
-
|
|
323
|
+
if (entry.path.startsWith(".github/workflows/") && principal.workflow === false) return json({ message: "Resource not accessible by personal access token" }, 404);
|
|
324
|
+
if (entry.sha === null) {
|
|
325
|
+
files.delete(entry.path);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const content = entry.content ?? target.blobs.get(entry.sha ?? "");
|
|
230
329
|
if (content === void 0) return json({ message: `No blob ${entry.sha}` }, 422);
|
|
231
330
|
files.set(entry.path, content);
|
|
232
331
|
}
|
|
233
|
-
const sha =
|
|
332
|
+
const sha = target.blobSha(`tree:${[...files.keys()].join(",")}:${trees.size}`);
|
|
234
333
|
trees.set(sha, files);
|
|
235
334
|
return json({ sha }, 201);
|
|
236
335
|
}
|
|
@@ -238,26 +337,24 @@ function fakeGitHub(options) {
|
|
|
238
337
|
const body = await request.json();
|
|
239
338
|
const files = trees.get(body.tree);
|
|
240
339
|
if (!files) return json({ message: "Tree not found" }, 422);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
site.pages = await request.json();
|
|
246
|
-
const [owner, name] = site.fullName.split("/");
|
|
247
|
-
return json({
|
|
248
|
-
html_url: `https://${owner}.github.io/${name}/`,
|
|
249
|
-
build_type: site.pages.build_type
|
|
250
|
-
}, 201);
|
|
340
|
+
const author = body.author?.name ?? (principal.bot ? principal.login : principal.name ?? principal.login);
|
|
341
|
+
const sha = target.addCommit(body.parents[0], body.message, new Map(files), author);
|
|
342
|
+
commitAuthors.set(sha, body.author ?? { name: author });
|
|
343
|
+
return json({ sha }, 201);
|
|
251
344
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
345
|
+
const ref = rest.match(/^\/git\/refs?\/heads\/(.+)$/);
|
|
346
|
+
if (ref && method === "PATCH") {
|
|
347
|
+
if (principal.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
348
|
+
const body = await request.json();
|
|
349
|
+
const branch = decodeURIComponent(ref[1] ?? "");
|
|
350
|
+
const found = target.commitAt(body.sha);
|
|
351
|
+
if (!found || !target.branches.has(branch)) return json({ message: "Reference does not exist" }, 422);
|
|
352
|
+
if (!body.force && found.parent !== target.branches.get(branch)) return json({ message: "Update is not a fast forward" }, 422);
|
|
353
|
+
if (protectedHistory && found.parent !== target.branches.get(branch)) return json({ message: "Cannot force-push to this branch" }, 422);
|
|
354
|
+
target.branches.set(branch, body.sha);
|
|
355
|
+
if (branch === target.defaultBranch) deploy(body.sha);
|
|
356
|
+
return json({ object: { sha: body.sha } });
|
|
256
357
|
}
|
|
257
|
-
return json({ message: "Not Found" }, 404);
|
|
258
|
-
}
|
|
259
|
-
function user(request) {
|
|
260
|
-
return tokens[request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""];
|
|
261
358
|
}
|
|
262
359
|
function deploy(sha) {
|
|
263
360
|
if (options.deployAfterChecks === void 0) return;
|
|
@@ -268,26 +365,251 @@ function fakeGitHub(options) {
|
|
|
268
365
|
checks: 0
|
|
269
366
|
});
|
|
270
367
|
}
|
|
271
|
-
function commit(changes, message, expectedHead, author) {
|
|
368
|
+
function commit(changes, message, expectedHead, author, branch = repo.defaultBranch) {
|
|
272
369
|
const sha = repo.commit(changes, message, {
|
|
273
370
|
expectedHead,
|
|
274
|
-
author
|
|
371
|
+
author,
|
|
372
|
+
branch
|
|
275
373
|
});
|
|
276
|
-
deploy(sha);
|
|
374
|
+
if (branch === repo.defaultBranch) deploy(sha);
|
|
277
375
|
return sha;
|
|
278
376
|
}
|
|
377
|
+
/** The person a login belongs to, as the test's tokens describe them. */
|
|
378
|
+
function person(login) {
|
|
379
|
+
return Object.values(tokens).find((candidate) => same(candidate.login, login)) ?? { login };
|
|
380
|
+
}
|
|
381
|
+
/** Simulates someone signing in to an app on GitHub's page: the code GitHub sends back to the app's callback. */
|
|
382
|
+
function authorize(login, clientId) {
|
|
383
|
+
const app = apps.find((candidate) => candidate.clientId === clientId);
|
|
384
|
+
if (!app) throw new Error(`No app with client ID ${clientId}`);
|
|
385
|
+
const code = `code-${nextToken++}`;
|
|
386
|
+
codes.set(code, {
|
|
387
|
+
app,
|
|
388
|
+
login
|
|
389
|
+
});
|
|
390
|
+
return code;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Simulates someone installing an app on GitHub's install page, choosing
|
|
394
|
+
* these repositories, or adding them to its installation on the account,
|
|
395
|
+
* and being signed in as they go: the code GitHub sends back to the app's callback.
|
|
396
|
+
*/
|
|
397
|
+
function install(slug, login, repositories, account = repositories[0]?.split("/")[0] ?? login) {
|
|
398
|
+
const app = apps.find((candidate) => candidate.slug === slug);
|
|
399
|
+
if (!app) throw new Error(`No app ${slug}`);
|
|
400
|
+
let installation = app.installations.find((candidate) => same(candidate.account, account));
|
|
401
|
+
if (!installation) {
|
|
402
|
+
installation = {
|
|
403
|
+
id: 1e3 + nextToken++,
|
|
404
|
+
account,
|
|
405
|
+
repositories: []
|
|
406
|
+
};
|
|
407
|
+
app.installations.push(installation);
|
|
408
|
+
}
|
|
409
|
+
const chosen = installation.repositories;
|
|
410
|
+
if (chosen !== "all") {
|
|
411
|
+
for (const repo of repositories) if (!chosen.some((name) => same(name, repo))) chosen.push(repo);
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
code: authorize(login, app.clientId),
|
|
415
|
+
installation: installation.id
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function userToken(app, login) {
|
|
419
|
+
const access = `ghu_fake${nextToken++}`;
|
|
420
|
+
const refresh = `ghr_fake${nextToken++}`;
|
|
421
|
+
const base = person(login);
|
|
422
|
+
principals.set(access, {
|
|
423
|
+
...base,
|
|
424
|
+
app,
|
|
425
|
+
permissions: app.permissions,
|
|
426
|
+
administration: app.permissions.administration === "write",
|
|
427
|
+
pages: app.permissions.pages === "write",
|
|
428
|
+
workflow: app.permissions.workflows === "write",
|
|
429
|
+
actions: app.permissions.actions !== void 0,
|
|
430
|
+
actionsWrite: app.permissions.actions === "write"
|
|
431
|
+
});
|
|
432
|
+
refreshTokens.set(refresh, {
|
|
433
|
+
app,
|
|
434
|
+
login
|
|
435
|
+
});
|
|
436
|
+
issued.push({
|
|
437
|
+
token: access,
|
|
438
|
+
kind: "user",
|
|
439
|
+
app: app.slug,
|
|
440
|
+
login,
|
|
441
|
+
permissions: app.permissions
|
|
442
|
+
});
|
|
443
|
+
return json({
|
|
444
|
+
access_token: access,
|
|
445
|
+
token_type: "bearer",
|
|
446
|
+
scope: "",
|
|
447
|
+
expires_in: 28800,
|
|
448
|
+
refresh_token: refresh,
|
|
449
|
+
refresh_token_expires_in: 15897600
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
async function appFromJwt(jwt) {
|
|
453
|
+
const [header, payload, signature] = jwt.split(".");
|
|
454
|
+
const claims = decodeJwtPart(payload ?? "");
|
|
455
|
+
if (!header || !claims || !signature || decodeJwtPart(header)?.alg !== "RS256") return void 0;
|
|
456
|
+
const app = apps.find((candidate) => String(candidate.id) === String(claims.iss));
|
|
457
|
+
if (!app || typeof claims.exp !== "number" || claims.exp * 1e3 < Date.now()) return void 0;
|
|
458
|
+
if (app.publicKey) {
|
|
459
|
+
const base64 = signature.replace(/-/g, "+").replace(/_/g, "/");
|
|
460
|
+
const bytes = Uint8Array.from(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)), (char) => char.charCodeAt(0));
|
|
461
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", app.publicKey, bytes, new TextEncoder().encode(`${header}.${payload}`))) return void 0;
|
|
462
|
+
}
|
|
463
|
+
return app;
|
|
464
|
+
}
|
|
465
|
+
/** What GitHub Apps call: OAuth's token exchange, revoking, and the app's own endpoints, which take its JWT. */
|
|
466
|
+
async function handleApps(request, path) {
|
|
467
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
468
|
+
if (path === "/login/oauth/access_token" && request.method === "POST") {
|
|
469
|
+
const body = await request.json();
|
|
470
|
+
const app = apps.find((candidate) => candidate.clientId === body.client_id);
|
|
471
|
+
if (!app || app.clientSecret !== body.client_secret) return json({ error: "incorrect_client_credentials" });
|
|
472
|
+
if (body.grant_type === "refresh_token") {
|
|
473
|
+
const found = refreshTokens.get(body.refresh_token ?? "");
|
|
474
|
+
if (!found || found.app !== app) return json({ error: "bad_refresh_token" });
|
|
475
|
+
refreshTokens.delete(body.refresh_token ?? "");
|
|
476
|
+
return userToken(app, found.login);
|
|
477
|
+
}
|
|
478
|
+
const found = codes.get(body.code ?? "");
|
|
479
|
+
if (!found || found.app !== app) return json({ error: "bad_verification_code" });
|
|
480
|
+
codes.delete(body.code ?? "");
|
|
481
|
+
return userToken(app, found.login);
|
|
482
|
+
}
|
|
483
|
+
const revoke = path.match(/^\/applications\/([^/]+)\/token$/);
|
|
484
|
+
if (revoke && request.method === "DELETE") {
|
|
485
|
+
const [id, secret] = atob(authorization.replace(/^Basic /, "")).split(":");
|
|
486
|
+
const app = apps.find((candidate) => candidate.clientId === decodeURIComponent(revoke[1] ?? ""));
|
|
487
|
+
if (!app || app.clientId !== id || app.clientSecret !== secret) return json({ message: "Not Found" }, 404);
|
|
488
|
+
const { access_token: token } = await request.json();
|
|
489
|
+
principals.delete(token);
|
|
490
|
+
for (const item of issued) if (item.token === token) item.revoked = true;
|
|
491
|
+
return new Response(null, { status: 204 });
|
|
492
|
+
}
|
|
493
|
+
if (path === "/installation/token" && request.method === "DELETE") {
|
|
494
|
+
const token = authorization.replace(/^Bearer /, "");
|
|
495
|
+
if (!principals.get(token)?.bot) return json({ message: "Bad credentials" }, 401);
|
|
496
|
+
principals.delete(token);
|
|
497
|
+
for (const item of issued) if (item.token === token) item.revoked = true;
|
|
498
|
+
return new Response(null, { status: 204 });
|
|
499
|
+
}
|
|
500
|
+
if (!/^Bearer ey/.test(authorization)) return void 0;
|
|
501
|
+
const app = await appFromJwt(authorization.slice(7));
|
|
502
|
+
if (!app) return json({ message: "A JSON web token could not be decoded" }, 401);
|
|
503
|
+
const installed = path.match(/^\/repos\/([^/]+\/[^/]+)\/installation$/);
|
|
504
|
+
if (installed) {
|
|
505
|
+
const fullName = decodeURIComponent(installed[1] ?? "");
|
|
506
|
+
const installation = app.installations.find((candidate) => covers(candidate, fullName));
|
|
507
|
+
return installation ? json({
|
|
508
|
+
id: installation.id,
|
|
509
|
+
account: { login: installation.account },
|
|
510
|
+
app_slug: app.slug
|
|
511
|
+
}) : json({ message: "Not Found" }, 404);
|
|
512
|
+
}
|
|
513
|
+
const mint = path.match(/^\/app\/installations\/(\d+)\/access_tokens$/);
|
|
514
|
+
if (mint && request.method === "POST") {
|
|
515
|
+
const installation = app.installations.find((candidate) => candidate.id === Number(mint[1]));
|
|
516
|
+
if (!installation) return json({ message: "Not Found" }, 404);
|
|
517
|
+
const body = await request.json();
|
|
518
|
+
const repositories = (body.repositories ?? []).map((name) => `${installation.account}/${name}`);
|
|
519
|
+
if (repositories.some((repo) => !covers(installation, repo))) return json({ message: "There is at least one repository that does not exist or is not accessible" }, 422);
|
|
520
|
+
const permissions = body.permissions ?? app.permissions;
|
|
521
|
+
for (const [name, level] of Object.entries(permissions)) {
|
|
522
|
+
const allowed = app.permissions[name];
|
|
523
|
+
if (!allowed || level === "write" && allowed !== "write") return json({ message: `The permissions requested are not granted to this installation.` }, 422);
|
|
524
|
+
}
|
|
525
|
+
const token = `ghs_fake${nextToken++}`;
|
|
526
|
+
principals.set(token, {
|
|
527
|
+
login: `${app.slug}[bot]`,
|
|
528
|
+
bot: true,
|
|
529
|
+
app,
|
|
530
|
+
repositories,
|
|
531
|
+
permissions,
|
|
532
|
+
push: permissions.contents === "write",
|
|
533
|
+
workflow: permissions.workflows === "write",
|
|
534
|
+
pages: permissions.pages === "write",
|
|
535
|
+
administration: permissions.administration === "write",
|
|
536
|
+
admin: permissions.administration === "write",
|
|
537
|
+
actions: permissions.actions !== void 0,
|
|
538
|
+
actionsWrite: permissions.actions === "write"
|
|
539
|
+
});
|
|
540
|
+
issued.push({
|
|
541
|
+
token,
|
|
542
|
+
kind: "installation",
|
|
543
|
+
app: app.slug,
|
|
544
|
+
repositories,
|
|
545
|
+
permissions
|
|
546
|
+
});
|
|
547
|
+
return json({
|
|
548
|
+
token,
|
|
549
|
+
expires_at: new Date(Date.now() + 36e5).toISOString(),
|
|
550
|
+
permissions
|
|
551
|
+
}, 201);
|
|
552
|
+
}
|
|
553
|
+
return json({ message: "Not Found" }, 404);
|
|
554
|
+
}
|
|
279
555
|
async function handle(request) {
|
|
280
556
|
requests.push(request.clone());
|
|
281
557
|
const url = new URL(request.url);
|
|
282
558
|
const path = url.pathname;
|
|
283
|
-
const
|
|
559
|
+
const fromApp = await handleApps(request, path);
|
|
560
|
+
if (fromApp) return fromApp;
|
|
561
|
+
const user = principals.get(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "");
|
|
284
562
|
if (!user) return json({ message: "Bad credentials" }, 401);
|
|
285
|
-
if (path === "/user")
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
563
|
+
if (path === "/user") {
|
|
564
|
+
if (user.bot) return json({ message: "Resource not accessible by integration" }, 403);
|
|
565
|
+
return json({
|
|
566
|
+
id: user.id ?? accountId(user.login),
|
|
567
|
+
login: user.login,
|
|
568
|
+
name: user.name ?? null,
|
|
569
|
+
avatar_url: `https://avatars.example/${user.login}`
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (path === "/user/installations" && user.app && !user.bot) {
|
|
573
|
+
const mine = user.app.installations.filter((installation) => same(installation.account, user.login) || (options.orgs ?? []).some((org) => same(org, installation.account)));
|
|
574
|
+
return json({
|
|
575
|
+
total_count: mine.length,
|
|
576
|
+
installations: mine.map((installation) => ({
|
|
577
|
+
id: installation.id,
|
|
578
|
+
account: {
|
|
579
|
+
login: installation.account,
|
|
580
|
+
type: installation.accountType ?? "User"
|
|
581
|
+
},
|
|
582
|
+
repository_selection: installation.repositories === "all" ? "all" : "selected"
|
|
583
|
+
}))
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const listRepos = path.match(/^\/user\/installations\/(\d+)\/repositories$/);
|
|
587
|
+
if (listRepos && request.method === "GET" && user.app && !user.bot) {
|
|
588
|
+
const installation = user.app.installations.find((candidate) => candidate.id === Number(listRepos[1]));
|
|
589
|
+
if (!installation) return json({ message: "Not Found" }, 404);
|
|
590
|
+
const repositories = (installation.repositories === "all" ? [options.repo, ...created.keys()].filter((name) => same(name.split("/")[0] ?? "", installation.account)) : installation.repositories).map((name) => ({
|
|
591
|
+
full_name: name,
|
|
592
|
+
default_branch: same(name, options.repo) ? repo.defaultBranch : created.get(name)?.repo.defaultBranch ?? "main",
|
|
593
|
+
permissions: {
|
|
594
|
+
admin: user.admin === true,
|
|
595
|
+
push: user.push !== false,
|
|
596
|
+
pull: true
|
|
597
|
+
}
|
|
598
|
+
}));
|
|
599
|
+
return json({
|
|
600
|
+
total_count: repositories.length,
|
|
601
|
+
repositories
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
const addRepo = path.match(/^\/user\/installations\/(\d+)\/repositories\/(\d+)$/);
|
|
605
|
+
if (addRepo && request.method === "PUT" && user.app && !user.bot) {
|
|
606
|
+
const installation = user.app.installations.find((candidate) => candidate.id === Number(addRepo[1]));
|
|
607
|
+
const target = [...created.values()].find((candidate) => candidate.id === Number(addRepo[2]));
|
|
608
|
+
if (!installation || !target) return json({ message: "Not Found" }, 404);
|
|
609
|
+
if (installation.repositories !== "all" && !installation.repositories.includes(target.fullName)) installation.repositories.push(target.fullName);
|
|
610
|
+
return new Response(null, { status: 204 });
|
|
611
|
+
}
|
|
612
|
+
const setup = await handleCreated(request, path, user);
|
|
291
613
|
if (setup) return setup;
|
|
292
614
|
const account = path.match(/^\/users\/([^/]+)$/);
|
|
293
615
|
if (account) {
|
|
@@ -295,6 +617,29 @@ function fakeGitHub(options) {
|
|
|
295
617
|
return accounts.has(login) ? json({ login }) : json({ message: "Not Found" }, 404);
|
|
296
618
|
}
|
|
297
619
|
if (path === "/graphql" && request.method === "POST") {
|
|
620
|
+
const body = await request.clone().json();
|
|
621
|
+
if (body.query.trimStart().startsWith("query")) {
|
|
622
|
+
if (`${body.variables?.owner}/${body.variables?.name}` !== options.repo) return json({
|
|
623
|
+
data: { repository: null },
|
|
624
|
+
errors: [{
|
|
625
|
+
type: "NOT_FOUND",
|
|
626
|
+
message: "Could not resolve"
|
|
627
|
+
}]
|
|
628
|
+
});
|
|
629
|
+
const repository = {};
|
|
630
|
+
for (const [, alias, oid] of body.query.matchAll(/(\w+): object\(oid: "([0-9a-f]{40})"\)/g)) {
|
|
631
|
+
const content = repo.blobs.get(oid ?? "");
|
|
632
|
+
repository[alias ?? ""] = content === void 0 ? null : typeof content === "string" ? {
|
|
633
|
+
text: content,
|
|
634
|
+
isBinary: false
|
|
635
|
+
} : {
|
|
636
|
+
text: null,
|
|
637
|
+
isBinary: true
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
graphqlReads += 1;
|
|
641
|
+
return json({ data: { repository } });
|
|
642
|
+
}
|
|
298
643
|
const { variables } = await request.json();
|
|
299
644
|
const { input } = variables;
|
|
300
645
|
if (input.branch.repositoryNameWithOwner !== options.repo) return json({ errors: [{
|
|
@@ -314,7 +659,7 @@ function fakeGitHub(options) {
|
|
|
314
659
|
})), ...(input.fileChanges.deletions ?? []).map((deletion) => ({
|
|
315
660
|
path: deletion.path,
|
|
316
661
|
delete: true
|
|
317
|
-
}))], input.message.headline, input.expectedHeadOid, user.name ?? user.login) } } } });
|
|
662
|
+
}))], input.message.headline, input.expectedHeadOid, user.name ?? user.login, input.branch.branchName) } } } });
|
|
318
663
|
} catch (error) {
|
|
319
664
|
if (error instanceof FakeConflictError) return json({
|
|
320
665
|
data: { createCommitOnBranch: null },
|
|
@@ -328,7 +673,58 @@ function fakeGitHub(options) {
|
|
|
328
673
|
}
|
|
329
674
|
const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
|
|
330
675
|
if (!match || decodeURIComponent(match[1] ?? "") !== options.repo) return json({ message: "Not Found" }, 404);
|
|
676
|
+
if (!canSee(user, options.repo)) return json({ message: "Not Found" }, 404);
|
|
331
677
|
const rest = match[2] ?? "";
|
|
678
|
+
const contents = rest.match(/^\/contents\/(.+)$/);
|
|
679
|
+
if (contents && request.method === "GET") {
|
|
680
|
+
if (user.bot && !user.permissions?.contents) return json({ message: "Resource not accessible by integration" }, 403);
|
|
681
|
+
const file = repo.files(url.searchParams.get("ref") ?? repo.defaultBranch).get(decodeURIComponent(contents[1] ?? ""));
|
|
682
|
+
if (file === void 0) return json({ message: "Not Found" }, 404);
|
|
683
|
+
return request.headers.get("accept")?.includes("raw") ? new Response(fakeFileBytes(file)) : json({
|
|
684
|
+
content: encodeBase64Bytes(fakeFileBytes(file)),
|
|
685
|
+
encoding: "base64"
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const data = await handleGitData(request, rest, repo, user, false);
|
|
689
|
+
if (data) return data;
|
|
690
|
+
if (rest === "/git/refs" && request.method === "POST") {
|
|
691
|
+
if (user.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
692
|
+
const body = await request.json();
|
|
693
|
+
const branch = body.ref.replace(/^refs\/heads\//, "");
|
|
694
|
+
if (repo.branches.has(branch)) return json({ message: "Reference already exists" }, 422);
|
|
695
|
+
if (!repo.commitAt(body.sha)) return json({ message: "Object does not exist" }, 422);
|
|
696
|
+
repo.branches.set(branch, body.sha);
|
|
697
|
+
return json({
|
|
698
|
+
ref: body.ref,
|
|
699
|
+
object: { sha: body.sha }
|
|
700
|
+
}, 201);
|
|
701
|
+
}
|
|
702
|
+
const branchRef = rest.match(/^\/git\/refs\/heads\/(.+)$/);
|
|
703
|
+
if (branchRef && request.method === "DELETE") {
|
|
704
|
+
if (user.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
705
|
+
const branch = decodeURIComponent(branchRef[1] ?? "");
|
|
706
|
+
if (!repo.branches.has(branch)) return json({ message: "Reference does not exist" }, 422);
|
|
707
|
+
repo.branches.delete(branch);
|
|
708
|
+
return new Response(null, { status: 204 });
|
|
709
|
+
}
|
|
710
|
+
if (rest === "/pulls" && request.method === "POST") {
|
|
711
|
+
if (user.bot && user.permissions?.pull_requests !== "write") return json({ message: "Resource not accessible by integration" }, 403);
|
|
712
|
+
const body = await request.json();
|
|
713
|
+
if (!repo.branches.has(body.head) || !repo.branches.has(body.base)) return json({ message: "Validation Failed" }, 422);
|
|
714
|
+
if (repo.reviews.some((review) => review.branch === body.head && review.state === "open")) return json({ message: "A pull request already exists" }, 422);
|
|
715
|
+
const review = repo.openReview(body.head, body.title, body.body ?? "", body.base);
|
|
716
|
+
return json({
|
|
717
|
+
number: review.number,
|
|
718
|
+
html_url: `https://github.com/${options.repo}/pull/${review.number}`
|
|
719
|
+
}, 201);
|
|
720
|
+
}
|
|
721
|
+
if (rest === "/pulls" && request.method === "GET") {
|
|
722
|
+
const head = url.searchParams.get("head")?.split(":")[1];
|
|
723
|
+
return json(repo.reviews.filter((review) => review.state === "open" && (!head || review.branch === head)).map((review) => ({
|
|
724
|
+
number: review.number,
|
|
725
|
+
html_url: `https://github.com/${options.repo}/pull/${review.number}`
|
|
726
|
+
})));
|
|
727
|
+
}
|
|
332
728
|
if (rest === "/pages" && request.method === "GET") {
|
|
333
729
|
const site = pages.current;
|
|
334
730
|
if (!site) return json({ message: "Not Found" }, 404);
|
|
@@ -358,6 +754,7 @@ function fakeGitHub(options) {
|
|
|
358
754
|
return new Response(null, { status: 204 });
|
|
359
755
|
}
|
|
360
756
|
if (rest === "") return json({
|
|
757
|
+
id: 1,
|
|
361
758
|
full_name: options.repo,
|
|
362
759
|
default_branch: repo.defaultBranch,
|
|
363
760
|
permissions: {
|
|
@@ -384,7 +781,8 @@ function fakeGitHub(options) {
|
|
|
384
781
|
tree: [...repo.files(commitSha)].map(([file, content]) => ({
|
|
385
782
|
path: file,
|
|
386
783
|
type: "blob",
|
|
387
|
-
sha: repo.blobSha(content)
|
|
784
|
+
sha: repo.blobSha(content),
|
|
785
|
+
size: fakeFileBytes(content).length
|
|
388
786
|
})),
|
|
389
787
|
truncated: false
|
|
390
788
|
});
|
|
@@ -471,8 +869,18 @@ function fakeGitHub(options) {
|
|
|
471
869
|
jobs: run.jobs
|
|
472
870
|
}) : json({ message: "Not Found" }, 404);
|
|
473
871
|
}
|
|
474
|
-
const compare = rest.match(/^\/compare\/(
|
|
475
|
-
if (compare)
|
|
872
|
+
const compare = rest.match(/^\/compare\/(.+)\.\.\.(.+)$/);
|
|
873
|
+
if (compare) {
|
|
874
|
+
const [from, to] = [compare[1], compare[2]].map((ref) => decodeURIComponent(ref ?? ""));
|
|
875
|
+
const resolve = (ref) => repo.branches.get(ref) ?? (repo.commitAt(ref) ? ref : void 0);
|
|
876
|
+
const [a, b] = [resolve(from ?? ""), resolve(to ?? "")];
|
|
877
|
+
if (!a || !b) return json({ message: "Not Found" }, 404);
|
|
878
|
+
const base = repo.mergeBase(a, b) ?? a;
|
|
879
|
+
return json({
|
|
880
|
+
merge_base_commit: { sha: base },
|
|
881
|
+
files: repo.changedPaths(base, b).map((filename) => ({ filename }))
|
|
882
|
+
});
|
|
883
|
+
}
|
|
476
884
|
if (rest === "/deployments") {
|
|
477
885
|
const sha = url.searchParams.get("sha");
|
|
478
886
|
return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
|
|
@@ -495,6 +903,16 @@ function fakeGitHub(options) {
|
|
|
495
903
|
}
|
|
496
904
|
return {
|
|
497
905
|
repo,
|
|
906
|
+
/** How many GraphQL requests read files, as `readFiles()` makes. */
|
|
907
|
+
get graphqlReads() {
|
|
908
|
+
return graphqlReads;
|
|
909
|
+
},
|
|
910
|
+
/** Tokens apps' sign-ins made, in order. */
|
|
911
|
+
issued,
|
|
912
|
+
/** Who commits made through the REST API name as their author, by commit. */
|
|
913
|
+
commitAuthors,
|
|
914
|
+
authorize,
|
|
915
|
+
install,
|
|
498
916
|
/** Repositories created through the API, by `owner/name`. */
|
|
499
917
|
created,
|
|
500
918
|
/** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
|