diff --git a/js/src/download.ts b/js/src/download.ts index ef41f6f..40546e8 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -227,7 +227,8 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi await verifyChecksum(filePath, expected); } -async function fetchChecksums(version?: string): Promise | null> { +/** @internal Exported for testing only. */ +export async function fetchChecksums(version?: string): Promise | null> { const v = version || getChromiumVersion(); const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL; diff --git a/js/tests/update.test.ts b/js/tests/update.test.ts index b0b589a..3cdec95 100644 --- a/js/tests/update.test.ts +++ b/js/tests/update.test.ts @@ -14,6 +14,7 @@ import { checkWrapperUpdate, clearCache, ensureBinary, + fetchChecksums, getLatestChromiumVersion, parseChecksums, resetWrapperUpdateChecked, @@ -269,6 +270,54 @@ describe("parseChecksums", () => { }); }); +describe("download fallback", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.CLOAKBROWSER_DOWNLOAD_URL; + }); + + it("checksum fetch falls back to GitHub on primary 429", async () => { + const HASH = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const checksumText = `${HASH} cloakbrowser-${getPlatformTag()}.tar.gz`; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (url.includes("cloakbrowser.dev")) { + return { + ok: false, + status: 429, + statusText: "Too Many Requests", + } as Response; + } + // GitHub fallback + return { ok: true, text: async () => checksumText } as Response; + }); + + const result = await fetchChecksums(); + expect(result).not.toBeNull(); + expect( + result!.has(`cloakbrowser-${getPlatformTag()}.tar.gz`) + ).toBe(true); + }); + + it("checksum fetch returns null when both sources fail", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 429, + statusText: "Too Many Requests", + } as Response); + + const result = await fetchChecksums(); + expect(result).toBeNull(); + }); +}); + describe("effective version", () => { it("returns platform version when no marker exists", () => { // Default behavior — no marker file in test environment diff --git a/tests/test_update.py b/tests/test_update.py index 7fed8cb..7ba0861 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -20,6 +20,8 @@ from cloakbrowser.config import ( ) from cloakbrowser.download import ( _check_wrapper_update, + _download_and_extract, + _fetch_checksums, _get_latest_chromium_version, _parse_checksums, _should_check_for_update, @@ -474,3 +476,75 @@ class TestWriteVersionMarker: marker = tmp_path / f"latest_version_{get_platform_tag()}" assert marker.exists() assert marker.read_text() == "999.0.0.0" + + +class TestDownloadFallback: + """Verify primary server (cloakbrowser.dev) → GitHub Releases fallback on HTTP errors.""" + + def test_binary_download_falls_back_on_http_error(self, tmp_path): + """HTTP error from primary triggers GitHub Releases fallback for binary download.""" + with patch.dict(os.environ, { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_DOWNLOAD_URL": "", + "CLOAKBROWSER_SKIP_CHECKSUM": "true", + }): + urls_called = [] + + def mock_download_file(url, dest): + urls_called.append(url) + if "cloakbrowser.dev" in url: + raise Exception("HTTP 429 Too Many Requests") + # GitHub fallback succeeds + dest.write_bytes(b"fake") + + with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \ + patch("cloakbrowser.download._extract_archive"), \ + patch("cloakbrowser.download._show_welcome"): + _download_and_extract() + + assert len(urls_called) == 2 + assert "cloakbrowser.dev" in urls_called[0] + assert "github.com" in urls_called[1] + + def test_binary_download_no_fallback_with_custom_url(self, tmp_path): + """Custom CLOAKBROWSER_DOWNLOAD_URL disables GitHub fallback — error propagates.""" + with patch.dict(os.environ, { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com/releases", + "CLOAKBROWSER_SKIP_CHECKSUM": "true", + }): + with patch("cloakbrowser.download._download_file", side_effect=Exception("503")): + with pytest.raises(Exception, match="503"): + _download_and_extract() + + def test_checksum_fetch_falls_back_on_http_error(self): + """HTTP error from primary checksum URL triggers GitHub fallback.""" + valid_checksums = ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + " cloakbrowser-linux-x64.tar.gz\n" + ) + + def mock_get(url, **kwargs): + resp = MagicMock() + if "cloakbrowser.dev" in url: + resp.raise_for_status.side_effect = Exception("HTTP 429") + return resp + # GitHub URL succeeds + resp.text = valid_checksums + resp.raise_for_status = MagicMock() + return resp + + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}): + with patch("cloakbrowser.download.httpx.get", side_effect=mock_get): + result = _fetch_checksums() + + assert result is not None + assert "cloakbrowser-linux-x64.tar.gz" in result + + def test_checksum_fetch_returns_none_when_both_fail(self): + """Both primary and GitHub checksum URLs fail → returns None (skip verification).""" + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}): + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network error")): + result = _fetch_checksums() + + assert result is None