| 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 2 | import { COMPUTER_USE_REPO, getComputerUseRelease, qualifiedComputerUseRelease } from "./computer-use-release"; |
| 3 | |
| 4 | const archive = "Codewhale-Computer-Use-0.3.0-macos-universal.zip"; |
| 5 | const sha256 = "a".repeat(64); |
| 6 | const asset = (name: string, size: number) => ({ name, size, state: "uploaded", |
| 7 | browser_download_url: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${name}`, digest: `sha256:${sha256}` }); |
| 8 | const fixture = () => ({ tag_name: "v0.3.0", draft: false, prerelease: false, |
| 9 | published_at: "2026-09-13T00:00:00Z", html_url: `${COMPUTER_USE_REPO}/releases/tag/v0.3.0`, |
| 10 | assets: [asset(archive, 80000000), asset("release.json", 500)] }); |
| 11 | const receipt = () => ({ version: "0.3.0", platform: "macos", arch: "universal", archive, |
| 12 | sha256, size: 80000000, notarized: true }); |
| 13 | const image = "Codewhale-Computer-Use-0.3.0-macos-universal.dmg"; |
| 14 | const imageSha = "c".repeat(64); |
| 15 | const imageAsset = () => ({ ...asset(image, 81000000), digest: `sha256:${imageSha}` }); |
| 16 | const imageReceipt = () => ({ ...receipt(), dmg: { archive: image, size: 81000000, sha256: imageSha, notarized: true } }); |
| 17 | |
| 18 | const API_LATEST = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest"; |
| 19 | const WEB_RECEIPT = `${COMPUTER_USE_REPO}/releases/latest/download/release.json`; |
| 20 | const OBJECT_URL = "https://objects.githubusercontent.com/github-production-release-asset/1/release.json?X-Amz-Signature=x"; |
| 21 | const status = (code: number) => new Response(null, { status: code }); |
| 22 | const redirect = (location: string) => new Response(null, { status: 302, headers: { location } }); |
| 23 | const stub = (...responses: unknown[]) => { |
| 24 | const fetcher = vi.fn(); |
| 25 | for (const r of responses) { |
| 26 | if (r instanceof Error) fetcher.mockRejectedValueOnce(r); else fetcher.mockResolvedValueOnce(r); |
| 27 | } |
| 28 | vi.stubGlobal("fetch", fetcher); |
| 29 | return fetcher; |
| 30 | }; |
| 31 | |
| 32 | afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); }); |
| 33 | |
| 34 | describe("Computer Use download qualification", () => { |
| 35 | it("offers the exact archive when the release, receipt and GitHub digest agree", () => { |
| 36 | expect(qualifiedComputerUseRelease(fixture(), receipt())).toMatchObject({ |
| 37 | status: "ready", version: "0.3.0", sha256, downloadUrl: asset(archive, 80000000).browser_download_url, |
| 38 | verification: "github-digest", |
| 39 | }); |
| 40 | }); |
| 41 | it.each([ |
| 42 | { notarized: false }, { version: "0.2.2" }, { size: 1 }, { sha256: "b".repeat(64) }, |
| 43 | { platform: "windows" }, { arch: "arm64" }, { archive: "unqualified.zip" }, |
| 44 | ])("withholds mismatched or unqualified receipts: %j", change => { |
| 45 | expect(qualifiedComputerUseRelease(fixture(), { ...receipt(), ...change }).status).toBe("pending"); |
| 46 | }); |
| 47 | it("refuses drafts, prereleases, missing assets and foreign download URLs", () => { |
| 48 | const foreign = fixture(); foreign.assets[0].browser_download_url = "https://example.com/app.zip"; |
| 49 | const duplicate = fixture(); duplicate.assets.push(duplicate.assets[0]); |
| 50 | const unsigned = fixture(); unsigned.assets[0].digest = ""; |
| 51 | const tooLarge = fixture(); tooLarge.assets[0].size = 300 * 1024 * 1024; |
| 52 | for (const release of [{ ...fixture(), draft: true }, { ...fixture(), prerelease: true }, |
| 53 | { ...fixture(), tag_name: "v0.3.0-rc1" }, { ...fixture(), assets: [] }, foreign, duplicate, unsigned, tooLarge]) { |
| 54 | expect(qualifiedComputerUseRelease(release, receipt()).status).toBe("pending"); |
| 55 | } |
| 56 | }); |
| 57 | it("loads only the canonical release and its matching receipt", async () => { |
| 58 | const fetcher = stub(Response.json(fixture()), Response.json(receipt())); |
| 59 | expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "github-digest" }); |
| 60 | expect(fetcher.mock.calls.map(c => c[0])).toEqual([API_LATEST, `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`]); |
| 61 | }); |
| 62 | it("sends the server-held token to the API exactly when one is passed, and never elsewhere", async () => { |
| 63 | const fetcher = stub(status(404), status(404), status(404)); |
| 64 | await getComputerUseRelease("ghp_secret"); |
| 65 | await getComputerUseRelease(); |
| 66 | const headers = (call: number) => fetcher.mock.calls[call][1].headers as Record<string, string>; |
| 67 | expect(headers(0).Authorization).toBe("Bearer ghp_secret"); |
| 68 | expect(headers(1)).not.toHaveProperty("Authorization"); |
| 69 | await getComputerUseRelease(""); |
| 70 | expect(headers(2)).not.toHaveProperty("Authorization"); |
| 71 | }); |
| 72 | it("reports no published installer when both the API and the release endpoint say so", async () => { |
| 73 | const fetcher = stub(status(404)); |
| 74 | expect((await getComputerUseRelease()).status).toBe("pending"); |
| 75 | expect(fetcher).toHaveBeenCalledTimes(1); |
| 76 | }); |
| 77 | it("falls back to the release web endpoint when the API refuses, and stays honest when that fails too", async () => { |
| 78 | const error = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 79 | stub(status(403), status(404)); |
| 80 | expect((await getComputerUseRelease()).status).toBe("pending"); |
| 81 | expect(error).toHaveBeenCalledWith("computer-use release check", 403); |
| 82 | stub(status(503), new Error("offline")); |
| 83 | expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable"); |
| 84 | stub(new Error("offline"), status(404)); |
| 85 | expect((await getComputerUseRelease("ghp_secret")).status).toBe("pending"); |
| 86 | expect(error).toHaveBeenCalledWith("computer-use release check failed", "offline"); |
| 87 | stub(status(403), status(500)); |
| 88 | expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable"); |
| 89 | expect(error.mock.calls.flat().join(" ")).not.toContain("ghp_"); |
| 90 | }); |
| 91 | it("qualifies the download from the receipt when the API is unreachable", async () => { |
| 92 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 93 | const fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), redirect(OBJECT_URL)); |
| 94 | expect(await getComputerUseRelease()).toEqual({ |
| 95 | status: "ready", version: "0.3.0", sha256, size: 80000000, verification: "receipt", |
| 96 | url: `${COMPUTER_USE_REPO}/releases/tag/v0.3.0`, |
| 97 | downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`, |
| 98 | receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`, |
| 99 | }); |
| 100 | expect(fetcher.mock.calls.map(c => [c[0], c[1].method ?? "GET", c[1].redirect])).toEqual([ |
| 101 | [API_LATEST, "GET", undefined], [WEB_RECEIPT, "GET", "manual"], [OBJECT_URL, "GET", "manual"], |
| 102 | [`${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`, "HEAD", "manual"], |
| 103 | ]); |
| 104 | expect(fetcher.mock.calls.every(c => !("Authorization" in (c[1].headers ?? {})))).toBe(true); |
| 105 | }); |
| 106 | it("accepts a directly served archive and a permanent redirect for the receipt", async () => { |
| 107 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 108 | stub(status(403), new Response(null, { status: 301, headers: { location: OBJECT_URL } }), Response.json(receipt()), status(200)); |
| 109 | expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "receipt" }); |
| 110 | }); |
| 111 | it.each([ |
| 112 | ["a disallowed host", redirect("https://example.com/release.json")], |
| 113 | ["a redirect without a location", new Response(null, { status: 302 })], |
| 114 | ["plain http", redirect("http://objects.githubusercontent.com/release.json")], |
| 115 | ])("refuses a receipt redirect onto %s", async (_label, hop) => { |
| 116 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 117 | const fetcher = stub(status(403), hop, Response.json(receipt())); |
| 118 | expect((await getComputerUseRelease()).status).toBe("unavailable"); |
| 119 | expect(fetcher).toHaveBeenCalledTimes(2); |
| 120 | }); |
| 121 | it("withholds the fallback when the receipt is unqualified or the archive is not served", async () => { |
| 122 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 123 | stub(status(403), redirect(OBJECT_URL), Response.json({ ...receipt(), notarized: false })); |
| 124 | expect((await getComputerUseRelease()).status).toBe("unavailable"); |
| 125 | stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), status(404)); |
| 126 | expect((await getComputerUseRelease()).status).toBe("unavailable"); |
| 127 | const fetcher = stub(status(403), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL)); |
| 128 | expect((await getComputerUseRelease()).status).toBe("unavailable"); |
| 129 | expect(fetcher).toHaveBeenCalledTimes(5); |
| 130 | }); |
| 131 | it("bounds malformed or oversized responses and does not fetch an unqualified receipt", async () => { |
| 132 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 133 | const fetcher = stub(new Response("x".repeat(128 * 1024 + 1)), new Error("offline"), |
| 134 | Response.json({ ...fixture(), draft: true })); |
| 135 | expect((await getComputerUseRelease()).status).toBe("unavailable"); |
| 136 | expect((await getComputerUseRelease()).status).toBe("pending"); |
| 137 | expect(fetcher).toHaveBeenCalledTimes(3); |
| 138 | }); |
| 139 | it("offers the disk image only when the receipt and GitHub's digest agree on it", () => { |
| 140 | const withImage = { ...fixture(), assets: [...fixture().assets, imageAsset()] }; |
| 141 | expect(qualifiedComputerUseRelease(withImage, imageReceipt())).toMatchObject({ |
| 142 | status: "ready", dmg: { downloadUrl: imageAsset().browser_download_url, size: 81000000, sha256: imageSha }, |
| 143 | }); |
| 144 | // A release without the image, a receipt without the entry, or a mismatch all fall back to the archive alone. |
| 145 | expect(qualifiedComputerUseRelease(fixture(), imageReceipt())).not.toHaveProperty("dmg"); |
| 146 | expect(qualifiedComputerUseRelease(withImage, receipt())).not.toHaveProperty("dmg"); |
| 147 | const mismatched = imageReceipt(); mismatched.dmg.sha256 = "d".repeat(64); |
| 148 | expect(qualifiedComputerUseRelease(withImage, mismatched)).toMatchObject({ status: "ready" }); |
| 149 | expect(qualifiedComputerUseRelease(withImage, mismatched)).not.toHaveProperty("dmg"); |
| 150 | const unnotarized = imageReceipt(); unnotarized.dmg.notarized = false; |
| 151 | expect(qualifiedComputerUseRelease(withImage, unnotarized)).not.toHaveProperty("dmg"); |
| 152 | }); |
| 153 | it("confirms the disk image is served before offering it from the receipt fallback", async () => { |
| 154 | vi.spyOn(console, "error").mockImplementation(() => {}); |
| 155 | let fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(imageReceipt()), redirect(OBJECT_URL), redirect(OBJECT_URL)); |
| 156 | expect(await getComputerUseRelease()).toMatchObject({ |
| 157 | status: "ready", verification: "receipt", |
| 158 | dmg: { downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${image}`, size: 81000000, sha256: imageSha }, |
| 159 | }); |
| 160 | expect(fetcher.mock.calls[4][0]).toBe(`${COMPUTER_USE_REPO}/releases/download/v0.3.0/${image}`); |
| 161 | expect(fetcher.mock.calls[4][1].method).toBe("HEAD"); |
| 162 | fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(imageReceipt()), redirect(OBJECT_URL), status(404)); |
| 163 | const withoutImage = await getComputerUseRelease(); |
| 164 | expect(withoutImage).toMatchObject({ status: "ready", verification: "receipt" }); |
| 165 | expect(withoutImage).not.toHaveProperty("dmg"); |
| 166 | expect(fetcher).toHaveBeenCalledTimes(5); |
| 167 | }); |
| 168 | it("keeps production builds offline without claiming that a release is available", async () => { |
| 169 | vi.stubEnv("NEXT_PHASE", "phase-production-build"); |
| 170 | const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher); |
| 171 | expect((await getComputerUseRelease()).status).toBe("unavailable"); |
| 172 | expect(fetcher).not.toHaveBeenCalled(); |
| 173 | }); |
| 174 | }); |
| 175 |