import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { previewName } from "../../scripts/deployment.ts"; import { commentOnPreview } from "../../scripts/preview-comment.ts"; const branch = "feature/search"; const sha = "a".repeat(40); const repo = "did:plc:repository"; const pull = `at://${repo}/org.tangled.track.ticket/3example`; const previewUrl = "https://pr-search-crosscredit.example.workers.dev"; const output = JSON.stringify({ type: "preview", version: 1, preview_name: previewName(branch), preview_urls: [previewUrl], deployment_urls: [] }); const fetchMock = vi.fn(); const logMock = vi.fn(); function requestUrl(input: RequestInfo | URL | undefined): URL { if (input instanceof URL) { return input; } if (input instanceof Request) { return new URL(input.url); } if (typeof input === "string") { return new URL(input); } throw new Error("Missing request URL"); } const pipeline = { id: "pipeline-id", repo, commit: sha, trigger: { $type: "org.tangled.event.pullRequest", pull, sourceBranch: branch, sourceSha: sha, targetBranch: "main" } }; const ticket = { uri: pull, cid: "current-ticket-cid", value: { $type: "org.tangled.track.ticket", state: "open", targetBranch: "main" } }; beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); vi.stubEnv("TANGLED_COMMENT_APP_PASSWORD", "test-app-password"); vi.stubEnv("TANGLED_COMMENT_PDS_URL", "https://pds.example.com"); vi.stubEnv("TANGLED_COMMENT_IDENTIFIER", "preview-bot.example.com"); vi.stubEnv("TANGLED_COMMENT_SPINDLE_URL", "https://spindle.example.com"); vi.stubEnv("TANGLED_REPO_KNOT", "knot.example.com"); vi.stubEnv("TANGLED_REPO_REPO_DID", repo); vi.stubEnv("TANGLED_PIPELINE_ID", "pipeline-id"); vi.spyOn(console, "info").mockImplementation(logMock); fetchMock.mockImplementation(async (input) => { switch (requestUrl(input).pathname) { case "/xrpc/org.tangled.ci.getPipeline": { return Response.json(pipeline); } case "/xrpc/com.atproto.repo.getRecord": { return Response.json(ticket); } case "/xrpc/com.atproto.server.createSession": { return Response.json({ accessJwt: "test-access-token" }); } case "/xrpc/com.atproto.server.getServiceAuth": { return Response.json({ token: "test-service-token" }); } case "/xrpc/com.atproto.repo.createRecord": { return Response.json({ uri: "at://comment", cid: "comment-cid" }); } default: { throw new Error("Unexpected API call"); } } }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("PR preview comments", () => { it("posts the returned Preview URL to the exact triggering PR using a method-scoped service token", async () => { await commentOnPreview(output, branch, sha); expect(fetchMock).toHaveBeenCalledTimes(5); const [pipelineUrl] = fetchMock.mock.calls[0] ?? []; expect(requestUrl(pipelineUrl).href).toBe( `https://spindle.example.com/xrpc/org.tangled.ci.getPipeline?repo=${encodeURIComponent(repo)}&pipeline=pipeline-id` ); const [authUrl, authOptions] = fetchMock.mock.calls[3] ?? []; expect(requestUrl(authUrl).searchParams.get("aud")).toBe(repo); expect(requestUrl(authUrl).searchParams.get("lxm")).toBe("com.atproto.repo.createRecord"); expect(authOptions?.headers).toEqual({ Authorization: "Bearer test-access-token" }); const [commentUrl, options] = fetchMock.mock.calls[4] ?? []; expect(requestUrl(commentUrl).href).toBe( "https://knot.example.com/xrpc/com.atproto.repo.createRecord" ); expect(options?.headers).toEqual({ "Content-Type": "application/json", Authorization: "Bearer test-service-token" }); expect(options?.redirect).toBe("error"); const body = options?.body; if (typeof body !== "string") { throw new TypeError("Expected a JSON comment body"); } expect(JSON.parse(body)).toMatchObject({ repo, collection: "org.tangled.feed.comment", record: { $type: "org.tangled.feed.comment", subject: { $type: "com.atproto.repo.strongRef", uri: pull, cid: "current-ticket-cid" }, body: { $type: "org.tangled.markup.markdown", text: `Preview ready: [Open preview](${previewUrl})\n\nCommit: \`${sha}\`` } } }); expect(body).not.toContain("test-app-password"); }); it("still logs the URL without any network calls when comments are not configured", async () => { vi.stubEnv("TANGLED_COMMENT_APP_PASSWORD", ""); await commentOnPreview(output, branch, sha); expect(fetchMock).not.toHaveBeenCalled(); expect(logMock).toHaveBeenCalledWith(`Preview ready: ${previewUrl}`); }); it("does not authenticate or comment after the PR has closed", async () => { fetchMock.mockResolvedValueOnce(Response.json(pipeline)).mockResolvedValueOnce( Response.json({ ...ticket, value: { ...ticket.value, state: "completed" } }) ); await commentOnPreview(output, branch, sha); expect(fetchMock).toHaveBeenCalledTimes(2); }); it.each([ { ...pipeline, repo: "did:plc:other" }, { ...pipeline, commit: "b".repeat(40) }, { ...pipeline, trigger: { ...pipeline.trigger, sourceBranch: "another-branch" } }, { ...pipeline, trigger: { ...pipeline.trigger, pull: "at://did:plc:other/org.tangled.track.ticket/3example" } } ])("refuses a mismatched pipeline or PR", async (value) => { fetchMock.mockResolvedValueOnce(Response.json(value)); await expect(commentOnPreview(output, branch, sha)).rejects.toThrow(); expect(fetchMock).toHaveBeenCalledOnce(); }); it("rejects a different PR returned by the knot before authenticating", async () => { fetchMock .mockResolvedValueOnce(Response.json(pipeline)) .mockResolvedValueOnce(Response.json({ ...ticket, uri: `${pull}other` })); await expect(commentOnPreview(output, branch, sha)).rejects.toThrow("different PR"); expect(fetchMock).toHaveBeenCalledTimes(2); }); it.each([ "not json", JSON.stringify({ type: "preview", version: 1, preview_name: "wrong", preview_urls: [previewUrl], deployment_urls: [] }), JSON.stringify({ type: "preview", version: 1, preview_name: previewName(branch), preview_urls: ["https://unrelated.example.com"], deployment_urls: [] }) ])("refuses invalid Cloudflare output", async (value) => { await expect(commentOnPreview(value, branch, sha)).rejects.toThrow(); expect(fetchMock).not.toHaveBeenCalled(); }); it("reports authentication failure without logging the response body or credentials", async () => { fetchMock .mockResolvedValueOnce(Response.json(pipeline)) .mockResolvedValueOnce(Response.json(ticket)) .mockResolvedValueOnce(Response.json({ message: "private-response-body" }, { status: 401 })); await expect(commentOnPreview(output, branch, sha)).rejects.toThrow( "createSession failed (HTTP 401)" ); expect(fetchMock).toHaveBeenCalledTimes(3); expect(JSON.stringify(logMock.mock.calls)).not.toMatch( /test-app-password|private-response-body|test-access-token/ ); }); it("recovers from a read timeout and completes the comment", async () => { vi.useFakeTimers(); fetchMock.mockRejectedValueOnce(new DOMException("private-error-detail", "TimeoutError")); const result = commentOnPreview(output, branch, sha); await vi.runAllTimersAsync(); await result; expect(fetchMock).toHaveBeenCalledTimes(6); expect(logMock).toHaveBeenCalledWith("Posted PR preview comment."); expect(JSON.stringify(logMock.mock.calls)).not.toContain("private-error-detail"); }); it.each([408, 429, 500, 502, 503, 504])( "retries a transient HTTP %s read response", async (status) => { vi.useFakeTimers(); fetchMock.mockResolvedValueOnce(Response.json({ message: "private-body" }, { status })); const result = commentOnPreview(output, branch, sha); await vi.runAllTimersAsync(); await result; expect(fetchMock).toHaveBeenCalledTimes(6); expect(JSON.stringify(logMock.mock.calls)).not.toContain("private-body"); } ); it("retries a network failure on a read", async () => { vi.useFakeTimers(); fetchMock.mockRejectedValueOnce(new TypeError("private-network-detail")); const result = commentOnPreview(output, branch, sha); await vi.runAllTimersAsync(); await result; expect(fetchMock).toHaveBeenCalledTimes(6); expect(JSON.stringify(logMock.mock.calls)).not.toContain("private-network-detail"); }); it.each([ [0, "https://spindle.example.com/xrpc/org.tangled.ci.getPipeline", 3], [1, "https://knot.example.com/xrpc/com.atproto.repo.getRecord", 3], [2, "https://pds.example.com/xrpc/com.atproto.server.createSession", 1], [3, "https://pds.example.com/xrpc/com.atproto.server.getServiceAuth", 3], [4, "https://knot.example.com/xrpc/com.atproto.repo.createRecord", 1] ] as const)( "identifies a timeout at step %s and bounds retries", async (step, service, attempts) => { vi.useFakeTimers(); const original = fetchMock.getMockImplementation(); let calls = 0; fetchMock.mockImplementation((input, options) => { calls += 1; if (calls > step) { return Promise.reject(new DOMException("private-detail", "TimeoutError")); } if (!original) { throw new Error("Missing fetch implementation"); } return original(input, options); }); const result = expect(commentOnPreview(output, branch, sha)).rejects.toThrow( `Tangled ${service} timed out after 30 seconds after ${attempts} attempt(s).` ); await vi.runAllTimersAsync(); await result; expect(fetchMock).toHaveBeenCalledTimes(step + attempts); } ); it("does not retry a non-transient read failure", async () => { fetchMock.mockResolvedValueOnce(Response.json({ message: "private-body" }, { status: 403 })); await expect(commentOnPreview(output, branch, sha)).rejects.toThrow("failed (HTTP 403)"); expect(fetchMock).toHaveBeenCalledOnce(); }); it("does not retry a transient comment write failure", async () => { const original = fetchMock.getMockImplementation(); fetchMock.mockImplementation((input, options) => { if (requestUrl(input).pathname.endsWith("repo.createRecord")) { return Promise.resolve(Response.json({}, { status: 503 })); } if (!original) { throw new Error("Missing fetch implementation"); } return original(input, options); }); await expect(commentOnPreview(output, branch, sha)).rejects.toThrow("failed (HTTP 503)"); expect(fetchMock).toHaveBeenCalledTimes(5); }); it("rejects an insecure configured service before sending credentials", async () => { vi.stubEnv("TANGLED_COMMENT_PDS_URL", "http://pds.example.com"); await expect(commentOnPreview(output, branch, sha)).rejects.toThrow("HTTPS origins"); expect(fetchMock).not.toHaveBeenCalled(); }); });