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"; import type { ProfileRecord } from "$lib/api/records"; import type { NewMigrationJob } from "$lib/api/migrator"; import { canonicalRepoName, fetchGitHubAccount, findRepoCollisions, importGitHubProfile, importFromUrl, importGitHubRepos, invalidateGitHubAccount, validateGitHubRepoUrl } from "$lib/api/githubImport"; const mockCreateOptimisticRepoRecord = vi.fn(); vi.mock("$lib/api/repoCreate", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createOptimisticRepoRecord: (...args: unknown[]) => mockCreateOptimisticRepoRecord(...args) }; }); const mockCreateMigrationTask = vi.fn(); vi.mock("$lib/api/migrator", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createMigrationTask: (...args: unknown[]) => mockCreateMigrationTask(...args) }; }); const mockUploadProfileAvatar = vi.fn(); vi.mock("$lib/api/profile", () => ({ uploadProfileAvatar: (...args: unknown[]) => mockUploadProfileAvatar(...args) })); const mockPutRecord = vi.fn(); const mockCreateRecord = vi.fn(); const mockReadRecord = vi.fn(); vi.mock("$lib/api/write", () => ({ createRecord: (...args: unknown[]) => mockCreateRecord(...args), putRecord: (...args: unknown[]) => mockPutRecord(...args), readRecord: (...args: unknown[]) => mockReadRecord(...args) })); const mockCreatePubKey = vi.fn(); vi.mock("$lib/api/settings", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createPubKey: (...args: unknown[]) => mockCreatePubKey(...args) }; }); const mockListEmails = vi.fn(); const mockAddEmail = vi.fn(); vi.mock("$lib/api/emails", () => ({ listEmails: (...args: unknown[]) => mockListEmails(...args), addEmail: (...args: unknown[]) => mockAddEmail(...args) })); const mockRpcCall = vi.fn(); vi.mock("$lib/auth/agent", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createClient: () => ({ call: (...args: unknown[]) => mockRpcCall(...args) }) }; }); vi.mock("$lib/api/deliberi", () => ({ 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.tngl.boltless.dev"; 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\.tngl\.boltless\.dev/); 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("creates optimistic records ahead of migration task when bobbinUrl is supplied", async () => { await importGitHubRepos( testAgent, "https://migrator.test", "https://github.com", [ { ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org", knot: "knot.test", repo: baseRepo } ], { bobbinUrl: "https://bobbin.test" } ); expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledTimes(1); expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledWith( testAgent, "https://bobbin.test", expect.objectContaining({ ownerDid: "did:plc:alice", ownerHandle: "alice.tangled.org", name: "sample-repo", knot: "knot.test", source: { kind: "import", url: "https://github.com/octocat/sample-repo.git" } }) ); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); }); 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, key) => ({ uri: `at://${agent.sub}/sh.tangled.publicKey/${name}`, cid: "bafycreated", rkey: `rkey-${name}` })); }); it("preserves existing PDS profile fields and passes swapRecord CID to putRecord", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "content-type": "image/png" } }) ); const existingProfile: ProfileRecord = { $type: "sh.tangled.actor.profile", bluesky: true, description: "Existing Tangled Bio", location: "San Francisco", links: ["https://example.com"] }; mockReadRecord.mockResolvedValue({ value: existingProfile, written: { uri: "at://did:plc:alice/sh.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, "sh.tangled.actor.profile", "self", { $type: "sh.tangled.actor.profile", bluesky: true, 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().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().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, "sh.tangled.actor.profile", { $type: "sh.tangled.actor.profile", bluesky: false, 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: { key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 old-comment" } } ], cursor: "cursor-2" }; const pdsPage2 = { records: [], cursor: undefined }; mockRpcCall.mockImplementation(async (_schema, options) => { if (options?.params?.collection === "sh.tangled.publicKey") { 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() .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"); }); it("creates an optimistic record ahead of migration task when bobbinUrl and owner are supplied", async () => { await importFromUrl( testAgent, "https://migrator.test", { sourceUrl: "https://git.example.com/team/project.git", name: "project", description: "a small tool", knot: "knot.test", ownerDid: "did:plc:alice" as Did, ownerHandle: "alice.tangled.org" }, { bobbinUrl: "https://bobbin.test" } ); expect(mockCreateOptimisticRepoRecord).toHaveBeenCalledWith( testAgent, "https://bobbin.test", expect.objectContaining({ name: "project", knot: "knot.test", source: { kind: "import", url: "https://git.example.com/team/project.git" } }) ); expect(mockCreateMigrationTask).toHaveBeenCalledTimes(1); }); }); 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(); 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) => expect(await res).toBeNull()], [ "500 failure", 500, async (res: Promise) => 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); }); });