Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461import { 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<string, string> = {}) => { 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<typeof globalThis.fetch>(); 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<typeof fixture>) => { 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<typeof connect>[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<typeof connect>[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<typeof connect>[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<typeof connect>[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<typeof callback>[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<typeof callback>[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<typeof readAccount>[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<typeof disconnect>[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<typeof avatar>[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<typeof fixture>, slug = "tangled-test") => { (f.event.platform as { env: Record<string, string> }).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<typeof connect>[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<typeof callback>[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<typeof install>[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<typeof install>[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<typeof install>[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<typeof install>[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<typeof createTask>[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<typeof createTask>[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<typeof createTask>[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<typeof createTask>[0]); expect(JSON.parse(String(f.fetch.mock.calls[0][1]?.body))).toEqual({ requestId: "request-1", jobs }); });});