import { describe, expect, it } from "bun:test"; import { GitHubClient, GitHubError, GitHubTokenError } from "./client"; function repository(id: string) { return { node_id: id, owner: { login: "octo-org" }, name: "score", full_name: "octo-org/score", private: true, html_url: "https://github.test/octo-org/score", default_branch: "main", permissions: { pull: true, push: true, admin: false }, }; } function installation(id = 123) { return { id, account: { login: "octo-org", avatar_url: "https://avatars.test/octo-org", type: "Organization", }, repository_selection: "selected", html_url: `https://github.test/settings/installations/${id}`, suspended_at: null, permissions: { contents: "read", pull_requests: "read", checks: "read", statuses: "read", }, }; } describe("the GitHub client", () => { it("builds an OAuth authorization URL with state and PKCE", () => { let client = new GitHubClient(); let url = new URL(client.authorize({ clientId: "client-id", redirectUri: "https://chopin.test/auth/github/callback", state: "state", challenge: "challenge", })); expect(url.origin).toBe("https://github.com"); expect(url.searchParams.get("client_id")).toBe("client-id"); expect(url.searchParams.get("redirect_uri")) .toBe("https://chopin.test/auth/github/callback"); expect(url.searchParams.has("scope")).toBe(false); expect(url.searchParams.get("state")).toBe("state"); expect(url.searchParams.get("code_challenge")).toBe("challenge"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); it("exchanges a code without putting the client secret in the URL", async () => { let called: { url: string; init?: RequestInit } | undefined; let client = new GitHubClient({ fetch: (async (input: string | URL | Request, init?: RequestInit) => { called = { url: String(input), init }; return Response.json({ access_token: "ghu_user", expires_in: 28_800, refresh_token: "ghr_refresh", refresh_token_expires_in: 15_897_600, token_type: "bearer", }); }), endpoints: { token: "https://github.test/token" }, }); expect( await client.exchange({ clientId: "client-id", clientSecret: "client-secret", redirectUri: "https://chopin.test/auth/github/callback", code: "code", verifier: "verifier", }), ).toEqual({ accessToken: "ghu_user", accessExpiresIn: 28_800, refreshToken: "ghr_refresh", refreshExpiresIn: 15_897_600, }); expect(called!.url).toBe("https://github.test/token"); expect(called!.url).not.toContain("client-secret"); let body = new URLSearchParams(called!.init!.body as string); expect(body.get("client_secret")).toBe("client-secret"); expect(body.get("redirect_uri")).toBe("https://chopin.test/auth/github/callback"); expect(body.get("code_verifier")).toBe("verifier"); expect(called!.init!.redirect).toBe("error"); }); it("rotates an expiring user token", async () => { let body: URLSearchParams | undefined; let client = new GitHubClient({ fetch: async (_input, init) => { body = new URLSearchParams(init!.body as string); return Response.json({ access_token: "ghu_next", expires_in: 28_800, refresh_token: "ghr_next", refresh_token_expires_in: 15_897_600, token_type: "bearer", }); }, endpoints: { token: "https://github.test/token" }, }); expect( await client.refresh({ clientId: "client-id", clientSecret: "client-secret", refreshToken: "ghr_current", }), ).toMatchObject({ accessToken: "ghu_next", refreshToken: "ghr_next" }); expect(body!.get("grant_type")).toBe("refresh_token"); expect(body!.get("refresh_token")).toBe("ghr_current"); }); it("requires expiring grants and identifies a rejected refresh token", async () => { let incomplete = new GitHubClient({ fetch: async () => Response.json({ access_token: "ghu_user", token_type: "bearer" }), endpoints: { token: "https://github.test/token" }, }); await expect(incomplete.exchange({ clientId: "client-id", clientSecret: "client-secret", redirectUri: "https://chopin.test/auth/github/callback", code: "code", verifier: "verifier", })).rejects.toThrow("invalid expiring user token"); let rejected = new GitHubClient({ fetch: async () => Response.json({ error: "bad_refresh_token" }), endpoints: { token: "https://github.test/token" }, }); try { await rejected.refresh({ clientId: "client-id", clientSecret: "client-secret", refreshToken: "ghr_bad", }); expect.unreachable(); } catch (err) { expect(err).toBeInstanceOf(GitHubTokenError); expect((err as GitHubTokenError).terminal).toBe(true); expect((err as GitHubTokenError).status).toBe(401); } }); it("normalizes the user, installations and installation repositories", async () => { let requests: Array<{ url: URL; authorization: string | null }> = []; let client = new GitHubClient({ fetch: (async (input: string | URL | Request, init?: RequestInit) => { let url = new URL(String(input)); let headers = new Headers(init?.headers); requests.push({ url, authorization: headers.get("authorization") }); if (url.pathname === "/user") { return Response.json({ node_id: "U_octocat", login: "octocat", avatar_url: "avatar" }); } if (url.pathname === "/user/installations") { return Response.json({ installations: [installation()] }, { headers: { link: '; rel="next"', }, }); } return Response.json({ repositories: [repository("R_score")] }, { headers: { link: '; rel="next"', }, }); }), endpoints: { api: "https://api.test" }, }); expect(await client.user("ghu_user")).toEqual({ id: "U_octocat", login: "octocat", avatarUrl: "avatar", }); let installations = await client.installations("ghu_user", 1); expect(installations.installations[0]).toMatchObject({ id: "123", account: { login: "octo-org", type: "organization" }, repositorySelection: "selected", suspended: false, permissions: { contents: true, pullRequests: true, checks: true, statuses: true, issues: false, }, }); expect(installations.nextPage).toBe(2); let page = await client.installationRepositories("ghu_user", "123", 1); expect(page.repositories).toEqual([{ id: "R_score", owner: "octo-org", name: "score", fullName: "octo-org/score", private: true, url: "https://github.test/octo-org/score", defaultBranch: "main", permissions: { pull: true, push: true, admin: false }, }]); expect(page.nextPage).toBe(2); expect(requests.every(request => request.authorization === "Bearer ghu_user")).toBe(true); }); it("passes conditional list validators through without caching response bodies", async () => { let validators: Array = []; let client = new GitHubClient({ fetch: async (input, init) => { let validator = new Headers(init?.headers).get("if-none-match"); validators.push(validator); let url = new URL(String(input)); let etag = url.pathname === "/user/installations" ? 'W/"installations"' : '"repositories"'; if (validator === etag) return new Response(null, { status: 304, headers: { etag } }); return Response.json( url.pathname === "/user/installations" ? { installations: [installation()] } : { repositories: [repository("R_score")] }, { headers: { etag } }, ); }, endpoints: { api: "https://api.test" }, }); let installations = await client.installations("ghu_user", 1, {}); expect("notModified" in installations).toBe(false); expect(installations.etag).toBe('W/"installations"'); let unchangedInstallations = await client.installations("ghu_user", 1, { ifNoneMatch: installations.etag, }); expect(unchangedInstallations).toEqual({ notModified: true, etag: 'W/"installations"', }); let repositories = await client.installationRepositories("ghu_user", "123", 1, {}); expect("notModified" in repositories).toBe(false); expect(repositories.etag).toBe('"repositories"'); let unchangedRepositories = await client.installationRepositories("ghu_user", "123", 1, { ifNoneMatch: repositories.etag, }); expect(unchangedRepositories).toEqual({ notModified: true, etag: '"repositories"', }); expect(validators).toEqual([ null, 'W/"installations"', null, '"repositories"', ]); }); it("resolves active, pending, and absent organization membership", async () => { let requested: string[] = []; let client = new GitHubClient({ fetch: async input => { let url = new URL(String(input)); requested.push(url.pathname); if (url.pathname.endsWith("/missing")) { return Response.json({ message: "Not Found" }, { status: 404 }); } return Response.json({ state: url.pathname.endsWith("/pending") ? "pending" : "active", role: "member", }); }, endpoints: { api: "https://api.test" }, }); expect(await client.organizationMembership("token", "GitHubNext")).toEqual({ state: "active", role: "member", }); expect(await client.organizationMembership("token", "pending")).toEqual({ state: "pending", role: "member", }); expect(await client.organizationMembership("token", "missing")).toBeUndefined(); expect(requested).toEqual([ "/user/memberships/orgs/GitHubNext", "/user/memberships/orgs/pending", "/user/memberships/orgs/missing", ]); }); it("rejects malformed organization membership responses", async () => { let client = new GitHubClient({ fetch: async () => Response.json({ state: "active", role: "outsider" }), endpoints: { api: "https://api.test" }, }); await expect(client.organizationMembership("token", "githubnext")) .rejects.toThrow("invalid organization membership"); }); it("grants channel access only through an active installation repository listing", async () => { let requests = 0; let now = 0; let client = new GitHubClient({ fetch: async input => { requests++; let url = new URL(String(input)); if (url.pathname === "/user/installations") { return Response.json({ installations: [installation()] }); } if (url.searchParams.get("page") === "1") { return Response.json({ repositories: [] }, { headers: { link: '; rel="next"', }, }); } if (url.searchParams.get("page") === "2") { return Response.json({ repositories: [repository("R_score")] }, { headers: { link: '; rel="next"', }, }); } return Response.json({ repositories: [] }); }, endpoints: { api: "https://api.test" }, clock: () => now, }); expect((await client.repositoryAccess("ghu_user", "octo-org", "score"))?.id) .toBe("R_score"); let afterFirst = requests; expect((await client.repositoryAccess("ghu_user", "octo-org", "score"))?.id) .toBe("R_score"); expect(requests).toBe(afterFirst); now = 60_001; expect((await client.repositoryAccess("ghu_user", "octo-org", "score"))?.id) .toBe("R_score"); expect(requests).toBeGreaterThan(afterFirst); let afterExpiry = requests; client.invalidate("ghu_user"); expect((await client.repositoryAccess("ghu_user", "octo-org", "score"))?.id) .toBe("R_score"); expect(requests).toBeGreaterThan(afterExpiry); expect(await client.repositoryAccess("ghu_user", "octo-org", "public-but-unaffiliated")) .toBeUndefined(); }); it("keeps direct bearer permissions separate from GitHub App installation access", async () => { let requests: Array<{ path: string; authorization: string | null }> = []; let client = new GitHubClient({ fetch: async (input, init) => { let url = new URL(String(input)); requests.push({ path: url.pathname, authorization: new Headers(init?.headers).get("authorization"), }); if (url.pathname === "/user") { return Response.json({ node_id: "U_octocat", login: "octocat", avatar_url: "avatar", }); } if (url.pathname === "/user/installations") { return Response.json({ message: "GitHub App token required" }, { status: 403 }); } return Response.json(repository("R_score")); }, endpoints: { api: "https://api.test" }, }); expect((await client.user("gho_cli")).login).toBe("octocat"); try { await client.repositoryAccess("gho_cli", "octo-org", "score"); expect.unreachable(); } catch (err) { expect(err).toBeInstanceOf(GitHubError); expect((err as GitHubError).status).toBe(403); } expect(await client.repository("gho_cli", "octo-org", "score")).toMatchObject({ id: "R_score", permissions: { pull: true, push: true, admin: false }, }); expect(requests.map(request => request.path)).toEqual([ "/user", "/user/installations", "/repos/octo-org/score", ]); expect(requests.every(request => request.authorization === "Bearer gho_cli")).toBe(true); }); it("preserves provider statuses for direct repository checks", async () => { for (let [provider, expected] of [[401, 401], [403, 403], [404, 404], [429, 429], [500, 502]]) { let client = new GitHubClient({ fetch: async () => Response.json({ message: "denied" }, { status: provider }), endpoints: { api: "https://api.test" }, }); try { await client.repository("token", "octo-org", "score"); expect.unreachable(); } catch (err) { expect(err).toBeInstanceOf(GitHubError); expect((err as GitHubError).status).toBe(expected); } } let rateLimited = new GitHubClient({ fetch: async () => Response.json({ message: "rate limited" }, { status: 403, headers: { "x-ratelimit-remaining": "0" }, }), endpoints: { api: "https://api.test" }, }); try { await rateLimited.repository("token", "octo-org", "score"); expect.unreachable(); } catch (err) { expect(err).toBeInstanceOf(GitHubError); expect((err as GitHubError).status).toBe(429); } let secondaryRateLimit = new GitHubClient({ fetch: async () => Response.json({ message: "You have exceeded a secondary rate limit.", }, { status: 403 }), endpoints: { api: "https://api.test" }, }); try { await secondaryRateLimit.repository("token", "octo-org", "score"); expect.unreachable(); } catch (err) { expect(err).toBeInstanceOf(GitHubError); expect((err as GitHubError).status).toBe(429); } }); it("rejects malformed or unsuccessful provider responses", async () => { let client = new GitHubClient({ fetch: async () => Response.json({ login: "missing id" }), endpoints: { api: "https://api.test" }, }); await expect(client.user("ghu_user")).rejects.toBeInstanceOf(GitHubError); let denied = new GitHubClient({ fetch: async () => Response.json({ message: "denied" }, { status: 401 }), endpoints: { api: "https://api.test" }, }); try { await denied.user("ghu_user"); expect.unreachable(); } catch (err) { expect(err).toBeInstanceOf(GitHubError); expect((err as GitHubError).status).toBe(401); } }); describe("reference summaries", () => { function pull(overrides: Record = {}) { return { number: 12, title: "Ship pills", state: "closed", draft: false, user: { login: "octocat", avatar_url: "https://avatars.test/octocat" }, labels: [ { name: "ui", color: "A2EEEF" }, { name: "bad", color: "url(javascript:1)" }, "not-an-object", ], comments: 3, created_at: "2026-10-01T10:00:00Z", updated_at: "2026-10-02T10:00:00Z", closed_at: "2026-10-02T10:00:00Z", merged_at: "2026-10-02T10:00:00Z", head: { ref: "github-pills" }, base: { ref: "main" }, html_url: "https://evil.test/redirect", ...overrides, }; } function issue(overrides: Record = {}) { return { number: 5, title: "Pills are missing", state: "closed", state_reason: "not_planned", user: null, labels: [], comments: 0, created_at: "2026-10-01T10:00:00Z", updated_at: "2026-10-02T10:00:00Z", closed_at: "2026-10-02T10:00:00Z", ...overrides, }; } function client(respond: (url: URL, headers: Headers) => Response) { let requests: Array<{ path: string; headers: Headers }> = []; let github = new GitHubClient({ fetch: async (input, init) => { let url = new URL(String(input)); let headers = new Headers(init?.headers); requests.push({ path: url.pathname, headers }); return respond(url, headers); }, endpoints: { api: "https://api.test" }, }); return { github, requests }; } it("maps a merged pull request and builds its URL from the repository", async () => { let { github, requests } = client(() => Response.json(pull())); let summary = await github.pullRequest("ghu_user", "octo-org", "score", 12); expect(requests[0]!.path).toBe("/repos/octo-org/score/pulls/12"); expect(requests[0]!.headers.get("authorization")).toBe("Bearer ghu_user"); expect(summary).toEqual({ kind: "pull", owner: "octo-org", repository: "score", number: 12, url: "https://github.com/octo-org/score/pull/12", title: "Ship pills", author: { login: "octocat", avatarUrl: "https://avatars.test/octocat" }, labels: [{ name: "ui", color: "a2eeef" }], comments: 3, createdAt: "2026-10-01T10:00:00.000Z", updatedAt: "2026-10-02T10:00:00.000Z", closedAt: "2026-10-02T10:00:00.000Z", state: "merged", draft: false, mergedAt: "2026-10-02T10:00:00.000Z", headBranch: "github-pills", baseBranch: "main", }); }); it("keeps open, draft and unmerged closed pull request states distinct", async () => { let next = pull(); let { github } = client(() => Response.json(next)); next = pull({ state: "open", merged_at: null, closed_at: null, draft: true }); expect(await github.pullRequest("t", "octo-org", "score", 12)).toMatchObject({ state: "open", draft: true, closedAt: null, mergedAt: null, }); next = pull({ merged_at: null }); expect((await github.pullRequest("t", "octo-org", "score", 12) as { state: string }).state) .toBe("closed"); }); it("maps issue state reasons", async () => { let next: Record = issue(); let { github, requests } = client(() => Response.json(next)); let summary = await github.issue("t", "octo-org", "score", 5); expect(requests[0]!.path).toBe("/repos/octo-org/score/issues/5"); expect(summary).toMatchObject({ kind: "issue", state: "closed", stateReason: "not_planned", author: null, url: "https://github.com/octo-org/score/issues/5", }); expect(summary).not.toHaveProperty("headBranch"); for ( let [reason, expected] of [ ["completed", "completed"], ["duplicate", "not_planned"], [null, "completed"], ] as const ) { next = issue({ state_reason: reason }); expect(await github.issue("t", "octo-org", "score", 5)).toMatchObject({ stateReason: expected, }); } next = issue({ state: "open", state_reason: "reopened", closed_at: null }); expect(await github.issue("t", "octo-org", "score", 5)).toMatchObject({ state: "open", stateReason: null, }); }); it("does not summarize a pull request through the issues endpoint", async () => { let { github } = client(() => Response.json(issue({ pull_request: { url: "x" } }))); await expect(github.issue("t", "octo-org", "score", 5)).rejects.toMatchObject({ status: 404, }); }); it("revalidates with an ETag", async () => { let { github, requests } = client((_url, headers) => headers.get("if-none-match") === '"v1"' ? new Response(null, { status: 304, headers: { etag: '"v1"' } }) : Response.json(pull(), { headers: { etag: '"v1"' } }) ); let first = await github.pullRequest("t", "octo-org", "score", 12, {}); expect(first).toMatchObject({ etag: '"v1"', state: "merged" }); expect(await github.pullRequest("t", "octo-org", "score", 12, { ifNoneMatch: '"v1"' })) .toEqual({ notModified: true, etag: '"v1"' }); expect(requests[1]!.headers.get("if-none-match")).toBe('"v1"'); }); it("classifies missing, forbidden, rate-limited and malformed responses", async () => { let cases: Array<[Response, number]> = [ [Response.json({ message: "Not Found" }, { status: 404 }), 404], [Response.json({ message: "Forbidden" }, { status: 403 }), 403], [Response.json({ message: "slow down" }, { status: 429 }), 429], [ Response.json({ message: "API rate limit exceeded" }, { status: 403, headers: { "x-ratelimit-remaining": "0" }, }), 429, ], [Response.json({ number: 12 }), 502], ]; for (let [response, status] of cases) { let { github } = client(() => response); await expect(github.pullRequest("t", "octo-org", "score", 12)).rejects.toMatchObject({ status, }); } let { github, requests } = client(() => Response.json(pull())); await expect(github.issue("t", "octo-org", "score", 0)).rejects.toMatchObject({ status: 404, }); expect(requests).toHaveLength(0); }); it("names the installation and its issue permission with repository access", async () => { let { github } = client(url => url.pathname === "/user/installations" ? Response.json({ installations: [{ ...installation(), permissions: { ...installation().permissions, issues: "read" }, }], }) : Response.json({ repositories: [repository("R_score")] }) ); let found = await github.installedRepository("t", "octo-org", "score"); expect(found?.repository.id).toBe("R_score"); expect(found?.installation).toMatchObject({ id: "123", permissions: { pullRequests: true, issues: true }, }); expect(await github.installedRepository("t", "octo-org", "elsewhere")).toBeUndefined(); expect(await github.installedRepository("t", "someone-else", "score")).toBeUndefined(); }); }); });