import { afterEach, describe, expect, it, vi } from "vitest";
import { type ClientResponseError } from "@atcute/client";
import { missingPermissions } from "#lib/auth/scopes.ts";
import oauthMetadata from "#lib/oauth-client-metadata.ts";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import type { Did } from "@atcute/lexicons/syntax";
import {
createMigrationTask,
grantRefused,
migrationRunning,
describeSourcePermission,
missingDescribeSourceScope,
migrationsInFlight,
migrationsUnfinished,
flowReturnPath,
returnPathFrom,
startUrlFrom,
MigrationGrantRequired
} from "#lib/api/migrator.ts";
import type { MigrationTask } from "#lib/api/migrator.ts";
import type { Fetch } from "#lib/fetch.ts";
const agent = {
sub: "did:plc:alice" as Did
} as unknown as OAuthUserAgent;
vi.mock("#lib/auth/agent.ts", () => ({
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: 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: 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.ts", () => ({
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("#lib/api/migrator.ts");
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.ts", () => ({
serviceUrlFor: (h: string) => (h.startsWith("http") ? h : `https://${h}`),
serviceClient: vi.fn(),
jsonGet
}));
const { describeSource: describe } = await import("#lib/api/migrator.ts");
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.ts", () => ({
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("#lib/api/migrator.ts");
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.ts", () => ({
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("#lib/api/migrator.ts");
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);
});
it("asks for a new grant on jobs a pds refused a scope for, old failures included", () => {
expect(grantRefused("authorization_required")).toBe(true);
const refused = `repo record write returned HTTP 403: {"error":"ScopeMissingError"}`;
expect(grantRefused("failed", refused)).toBe(true);
expect(grantRefused("failed", "repo record write returned HTTP 500")).toBe(false);
expect(grantRefused("failed")).toBe(false);
});
});
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: 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([]);
});
});