Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393import { 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([]); });});