From 9859836081bcbe83f29465a6d5453c512b4d5fc7 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 1 Sep 2026 11:48:43 +0100 Subject: [PATCH 1/3] Skip the R2 lookup entirely for releases older than mirroring began Every fresh-fetch cycle called getReleaseR2Object() for every release, including ones like 0.1.0 that were never uploaded to R2 and never will be - create-release.yml's R2 upload step only runs once, at release-publish time, and mirroring began at 0.8.0. The resulting HeadObject miss doesn't break anything (getReleaseR2Object already returns null and the code falls back to the GitHub URL correctly), but Cloudflare's own R2 binding instrumentation logs every miss as an error-level span regardless of how our code handles it - so every historical pre-0.8.0 release generates a daily 'r2_head ERROR' log entry (once per cache refresh) forever, plus the wasted R2 Class B operation. Added R2_MIRROR_MIN_VERSION ('0.8.0', confirmed against when mirroring actually began - not guessed from git history) and skip the R2 lookup outright for any release below it, rather than issuing a HeadObject that's guaranteed to miss. Updated the versions/v1 README to note the cutoff. Reworked the R2 mirror test fixtures: the shared mockGitHubReleases fixture tops out at 0.6.0 (below the new cutoff, and relied on as 'latest' by unrelated describe blocks), so the R2-mirror tests in index.test.ts and stats/v1/index.test.ts now inject an eligible 0.8.0 release on top of it rather than exercising the mirror path with an ineligible one. Added a regression test proving the lookup is skipped outright (seeds the R2 key for a pre-cutoff version and confirms it's never served, rather than merely not finding anything). npm run typecheck/lint/test: all clean, 533 tests pass. --- src/services/versions/v1/README.md | 2 +- src/services/versions/v1/index.ts | 31 +++-- test/services/stats/v1/index.test.ts | 45 +++++-- test/services/versions/v1/index.test.ts | 148 ++++++++++++++++++------ 4 files changed, 174 insertions(+), 52 deletions(-) diff --git a/src/services/versions/v1/README.md b/src/services/versions/v1/README.md index 13b61e4..59bfded 100644 --- a/src/services/versions/v1/README.md +++ b/src/services/versions/v1/README.md @@ -142,5 +142,5 @@ When GitHub is unavailable and no cached data exists: - `GITHUB_TOKEN` is required for GitHub API access. - `DOWNLOAD_BUCKET` (R2 binding, shared with `previews/v1`) backs `download_url`/`digest` below. - Releases before 0.5.0 read `src/composer.json`; newer releases use `composer.json`. -- `download_url` is `download.fossbilling.org` when the release has been mirrored to R2 - github.com has no AAAA record, so IPv6-only hosts can't reach a GitHub asset URL (see FOSSBilling/FOSSBilling#2479). It falls back to the GitHub asset URL for releases that predate mirroring. +- `download_url` is `download.fossbilling.org` when the release has been mirrored to R2 and the requesting client's own version trusts that host (see `Update::$allowedDownloadPrefixes` in FOSSBilling/FOSSBilling) - github.com has no AAAA record, so IPv6-only hosts can't reach a GitHub asset URL (see FOSSBilling/FOSSBilling#2479). It falls back to the GitHub asset URL otherwise. Mirroring began at 0.8.0, so releases before that are never looked up in R2 at all. - `digest` is the SHA-256 digest of the release zip (`sha256:`). It's read from the R2 object's metadata when mirrored (set by CI from the exact uploaded file); otherwise it's GitHub's asset digest, or `null` if GitHub hasn't computed one. diff --git a/src/services/versions/v1/index.ts b/src/services/versions/v1/index.ts index 2948dbc..3d195f3 100644 --- a/src/services/versions/v1/index.ts +++ b/src/services/versions/v1/index.ts @@ -138,6 +138,14 @@ const USER_AGENT_VERSION_PATTERN = /^FOSSBilling\/(.+)$/; // incident following #4255/#2479. const MIRROR_TRUST_MIN_VERSION = "0.8.7"; +// First release actually mirrored to R2 - create-release.yml only started +// uploading archives there from 0.8.0 onward, and that's a one-time step +// run at release-publish time, so nothing before it will ever appear in +// the bucket. Not to be confused with MIRROR_TRUST_MIN_VERSION above: this +// is "does a mirror exist to look up", that's "does the requesting client +// trust it". +const R2_MIRROR_MIN_VERSION = "0.8.0"; + function clientTrustsMirror(userAgent: string | undefined | null): boolean { if (!userAgent) return false; @@ -552,15 +560,22 @@ export async function getReleases( ? cachedPhpVersion : (batchPhpVersions.get(tag) ?? ""); + // Releases before R2_MIRROR_MIN_VERSION were never uploaded to R2 + // and never will be, so skip the lookup rather than issuing a + // HeadObject that's guaranteed to miss - Cloudflare's own R2 + // binding instrumentation logs every miss as an error-level span, + // regardless of how we handle the resulting null here. let r2Object = null; - try { - r2Object = await getReleaseR2Object(downloadBucket, tag); - } catch (r2Error) { - logWarn("versions", "Failed to look up release in R2", { - tag, - error: - r2Error instanceof Error ? r2Error.message : String(r2Error) - }); + if (semverGte(tag, R2_MIRROR_MIN_VERSION)) { + try { + r2Object = await getReleaseR2Object(downloadBucket, tag); + } catch (r2Error) { + logWarn("versions", "Failed to look up release in R2", { + tag, + error: + r2Error instanceof Error ? r2Error.message : String(r2Error) + }); + } } const releaseDetails: ReleaseDetails = { diff --git a/test/services/stats/v1/index.test.ts b/test/services/stats/v1/index.test.ts index 2350f98..6fc0117 100644 --- a/test/services/stats/v1/index.test.ts +++ b/test/services/stats/v1/index.test.ts @@ -54,7 +54,7 @@ describe("Stats API v1", () => { restoreConsole = suppressConsole(); await env.CACHE_KV.delete("gh-fossbilling-releases"); await env.CACHE_KV.delete("fossbilling-stats-data"); - await env.DOWNLOAD_BUCKET.delete("releases/0.6.0/FOSSBilling-0.6.0.zip"); + await env.DOWNLOAD_BUCKET.delete("releases/0.8.0/FOSSBilling-0.8.0.zip"); const testUpdateToken = "test-update-token-12345"; await env.AUTH_KV.put("UPDATE_TOKEN", testUpdateToken); @@ -272,14 +272,43 @@ describe("Stats API v1", () => { // 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 () => { + // Mirroring began at 0.8.0 (R2_MIRROR_MIN_VERSION in versions/v1) - + // nothing in the shared mockGitHubReleases fixture (which tops out at + // 0.6.0) is eligible, so inject a release that is. + setupGitHubApiMock( + vi.mocked(ghRequest) as MockGitHubRequest, + vi.mocked(graphql) as unknown as MockGitHubGraphQL, + [ + ...mockGitHubReleases, + { + id: 1010, + tag_name: "0.8.0", + name: "0.8.0", + published_at: "2023-05-01T00:00:00Z", + prerelease: false, + body: "## 0.8.0\n- First mirrored release", + assets: [ + { + name: "FOSSBilling.zip", + browser_download_url: + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", + size: 1040000, + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + ] + } + ], + mockComposerJson + ); await env.DOWNLOAD_BUCKET.put( - "releases/0.6.0/FOSSBilling-0.6.0.zip", + "releases/0.8.0/FOSSBilling-0.8.0.zip", "mirrored archive contents", { customMetadata: { digest: "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000", - version: "0.6.0" + version: "0.8.0" } } ); @@ -297,13 +326,13 @@ describe("Stats API v1", () => { expect(cached).toBeTruthy(); 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.8.0"].download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.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.8.0"].mirror_download_url).toBe( + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); - expect(releases["0.6.0"].mirror_digest).toBe( + expect(releases["0.8.0"].mirror_digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" ); }); diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 712173b..66ea8d2 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -53,6 +53,7 @@ describe("Versions API v1", () => { await env.CACHE_KV.delete("gh-fossbilling-releases"); await env.DOWNLOAD_BUCKET.delete("releases/0.5.0/FOSSBilling-0.5.0.zip"); await env.DOWNLOAD_BUCKET.delete("releases/0.6.0/FOSSBilling-0.6.0.zip"); + await env.DOWNLOAD_BUCKET.delete("releases/0.8.0/FOSSBilling-0.8.0.zip"); resetUpdateTokenCache(); const testUpdateToken = "test-update-token-12345"; @@ -227,22 +228,57 @@ describe("Versions API v1", () => { // 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() { + // + // R2_MIRROR_MIN_VERSION gates the R2 lookup itself at 0.8.0 - mirroring + // began there, so nothing older is ever eligible. The shared + // mockGitHubReleases fixture tops out at 0.6.0 (below that cutoff, and + // relied on as "latest" by describe blocks outside this one), so these + // tests inject an additional 0.8.0 release on top of it rather than + // changing the shared fixture. + const MOCK_MIRRORED_RELEASE = { + id: 1010, + tag_name: "0.8.0", + name: "0.8.0", + published_at: "2023-05-01T00:00:00Z", + prerelease: false, + body: "## 0.8.0\n- First mirrored release", + assets: [ + { + name: "FOSSBilling.zip", + browser_download_url: + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", + size: 1040000, + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + ] + }; + + beforeEach(() => { + setupGitHubApiMock( + vi.mocked(ghRequest) as MockGitHubRequest, + vi.mocked(graphql) as unknown as MockGitHubGraphQL, + [...mockGitHubReleases, MOCK_MIRRORED_RELEASE], + mockComposerJson + ); + }); + + async function mirrorRelease080() { await env.DOWNLOAD_BUCKET.put( - "releases/0.6.0/FOSSBilling-0.6.0.zip", + "releases/0.8.0/FOSSBilling-0.8.0.zip", "mirrored archive contents", { customMetadata: { digest: "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000", - version: "0.6.0" + version: "0.8.0" } } ); } it("prefers the R2 mirror's download_url and digest for a client that trusts it", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -260,7 +296,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(data.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" @@ -282,6 +318,44 @@ describe("Versions API v1", () => { if (!data.result) { throw new Error("Expected latest release data"); } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" + ); + expect(data.result.digest).toBe( + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); + }); + + it("skips the R2 lookup entirely for a release older than mirroring began, even if something exists at that R2 key", async () => { + // Mirroring began at 0.8.0 (R2_MIRROR_MIN_VERSION); nothing older was + // ever uploaded there. Seed the R2 key anyway (e.g. a stray/manual + // object) to prove the version gate skips the lookup outright rather + // than happening to not find anything. + await env.DOWNLOAD_BUCKET.put( + "releases/0.6.0/FOSSBilling-0.6.0.zip", + "should never be looked up", + { + customMetadata: { + digest: + "sha256:00000000000000000000000000000000000000000000000000000000000000", + version: "0.6.0" + } + } + ); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/0.6.0", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected release data"); + } expect(data.result.download_url).toBe( "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" ); @@ -291,7 +365,7 @@ describe("Versions API v1", () => { }); it("falls back to the GitHub asset for a client older than the mirror-trust cutoff, even when mirrored", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -307,7 +381,7 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); expect(data.result.digest).toBe( "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" @@ -317,7 +391,7 @@ describe("Versions API v1", () => { 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(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request("/versions/v1/latest", {}, env, ctx); @@ -328,12 +402,12 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); }); it("falls back to the GitHub asset for an unparseable User-Agent, even when mirrored", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -349,12 +423,12 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); }); it("never exposes the internal mirror_download_url/mirror_digest fields in the response", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -378,7 +452,7 @@ describe("Versions API v1", () => { // 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(); + await mirrorRelease080(); const ctx1 = createExecutionContext(); const warmingResponse = await app.request( @@ -394,7 +468,7 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); @@ -415,7 +489,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(data.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" @@ -423,7 +497,7 @@ describe("Versions API v1", () => { }); it("falls back to the GitHub asset for an older client even when a trusting client warmed the shared cache", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx1 = createExecutionContext(); const warmingResponse = await app.request( @@ -439,7 +513,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); @@ -458,7 +532,7 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); expect(data.result.digest).toBe( "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" @@ -472,6 +546,10 @@ describe("Versions API v1", () => { // 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 () => { + // Deliberately below R2_MIRROR_MIN_VERSION and requested by exact + // version rather than "latest" (now 0.8.0, from the injected release + // above) - this proves invalidation/rebuild works independently of + // R2 mirror eligibility. const legacyCachedReleases = { "0.6.0": { version: "0.6.0", @@ -494,7 +572,7 @@ describe("Versions API v1", () => { const ctx = createExecutionContext(); const response = await app.request( - "/versions/v1/latest", + "/versions/v1/0.6.0", { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, env, ctx @@ -525,18 +603,18 @@ describe("Versions API v1", () => { // 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(); + await mirrorRelease080(); const poisonedCachedReleases = { - "0.6.0": { - version: "0.6.0", - released_on: "2023-04-01T00:00:00Z", + "0.8.0": { + version: "0.8.0", + released_on: "2023-05-01T00:00:00Z", minimum_php_version: "8.1", download_url: - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip", + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip", size_bytes: 15485760, is_prerelease: false, github_release_id: 987654321, - changelog: "## 0.6.0", + changelog: "## 0.8.0", digest: "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" } @@ -562,7 +640,7 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); // The rebuilt cache is now correctly shaped, so a trusting client @@ -584,7 +662,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(newClientData.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" @@ -599,22 +677,22 @@ describe("Versions API v1", () => { // 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(); + await mirrorRelease080(); const partialCachedReleases = { - "0.6.0": { - version: "0.6.0", - released_on: "2023-04-01T00:00:00Z", + "0.8.0": { + version: "0.8.0", + released_on: "2023-05-01T00:00:00Z", minimum_php_version: "8.1", download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip", + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", size_bytes: 15485760, is_prerelease: false, github_release_id: 987654321, - changelog: "## 0.6.0", + changelog: "## 0.8.0", digest: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", mirror_download_url: - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" // mirror_digest is missing entirely } }; @@ -640,7 +718,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(data.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" From aad3e92a2416b5f1468a1f4a212bfdf04c921341 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 1 Sep 2026 11:54:57 +0100 Subject: [PATCH 2/3] Clarify download_url description in README Removed mention of GitHub asset URL's IPv6 issue for clarity. --- src/services/versions/v1/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/versions/v1/README.md b/src/services/versions/v1/README.md index 59bfded..59ef666 100644 --- a/src/services/versions/v1/README.md +++ b/src/services/versions/v1/README.md @@ -142,5 +142,5 @@ When GitHub is unavailable and no cached data exists: - `GITHUB_TOKEN` is required for GitHub API access. - `DOWNLOAD_BUCKET` (R2 binding, shared with `previews/v1`) backs `download_url`/`digest` below. - Releases before 0.5.0 read `src/composer.json`; newer releases use `composer.json`. -- `download_url` is `download.fossbilling.org` when the release has been mirrored to R2 and the requesting client's own version trusts that host (see `Update::$allowedDownloadPrefixes` in FOSSBilling/FOSSBilling) - github.com has no AAAA record, so IPv6-only hosts can't reach a GitHub asset URL (see FOSSBilling/FOSSBilling#2479). It falls back to the GitHub asset URL otherwise. Mirroring began at 0.8.0, so releases before that are never looked up in R2 at all. +- `download_url` is `download.fossbilling.org` when the release has been mirrored to R2 and the requesting client's own version trusts that host (see `Update::$allowedDownloadPrefixes` in FOSSBilling/FOSSBilling) - github.com has no AAAA record, so IPv6-only hosts can't reach a GitHub asset URL. It falls back to the GitHub asset URL otherwise. Mirroring began at 0.8.0, so releases before that are never looked up in R2 at all. - `digest` is the SHA-256 digest of the release zip (`sha256:`). It's read from the R2 object's metadata when mirrored (set by CI from the exact uploaded file); otherwise it's GitHub's asset digest, or `null` if GitHub hasn't computed one. From ebfb7e596a4e00c866be04944326f8c87be8dc28 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 1 Sep 2026 11:59:36 +0100 Subject: [PATCH 3/3] Extract the shared mirrored-release test fixture MOCK_MIRRORED_RELEASE in versions/v1/index.test.ts and the equivalent inline literal in stats/v1/index.test.ts were byte-for-byte identical, duplicated by copy-paste when I added the R2_MIRROR_MIN_VERSION test coverage. Both files already import shared fixtures (mockGitHubReleases, mockComposerJson) from test/mocks/github-releases.ts, so moved this one there too as mockMirroredRelease - avoids the two copies drifting apart. npm run typecheck/lint/test: all clean, 533 tests pass. --- test/mocks/github-releases.ts | 23 ++++++++++++++++++++ test/services/stats/v1/index.test.ts | 27 ++++-------------------- test/services/versions/v1/index.test.ts | 28 +++++-------------------- 3 files changed, 32 insertions(+), 46 deletions(-) diff --git a/test/mocks/github-releases.ts b/test/mocks/github-releases.ts index f50614a..e06c2de 100644 --- a/test/mocks/github-releases.ts +++ b/test/mocks/github-releases.ts @@ -67,6 +67,29 @@ export const mockGitHubReleases = [ } ]; +// A release at/after R2_MIRROR_MIN_VERSION (0.8.0, where mirroring began) - +// mockGitHubReleases above tops out at 0.6.0, below that cutoff, so R2 +// mirror tests in versions/v1 and stats/v1 spread this on top of it rather +// than duplicating the literal in each file. +export const mockMirroredRelease = { + id: 1010, + tag_name: "0.8.0", + name: "0.8.0", + published_at: "2023-05-01T00:00:00Z", + prerelease: false, + body: "## 0.8.0\n- First mirrored release", + assets: [ + { + name: "FOSSBilling.zip", + browser_download_url: + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", + size: 1040000, + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + ] +}; + export const mockComposerJson = { require: { php: "^8.1" diff --git a/test/services/stats/v1/index.test.ts b/test/services/stats/v1/index.test.ts index 6fc0117..2241a83 100644 --- a/test/services/stats/v1/index.test.ts +++ b/test/services/stats/v1/index.test.ts @@ -7,7 +7,8 @@ import { import app from "../../../../src/app"; import { mockGitHubReleases, - mockComposerJson + mockComposerJson, + mockMirroredRelease } from "../../../mocks/github-releases"; import { suppressConsole, @@ -274,31 +275,11 @@ describe("Stats API v1", () => { it("resolves both the GitHub and R2 mirror URLs when it triggers the shared release fetch", async () => { // Mirroring began at 0.8.0 (R2_MIRROR_MIN_VERSION in versions/v1) - // nothing in the shared mockGitHubReleases fixture (which tops out at - // 0.6.0) is eligible, so inject a release that is. + // 0.6.0) is eligible, so inject mockMirroredRelease on top of it. setupGitHubApiMock( vi.mocked(ghRequest) as MockGitHubRequest, vi.mocked(graphql) as unknown as MockGitHubGraphQL, - [ - ...mockGitHubReleases, - { - id: 1010, - tag_name: "0.8.0", - name: "0.8.0", - published_at: "2023-05-01T00:00:00Z", - prerelease: false, - body: "## 0.8.0\n- First mirrored release", - assets: [ - { - name: "FOSSBilling.zip", - browser_download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", - size: 1040000, - digest: - "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - } - ] - } - ], + [...mockGitHubReleases, mockMirroredRelease], mockComposerJson ); await env.DOWNLOAD_BUCKET.put( diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 66ea8d2..13462d3 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -9,7 +9,8 @@ import app from "../../../../src/app"; import { mockGitHubReleases, - mockComposerJson + mockComposerJson, + mockMirroredRelease } from "../../../mocks/github-releases"; import { suppressConsole, @@ -233,32 +234,13 @@ describe("Versions API v1", () => { // began there, so nothing older is ever eligible. The shared // mockGitHubReleases fixture tops out at 0.6.0 (below that cutoff, and // relied on as "latest" by describe blocks outside this one), so these - // tests inject an additional 0.8.0 release on top of it rather than - // changing the shared fixture. - const MOCK_MIRRORED_RELEASE = { - id: 1010, - tag_name: "0.8.0", - name: "0.8.0", - published_at: "2023-05-01T00:00:00Z", - prerelease: false, - body: "## 0.8.0\n- First mirrored release", - assets: [ - { - name: "FOSSBilling.zip", - browser_download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", - size: 1040000, - digest: - "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - } - ] - }; - + // tests inject mockMirroredRelease on top of it rather than changing + // the shared fixture. beforeEach(() => { setupGitHubApiMock( vi.mocked(ghRequest) as MockGitHubRequest, vi.mocked(graphql) as unknown as MockGitHubGraphQL, - [...mockGitHubReleases, MOCK_MIRRORED_RELEASE], + [...mockGitHubReleases, mockMirroredRelease], mockComposerJson ); });