Files
paperclip/server/src/__tests__/gitea-skills.test.ts
T
Chris Farhood 33ab4f8cdd fork: address PR #19 review findings for Gitea skill support
- Fix GitHub Enterprise regression: dispatcher now probes for Gitea only
  on non-github.com hosts and falls back to the GitHub path for unknown
  hosts, preserving GHE support that the earlier strict github.com match
  broke.
- Refactor readUrlSkillImports into a flat dispatcher with a sibling
  readGitHubUrlSkillImports helper, mirroring readGiteaUrlSkillImports.
- Add SSRF guard (isPrivateOrLoopbackHost + assertPublicHost) in
  gitea-fetch; short-circuit probeGiteaHost and reject parseGiteaSourceUrl
  for loopback / RFC1918 / link-local literal IPs.
- Throw on fetchGiteaTreeBlobPaths cap-hit instead of silently returning a
  partial blob listing (would hide SKILL.md files).
- Validate non-empty repo in parseGiteaSourceUrl after .git strip.
- Remove dead resolveGiteaCommitSha + GiteaCommitResponse (unused since
  the branches-endpoint follow-up).
- Tests updated and extended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 23:07:51 -04:00

366 lines
12 KiB
TypeScript

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("<html>not gitea</html>"),
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();
});