Repository: beeware/briefcase
Pull request #1618 (2024-01-25 17:33:20)

Test download URLs for tools
## Changes
- Ensures all download URLs can be reached during tests
  - ref: https://github.com/beeware/briefcase/pull/1611#pullrequestreview-1830629615

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [X] All new features have been tested
- [X] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct


Patch:
diff --git a/changes/1618.misc.rst b/changes/1618.misc.rst
new file mode 100644
index 00000000..5979d0a4
--- /dev/null
+++ b/changes/1618.misc.rst
@@ -0,0 +1,1 @@
+The download URLs for tools are now verified during tests.
diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py
index 6b51ee62..96cc8f91 100644
--- a/src/briefcase/integrations/wix.py
+++ b/src/briefcase/integrations/wix.py
@@ -7,8 +7,6 @@ from pathlib import Path
 from briefcase.exceptions import BriefcaseCommandError, MissingToolError
 from briefcase.integrations.base import ManagedTool, ToolCache
 
-WIX_DOWNLOAD_URL = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"
-
 
 class WiX(ManagedTool):
     name = "wix"
@@ -37,6 +35,10 @@ class WiX(ManagedTool):
             self.wix_home = tools.base_path / "wix"
         self.bin_install = bin_install
 
+    @property
+    def download_url(self) -> str:
+        return "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"
+
     @property
     def heat_exe(self) -> Path:
         if self.bin_install:
@@ -133,7 +135,7 @@ does not point to an install of the WiX Toolset.
     def install(self):
         """Download and install WiX."""
         wix_zip_path = self.tools.download.file(
-            url=WIX_DOWNLOAD_URL,
+            url=self.download_url,
             download_path=self.tools.base_path,
             role="WiX",
         )


Test patch:
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_properties.py b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
index ace5b89b..7c4fdb56 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_properties.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_properties.py
@@ -5,6 +5,7 @@ import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
 
+from ....utils import assert_url_resolvable
 from ..conftest import SDK_MGR_DL_VER, SDK_MGR_VER
 
 
@@ -25,6 +26,7 @@ def test_cmdline_tools_url(mock_tools, android_sdk, host_os, host_arch, name):
     assert android_sdk.cmdline_tools_url == (
         f"https://dl.google.com/android/repository/commandlinetools-{name}-{SDK_MGR_DL_VER}_latest.zip"
     )
+    assert_url_resolvable(android_sdk.cmdline_tools_url)
 
 
 @pytest.mark.parametrize(
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index 12ce6f06..8d06e451 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -1,12 +1,10 @@
 import os
-import platform
 import shutil
 import subprocess
 from pathlib import Path
 from unittest import mock
 
 import pytest
-import requests
 
 from briefcase.exceptions import (
     BriefcaseCommandError,
@@ -17,6 +15,7 @@ from briefcase.exceptions import (
 )
 from briefcase.integrations.java import JDK
 
+from ...utils import assert_url_resolvable
 from .conftest import JDK_BUILD, JDK_RELEASE
 
 CALL_JAVA_HOME = mock.call(["/usr/libexec/java_home"])
@@ -520,15 +519,8 @@ def test_successful_jdk_download(
     )
     # The original archive was deleted
     archive.unlink.assert_called_once_with()
-
-
-def test_jdk_url_exists(mock_tools, tmp_path):
-    """The JDK download URL is resolvable for the platform."""
-    # ensure the system hasn't been mocked
-    mock_tools.host_arch = platform.machine()
-    mock_tools.host_os = platform.system()
-
-    requests.head(JDK(mock_tools, tmp_path).OpenJDK_download_url).raise_for_status()
+    # The download URL for JDK exists
+    assert_url_resolvable(mock_tools.java.OpenJDK_download_url)
 
 
 def test_not_installed(mock_tools, tmp_path):
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py
index 6eba0966..03c597df 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py
@@ -2,6 +2,8 @@ import pytest
 
 from briefcase.integrations.linuxdeploy import LinuxDeployGtkPlugin
 
+from ...utils import assert_url_resolvable
+
 
 @pytest.fixture
 def linuxdeploy_gtk_plugin(mock_tools):
@@ -32,3 +34,4 @@ def test_download_url(linuxdeploy_gtk_plugin):
         "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/"
         "master/linuxdeploy-plugin-gtk.sh"
     )
+    assert_url_resolvable(linuxdeploy_gtk_plugin.download_url)
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py
index 26fc58fc..93fea010 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py
@@ -2,6 +2,8 @@ import pytest
 
 from briefcase.integrations.linuxdeploy import LinuxDeployQtPlugin
 
+from ...utils import assert_url_resolvable
+
 
 @pytest.fixture
 def linuxdeploy_qt_plugin(mock_tools):
@@ -60,3 +62,4 @@ def test_download_url(mock_tools, host_os, host_arch, linuxdeploy_arch):
         "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/"
         f"releases/download/continuous/linuxdeploy-plugin-qt-{linuxdeploy_arch}.AppImage"
     )
+    assert_url_resolvable(linuxdeploy_qt_plugin.download_url)
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
index 6560c029..01a23ca4 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
@@ -7,6 +7,8 @@ from briefcase.integrations.linuxdeploy import (
     LinuxDeployQtPlugin,
 )
 
+from ...utils import assert_url_resolvable
+
 
 def test_managed_install(linuxdeploy):
     """All linuxdeploy installs are managed."""
@@ -72,6 +74,7 @@ def test_download_url(mock_tools, host_os, host_arch, linuxdeploy_arch):
         "https://github.com/linuxdeploy/linuxdeploy/"
         f"releases/download/continuous/linuxdeploy-{linuxdeploy_arch}.AppImage"
     )
+    assert_url_resolvable(linuxdeploy.download_url)
 
 
 def test_plugins(linuxdeploy):
diff --git a/tests/integrations/rcedit/test_RCEdit__properties.py b/tests/integrations/rcedit/test_RCEdit__properties.py
index 4b30ffa2..88714436 100644
--- a/tests/integrations/rcedit/test_RCEdit__properties.py
+++ b/tests/integrations/rcedit/test_RCEdit__properties.py
@@ -1,3 +1,15 @@
+from ...utils import assert_url_resolvable
+
+
 def test_managed_install(mock_tools, rcedit):
     """All rcedit installs are managed."""
     assert rcedit.managed_install
+
+
+def test_rcedit_url(mock_tools, rcedit):
+    """The URL for RCEdit is correct."""
+    assert (
+        rcedit.download_url
+        == "https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe"
+    )
+    assert_url_resolvable(rcedit.download_url)
diff --git a/tests/integrations/wix/conftest.py b/tests/integrations/wix/conftest.py
index 6c74c2e7..f9503681 100644
--- a/tests/integrations/wix/conftest.py
+++ b/tests/integrations/wix/conftest.py
@@ -7,6 +7,8 @@ from briefcase.integrations.base import ToolCache
 from briefcase.integrations.download import Download
 from briefcase.integrations.subprocess import Subprocess
 
+WIX_DOWNLOAD_URL = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"
+
 
 @pytest.fixture
 def mock_tools(tmp_path, mock_tools) -> ToolCache:
diff --git a/tests/integrations/wix/test_WiX__upgrade.py b/tests/integrations/wix/test_WiX__upgrade.py
index a5d022f6..70efb24a 100644
--- a/tests/integrations/wix/test_WiX__upgrade.py
+++ b/tests/integrations/wix/test_WiX__upgrade.py
@@ -9,7 +9,9 @@ from briefcase.exceptions import (
     NetworkFailure,
     NonManagedToolError,
 )
-from briefcase.integrations.wix import WIX_DOWNLOAD_URL, WiX
+from briefcase.integrations.wix import WiX
+
+from .conftest import WIX_DOWNLOAD_URL
 
 
 def test_non_managed_install(mock_tools, tmp_path, capsys):
diff --git a/tests/integrations/wix/test_WiX__verify.py b/tests/integrations/wix/test_WiX__verify.py
index d15f14c0..7609fe2e 100644
--- a/tests/integrations/wix/test_WiX__verify.py
+++ b/tests/integrations/wix/test_WiX__verify.py
@@ -9,7 +9,10 @@ from briefcase.exceptions import (
     NetworkFailure,
     UnsupportedHostError,
 )
-from briefcase.integrations.wix import WIX_DOWNLOAD_URL, WiX
+from briefcase.integrations.wix import WiX
+
+from ...utils import assert_url_resolvable
+from .conftest import WIX_DOWNLOAD_URL
 
 
 def test_short_circuit(mock_tools):
@@ -135,6 +138,9 @@ def test_download_wix(mock_tools, tmp_path):
     assert wix.light_exe == tmp_path / "tools/wix/light.exe"
     assert wix.candle_exe == tmp_path / "tools/wix/candle.exe"
 
+    # The WiX URL is resolvable
+    assert_url_resolvable(wix.download_url)
+
 
 def test_dont_install(mock_tools, tmp_path):
     """If there's no existing managed WiX install, an install is not requested, verify
diff --git a/tests/utils.py b/tests/utils.py
index 4999d9ab..a1096ffa 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -8,7 +8,10 @@ import zipfile
 from email.message import EmailMessage
 from pathlib import Path
 
+import requests
+from requests.adapters import HTTPAdapter
 from rich.markup import escape
+from urllib3.util.retry import Retry
 
 from briefcase.console import Console, InputDisabled
 
@@ -298,3 +301,13 @@ def file_content(path: Path) -> str | None:
         return None
     with path.open(encoding="utf-8") as f:
         return f.read()
+
+
+def assert_url_resolvable(url: str):
+    """Tests whether a URL is resolvable with retries; raises for failure."""
+    with requests.session() as sess:
+        adapter = HTTPAdapter(max_retries=Retry(status_forcelist={500, 502, 504}))
+        sess.mount("http://", adapter)
+        sess.mount("https://", adapter)
+
+        sess.head(url).raise_for_status()