diff --git a/src/services/versions/v1/index.ts b/src/services/versions/v1/index.ts index 59e0db8..2948dbc 100644 --- a/src/services/versions/v1/index.ts +++ b/src/services/versions/v1/index.ts @@ -12,7 +12,7 @@ import { gte as semverGte, valid as semverValid } from "semver"; -import { Releases, ReleaseDetails } from "./interfaces"; +import { Releases, ReleaseDetails, ResolvedReleaseDetails } from "./interfaces"; import { getReleaseR2Object } from "./r2"; import { getPlatform } from "../../../lib/middleware"; import { ICache } from "../../../lib/interfaces"; @@ -124,6 +124,53 @@ function buildSuccessResponse( }; } +// FOSSBilling's own Update.php sends this on every request already (src/di.php, +// added in 0.8.4), so no client change is needed to gate the R2 mirror by version. +// Anything we can't confidently read a trusted version out of - no header at all +// (<=0.8.3, which predates this header), or a version we don't recognize - falls +// through to `false`, matching the fully-backward-compatible default in +// resolveReleaseForClient() below. +const USER_AGENT_VERSION_PATTERN = /^FOSSBilling\/(.+)$/; + +// First version whose Update::$allowedDownloadPrefixes trusts +// download.fossbilling.org. Older clients reject that URL outright with +// "Update canceled for security reasons" - see FOSSBilling/FOSSBilling +// incident following #4255/#2479. +const MIRROR_TRUST_MIN_VERSION = "0.8.7"; + +function clientTrustsMirror(userAgent: string | undefined | null): boolean { + if (!userAgent) return false; + + const match = userAgent.match(USER_AGENT_VERSION_PATTERN); + if (!match) return false; + + const version = match[1]; + if (!semverValid(version)) return false; + + return semverGte(version, MIRROR_TRUST_MIN_VERSION); +} + +// Resolves a release's dual (GitHub + optional R2 mirror) download info down to +// the single URL/digest pair this specific requester should be sent, and drops +// the internal-only mirror_* fields from the public response shape. +function resolveReleaseForClient( + release: ReleaseDetails, + userAgent: string | undefined | null +): ResolvedReleaseDetails { + const { + mirror_download_url: mirrorDownloadUrl, + mirror_digest: mirrorDigest, + ...resolved + } = release; + + if (mirrorDownloadUrl !== null && clientTrustsMirror(userAgent)) { + resolved.download_url = mirrorDownloadUrl; + resolved.digest = mirrorDigest; + } + + return resolved; +} + interface ReleaseAsset { name: string; browser_download_url: string; @@ -152,9 +199,19 @@ registerCachedRoute("/", async (c) => { if (hasNoReleases(releases)) { c.header("Vary", "*"); + } else { + c.header("Vary", "User-Agent"); } - return c.json(buildSuccessResponse(releases, result.source)); + const userAgent = c.req.header("User-Agent"); + const resolvedReleases = Object.fromEntries( + Object.entries(releases).map(([tag, release]) => [ + tag, + resolveReleaseForClient(release, userAgent) + ]) + ); + + return c.json(buildSuccessResponse(resolvedReleases, result.source)); }); versionsV1.get( @@ -310,17 +367,26 @@ registerCachedRoute("/:version", async (c) => { }); } + c.header("Vary", "User-Agent"); + const userAgent = c.req.header("User-Agent"); + if (version === "latest") { const sortedKeys = Object.keys(releases).sort(semverCompare); const lastKey = sortedKeys.at(-1); + const resolved = lastKey + ? resolveReleaseForClient(releases[lastKey], userAgent) + : null; - return c.json( - buildSuccessResponse(lastKey ? releases[lastKey] : null, result.source) - ); + return c.json(buildSuccessResponse(resolved, result.source)); } if (version in releases) { - return c.json(buildSuccessResponse(releases[version], result.source)); + return c.json( + buildSuccessResponse( + resolveReleaseForClient(releases[version], userAgent), + result.source + ) + ); } c.status(404); @@ -473,9 +539,11 @@ export async function getReleases( } } - // Prefer the R2 mirror over the GitHub asset - github.com has no AAAA - // record, so IPv6-only hosts can't reach it. Fall back to GitHub for - // releases that predate mirroring or if the R2 lookup fails. + // Record both the GitHub asset and the R2 mirror (if this release has + // one) rather than resolving to a single download_url here - which of + // the two a given client should actually be sent depends on whether + // *that client's own version* trusts download.fossbilling.org, and is + // decided per-request in resolveReleaseForClient(). const releaseEntries: [string, ReleaseDetails][] = await Promise.all( releasesToProcess.map( async ({ tag, release, zipAsset, cachedPhpVersion }) => { @@ -499,13 +567,14 @@ export async function getReleases( version: release.name || tag, released_on: release.published_at ?? "", minimum_php_version: phpVersion, - download_url: - r2Object?.downloadUrl ?? zipAsset.browser_download_url, + download_url: zipAsset.browser_download_url, + mirror_download_url: r2Object?.downloadUrl ?? null, size_bytes: zipAsset.size, is_prerelease: Boolean(release.prerelease), github_release_id: release.id ?? 0, changelog: release.body || "", - digest: r2Object?.digest ?? zipAsset.digest ?? null + digest: zipAsset.digest ?? null, + mirror_digest: r2Object?.digest ?? null }; return [tag, releaseDetails]; } @@ -612,6 +681,35 @@ function parseCachedReleases( if (release.digest === undefined) { release.digest = null; } + + // A release missing *either* mirror field predates them (both are + // always written together - see getReleases() below - so a partial + // pair means this entry is malformed or from before they existed), + // which includes the window when download_url itself was written + // as the (unconditionally preferred) R2 mirror URL, with no + // separate field preserving the GitHub URL every older client + // trusts. There's no `null` to backfill that repairs it: the + // GitHub URL isn't recoverable from this cache entry at all, and + // serving download_url as-is risks handing every client - not + // just old ones - the untrusted mirror URL. A one-sided pair is + // just as unsafe: resolveReleaseForClient() would pair a real + // mirror_download_url with an undefined mirror_digest (or vice + // versa) for a trusting client, and JSON drops that undefined key + // from the response entirely - incomplete update metadata. Invalidate + // the whole cache so getReleases() falls through to a fresh fetch, + // which rebuilds every entry with its GitHub and mirror URLs kept + // separate again. + if ( + release.mirror_download_url === undefined || + release.mirror_digest === undefined + ) { + logWarn( + "versions", + "Cache entry predates mirror fields; invalidating cache to rebuild with separated URLs", + { cacheKey: RELEASE_CACHE_KEY, version: release.version } + ); + return null; + } } return parsedCache as Releases; } diff --git a/src/services/versions/v1/interfaces.ts b/src/services/versions/v1/interfaces.ts index e1e11a4..2bffd34 100644 --- a/src/services/versions/v1/interfaces.ts +++ b/src/services/versions/v1/interfaces.ts @@ -2,7 +2,14 @@ export type ReleaseDetails = { version: string; released_on: string; minimum_php_version: string; + // Always the GitHub asset URL - every FOSSBilling version ever released + // trusts this, so it's the safe default for a client we can't identify. download_url: string; + // The R2 mirror URL, if this release has one. Only versions >= + // MIRROR_TRUST_MIN_VERSION have download.fossbilling.org in their signed + // download-URL allowlist (Update::$allowedDownloadPrefixes) and will + // accept it - see resolveReleaseForClient(). + mirror_download_url: string | null; size_bytes: number; is_prerelease: boolean; github_release_id: number; @@ -10,8 +17,19 @@ export type ReleaseDetails = { // SHA-256 digest of the release zip (`sha256:`), as computed by // GitHub; null for older assets predating GitHub's digest support. digest: string | null; + // Digest for mirror_download_url. Set alongside it from the R2 object's + // own custom metadata - see getReleaseR2Object(). + mirror_digest: string | null; }; +// The public response shape returned to FOSSBilling clients - download_url/digest +// already resolved to whichever source (GitHub or the R2 mirror) that specific +// client trusts. See resolveReleaseForClient(). +export type ResolvedReleaseDetails = Omit< + ReleaseDetails, + "mirror_download_url" | "mirror_digest" +>; + export type Releases = { [version: string]: ReleaseDetails; }; diff --git a/test/services/stats/v1/index.test.ts b/test/services/stats/v1/index.test.ts index d2e5dfa..2350f98 100644 --- a/test/services/stats/v1/index.test.ts +++ b/test/services/stats/v1/index.test.ts @@ -262,10 +262,16 @@ describe("Stats API v1", () => { describe("Shared release cache", () => { // getReleases writes gh-fossbilling-releases - the same cache key the // versions service reads - so a stats-triggered fresh fetch must still - // resolve R2 download_url/digest. Otherwise stats would overwrite that - // cache with GitHub-only URLs for up to 24h, silently undoing the R2 - // preference for IPv6-only hosts. See FOSSBilling/FOSSBilling#2479. - it("resolves R2 download_url/digest when it triggers the shared release fetch", async () => { + // resolve the R2 mirror. Otherwise stats would overwrite that cache with + // GitHub-only entries for up to 24h, silently undoing the R2 mirror for + // clients that trust it. See FOSSBilling/FOSSBilling#2479. + // + // The cache stores both download_url (GitHub - always trusted) and + // mirror_download_url (R2, when mirrored); which one a given FOSSBilling + // install is actually sent is resolved per-request in the versions + // service based on that client's own reported version, not baked into + // this shared cache - see resolveReleaseForClient() there. + it("resolves both the GitHub and R2 mirror URLs when it triggers the shared release fetch", async () => { await env.DOWNLOAD_BUCKET.put( "releases/0.6.0/FOSSBilling-0.6.0.zip", "mirrored archive contents", @@ -292,9 +298,12 @@ describe("Stats API v1", () => { const releases = JSON.parse(cached!); expect(releases["0.6.0"].download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + expect(releases["0.6.0"].mirror_download_url).toBe( "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" ); - expect(releases["0.6.0"].digest).toBe( + expect(releases["0.6.0"].mirror_digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" ); }); diff --git a/test/services/versions/v1/errors.test.ts b/test/services/versions/v1/errors.test.ts index f09278e..da8ecd2 100644 --- a/test/services/versions/v1/errors.test.ts +++ b/test/services/versions/v1/errors.test.ts @@ -249,7 +249,12 @@ describe("Versions API v1 - Error Handling", () => { size_bytes: 1000, is_prerelease: false, github_release_id: 1, - changelog: "Release notes" + changelog: "Release notes", + // Present (as null) so this entry isn't treated as predating the + // mirror fields entirely - see the "R2 release mirror" describe + // block in index.test.ts for that invalidation behavior. + mirror_download_url: null, + mirror_digest: null } }) ); diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 98a34f1..712173b 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -221,8 +221,13 @@ describe("Versions API v1", () => { describe("R2 release mirror", () => { // github.com has no AAAA record, so IPv6-only hosts must download - // from the R2 mirror instead - see FOSSBilling/FOSSBilling#2479. - it("prefers the R2 mirror's download_url and digest when mirrored", async () => { + // from the R2 mirror instead - see FOSSBilling/FOSSBilling#2479. But + // only clients whose own version trusts download.fossbilling.org + // (Update::$allowedDownloadPrefixes, added alongside the mirror itself) + // should ever be sent that URL - anything older rejects it outright + // with "Update canceled for security reasons" (the incident this + // describe block guards against). + async function mirrorRelease060() { await env.DOWNLOAD_BUCKET.put( "releases/0.6.0/FOSSBilling-0.6.0.zip", "mirrored archive contents", @@ -234,12 +239,22 @@ describe("Versions API v1", () => { } } ); + } + + it("prefers the R2 mirror's download_url and digest for a client that trusts it", async () => { + await mirrorRelease060(); const ctx = createExecutionContext(); - const response = await app.request("/versions/v1/latest", {}, env, ctx); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(response.status).toBe(200); + expect(response.headers.get("Vary")).toContain("User-Agent"); const data: ApiResponse = await response.json(); if (!data.result) { throw new Error("Expected latest release data"); @@ -254,7 +269,12 @@ describe("Versions API v1", () => { it("falls back to the GitHub asset when a release hasn't been mirrored to R2", async () => { const ctx = createExecutionContext(); - const response = await app.request("/versions/v1/latest", {}, env, ctx); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); await waitOnExecutionContext(ctx); expect(response.status).toBe(200); @@ -269,6 +289,363 @@ describe("Versions API v1", () => { "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" ); }); + + it("falls back to the GitHub asset for a client older than the mirror-trust cutoff, even when mirrored", async () => { + await mirrorRelease060(); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.6" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + expect(data.result.digest).toBe( + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); + }); + + it("falls back to the GitHub asset for a client sending no User-Agent, even when mirrored", async () => { + // Versions <=0.8.3 predate FOSSBilling's own User-Agent header entirely + // (added in 0.8.4) and send none of their own. + await mirrorRelease060(); + + const ctx = createExecutionContext(); + const response = await app.request("/versions/v1/latest", {}, env, ctx); + await waitOnExecutionContext(ctx); + + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + }); + + it("falls back to the GitHub asset for an unparseable User-Agent, even when mirrored", async () => { + await mirrorRelease060(); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "curl/8.0.0" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + }); + + it("never exposes the internal mirror_download_url/mirror_digest fields in the response", async () => { + await mirrorRelease060(); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + const data: ApiResponse = await response.json(); + expect(data.result).not.toHaveProperty("mirror_download_url"); + expect(data.result).not.toHaveProperty("mirror_digest"); + }); + + // These are the actual point of the fix: the release cache is shared + // across every requester, and each response must still be resolved + // against *that request's own* User-Agent - not whichever client + // happened to trigger the fetch that populated the cache. Every test + // above only ever makes one request, so a version that wrongly baked + // the URL choice into the stored cache (using the first requester's + // trust) would still pass them all. + it("serves the R2 mirror to a trusting client even when an older client warmed the shared cache", async () => { + await mirrorRelease060(); + + const ctx1 = createExecutionContext(); + const warmingResponse = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.6" } }, + env, + ctx1 + ); + await waitOnExecutionContext(ctx1); + const warmingData: ApiResponse = + await warmingResponse.json(); + if (!warmingData.result) { + throw new Error("Expected latest release data"); + } + expect(warmingData.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + + const ctx2 = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx2 + ); + await waitOnExecutionContext(ctx2); + + // Still just the one GitHub fetch from warming the cache above - + // this request was served from that shared cache, not a fresh fetch. + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + ); + expect(data.result.digest).toBe( + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" + ); + }); + + it("falls back to the GitHub asset for an older client even when a trusting client warmed the shared cache", async () => { + await mirrorRelease060(); + + const ctx1 = createExecutionContext(); + const warmingResponse = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx1 + ); + await waitOnExecutionContext(ctx1); + const warmingData: ApiResponse = + await warmingResponse.json(); + if (!warmingData.result) { + throw new Error("Expected latest release data"); + } + expect(warmingData.result.download_url).toBe( + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + ); + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + + const ctx2 = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.6" } }, + env, + ctx2 + ); + await waitOnExecutionContext(ctx2); + + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + expect(data.result.digest).toBe( + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); + }); + + // Regression test: a cache entry missing both mirror_* keys entirely - + // whether its download_url happens to be the GitHub URL (this case) or + // the R2 mirror URL (next case) - predates those fields and can't be + // trusted as-is. parseCachedReleases() must invalidate it and force a + // fresh fetch, not serve it (backfilling the missing keys to `null` + // isn't enough - see the next test for why). + it("invalidates a legacy-shaped cache entry and rebuilds it via a fresh fetch", async () => { + const legacyCachedReleases = { + "0.6.0": { + version: "0.6.0", + released_on: "2023-04-01T00:00:00Z", + minimum_php_version: "8.1", + download_url: + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip", + size_bytes: 15485760, + is_prerelease: false, + github_release_id: 987654321, + changelog: "## 0.6.0", + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + }; + await env.CACHE_KV.put( + "gh-fossbilling-releases", + JSON.stringify(legacyCachedReleases) + ); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + // A live fetch happened rather than the legacy entry being served + // (backfilled or otherwise) straight from cache. + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + expect(data.result).not.toHaveProperty("mirror_download_url"); + expect(data.result).not.toHaveProperty("mirror_digest"); + }); + + // The dangerous case: between #207 (R2 preferred unconditionally, no + // mirror_* fields yet) and this gating fix shipping, a mirrored + // release's cached download_url was the R2 mirror URL itself, with no + // separate field preserving the GitHub URL. Backfilling the missing + // mirror_* keys to `null` can't repair that - there's nothing to fall + // back to in the entry itself - and would leave every client, old and + // new, reading download_url straight off this poisoned entry. Given the + // 24h cache TTL and that #207 shipped over 24h before this fix, this is + // what's actually sitting in the production cache right now. + it("invalidates a legacy cache entry poisoned with the R2 URL as download_url, for clients of any trust", async () => { + await mirrorRelease060(); + const poisonedCachedReleases = { + "0.6.0": { + version: "0.6.0", + released_on: "2023-04-01T00:00:00Z", + minimum_php_version: "8.1", + download_url: + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip", + size_bytes: 15485760, + is_prerelease: false, + github_release_id: 987654321, + changelog: "## 0.6.0", + digest: + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" + } + }; + await env.CACHE_KV.put( + "gh-fossbilling-releases", + JSON.stringify(poisonedCachedReleases) + ); + + const oldClientCtx = createExecutionContext(); + const oldClientResponse = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.6" } }, + env, + oldClientCtx + ); + await waitOnExecutionContext(oldClientCtx); + + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + const oldClientData: ApiResponse = + await oldClientResponse.json(); + if (!oldClientData.result) { + throw new Error("Expected latest release data"); + } + expect(oldClientData.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + + // The rebuilt cache is now correctly shaped, so a trusting client + // right after gets the mirror - from that same (now healthy) cache, + // with no second live fetch. + const newClientCtx = createExecutionContext(); + const newClientResponse = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + newClientCtx + ); + await waitOnExecutionContext(newClientCtx); + + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + const newClientData: ApiResponse = + await newClientResponse.json(); + if (!newClientData.result) { + throw new Error("Expected latest release data"); + } + expect(newClientData.result.download_url).toBe( + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + ); + expect(newClientData.result.digest).toBe( + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" + ); + }); + + // A one-sided pair (one mirror field present, the other missing) is just + // as unsafe as both being missing: resolveReleaseForClient() would pair + // a real mirror_download_url with an undefined mirror_digest for a + // trusting client, and JSON drops that undefined key from the response + // entirely - incomplete update metadata (missing digest here; a present + // mirror_digest with a missing mirror_download_url loses download_url + // instead, the same failure mode as the fully-legacy case above). + it("invalidates a cache entry with only one of the two mirror fields present", async () => { + await mirrorRelease060(); + const partialCachedReleases = { + "0.6.0": { + version: "0.6.0", + released_on: "2023-04-01T00:00:00Z", + minimum_php_version: "8.1", + download_url: + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip", + size_bytes: 15485760, + is_prerelease: false, + github_release_id: 987654321, + changelog: "## 0.6.0", + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + mirror_download_url: + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + // mirror_digest is missing entirely + } + }; + await env.CACHE_KV.put( + "gh-fossbilling-releases", + JSON.stringify(partialCachedReleases) + ); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/latest", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + // A live fetch happened rather than the malformed entry being served + // straight from cache. + expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + ); + expect(data.result.digest).toBe( + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" + ); + }); }); describe("GET /:version", () => {