import { describe, expect, it, vi, beforeEach } from "vitest";
import type { Did } from "@atcute/lexicons/syntax";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import type { GitHubAccount, GitHubRepo } from "#lib/github.ts";
import type { ProfileRecord } from "#lib/api/records.ts";
import type { NewMigrationJob } from "#lib/api/migrator.ts";
import {
canonicalRepoName,
fetchGitHubAccount,
findRepoCollisions,
importGitHubProfile,
importFromUrl,
importGitHubRepos,
invalidateGitHubAccount,
validateGitHubRepoUrl
} from "#lib/api/githubImport.ts";
import type { Fetch } from "#lib/fetch.ts";
const mockCreateMigrationTask = vi.fn();
vi.mock("#lib/api/migrator.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("#lib/api/migrator.ts")>();
return {
...actual,
createMigrationTask: (...args: unknown[]) => mockCreateMigrationTask(...args)
};
});
const mockUploadProfileAvatar = vi.fn();
vi.mock("#lib/api/profile.ts", () => ({
uploadProfileAvatar: (...args: unknown[]) => mockUploadProfileAvatar(...args)
}));
const mockPutRecord = vi.fn();
const mockCreateRecord = vi.fn();
const mockReadRecord = vi.fn();
vi.mock("#lib/api/write.ts", () => ({
createRecord: (...args: unknown[]) => mockCreateRecord(...args),
putRecord: (...args: unknown[]) => mockPutRecord(...args),
readRecord: (...args: unknown[]) => mockReadRecord(...args)
}));
const mockCreatePubKey = vi.fn();
vi.mock("#lib/api/settings.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("#lib/api/settings.ts")>();
return {
...actual,
createPubKey: (...args: unknown[]) => mockCreatePubKey(...args)
};
});
const mockListEmails = vi.fn();
const mockAddEmail = vi.fn();
vi.mock("#lib/api/emails.ts", () => ({
listEmails: (...args: unknown[]) => mockListEmails(...args),
addEmail: (...args: unknown[]) => mockAddEmail(...args)
}));
const mockRpcCall = vi.fn();
vi.mock("#lib/auth/agent.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("#lib/auth/agent.ts")>();
return {
...actual,
createClient: () => ({
call: (...args: unknown[]) => mockRpcCall(...args)
})
};
});
vi.mock("#lib/api/deliberi.ts", () => ({
createDeliberiClient: (opts: unknown) => opts
}));
const testAgent = {
sub: "did:plc:alice" as Did
} as unknown as OAuthUserAgent;
const baseRepo: GitHubRepo = {
id: 12345,
name: "sample-repo",
fullName: "octocat/sample-repo",
description: "A great sample project",
cloneUrl: "https://github.com/octocat/sample-repo.git",
htmlUrl: "https://github.com/octocat/sample-repo",
defaultBranch: "main"
};
const GITHUB_ORIGIN = "https://github.com";
const validate = (
url: string,
fullName: string,
kind: "htmlUrl" | "cloneUrl",
origin: string = GITHUB_ORIGIN
) => validateGitHubRepoUrl(url, fullName, kind, origin);
describe("validateGitHubRepoUrl", () => {
it("accepts valid canonical HTTPS github.com URLs matching fullName", () => {
expect(
validate("https://github.com/octocat/sample-repo", "octocat/sample-repo", "htmlUrl")
).toBe("https://github.com/octocat/sample-repo");
expect(
validate(
"https://github.com/octocat/sample-repo.git",
"octocat/sample-repo",
"cloneUrl"
)
).toBe("https://github.com/octocat/sample-repo.git");
expect(
validate("https://github.com/octocat/sample-repo", "octocat/sample-repo", "cloneUrl")
).toBe("https://github.com/octocat/sample-repo");
});
it("rejects URLs whose path does not match fullName", () => {
expect(() =>
validate(
"https://github.com/someone-else/sample-repo",
"octocat/sample-repo",
"htmlUrl"
)
).toThrow(/pathname must match repository/);
});
it("rejects non-HTTPS, credentials, nondefault ports, query, and hash", () => {
expect(() =>
validate("http://github.com/octocat/sample-repo", "octocat/sample-repo", "htmlUrl")
).toThrow(/protocol must be https/);
expect(() =>
validate(
"https://user:pass@github.com/octocat/sample-repo",
"octocat/sample-repo",
"htmlUrl"
)
).toThrow(/credentials are not allowed/);
expect(() =>
validate(
"https://github.com:8443/octocat/sample-repo",
"octocat/sample-repo",
"htmlUrl"
)
).toThrow(/non-default port is not allowed/);
expect(() =>
validate(
"https://github.com/octocat/sample-repo?ref=main",
"octocat/sample-repo",
"htmlUrl"
)
).toThrow(/query parameters are not allowed/);
expect(() =>
validate(
"https://github.com/octocat/sample-repo#readme",
"octocat/sample-repo",
"htmlUrl"
)
).toThrow(/hash fragments are not allowed/);
});
it("checks the host against the account's configured origin", () => {
const stub = "https://github.example.com";
expect(
validate(`${stub}/octocat/sample-repo.git`, "octocat/sample-repo", "cloneUrl", stub)
).toBe(`${stub}/octocat/sample-repo.git`);
expect(() =>
validate(
"https://github.com/octocat/sample-repo.git",
"octocat/sample-repo",
"cloneUrl",
stub
)
).toThrow(/host must be github\.example\.com/);
expect(() =>
validate(
`${stub}/octocat/sample-repo.git`,
"octocat/sample-repo",
"cloneUrl",
GITHUB_ORIGIN
)
).toThrow(/host must be github\.com/);
});
it("refuses an origin that is not https, rather than quietly allowing it", () => {
expect(() =>
validate(
"http://github.com/octocat/sample-repo.git",
"octocat/sample-repo",
"cloneUrl",
"http://github.com"
)
).toThrow(/protocol must be https/);
expect(() =>
validate(
"https://github.com/octocat/sample-repo.git",
"octocat/sample-repo",
"cloneUrl",
"not a url"
)
).toThrow(/is not a URL/);
});
it("rejects malformed fullName grammar", () => {
expect(() =>
validate("https://github.com/octocat/sample-repo", "single-name", "htmlUrl")
).toThrow(/Invalid GitHub fullName/);
});
});
describe("canonicalRepoName & single normalization", () => {
it("matches validateRepoName semantics and lowercases", () => {
expect(canonicalRepoName("Demo-Repo.git")).toBe("demo-repo");
expect(canonicalRepoName("foo.git.git")).toBe("foo.git");
expect(canonicalRepoName("plain-name")).toBe("plain-name");
});
});
describe("findRepoCollisions", () => {
it("detects collisions across batch and existing repositories with string or object candidates", () => {
const candidates = ["awesome-app", { name: "Awesome-App.git" }, "other-tool"];
const existing = ["Other-Tool.git", "prior-repo"];
const collisions = findRepoCollisions(candidates, existing);
expect(collisions.get("awesome-app")?.hasCollision).toBe(true);
expect(collisions.get("awesome-app")?.candidates).toEqual([
"awesome-app",
{ name: "Awesome-App.git" }
]);
expect(collisions.get("awesome-app")?.conflictsWithExisting).toBe(false);
expect(collisions.get("other-tool")?.hasCollision).toBe(true);
expect(collisions.get("other-tool")?.conflictsWithExisting).toBe(true);
});
});
describe("importGitHubRepos", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateMigrationTask.mockImplementation(
async (
_agent: unknown,
_host: unknown,
_requestId: unknown,
jobs: NewMigrationJob[]
) => ({
id: "task-1",
ownerDid: "did:plc:alice",
createdAt: "2026-01-01T00:00:00Z",
jobs: jobs.map((job, index) => ({
id: String(index + 1),
name: job.name,
knotDid: job.knotDid,
sourceUrl: job.sourceUrl,
private: false,
status: "queued",
attempts: 0
}))
})
);
});
it("hands the clone url to the migrator, with the knot to create the repository on", async () => {
const task = await importGitHubRepos(
testAgent,
"https://migrator.test",
"https://github.com",
[
{
ownerDid: "did:plc:alice" as Did,
ownerHandle: "alice.tangled.org",
knot: "knot.test",
repo: baseRepo
}
]
);
expect(task.id).toBe("task-1");
expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1);
const [, host, requestId, jobs] = mockCreateMigrationTask.mock.calls[0];
expect(host).toBe("https://migrator.test");
expect(typeof requestId).toBe("string");
expect(jobs).toEqual([
{
name: "sample-repo",
knotDid: "did:web:knot.test",
sourceUrl: "https://github.com/octocat/sample-repo.git"
}
]);
});
it("batches multiple repositories, marking private jobs, preserving knot DIDs, and normalising names", async () => {
const task = await importGitHubRepos(
testAgent,
"https://migrator.test",
"https://github.com",
[
{
ownerDid: "did:plc:alice" as Did,
ownerHandle: "alice.tangled.org",
knot: "knot.test",
repo: { ...baseRepo, private: true }
},
{
ownerDid: "did:plc:alice" as Did,
ownerHandle: "alice.tangled.org",
knot: "did:web:custom-knot.test",
name: "second-repo.git.git",
repo: {
...baseRepo,
id: 99,
name: "second-repo",
fullName: "octocat/second-repo",
cloneUrl: "https://github.com/octocat/second-repo.git"
}
}
]
);
expect(task.id).toBe("task-1");
expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1);
const [, , , jobs] = mockCreateMigrationTask.mock.calls[0];
expect(jobs).toHaveLength(2);
expect(jobs[0]).toMatchObject({
name: "sample-repo",
knotDid: "did:web:knot.test",
private: true
});
expect(jobs[1]).toMatchObject({
name: "second-repo.git",
knotDid: "did:web:custom-knot.test"
});
expect(jobs[1].private).toBeUndefined();
});
it("rejects a clone url that does not belong to the named repository", async () => {
const mismatchedRepo: GitHubRepo = {
...baseRepo,
cloneUrl: "https://github.com/imposter/sample-repo.git"
};
await expect(
importGitHubRepos(testAgent, "https://migrator.test", "https://github.com", [
{
ownerDid: "did:plc:alice" as Did,
ownerHandle: "alice.tangled.org",
knot: "knot.test",
repo: mismatchedRepo
}
])
).rejects.toThrow(/pathname must match repository/);
expect(mockCreateMigrationTask).not.toHaveBeenCalled();
});
});
describe("importGitHubProfile", () => {
const sampleAccount: GitHubAccount = {
publicOrigin: "https://github.com",
login: "octocat",
avatarUrl: "https://avatars.githubusercontent.com/u/583231",
email: "octocat@github.com",
bio: "builds things",
blog: "https://octocat.example.com",
keys: [
{
id: 1,
title: "Laptop",
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 user@laptop"
},
{
id: 2,
title: "Desktop",
key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 user@desktop"
}
],
repos: []
};
beforeEach(() => {
vi.clearAllMocks();
mockReadRecord.mockReset();
mockReadRecord.mockResolvedValue(null);
mockCreatePubKey.mockReset();
mockCreatePubKey.mockImplementation(async (agent, name) => ({
uri: `at://${agent.sub}/org.tangled.key.sshKey/${name}`,
cid: "bafycreated",
rkey: `rkey-${name}`
}));
});
it("preserves existing PDS profile fields and passes swapRecord CID to putRecord", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" }
})
);
const existingProfile: ProfileRecord = {
$type: "org.tangled.actor.profile",
description: "Existing Tangled Bio",
location: "San Francisco",
links: ["https://example.com"]
};
mockReadRecord.mockResolvedValue({
value: existingProfile,
written: {
uri: "at://did:plc:alice/org.tangled.actor.profile/self",
cid: "bafk-existing-cid"
}
});
mockRpcCall.mockResolvedValue({ ok: true, data: { records: [] } });
mockUploadProfileAvatar.mockResolvedValue({
$type: "blob",
ref: { $link: "new-blob-cid" },
mimeType: "image/png",
size: 3
});
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test", fetch: fetchMock },
sampleAccount,
{ avatar: true, keys: false, email: false }
);
expect(result.errors).toEqual([]);
expect(result.imported).toContain("avatar");
expect(fetchMock).toHaveBeenCalledWith("/_internal/github/avatar");
expect(mockUploadProfileAvatar).toHaveBeenCalled();
expect(mockPutRecord).toHaveBeenCalledWith(
testAgent,
"org.tangled.actor.profile",
"self",
{
$type: "org.tangled.actor.profile",
description: "Existing Tangled Bio",
location: "San Francisco",
links: ["https://example.com"],
avatar: {
$type: "blob",
ref: { $link: "new-blob-cid" },
mimeType: "image/png",
size: 3
}
},
"bafk-existing-cid"
);
});
it("regression: a failed profile read never writes or blanks the profile", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" }
})
);
mockUploadProfileAvatar.mockResolvedValue({
$type: "blob",
ref: { $link: "blob-cid" },
mimeType: "image/png",
size: 3
});
// network failure must not be treated as a missing profile
mockReadRecord.mockRejectedValue(new Error("Connection reset by peer"));
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test", fetch: fetchMock },
sampleAccount,
{ avatar: true, keys: false, email: false }
);
expect(mockPutRecord).not.toHaveBeenCalled();
expect(mockCreateRecord).not.toHaveBeenCalled();
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("Connection reset by peer");
expect(result.imported).not.toContain("avatar");
});
it("creates an absent profile without overwriting a concurrent creation", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "image/png" }
})
);
mockUploadProfileAvatar.mockResolvedValue({
$type: "blob",
ref: { $link: "fresh-blob-cid" },
mimeType: "image/png",
size: 3
});
mockRpcCall.mockResolvedValue({
ok: false,
status: 404,
headers: new Headers(),
data: { error: "RecordNotFound" }
});
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test", fetch: fetchMock },
sampleAccount,
{ avatar: true, keys: false, email: false }
);
expect(result.errors).toEqual([]);
expect(result.imported).toContain("avatar");
expect(mockPutRecord).not.toHaveBeenCalled();
expect(mockCreateRecord).toHaveBeenCalledWith(
testAgent,
"org.tangled.actor.profile",
{
$type: "org.tangled.actor.profile",
avatar: {
$type: "blob",
ref: { $link: "fresh-blob-cid" },
mimeType: "image/png",
size: 3
}
},
"self"
);
});
it("reads all pages of PDS public keys and dedups raw SSH algorithm+base64, avoiding retries and duplicates", async () => {
const pdsPage1 = {
records: [
{
value: {
publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 old-comment"
}
}
],
cursor: "cursor-2"
};
const pdsPage2 = {
records: [],
cursor: undefined
};
mockRpcCall.mockImplementation(async (_schema, options) => {
if (options?.params?.collection === "org.tangled.key.sshKey") {
if (options?.params?.cursor === "cursor-2") {
return { ok: true, data: pdsPage2 };
}
return { ok: true, data: pdsPage1 };
}
return { ok: true, data: { records: [] } };
});
const accountWithDupes: GitHubAccount = {
...sampleAccount,
keys: [
...sampleAccount.keys,
{
id: 3,
title: "Desktop Duplicate",
key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 different@comment"
},
{
id: 4,
title: "Broken Key",
key: "malformed"
}
]
};
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test" },
accountWithDupes,
{ avatar: false, keys: true, email: false }
);
expect(mockCreatePubKey).toHaveBeenCalledTimes(1);
expect(mockCreatePubKey).toHaveBeenCalledWith(
testAgent,
"Desktop",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 user@desktop"
);
expect(result.imported).toContain("key:2");
expect(result.keys).toEqual([
{
rkey: "rkey-Desktop",
name: "Desktop",
key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC456 user@desktop"
}
]);
expect(result.errors.some((e) => e.includes("Invalid SSH key format"))).toBe(true);
});
it("handles per-key errors independently without aborting remaining keys", async () => {
mockRpcCall.mockResolvedValue({ ok: true, data: { records: [] } });
mockCreatePubKey
.mockRejectedValueOnce(new Error("Network timeout writing record"))
.mockResolvedValueOnce({ uri: "at://uri", cid: "cid", rkey: "rkey" });
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test" },
sampleAccount,
{ avatar: false, keys: true, email: false }
);
expect(mockCreatePubKey).toHaveBeenCalledTimes(2);
expect(result.imported).toEqual(["key:2"]);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("Network timeout writing record");
});
it.each([
["absent", [{ address: "existing@tangled.org", verified: true, primary: true }], true],
[
"already registered",
[{ address: "octocat@github.com", verified: true, primary: false }],
false
]
])("imports email when %s in deliberi", async (_case, existingEmails, shouldAdd) => {
mockListEmails.mockResolvedValue({ emails: existingEmails });
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test" },
sampleAccount,
{ avatar: false, keys: false, email: true }
);
expect(mockListEmails).toHaveBeenCalledTimes(1);
if (shouldAdd) {
expect(mockAddEmail).toHaveBeenCalledWith(expect.anything(), "octocat@github.com");
expect(result.imported.some((msg) => msg.includes("verification required"))).toBe(true);
} else {
expect(mockAddEmail).not.toHaveBeenCalled();
}
expect(result.errors).toEqual([]);
});
it("executes best-effort independent operations when multiple options are selected", async () => {
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(new Response("Internal error", { status: 500 }));
mockRpcCall.mockResolvedValue({ ok: true, data: { records: [] } });
mockCreatePubKey.mockResolvedValue({ uri: "at://uri", cid: "cid", rkey: "rkey" });
mockListEmails.mockResolvedValue({ emails: [] });
const result = await importGitHubProfile(
testAgent,
{ deliberiUrl: "https://deliberi.test", fetch: fetchMock },
sampleAccount,
{ avatar: true, keys: true, email: true }
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("failed to fetch avatar");
expect(result.imported).toEqual([
"key:1",
"key:2",
"email:octocat@github.com (verification required)"
]);
});
});
describe("importFromUrl", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateMigrationTask.mockImplementation(
async (
_agent: unknown,
_host: unknown,
_requestId: unknown,
jobs: NewMigrationJob[]
) => ({
id: "task-url",
ownerDid: "did:plc:alice",
createdAt: "2026-01-01T00:00:00Z",
jobs: jobs.map((job, index) => ({
id: String(index + 1),
name: job.name,
knotDid: job.knotDid,
sourceUrl: job.sourceUrl,
private: false,
status: "queued",
attempts: 0
}))
})
);
});
it("hands any https git host to the migrator, with the description", async () => {
const started = await importFromUrl(testAgent, "https://migrator.test", {
sourceUrl: "https://git.example.com/team/project.git",
name: "project",
description: "a small tool",
knot: "knot.test"
});
expect(started).toEqual({ taskId: "task-url", name: "project", jobId: "1" });
const [, host, requestId, jobs] = mockCreateMigrationTask.mock.calls[0];
expect(host).toBe("https://migrator.test");
expect(typeof requestId).toBe("string");
expect(jobs).toEqual([
{
name: "project",
knotDid: "did:web:knot.test",
sourceUrl: "https://git.example.com/team/project.git",
description: "a small tool"
}
]);
});
it.each([
["non-https scheme", "http://git.example.com/team/project.git", /https/],
["custom port", "https://git.example.com:8443/team/project.git", /custom port/]
])("refuses an invalid source URL with %s", async (_case, sourceUrl, pattern) => {
await expect(
importFromUrl(testAgent, "https://migrator.test", {
sourceUrl,
name: "project",
knot: "knot.test"
})
).rejects.toThrow(pattern);
expect(mockCreateMigrationTask).not.toHaveBeenCalled();
});
it("accepts an explicit 443 port", async () => {
await importFromUrl(testAgent, "https://migrator.test", {
sourceUrl: "https://git.example.com:443/team/project.git",
name: "project",
knot: "knot.test"
});
const [, , , jobs] = mockCreateMigrationTask.mock.calls[0];
expect(jobs[0].sourceUrl).toBe("https://git.example.com/team/project.git");
});
});
describe("fetchGitHubAccount", () => {
const accountOf = (login: string): GitHubAccount => ({
publicOrigin: "https://github.com",
bio: null,
blog: null,
login,
avatarUrl: "",
email: null,
keys: [],
repos: []
});
const fetcherOf = () => {
const fetcher = vi.fn<Fetch>();
return fetcher;
};
it("serves repeated loads from the cache and refetches after invalidation", async () => {
const fetcher = fetcherOf();
fetcher.mockImplementation(async () => Response.json(accountOf("octocat")));
expect((await fetchGitHubAccount(fetcher))?.login).toBe("octocat");
expect((await fetchGitHubAccount(fetcher))?.login).toBe("octocat");
expect(fetcher).toHaveBeenCalledTimes(1);
invalidateGitHubAccount(fetcher);
await fetchGitHubAccount(fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("caches per fetcher, so test doubles never share entries", async () => {
const first = fetcherOf();
const second = fetcherOf();
first.mockResolvedValue(Response.json(accountOf("octocat")));
second.mockResolvedValue(Response.json(accountOf("monalisa")));
await fetchGitHubAccount(first);
expect((await fetchGitHubAccount(second))?.login).toBe("monalisa");
expect(first).toHaveBeenCalledTimes(1);
});
it.each([
["401 signed-out", 401, async (res: Promise<unknown>) => expect(await res).toBeNull()],
[
"500 failure",
500,
async (res: Promise<unknown>) => expect(res).rejects.toThrow(/Try connecting again/)
]
])("does not cache %s responses", async (_name, status, assertFirst) => {
const fetcher = fetcherOf();
fetcher.mockResolvedValue(new Response("err", { status }));
await assertFirst(fetchGitHubAccount(fetcher));
fetcher.mockResolvedValue(Response.json(accountOf("octocat")));
expect((await fetchGitHubAccount(fetcher))?.login).toBe("octocat");
expect(fetcher).toHaveBeenCalledTimes(2);
});
});