import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fetchGiteaBranch, fetchGiteaText, fetchGiteaTreeBlobPaths, giteaApiBase, giteaHostProbeCache, isPrivateOrLoopbackHost, parseGiteaSourceUrl, probeGiteaHost, resolveGiteaPinnedRef, resolveRawGiteaUrl, resolveRawGiteaUrlLegacy, setGiteaHostProbe, } from "../services/gitea-skills.js"; function jsonResponse(body: unknown, init?: { status?: number; ok?: boolean }) { const status = init?.status ?? 200; return { ok: init?.ok ?? (status >= 200 && status < 300), status, statusText: status === 200 ? "OK" : "Error", text: () => Promise.resolve(JSON.stringify(body)), json: () => Promise.resolve(body), } as unknown as Response; } function textResponse(body: string, init?: { status?: number; ok?: boolean }) { const status = init?.status ?? 200; return { ok: init?.ok ?? (status >= 200 && status < 300), status, statusText: status === 200 ? "OK" : "Error", text: () => Promise.resolve(body), json: () => Promise.reject(new Error("not json")), } as unknown as Response; } describe("giteaApiBase", () => { it("returns the Gitea API base", () => { expect(giteaApiBase("git.example.com")).toBe("https://git.example.com/api/v1"); }); }); describe("resolveRawGiteaUrl", () => { it("builds the modern /raw/branch/ URL", () => { expect(resolveRawGiteaUrl("git.example.com", "acme", "skills", "main", "foo/SKILL.md")) .toBe("https://git.example.com/acme/skills/raw/branch/main/foo/SKILL.md"); }); it("strips leading slashes from the file path", () => { expect(resolveRawGiteaUrl("git.example.com", "acme", "skills", "abc123", "/nested/file.md")) .toBe("https://git.example.com/acme/skills/raw/branch/abc123/nested/file.md"); }); }); describe("resolveRawGiteaUrlLegacy", () => { it("builds the legacy /raw/ URL", () => { expect(resolveRawGiteaUrlLegacy("git.example.com", "acme", "skills", "main", "SKILL.md")) .toBe("https://git.example.com/acme/skills/raw/main/SKILL.md"); }); }); describe("parseGiteaSourceUrl", () => { it("parses a plain repo URL", () => { expect(parseGiteaSourceUrl("https://git.example.com/acme/skills")).toEqual({ hostname: "git.example.com", owner: "acme", repo: "skills", ref: "main", basePath: "", filePath: null, explicitRef: false, }); }); it("strips .git suffix", () => { expect(parseGiteaSourceUrl("https://git.example.com/acme/skills.git").repo).toBe("skills"); }); it("parses a /tree/{ref}/{basePath} URL", () => { expect(parseGiteaSourceUrl("https://git.example.com/acme/skills/tree/dev/skills/web")).toEqual({ hostname: "git.example.com", owner: "acme", repo: "skills", ref: "dev", basePath: "skills/web", filePath: null, explicitRef: true, }); }); it("parses a /blob/{ref}/{filePath} URL", () => { expect(parseGiteaSourceUrl("https://git.example.com/acme/skills/blob/main/SKILL.md")).toEqual({ hostname: "git.example.com", owner: "acme", repo: "skills", ref: "main", basePath: ".", filePath: "SKILL.md", explicitRef: true, }); }); it("rejects non-HTTPS URLs", () => { expect(() => parseGiteaSourceUrl("http://git.example.com/acme/skills")).toThrow(/HTTPS/); }); it("rejects URLs with fewer than 2 path segments", () => { expect(() => parseGiteaSourceUrl("https://git.example.com/acme")).toThrow(/Invalid Gitea URL/); }); it("rejects URLs with empty repo after .git strip", () => { expect(() => parseGiteaSourceUrl("https://git.example.com/acme/.git")).toThrow( /owner and repo are required/, ); }); it("rejects URLs pointing at private/loopback hosts", () => { expect(() => parseGiteaSourceUrl("https://192.168.1.10/acme/skills")).toThrow( /private, loopback/, ); expect(() => parseGiteaSourceUrl("https://localhost/acme/skills")).toThrow( /private, loopback/, ); }); }); describe("isPrivateOrLoopbackHost", () => { it("flags loopback and localhost variants", () => { expect(isPrivateOrLoopbackHost("localhost")).toBe(true); expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true); expect(isPrivateOrLoopbackHost("127.99.99.99")).toBe(true); expect(isPrivateOrLoopbackHost("::1")).toBe(true); expect(isPrivateOrLoopbackHost("foo.localhost")).toBe(true); }); it("flags RFC1918 ranges", () => { expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true); expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true); expect(isPrivateOrLoopbackHost("172.31.255.254")).toBe(true); expect(isPrivateOrLoopbackHost("192.168.1.1")).toBe(true); }); it("flags link-local and 0.0.0.0", () => { expect(isPrivateOrLoopbackHost("169.254.169.254")).toBe(true); expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true); expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true); expect(isPrivateOrLoopbackHost("fd00::1")).toBe(true); }); it("allows public hosts", () => { expect(isPrivateOrLoopbackHost("git.example.com")).toBe(false); expect(isPrivateOrLoopbackHost("gitea.com")).toBe(false); expect(isPrivateOrLoopbackHost("172.32.0.1")).toBe(false); expect(isPrivateOrLoopbackHost("11.0.0.1")).toBe(false); }); }); describe("probeGiteaHost", () => { beforeEach(() => { giteaHostProbeCache.clear(); }); it("returns true when /api/v1/version returns Gitea-shaped JSON", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(jsonResponse({ version: "1.21.0" })), ); const result = await probeGiteaHost("git.example.com"); expect(result).toBe(true); }); it("returns false on 200 with non-JSON body", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, text: () => Promise.resolve("not gitea"), json: () => Promise.reject(new Error("not json")), } as unknown as Response), ); const result = await probeGiteaHost("git.example.com"); expect(result).toBe(false); }); it("returns false on 404", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(jsonResponse({}, { status: 404, ok: false })), ); const result = await probeGiteaHost("git.example.com"); expect(result).toBe(false); }); it("returns false on network error", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); const result = await probeGiteaHost("git.example.com"); expect(result).toBe(false); }); it("caches positive and negative results", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ version: "1.21.0" })); vi.stubGlobal("fetch", fetchMock); await probeGiteaHost("git.example.com"); await probeGiteaHost("git.example.com"); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("reuses a manually-set cache entry without hitting fetch", async () => { setGiteaHostProbe("git.example.com", true); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const result = await probeGiteaHost("git.example.com"); expect(result).toBe(true); expect(fetchMock).not.toHaveBeenCalled(); }); it("short-circuits to false for private/loopback hosts without making a request", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); expect(await probeGiteaHost("127.0.0.1")).toBe(false); expect(await probeGiteaHost("192.168.1.1")).toBe(false); expect(await probeGiteaHost("localhost")).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("resolveGiteaPinnedRef", () => { beforeEach(() => { giteaHostProbeCache.clear(); }); it("returns the 40-hex ref as the pinned ref", async () => { const sha = "0123456789abcdef0123456789abcdef01234567"; vi.stubGlobal("fetch", vi.fn()); const result = await resolveGiteaPinnedRef( parseGiteaSourceUrl(`https://git.example.com/acme/skills/tree/${sha}`), ); expect(result).toEqual({ pinnedRef: sha, trackingRef: sha }); }); it("resolves a branch ref to its commit SHA via the branches endpoint", async () => { const branchSha = "fedcba9876543210fedcba9876543210fedcba98"; const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ default_branch: "main" })) .mockResolvedValueOnce(jsonResponse({ name: "main", commit: { id: branchSha } })); vi.stubGlobal("fetch", fetchMock); const result = await resolveGiteaPinnedRef( parseGiteaSourceUrl("https://git.example.com/acme/skills"), ); expect(result).toEqual({ pinnedRef: branchSha, trackingRef: "main" }); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe("fetchGiteaTreeBlobPaths", () => { it("returns blob paths from a single-page tree", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( jsonResponse({ tree: [ { path: "README.md", type: "blob" }, { path: "skills", type: "tree" }, { path: "skills/web/SKILL.md", type: "blob" }, { path: "skills/cli/SKILL.md", type: "blob" }, ], truncated: false, }), ), ); const paths = await fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"); expect(paths).toEqual(["README.md", "skills/web/SKILL.md", "skills/cli/SKILL.md"]); }); it("paginates when the tree is truncated", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( jsonResponse({ tree: [{ path: "a.md", type: "blob" }], truncated: true, }), ) .mockResolvedValueOnce( jsonResponse({ tree: [{ path: "b.md", type: "blob" }], truncated: false, }), ); vi.stubGlobal("fetch", fetchMock); const paths = await fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"); expect(paths).toEqual(["a.md", "b.md"]); expect(fetchMock).toHaveBeenCalledTimes(2); expect(String(fetchMock.mock.calls[1]?.[0])).toContain("page=2"); }); it("throws when the page cap is hit while the tree is still truncated", async () => { // Return truncated=true on every page so the loop hits MAX_PAGES (50). vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( jsonResponse({ tree: [{ path: "page.md", type: "blob" }], truncated: true, }), ), ); await expect( fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"), ).rejects.toThrow(/exceeds .* entries/); }); }); describe("fetchGiteaText", () => { it("returns the body from the canonical URL on 200", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(textResponse("---\nname: x\n---\n# body\n"))); const content = await fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"); expect(content).toContain("# body"); }); it("falls back to the legacy /raw/ URL on 404", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(textResponse("", { status: 404, ok: false })) .mockResolvedValueOnce(textResponse("legacy body")); vi.stubGlobal("fetch", fetchMock); const content = await fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"); expect(content).toBe("legacy body"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/raw/branch/"); expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/raw/main/"); }); it("throws unprocessable when both URLs fail with non-404", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(textResponse("", { status: 500, ok: false })); vi.stubGlobal("fetch", fetchMock); await expect( fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"), ).rejects.toThrow(/500/); }); }); describe("fetchGiteaBranch", () => { it("returns the branch response", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( jsonResponse({ name: "main", commit: { id: "deadbeef".repeat(5) } }), ), ); const branch = await fetchGiteaBranch(giteaApiBase("git.example.com"), "acme", "skills", "main"); expect(branch.commit?.id).toBe("deadbeef".repeat(5)); }); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });