import { afterEach, describe, expect, it, vi } from "vitest"; import { type ClientResponseError } from "@atcute/client"; import { missingPermissions } from "$lib/auth/scopes"; import oauthMetadata from "$lib/oauth-client-metadata"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import type { Did } from "@atcute/lexicons/syntax"; import { createMigrationTask, describeSource, getMigrationTask, listMigrationTasks, migrationRunning, describeSourcePermission, missingDescribeSourceScope, migrationsInFlight, migrationsUnfinished, retryMigrationJob, flowReturnPath, returnPathFrom, startUrlFrom, MigrationGrantRequired } from "./migrator"; import type { MigrationTask } from "./migrator"; const agent = { sub: "did:plc:alice" as Did } as unknown as OAuthUserAgent; vi.mock("$lib/auth/agent", () => ({ mintServiceAuth: vi.fn(async (_agent, { aud, lxm }) => `mock-token-for-${aud}-${lxm}`), serviceDidForHost: (host: string) => `did:web:${host.replace(/^https?:\/\//, "").replace(/\/+$/, "")}` })); describe("migrator client", () => { const host = "https://migrator.test"; it("createMigrationTask posts to broker with minted auth and parses response", async () => { const customFetch: typeof fetch = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.href; expect(url).toBe("/_internal/github/migrator/createTask"); expect(init?.method).toBe("POST"); expect(new Headers(init?.headers).get("authorization")).toBe( "Bearer mock-token-for-did:web:migrator.test-org.tangled.temp.migrator.createTask" ); return new Response( JSON.stringify({ id: "task-1", ownerDid: "did:plc:alice", createdAt: new Date().toISOString(), jobs: [ { id: "job-1", name: "repo", knotDid: "did:web:knot.test", sourceUrl: "https://github.com/a/b", status: "queued", attempts: 0, private: false } ] }), { status: 200, headers: { "content-type": "application/json" } } ); } ); const task = await createMigrationTask( agent, host, "req-1", [ { name: "repo", knotDid: "did:web:knot.test" as Did, sourceUrl: "https://github.com/a/b" as `https://${string}`, private: false } ], { fetch: customFetch } ); expect(task.id).toBe("task-1"); expect(task.jobs).toHaveLength(1); }); it("createMigrationTask maps 428 to MigrationGrantRequired with startUrl", async () => { const customFetch: typeof fetch = vi.fn(async () => { return new Response( JSON.stringify({ error: "GrantRequired", message: "grant needed", startUrl: "https://migrator.test/oauth/start?did=did%3Aplc%3Aalice" }), { status: 428, headers: { "content-type": "application/json" } } ); }); await expect( createMigrationTask( agent, host, "req-1", [ { name: "repo", knotDid: "did:web:knot.test" as Did, sourceUrl: "https://github.com/a/b" as `https://${string}`, private: false } ], { fetch: customFetch } ) ).rejects.toThrow(MigrationGrantRequired); }); it("getMigrationTask includes caller did in startUrl on 428 rather than dropping it", async () => { vi.resetModules(); vi.doMock("$lib/api/_request", () => ({ serviceUrlFor: (h: string) => (h.startsWith("http") ? h : `https://${h}`), serviceClient: () => ({}), jsonGet: vi.fn(async () => { const { ClientResponseError } = await import("@atcute/client"); throw new ClientResponseError({ status: 428, headers: new Headers(), data: { error: "GrantRequired", message: "grant expired" } }); }) })); const { getMigrationTask: getTask } = await import("./migrator"); let caught: unknown; try { await getTask(agent, host, "task-1"); } catch (e) { caught = e; } const grant = caught as MigrationGrantRequired; expect(grant?.name).toBe("MigrationGrantRequired"); expect(grant.startUrl).toBe("https://migrator.test/oauth/start?did=did%3Aplc%3Aalice"); }); it("describeSource queries the migrator directly and parses name and default branch", async () => { vi.resetModules(); const jsonGet = vi.fn(async (_ctx: unknown, _nsid: string, _params: unknown) => ({ name: "repo", defaultBranch: "main" })); vi.doMock("$lib/api/_request", () => ({ serviceUrlFor: (h: string) => (h.startsWith("http") ? h : `https://${h}`), serviceClient: vi.fn(), jsonGet })); const { describeSource: describe } = await import("./migrator"); const out = await describe(agent, host, "https://example.com/a/repo.git"); expect(jsonGet).toHaveBeenCalledTimes(1); const [, nsid, params] = jsonGet.mock.calls[0]; expect(nsid).toBe("org.tangled.temp.migrator.describeSource"); expect(params).toEqual({ sourceUrl: "https://example.com/a/repo.git" }); expect(out).toEqual({ name: "repo", defaultBranch: "main" }); }); it("describeSource maps a grant requirement to MigrationGrantRequired", async () => { vi.resetModules(); vi.doMock("$lib/api/_request", () => ({ serviceUrlFor: (h: string) => (h.startsWith("http") ? h : `https://${h}`), serviceClient: () => ({}), jsonGet: vi.fn(async () => { const { ClientResponseError } = await import("@atcute/client"); throw new ClientResponseError({ status: 428, headers: new Headers(), data: { error: "GrantRequired", message: "grant needed" } }); }) })); const { describeSource: describe } = await import("./migrator"); let caught: unknown; try { await describe(agent, host, "https://example.com/a/repo.git"); } catch (e) { caught = e; } // re-imported module has its own class instance; match by name instead of instanceof const grant = caught as MigrationGrantRequired; expect(grant?.name).toBe("MigrationGrantRequired"); expect(grant.startUrl).toBe("https://migrator.test/oauth/start?did=did%3Aplc%3Aalice"); }); it("describeSource keeps the server's error tag so the UI can show the message", async () => { vi.resetModules(); vi.doMock("$lib/api/_request", () => ({ serviceUrlFor: (h: string) => (h.startsWith("http") ? h : `https://${h}`), serviceClient: () => ({}), jsonGet: vi.fn(async () => { const { ClientResponseError } = await import("@atcute/client"); throw new ClientResponseError({ status: 400, headers: new Headers(), data: { error: "UnreachableSource", message: "could not read the repository: ls-remote: exit status 128" } }); }) })); const { describeSource: describe } = await import("./migrator"); let caught: unknown; try { await describe(agent, host, "https://example.com/a/repo.git"); } catch (e) { caught = e; } const err = caught as ClientResponseError; expect(err.error).toBe("UnreachableSource"); expect(err.message).toContain("could not read the repository"); }); it("treats unknown job statuses as running so polling keeps going", () => { for (const terminal of ["completed", "failed", "authorization_required"]) expect(migrationRunning(terminal)).toBe(false); for (const running of ["queued", "cloning", "importing"]) expect(migrationRunning(running)).toBe(true); expect(migrationRunning("fetching_from_the_future")).toBe(true); }); }); describe("grant return path", () => { afterEach(() => vi.unstubAllGlobals()); const job = { name: "repo", knotDid: "did:web:knot.test" as Did, sourceUrl: "https://github.com/a/b" as `https://${string}`, private: false }; const task = (statuses: string[]) => ({ id: "task-1", ownerDid: "did:plc:alice", createdAt: "2026-09-17T00:00:00Z", jobs: statuses.map((status, index) => ({ id: `job-${index}`, ...job, knotDid: "did:web:knot.test", status, attempts: 0 })) }) as unknown as MigrationTask; it("drops the migrator's own transport params and keeps the rest", () => { expect(returnPathFrom({ pathname: "/repo/migrate", search: "" })).toBe( "/repo/migrate" ); expect( returnPathFrom({ pathname: "/repo/migrate", search: "?return_to=%2Fwelcome&oauth_error=grant_failed&migrator=granted" }) ).toBe("/repo/migrate"); expect( returnPathFrom({ pathname: "/repo/migrate", search: "?oauth_error=grant_failed&page=2" }) ).toBe("/repo/migrate?page=2"); }); it("flowReturnPath keeps the params that reopen the flow and drops the error", () => { expect(flowReturnPath({ pathname: "/welcome", search: "?step=github" })).toBe( "/welcome?step=github" ); expect( flowReturnPath({ pathname: "/welcome", search: "?step=github&github_error=denied" }) ).toBe("/welcome?step=github"); expect( flowReturnPath({ pathname: "/repo/migrate", search: "?github_error=denied" }) ).toBe("/repo/migrate"); expect(flowReturnPath({ pathname: "/welcome", search: "" })).toBe("/welcome"); }); it("startUrlFrom constructs oauth start URL with did and return_to", () => { expect(startUrlFrom("https://migrator.test", "did:plc:alice", "/repo/migrate")).toBe( "https://migrator.test/oauth/start?did=did%3Aplc%3Aalice&return_to=%2Frepo%2Fmigrate" ); expect(startUrlFrom("https://migrator.test", "did:plc:alice", "")).toBe( "https://migrator.test/oauth/start?did=did%3Aplc%3Aalice" ); expect(startUrlFrom("https://migrator.test", undefined)).toBeNull(); }); it("startUrl sends the grant back to the page that asked for it", async () => { vi.stubGlobal("window", { location: { pathname: "/repo/migrate", search: "?oauth_error=grant_failed&page=2" } }); const customFetch: typeof fetch = vi.fn( async () => new Response(JSON.stringify({ error: "GrantRequired", message: "grant needed" }), { status: 428, headers: { "content-type": "application/json" } }) ); let caught: unknown; try { await createMigrationTask(agent, "https://migrator.test", "req-1", [job], { fetch: customFetch }); } catch (cause) { caught = cause; } expect(caught).toBeInstanceOf(MigrationGrantRequired); expect((caught as MigrationGrantRequired).startUrl).toBe( "https://migrator.test/oauth/start?did=did%3Aplc%3Aalice&return_to=%2Frepo%2Fmigrate%3Fpage%3D2" ); }); it("keeps a migration unfinished while it still owes the user something", () => { expect(migrationsUnfinished([])).toBe(false); expect(migrationsUnfinished([task(["completed", "failed"])])).toBe(false); expect(migrationsUnfinished([task(["authorization_required"])])).toBe(true); expect(migrationsUnfinished([task(["completed"]), task(["queued"])])).toBe(true); expect(migrationsUnfinished([task(["queued"]), task(["authorization_required"])])).toBe( true ); expect(migrationsUnfinished([task(["completed"]), task(["authorization_required"])])).toBe( false ); }); it("reports a migration in flight only while some job is still running", () => { expect(migrationsInFlight([])).toBe(false); expect(migrationsInFlight([task([])])).toBe(false); expect(migrationsInFlight([task(["completed", "failed"])])).toBe(false); expect(migrationsInFlight([task(["authorization_required"])])).toBe(false); expect(migrationsInFlight([task(["completed"]), task(["queued"])])).toBe(true); expect(migrationsInFlight([task(["importing"])])).toBe(true); }); }); describe("migrator scopes", () => { const host = "https://migrator.test"; const sessionAgent = (scope: string) => ({ sub: "did:plc:alice" as Did, session: { token: { scope } } }) as unknown as OAuthUserAgent; it("the declared oauth scopes cover describeSource", () => { const wanted = describeSourcePermission(host); expect(wanted).not.toBeNull(); expect(missingPermissions(oauthMetadata.scope, [wanted!])).toEqual([]); }); it("describeSourcePermission names the migrator's method and audience", () => { expect(describeSourcePermission(host)).toEqual({ resource: "rpc", lxm: "org.tangled.temp.migrator.describeSource", aud: "did:web:migrator.test" }); }); it("missingDescribeSourceScope reports the grant an older session lacks", () => { expect(missingDescribeSourceScope(sessionAgent("atproto"), host)).toHaveLength(1); expect( missingDescribeSourceScope( sessionAgent("atproto rpc:org.tangled.temp.migrator.describeSource?aud=*"), host ) ).toEqual([]); }); });