import { describe, expect, it, vi } from "vitest"; import type { RequestEvent } from "@sveltejs/kit"; import { GET as connect } from "$routes/_internal/github/connect/+server"; import { GET as callback } from "$routes/_internal/github/callback/+server"; import { GET as avatar } from "$routes/_internal/github/avatar/+server"; import { GET as readAccount, DELETE as disconnect } from "./+server"; import { GET as install } from "$routes/_internal/github/install/callback/+server"; import { POST as createTask } from "$routes/_internal/github/migrator/createTask/+server"; import { GITHUB_STATE_COOKIE, GITHUB_TOKEN_COOKIE, open, pkceChallenge, seal } from "$lib/server/github"; const secret = "test-only-session-secret"; const did = "did:plc:alice"; const fixture = (path: string, initial: Record = {}) => { const jar = new Map(Object.entries({ "tangled.currentDid": did, ...initial })); const cookies = { get: (key: string) => jar.get(key), set: vi.fn((key: string, value: string) => { jar.set(key, value); }), delete: vi.fn((key: string) => { jar.delete(key); }) }; const url = new URL(path, "https://tangled.test"); const fetch = vi.fn(); const event = { url, request: new Request(url), cookies, fetch, platform: { env: { GITHUB_CLIENT_ID: "client", GITHUB_CLIENT_SECRET: "client-secret", GITHUB_SESSION_SECRET: secret, MIGRATOR_URL: "https://migrator.test" } } } as unknown as RequestEvent; return { event, jar, cookies, fetch }; }; const savedState = (overrides = {}) => seal( { did, landing: { kind: "page", href: "/repo/import/github" }, verifier: "verifier", exp: Date.now() + 60_000, ...overrides }, secret ); const savedToken = (overrides = {}) => seal( { did, token: "private-token", exp: Date.now() + 60_000, ...overrides }, secret ); const forwarded = (f: ReturnType) => { const url = new URL(f.event.url); url.protocol = "http:"; f.event.url = url; f.event.request = new Request(url, { headers: { "x-forwarded-proto": "https" } }); return f; }; describe("github connection handlers", () => { it("keeps the onboarding github step as a return target", async () => { const f = forwarded( fixture( "/_internal/github/connect?return_to=" + encodeURIComponent("/welcome?step=github") ) ); const response = await connect(f.event as Parameters[0]); const state = f.jar.get(GITHUB_STATE_COOKIE)!; expect(await open(state, secret)).toMatchObject({ did, landing: { kind: "page", href: "/welcome?step=github" } }); expect(new URL(response.headers.get("location")!).pathname).toBe("/login/oauth/authorize"); }); it("still refuses a return target outside the entry points", async () => { const f = forwarded(fixture("/_internal/github/connect?return_to=/settings/emails")); await connect(f.event as Parameters[0]); const state = f.jar.get(GITHUB_STATE_COOKIE)!; expect(await open(state, secret)).toMatchObject({ did, landing: { kind: "refused" } }); }); it("binds authorization to the account and uses the proxy-facing origin", async () => { const f = forwarded(fixture("/_internal/github/connect?return_to=/repo/import/github")); const response = await connect(f.event as Parameters[0]); const location = new URL(response.headers.get("location")!); const state = f.jar.get(GITHUB_STATE_COOKIE)!; expect(location.searchParams.get("redirect_uri")).toBe( "https://tangled.test/_internal/github/callback" ); expect(location.searchParams.get("state")).toBe(state); expect(location.searchParams.get("code_challenge_method")).toBe("S256"); expect(location.searchParams.has("scope")).toBe(false); expect(location.toString()).not.toContain("client-secret"); expect(await open(state, secret)).toMatchObject({ did, landing: { kind: "page", href: "/repo/import/github" } }); expect(f.cookies.set).toHaveBeenCalledWith( GITHUB_STATE_COOKIE, state, expect.objectContaining({ httpOnly: true, secure: true, sameSite: "lax", path: "/_internal/github", maxAge: 600 }) ); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("redirects a signed-out browser back to the importer", async () => { const f = fixture("/_internal/github/connect?return_to=/repo/import/github"); f.jar.delete("tangled.currentDid"); const response = await connect(f.event as Parameters[0]); expect(response.status).toBe(302); expect(response.headers.get("location")).toMatch( /^\/repo\/import\/github\?github_error=Sign\+in/ ); expect(f.fetch).not.toHaveBeenCalled(); }); it.each(["expired", "mismatch", "switched", "tampered", "denied"])( "redirects %s callback before exchange", async (kind) => { const state = await savedState(kind === "expired" ? { exp: Date.now() - 1 } : {}); const cookie = kind === "tampered" ? state + "x" : state; const query = kind === "denied" ? "?error=access_denied&state=" + encodeURIComponent(cookie) : "?code=code&state=" + encodeURIComponent(kind === "mismatch" ? "wrong" : cookie); const f = fixture("/_internal/github/callback" + query, { [GITHUB_STATE_COOKIE]: cookie }); if (kind === "switched") f.jar.set("tangled.currentDid", "did:plc:bob"); const response = await callback(f.event as Parameters[0]); expect(response.status).toBe(302); expect(response.headers.get("location")).toContain("github_error="); expect(f.fetch).not.toHaveBeenCalled(); if (kind !== "denied") { expect(f.jar.has(GITHUB_STATE_COOKIE)).toBe(false); expect(f.jar.has(GITHUB_TOKEN_COOKIE)).toBe(false); } } ); it("uses the proxy origin for exchange and scopes the sealed token cookie", async () => { const state = await savedState(); const f = forwarded( fixture("/_internal/github/callback?code=code&state=" + encodeURIComponent(state), { [GITHUB_STATE_COOKIE]: state }) ); f.fetch.mockResolvedValue(Response.json({ access_token: "private-token" })); const response = await callback(f.event as Parameters[0]); const cookie = f.jar.get(GITHUB_TOKEN_COOKIE)!; expect(cookie).not.toContain("private-token"); expect(await open(cookie, secret)).toMatchObject({ token: "private-token", did }); expect(f.cookies.set).toHaveBeenCalledWith( GITHUB_TOKEN_COOKIE, cookie, expect.objectContaining({ httpOnly: true, secure: true, path: "/_internal/github", maxAge: 3600 }) ); expect(response.headers.get("location")).toBe("/repo/import/github"); expect(JSON.parse(String(f.fetch.mock.calls[0][1]?.body))).toMatchObject({ redirect_uri: "https://tangled.test/_internal/github/callback", code_verifier: "verifier" }); expect(await response.text()).not.toContain("private-token"); }); it.each(["expired", "switched", "signed-out", "revoked"])( "does not expose account data for a %s connection", async (kind) => { const token = await savedToken({ exp: kind === "expired" ? Date.now() - 1 : Date.now() + 60_000 }); const f = fixture("/_internal/github", { [GITHUB_TOKEN_COOKIE]: token }); if (kind === "switched") f.jar.set("tangled.currentDid", "did:plc:bob"); if (kind === "signed-out") f.jar.delete("tangled.currentDid"); f.fetch.mockImplementation(async () => Response.json({ message: "bad credentials" }, { status: 401 }) ); const response = await readAccount(f.event as Parameters[0]); expect(response.status).toBe(401); expect(await response.text()).not.toContain("private-token"); if (kind === "revoked") expect(f.jar.has(GITHUB_TOKEN_COOKIE)).toBe(false); else expect(f.fetch).not.toHaveBeenCalled(); } ); it("refuses cross-origin disconnect", async () => { const f = fixture("/_internal/github", { [GITHUB_TOKEN_COOKIE]: "retained" }); f.event.request = new Request(f.event.url, { method: "DELETE", headers: { origin: "https://evil.test" } }); expect((await disconnect(f.event as Parameters[0])).status).toBe(403); expect(f.jar.get(GITHUB_TOKEN_COOKIE)).toBe("retained"); }); it.each(["redirect", "oversize", "invalid-host"])( "rejects a %s avatar with a redacted, uncacheable error", async (kind) => { const token = await savedToken(); const f = fixture("/_internal/github/avatar", { [GITHUB_TOKEN_COOKIE]: token }); f.fetch.mockResolvedValueOnce( Response.json({ avatar_url: kind === "invalid-host" ? "https://evil.test/secret" : "https://avatars.githubusercontent.com/u/1" }) ); if (kind === "redirect") f.fetch.mockRejectedValueOnce(new Error("private upstream details")); else f.fetch.mockResolvedValueOnce( new Response(new Uint8Array(1_000_001), { headers: { "content-type": "image/png" } }) ); const response = await avatar(f.event as Parameters[0]); expect(response.status).toBe(502); expect(response.headers.get("cache-control")).toBe("no-store"); expect(await response.text()).toBe('{"error":"GitHub avatar unavailable"}'); if (kind === "invalid-host") expect(f.fetch).toHaveBeenCalledTimes(1); else expect(f.fetch.mock.calls[1][1]).toMatchObject({ redirect: "error" }); } ); }); describe("github app install", () => { const withSlug = (f: ReturnType, slug = "tangled-test") => { (f.event.platform as { env: Record }).env.GITHUB_APP_SLUG = slug; return f; }; it.each([ ["uninstalled", [], "/apps/tangled-test/installations/new"], ["installed", [{ id: 7 }], "/login/oauth/authorize"] ])("routes connected %s account to %s", async (_kind, installations, expectedPath) => { const token = await savedToken(); const f = withSlug( fixture("/_internal/github/connect?return_to=/repo/import/github", { [GITHUB_TOKEN_COOKIE]: token }) ); f.fetch.mockResolvedValue(Response.json({ installations })); const response = await connect(f.event as Parameters[0]); expect(new URL(response.headers.get("location")!).pathname).toBe(expectedPath); }); it("installs after first authorization when the account has no installation", async () => { const state = await savedState(); const f = withSlug( fixture("/_internal/github/callback?code=code&state=" + encodeURIComponent(state), { [GITHUB_STATE_COOKIE]: state }) ); f.fetch .mockResolvedValueOnce(Response.json({ access_token: "private-token" })) .mockResolvedValueOnce(Response.json({ installations: [] })); const response = await callback(f.event as Parameters[0]); expect(new URL(response.headers.get("location")!).pathname).toBe( "/apps/tangled-test/installations/new" ); expect(f.jar.get(GITHUB_STATE_COOKIE)).toBe(state); }); it("hands the setup redirect back to authorize with the sealed verifier", async () => { const state = await savedState(); const f = fixture( `/_internal/github/install/callback?installation_id=7&setup_action=install&state=${encodeURIComponent(state)}` ); const response = await install(f.event as Parameters[0]); const location = new URL(response.headers.get("location")!); expect(location.origin).toBe("https://github.com"); expect(location.pathname).toBe("/login/oauth/authorize"); expect(location.searchParams.get("code_challenge_method")).toBe("S256"); expect(location.searchParams.get("code_challenge")).toBe(await pkceChallenge("verifier")); expect(await open(f.jar.get(GITHUB_STATE_COOKIE)!, secret)).toMatchObject({ did, landing: { kind: "page", href: "/repo/import/github" } }); }); it.each(["expired", "switched", "signed-out"])( "rejects a %s installation callback before authorization", async (kind) => { const state = await savedState(kind === "expired" ? { exp: Date.now() - 1 } : {}); const f = fixture( `/_internal/github/install/callback?state=${encodeURIComponent(state)}` ); if (kind === "switched") f.jar.set("tangled.currentDid", "did:plc:bob"); if (kind === "signed-out") f.jar.delete("tangled.currentDid"); const response = await install(f.event as Parameters[0]); expect(response.status).toBe(302); expect(response.headers.get("location")).toContain("github_error="); expect(f.cookies.set).not.toHaveBeenCalled(); } ); it("carries the starting page through an installation", async () => { const state = await savedState({ landing: { kind: "page", href: "/repo/migrate?knot=at" } }); const f = fixture(`/_internal/github/install/callback?state=${encodeURIComponent(state)}`); await install(f.event as Parameters[0]); expect(await open(f.jar.get(GITHUB_STATE_COOKIE)!, secret)).toMatchObject({ landing: { kind: "page", href: "/repo/migrate?knot=at" } }); }); it("survives a setup redirect that lost its state", async () => { const f = fixture( "/_internal/github/install/callback?installation_id=7&setup_action=install" ); const response = await install(f.event as Parameters[0]); expect(new URL(response.headers.get("location")!).pathname).toBe("/login/oauth/authorize"); const state = f.jar.get(GITHUB_STATE_COOKIE)!; expect(await open(state, secret)).toMatchObject({ did, landing: { kind: "home" } }); }); }); const serviceAuthFor = (iss: string) => { const encode = (value: unknown) => btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); return `${encode({ alg: "ES256K" })}.${encode({ iss, aud: "did:web:migrator.test" })}.sig`; }; describe("github migrator broker", () => { it("adds the sealed token server-side and passes the migrator response through", async () => { const token = await savedToken(); const f = fixture("/_internal/github/migrator/createTask", { [GITHUB_TOKEN_COOKIE]: token }); f.event.request = new Request(f.event.url, { method: "POST", headers: { authorization: `Bearer ${serviceAuthFor(did)}`, "content-type": "application/json" }, body: JSON.stringify({ requestId: "request-1", jobs: [{ name: "private", private: true }] }) }); f.fetch.mockResolvedValue( Response.json({ id: "task-1", ownerDid: did, jobs: [] }, { status: 202 }) ); const response = await createTask(f.event as Parameters[0]); const forwardedRequest = f.fetch.mock.calls[0]; expect(forwardedRequest[0]).toBe( "https://migrator.test/xrpc/org.tangled.temp.migrator.createTask" ); expect(new Headers(forwardedRequest[1]?.headers).get("authorization")).toBe( `Bearer ${serviceAuthFor(did)}` ); expect(JSON.parse(String(forwardedRequest[1]?.body))).toEqual({ requestId: "request-1", jobs: [{ name: "private", private: true }], githubToken: "private-token" }); expect(response.status).toBe(202); const responseBody = await response.text(); expect(responseBody).not.toContain("private-token"); expect(responseBody).toBe(JSON.stringify({ id: "task-1", ownerDid: did, jobs: [] })); }); it("passes GrantRequired through without changing its status or body", async () => { const f = fixture("/_internal/github/migrator/createTask"); f.event.request = new Request(f.event.url, { method: "POST", headers: { authorization: "Bearer caller-service-auth", "content-type": "application/json" }, body: JSON.stringify({ requestId: "request-1", jobs: [{ name: "public" }] }) }); const body = { error: "GrantRequired", startUrl: "https://migrator.test/oauth/start" }; f.fetch.mockResolvedValue(Response.json(body, { status: 428 })); const response = await createTask(f.event as Parameters[0]); expect(response.status).toBe(428); expect(await response.json()).toEqual(body); }); it("rejects a request without service authorization before reading its body", async () => { const f = fixture("/_internal/github/migrator/createTask"); f.event.request = new Request(f.event.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId: "request-1", jobs: [] }) }); const response = await createTask(f.event as Parameters[0]); expect(response.status).toBe(401); expect(f.fetch).not.toHaveBeenCalled(); expect(await response.text()).not.toContain("private-token"); }); it.each([ ["public-only jobs", [{ name: "public" }], () => `Bearer ${serviceAuthFor(did)}`, true], [ "a private job for a different actor", [{ name: "private", private: true }], () => `Bearer ${serviceAuthFor("did:plc:bob")}`, true ], [ "a caller with no GitHub session", [{ name: "public" }], () => "Bearer caller-service-auth", false ] ])("omits githubToken for %s", async (_kind, jobs, auth, hasSession) => { const token = hasSession ? await savedToken() : undefined; const f = fixture( "/_internal/github/migrator/createTask", token ? { [GITHUB_TOKEN_COOKIE]: token } : {} ); f.event.request = new Request(f.event.url, { method: "POST", headers: { authorization: auth(), "content-type": "application/json" }, body: JSON.stringify({ requestId: "request-1", jobs }) }); f.fetch.mockResolvedValue(Response.json({ id: "task-1" }, { status: 202 })); await createTask(f.event as Parameters[0]); expect(JSON.parse(String(f.fetch.mock.calls[0][1]?.body))).toEqual({ requestId: "request-1", jobs }); }); });