"""Unit tests for sideload module — no network, ADB, or Android SDK required."""
from __future__ import annotations
import hashlib
from http.client import HTTPMessage
import io
import json
import subprocess
import sys
import urllib.error
import urllib.request
from contextlib import contextmanager
from pathlib import Path
from unittest import mock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
import sideload # noqa: E402
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_apk_bytes(content: bytes = b"fake-apk-content") -> bytes:
return content
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _write_config(path: Path, content: str) -> Path:
path.write_text(content)
return path
def _mock_urlopen_response(data: bytes):
"""Return a MagicMock that acts as a urlopen context manager returning *data*."""
m = mock.MagicMock()
m.__enter__ = mock.MagicMock(return_value=m)
m.__exit__ = mock.MagicMock(return_value=False)
m.read.side_effect = [data, b""]
return m
def _generic_response(data: str | bytes, url: str):
response = _mock_urlopen_response(data.encode() if isinstance(data, str) else data)
response.geturl.return_value = url
return response
@contextmanager
def _mock_download_opener(response=None, *, error=None):
with mock.patch("sideload._generic_opener") as opener_factory:
if error is not None:
opener_factory.return_value.open.side_effect = error
else:
opener_factory.return_value.open.return_value = response
yield
# ── fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def tmp_config(tmp_path: Path) -> Path:
return _write_config(
tmp_path / "config.toml",
"""\
[cache]
directory = "/tmp/test-cache"
[[apps]]
name = "TestApp"
source = "fdroid"
app_id = "org.example.test"
repo_url = "https://f-droid.org/repo"
""",
)
@pytest.fixture
def tmp_config_multi(tmp_path: Path) -> Path:
return _write_config(
tmp_path / "config.toml",
"""\
[[apps]]
name = "Fennec"
source = "fdroid"
app_id = "org.mozilla.fennec_fdroid"
repo_url = "https://f-droid.org/repo"
[[apps]]
name = "NewPipe"
source = "github"
app_id = "org.schabi.newpipe"
owner = "TeamNewPipe"
repo = "NewPipe"
[[apps]]
name = "Aurora"
source = "gitlab"
app_id = "com.aurora.store"
project_url = "https://gitlab.com/AuroraOSS/AuroraStore"
""",
)
@pytest.fixture
def app_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="TestApp",
source="fdroid",
app_id="org.example.test",
config={"repo_url": "https://f-droid.org/repo"},
)
@pytest.fixture
def github_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="NewPipe",
source="github",
app_id="org.schabi.newpipe",
config={"owner": "TeamNewPipe", "repo": "NewPipe"},
)
@pytest.fixture
def gitlab_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="Aurora",
source="gitlab",
app_id="com.aurora.store",
config={"project_url": "https://gitlab.com/AuroraOSS/AuroraStore"},
)
@pytest.fixture
def codeberg_entry() -> sideload.AppEntry:
return sideload.AppEntry(
name="CoMaps",
source="codeberg",
app_id="app.comaps",
config={
"url": "https://codeberg.org/comaps/comaps",
"asset_pattern": r"^CoMaps-.*-main-release\.apk$",
},
)
@pytest.fixture
def config(tmp_config: Path) -> sideload.Config:
return sideload.Config.from_file(tmp_config)
@pytest.fixture
def cache(tmp_path: Path) -> sideload.DownloadCache:
return sideload.DownloadCache(tmp_path / "cache")
@pytest.fixture
def apk_bytes() -> bytes:
return _make_apk_bytes()
@pytest.fixture
def apk_file(tmp_path: Path, apk_bytes: bytes) -> Path:
p = tmp_path / "test.apk"
p.write_bytes(apk_bytes)
return p
# ── Config tests ──────────────────────────────────────────────────────────────
class TestConfig:
def test_from_file_basic(self, tmp_config: Path):
cfg = sideload.Config.from_file(tmp_config)
assert len(cfg.apps) == 1
assert cfg.apps[0].name == "TestApp"
assert cfg.apps[0].source == "fdroid"
assert cfg.apps[0].app_id == "org.example.test"
assert cfg.apps[0].config["repo_url"] == "https://f-droid.org/repo"
assert cfg.cache_dir == Path("/tmp/test-cache")
assert cfg.adb_binary == "adb"
def test_from_file_multi(self, tmp_config_multi: Path):
cfg = sideload.Config.from_file(tmp_config_multi)
assert len(cfg.apps) == 3
sources = {a.name: a.source for a in cfg.apps}
assert sources == {"Fennec": "fdroid", "NewPipe": "github", "Aurora": "gitlab"}
def test_from_file_default_cache(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
'[[apps]]\nname="X"\nsource="fdroid"\napp_id="x"\nrepo_url="https://r"',
)
cfg = sideload.Config.from_file(p)
assert cfg.cache_dir == Path("~/.cache/adb-sideload").expanduser().resolve()
def test_from_file_no_apps_raises(self, tmp_path: Path):
p = _write_config(tmp_path / "cfg.toml", "")
with pytest.raises(ValueError, match="no apps configured"):
sideload.Config.from_file(p)
def test_from_file_missing_required_field(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
'[[apps]]\nname="X"\nsource="fdroid"\napp_id="x"',
)
cfg = sideload.Config.from_file(p)
assert cfg.apps[0].config.get("repo_url") is None
def test_from_file_package_and_url_format(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
"""\
[[apps]]
name = "NewPipe"
source = "github"
package = "org.schabi.newpipe"
url = "https://github.com/TeamNewPipe/NewPipe"
asset_pattern = ".*\\\\.apk$"
""",
)
cfg = sideload.Config.from_file(p)
assert cfg.apps[0].app_id == "org.schabi.newpipe"
assert cfg.apps[0].config["url"].startswith("https://github.com/")
def test_generic_requires_https_url_and_asset_pattern(self, tmp_path: Path):
p = _write_config(
tmp_path / "cfg.toml",
'[[apps]]\nname="X"\nsource="generic"\npackage="x"\nurl="http://example.test/"\nasset_pattern=".*\\\\.apk"',
)
with pytest.raises(ValueError, match="absolute HTTPS"):
sideload.Config.from_file(p)
p.write_text(
'[[apps]]\nname="X"\nsource="generic"\npackage="x"\n'
'url="https://example.test/"\nasset_pattern=".*\\\\.apk"\nsha256="not-a-sha"'
)
with pytest.raises(ValueError, match="64 hexadecimal"):
sideload.Config.from_file(p)
# ── DownloadCache tests ───────────────────────────────────────────────────────
class TestDownloadCache:
def test_cached_path(self, cache: sideload.DownloadCache):
p = cache.cached_path("org.example", "abc123")
assert p == cache.root / "org.example" / "abc123.apk"
def test_find_cached_missing(self, cache: sideload.DownloadCache):
assert cache.find_cached("org.example", "abc123") is None
def test_find_cached_present(self, cache: sideload.DownloadCache, apk_file: Path):
sha = sideload._sha256_file(apk_file)
cache.store("org.example", apk_file, sha)
found = cache.find_cached("org.example", sha)
assert found is not None
assert found.is_file()
def test_find_cached_wrong_sha(self, cache: sideload.DownloadCache, apk_file: Path):
cache.store("org.example", apk_file, "abc123")
assert cache.find_cached("org.example", "abc123") is None
def test_store_computes_sha(self, cache: sideload.DownloadCache, apk_file: Path):
dest = cache.store("org.example", apk_file)
assert dest.is_file()
assert dest.read_bytes() == apk_file.read_bytes()
def test_store_idempotent(self, cache: sideload.DownloadCache, apk_file: Path):
sha = sideload._sha256_file(apk_file)
d1 = cache.store("org.example", apk_file, sha)
d2 = cache.store("org.example", apk_file, sha)
assert d1 == d2
# ── FdroidSource tests ────────────────────────────────────────────────────────
class TestFdroidSource:
FDROID_INDEX = {
"packages": {
"org.example.test": {
"12345": {
"file": {
"name": "/org.example.test_12345.apk",
"sha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"size": 1234567,
}
},
"12344": {
"file": {
"name": "/org.example.test_12344.apk",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
}
},
}
}
}
def test_get_latest_release(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.FDROID_INDEX).encode()):
release = source.get_latest_release()
assert release.version == "12345"
assert release.download_url == "https://f-droid.org/repo/org.example.test_12345.apk"
assert release.expected_sha256 == self.FDROID_INDEX["packages"]["org.example.test"]["12345"]["file"]["sha256"]
assert release.file_size == 1234567
def test_get_latest_release_app_not_found(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
index = {"packages": {}}
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
with pytest.raises(ValueError, match="not found in F-Droid index"):
source.get_latest_release()
def test_get_latest_release_no_versions(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
index = {"packages": {"org.example.test": {}}}
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
with pytest.raises(ValueError, match="no versions"):
source.get_latest_release()
def test_get_latest_release_no_file_name(self, app_entry: sideload.AppEntry):
source = sideload.FdroidSource(app_entry)
index = {"packages": {"org.example.test": {"1": {"file": {}}}}}
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
with pytest.raises(ValueError, match="no file name"):
source.get_latest_release()
def test_get_latest_release_index_v2_versions_key(self, app_entry: sideload.AppEntry):
index = {
"packages": {
"org.example.test": {
"metadata": {"name": "Test"},
"versions": {
"12345": {
"file": {
"name": "org.example.test_12345.apk",
"sha256": "a" * 64,
}
}
},
}
}
}
source = sideload.FdroidSource(app_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
release = source.get_latest_release()
assert release.version == "12345"
assert release.download_url.endswith("/repo/org.example.test_12345.apk")
def test_get_latest_release_uses_manifest_version_code(self, app_entry: sideload.AppEntry):
index = {
"packages": {
"org.example.test": {
"versions": {
"sha256-of-version": {
"file": {"name": "/org.example.test_36.apk", "sha256": "a" * 64},
"manifest": {"versionCode": 36, "versionName": "5.1.1"},
},
"older-version": {
"file": {"name": "/org.example.test_34.apk", "sha256": "b" * 64},
"manifest": {"versionCode": 34, "versionName": "5.0.0"},
},
}
}
}
}
source = sideload.FdroidSource(app_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(index).encode()):
release = source.get_latest_release()
assert release.version == "5.1.1"
assert release.download_url.endswith("/org.example.test_36.apk")
def test_missing_repo_url_raises(self):
entry = sideload.AppEntry(name="X", source="fdroid", app_id="x", config={})
with pytest.raises(ValueError, match="repo_url is required"):
sideload.FdroidSource(entry)
def test_download_apk_cached(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache, apk_file: Path):
sha = sideload._sha256_file(apk_file)
cache.store(app_entry.app_id, apk_file, sha)
source = sideload.FdroidSource(app_entry)
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="1",
download_url="https://example.com/x.apk",
expected_sha256=sha,
)
progress = mock.Mock()
result = source.download_apk(release, cache, progress=progress)
assert result == cache.cached_path(app_entry.app_id, sha)
progress.assert_not_called()
def test_download_apk_sha256_mismatch(self, app_entry: sideload.AppEntry, cache: sideload.DownloadCache):
apk_data = b"real-content"
wrong_sha = "0" * 64
source = sideload.FdroidSource(app_entry)
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="1",
download_url="https://example.com/x.apk",
expected_sha256=wrong_sha,
)
mock_resp = _mock_urlopen_response(apk_data)
with _mock_download_opener(mock_resp):
with pytest.raises(ValueError, match="SHA-256 mismatch"):
source.download_apk(release, cache)
# ── GitHubSource tests ────────────────────────────────────────────────────────
class TestGitHubSource:
GITHUB_RELEASE = {
"tag_name": "v0.27.0",
"assets": [
{
"name": "NewPipe_v0.27.0.apk",
"browser_download_url": "https://github.com/TeamNewPipe/NewPipe/releases/download/v0.27.0/NewPipe_v0.27.0.apk",
"size": 12345678,
},
{
"name": "Source code (zip)",
"browser_download_url": "https://github.com/TeamNewPipe/NewPipe/archive/v0.27.0.zip",
},
],
}
def test_get_latest_release(self, github_entry: sideload.AppEntry):
source = sideload.GitHubSource(github_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.GITHUB_RELEASE).encode()):
release = source.get_latest_release()
assert release.version == "0.27.0"
assert "NewPipe_v0.27.0.apk" in release.download_url
assert release.expected_sha256 is None
assert release.file_size == 12345678
def test_get_latest_release_no_apk(self, github_entry: sideload.AppEntry):
source = sideload.GitHubSource(github_entry)
data = {"tag_name": "v1.0", "assets": [{"name": "source.zip"}]}
with mock.patch("sideload._http_get", return_value=json.dumps(data).encode()):
with pytest.raises(ValueError, match="no APK asset"):
source.get_latest_release()
def test_missing_owner_raises(self):
entry = sideload.AppEntry(name="X", source="github", app_id="x", config={"repo": "r"})
with pytest.raises(ValueError, match="owner and repo are required"):
sideload.GitHubSource(entry)
def test_missing_repo_raises(self):
entry = sideload.AppEntry(name="X", source="github", app_id="x", config={"owner": "o"})
with pytest.raises(ValueError, match="owner and repo are required"):
sideload.GitHubSource(entry)
def test_download_apk(self, github_entry: sideload.AppEntry, cache: sideload.DownloadCache):
source = sideload.GitHubSource(github_entry)
release = sideload.ReleaseInfo(
app_entry=github_entry,
version="0.27.0",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
apk_data = b"github-apk-content"
mock_resp = _mock_urlopen_response(apk_data)
with _mock_download_opener(mock_resp):
result = source.download_apk(release, cache)
assert result.is_file()
assert result.read_bytes() == apk_data
# ── CodebergSource tests ──────────────────────────────────────────────────────
class TestCodebergSource:
CODEBERG_RELEASE = {
"tag_name": "v2026.08.0",
"assets": [
{
"name": "CoMaps-2026.08.0-main-release.apk",
"browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-main-release.apk",
"size": 12345678,
},
{
"name": "CoMaps-2026.08.0-debug.apk",
"browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-debug.apk",
},
{
"name": "CoMaps-2026.08.0-test.apk",
"browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-test.apk",
},
{
"name": "CoMaps-2026.08.0-unsigned.apk",
"browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-unsigned.apk",
},
{
"name": "Source code (zip)",
"browser_download_url": "https://codeberg.org/comaps/comaps/archive/v2026.08.0.zip",
},
],
}
def test_get_latest_release(self, codeberg_entry: sideload.AppEntry):
source = sideload.CodebergSource(codeberg_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.CODEBERG_RELEASE).encode()) as http_get:
release = source.get_latest_release()
http_get.assert_called_once_with(
"https://codeberg.org/api/v1/repos/comaps/comaps/releases/latest"
)
assert release.version == "2026.08.0"
assert release.download_url.endswith("CoMaps-2026.08.0-main-release.apk")
assert release.file_size == 12345678
def test_get_latest_release_ambiguous_assets(self, codeberg_entry: sideload.AppEntry):
source = sideload.CodebergSource(codeberg_entry)
release = {
"tag_name": "v1.0",
"assets": [
{"name": "CoMaps-one-main-release.apk", "browser_download_url": "https://example.com/one.apk"},
{"name": "CoMaps-two-main-release.apk", "browser_download_url": "https://example.com/two.apk"},
],
}
with mock.patch("sideload._http_get", return_value=json.dumps(release).encode()):
with pytest.raises(ValueError, match="multiple APK assets match"):
source.get_latest_release()
def test_get_latest_release_no_apk(self, codeberg_entry: sideload.AppEntry):
source = sideload.CodebergSource(codeberg_entry)
release = {"tag_name": "v1.0", "assets": [{"name": "Source code (tar.gz)"}]}
with mock.patch("sideload._http_get", return_value=json.dumps(release).encode()):
with pytest.raises(ValueError, match="no APK asset"):
source.get_latest_release()
@pytest.mark.parametrize(
"url",
[
"http://codeberg.org/comaps/comaps",
"https://example.com/comaps/comaps",
"https://codeberg.org/comaps",
"https://codeberg.org/comaps/comaps/releases",
],
)
def test_invalid_url_raises(self, url: str):
entry = sideload.AppEntry(
name="CoMaps", source="codeberg", app_id="app.comaps", config={"url": url}
)
with pytest.raises(ValueError, match="HTTPS Codeberg repository URL"):
sideload.CodebergSource(entry)
def test_get_latest_release_network_error(self, codeberg_entry: sideload.AppEntry):
source = sideload.CodebergSource(codeberg_entry)
with mock.patch("sideload._http_get", side_effect=RuntimeError("network down")):
with pytest.raises(RuntimeError, match="network down"):
source.get_latest_release()
# ── GitLabSource tests ────────────────────────────────────────────────────────
class TestGitLabSource:
GITLAB_RELEASE = [
{
"tag_name": "v4.4.1",
"name": "Aurora Store 4.4.1",
"assets": {
"links": [
{
"name": "AuroraStore-4.4.1.apk",
"url": "https://gitlab.com/AuroraOSS/AuroraStore/-/releases/v4.4.1/downloads/AuroraStore-4.4.1.apk",
}
]
},
}
]
def test_get_latest_release(self, gitlab_entry: sideload.AppEntry):
source = sideload.GitLabSource(gitlab_entry)
with mock.patch("sideload._http_get", return_value=json.dumps(self.GITLAB_RELEASE).encode()):
release = source.get_latest_release()
assert release.version == "4.4.1"
assert "AuroraStore-4.4.1.apk" in release.download_url
assert release.expected_sha256 is None
def test_get_latest_release_no_releases(self, gitlab_entry: sideload.AppEntry):
source = sideload.GitLabSource(gitlab_entry)
with mock.patch("sideload._http_get", return_value=b"[]"):
with pytest.raises(ValueError, match="no releases found"):
source.get_latest_release()
def test_get_latest_release_no_apk(self, gitlab_entry: sideload.AppEntry):
source = sideload.GitLabSource(gitlab_entry)
data = [{"tag_name": "v1.0", "assets": {"links": [{"name": "source.zip"}]}}]
with mock.patch("sideload._http_get", return_value=json.dumps(data).encode()):
with pytest.raises(ValueError, match="no APK link"):
source.get_latest_release()
def test_missing_project_url_raises(self):
entry = sideload.AppEntry(name="X", source="gitlab", app_id="x", config={})
with pytest.raises(ValueError, match="project_url is required"):
sideload.GitLabSource(entry)
def test_download_apk(self, gitlab_entry: sideload.AppEntry, cache: sideload.DownloadCache):
source = sideload.GitLabSource(gitlab_entry)
release = sideload.ReleaseInfo(
app_entry=gitlab_entry,
version="4.4.1",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
apk_data = b"gitlab-apk-content"
mock_resp = _mock_urlopen_response(apk_data)
with _mock_download_opener(mock_resp):
result = source.download_apk(release, cache)
assert result.is_file()
assert result.read_bytes() == apk_data
# ── ApkValidator tests ────────────────────────────────────────────────────────
class TestGenericSource:
def _entry(self, **config):
return sideload.AppEntry(
name="Firefox", source="generic", app_id="org.mozilla.firefox",
config={
"url": "https://downloads.example/releases/",
"intermediate_patterns": [r"(\d+)\/$", "android/$"],
"asset_pattern": r"^fenix-.*\.apk$",
**config,
},
)
def test_directory_navigation_and_numeric_version_selection(self):
entry = self._entry()
pages = [
_generic_response('110bad', entry.config["url"]),
_generic_response('android', "https://downloads.example/releases/10/"),
_generic_response('apk', "https://downloads.example/releases/10/android/"),
]
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.side_effect = pages
release = sideload.GenericSource(entry).get_latest_release()
assert release.download_url.endswith(".apk?download=1")
assert release.version == "fenix-1.multi.android-arm64-v8a.apk"
def test_ambiguous_intermediate_links(self):
entry = self._entry(intermediate_patterns=[r"release/$"])
page = _generic_response('ab', entry.config["url"])
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.return_value = page
with pytest.raises(ValueError, match="multiple intermediate"):
sideload.GenericSource(entry).get_latest_release()
def test_excludes_debug_and_rejects_http_assets(self):
entry = self._entry(intermediate_patterns=[], asset_pattern=r"^good\.apk$")
page = _generic_response(
'http'
'debuggood',
entry.config["url"],
)
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.return_value = page
release = sideload.GenericSource(entry).get_latest_release()
assert release.download_url.endswith("good.apk")
def test_initial_http_and_network_error(self):
bad = self._entry(url="http://downloads.example/releases/")
with pytest.raises(ValueError, match="absolute HTTPS"):
sideload.GenericSource(bad)
entry = self._entry()
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.side_effect = urllib.error.URLError("offline")
with pytest.raises(RuntimeError, match="network error"):
sideload.GenericSource(entry).get_latest_release()
def test_headers_are_sent_but_not_exposed(self, capsys):
entry = self._entry(intermediate_patterns=[], asset_pattern=r"^good\.apk$", headers={"Authorization": "secret-token"})
response = _generic_response('good', entry.config["url"])
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.return_value = response
sideload.GenericSource(entry).get_latest_release()
request = opener_factory.return_value.open.call_args.args[0]
assert request.get_header("Authorization") == "secret-token"
captured = capsys.readouterr()
assert "secret-token" not in captured.out + captured.err
def test_https_redirect_is_blocked_before_following(self):
request = urllib.request.Request(
"https://downloads.example/start", headers={"Authorization": "secret-token"}
)
with pytest.raises(ValueError, match="non-HTTPS"):
sideload._HttpsOnlyRedirectHandler().redirect_request(
request, mock.Mock(), 302, "Found", {}, "http://evil.example/"
)
def test_cross_host_redirect_is_blocked_and_same_origin_keeps_headers(self):
request = urllib.request.Request(
"https://downloads.example/start", headers={"Authorization": "secret-token"}
)
handler = sideload._HttpsOnlyRedirectHandler(
"https://downloads.example/start", same_origin=True
)
with pytest.raises(ValueError, match="changed origin"):
handler.redirect_request(
request, mock.Mock(), 302, "Found", {}, "https://cdn.example/file"
)
redirected = handler.redirect_request(
request, mock.Mock(), 302, "Found", {}, "https://downloads.example/file"
)
assert redirected.get_header("Authorization") == "secret-token"
def test_github_cdn_redirect_drops_unredirected_host_and_keeps_authorization(self):
request = urllib.request.Request(
"https://github.com/example/app/releases/download/v1/app.apk",
headers={"Authorization": "secret-token"},
)
request.unredirected_hdrs["Host"] = "github.com"
handler = sideload._HttpsOnlyRedirectHandler(same_origin=False)
redirected = handler.redirect_request(
request,
mock.Mock(),
302,
"Found",
{},
"https://objects.githubusercontent.com/app.apk",
)
assert redirected.get_header("Host") is None
assert redirected.get_header("Authorization") == "secret-token"
def test_https_redirect_accepts_http_message_headers(self):
request = urllib.request.Request(
"https://downloads.example/start", headers={"Authorization": "secret-token"}
)
headers = HTTPMessage()
headers.add_header("Location", "https://downloads.example/file.apk")
handler = sideload._HttpsOnlyRedirectHandler(
"https://downloads.example/start", same_origin=True
)
redirected = handler.redirect_request(
request,
mock.Mock(),
302,
"Found",
headers,
"https://downloads.example/file.apk",
)
assert redirected.full_url == "https://downloads.example/file.apk"
assert redirected.get_header("Authorization") == "secret-token"
def test_atomic_download_rejects_downgrade_before_response_body(self):
request = urllib.request.Request("https://downloads.example/file.apk")
handler = sideload._HttpsOnlyRedirectHandler(
"https://downloads.example/file.apk", same_origin=False
)
with pytest.raises(ValueError, match="non-HTTPS"):
handler.redirect_request(
request, mock.Mock(read=mock.Mock(side_effect=AssertionError)),
302, "Found", {}, "http://downloads.example/file.apk"
)
def test_redirected_page_url_is_used_for_relative_links(self):
entry = self._entry(intermediate_patterns=[] , asset_pattern=r"^good\.apk$")
response = _generic_response('good', "https://cdn.example/final/")
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.return_value = response
release = sideload.GenericSource(entry).get_latest_release()
assert release.download_url == "https://cdn.example/final/good.apk"
def test_generic_cache_uses_conditional_request_without_sha(self, tmp_path: Path):
entry = self._entry(intermediate_patterns=[] , asset_pattern=r"^good\.apk$")
source = sideload.GenericSource(entry)
release = sideload.ReleaseInfo(entry, "good.apk", "https://downloads.example/good.apk", None)
first = _generic_response(b"apk-content", release.download_url)
first.headers = {"ETag": "v1"}
first.getcode.return_value = 200
second = _generic_response(b"", release.download_url)
second.headers = {}
second.getcode.return_value = 304
with mock.patch("sideload._generic_opener") as opener_factory:
opener_factory.return_value.open.side_effect = [first, second]
cache = sideload.DownloadCache(tmp_path / "cache")
cached_first = source.download_apk(release, cache)
cached_second = source.download_apk(release, cache)
assert cached_second == cached_first
assert cached_second.read_bytes() == b"apk-content"
second_request = opener_factory.return_value.open.call_args_list[1].args[0]
assert second_request.get_header("If-none-match") == "v1"
def test_generic_cache_rejects_304_without_valid_cache(self, tmp_path: Path):
entry = self._entry(intermediate_patterns=[], asset_pattern=r"^good\.apk$")
release = sideload.ReleaseInfo(entry, "good.apk", "https://downloads.example/good.apk", None)
response = _generic_response(b"", release.download_url)
response.getcode.return_value = 304
with _mock_download_opener(response):
with pytest.raises(ValueError, match="304"):
sideload.GenericSource(entry).download_apk(
release, sideload.DownloadCache(tmp_path / "cache")
)
class TestApkValidator:
def test_validate_success(self, apk_file: Path):
validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="org.example.test\n", stderr=""
),
):
err = validator.validate(apk_file, "org.example.test")
assert err is None
def test_validate_mismatch(self, apk_file: Path):
validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="com.other.app\n", stderr=""
),
):
err = validator.validate(apk_file, "org.example.test")
assert err is not None
assert "mismatch" in err
def test_validate_aapt_fails(self, apk_file: Path):
validator = sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr="invalid APK"
),
):
err = validator.validate(apk_file, "org.example.test")
assert err is not None
assert "failed" in err
# ── AdbManager tests ──────────────────────────────────────────────────────────
class TestAdbManager:
ADB_DEVICES_OUTPUT = (
"List of devices attached\n"
"R5CT1234ABCD device usb:1-1 product:blueline model:Pixel_3 device:blueline transport_id:1\n"
"192.168.1.100:5555 device product:redfin model:Pixel_5 device:redfin transport_id:2\n"
)
def test_list_devices(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=self.ADB_DEVICES_OUTPUT, stderr=""
),
):
devices = adb.list_devices()
assert len(devices) == 2
assert devices[0].serial == "R5CT1234ABCD"
assert devices[0].model == "Pixel_3"
assert devices[0].transport == "usb"
assert devices[1].serial == "192.168.1.100:5555"
assert devices[1].model == "Pixel_5"
assert devices[1].transport == "wifi"
def test_list_devices_empty(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="List of devices attached\n", stderr=""
),
):
devices = adb.list_devices()
assert devices == []
def test_list_devices_skips_offline(self):
adb = sideload.AdbManager(binary="fake-adb")
output = "List of devices attached\nABCD1234 offline\n"
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=output, stderr=""
),
):
devices = adb.list_devices()
assert devices == []
def test_connect_wifi_success(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="connected to 192.168.1.100:5555\n", stderr=""
),
):
err = adb.connect_wifi("192.168.1.100:5555")
assert err is None
def test_connect_wifi_failure(self):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=1, stdout="cannot connect\n", stderr=""
),
):
err = adb.connect_wifi("192.168.1.100:5555")
assert err is not None
assert "failed to connect" in err
def test_install_success(self, apk_file: Path):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="Performing Streamed Install\nSuccess\n", stderr=""
),
):
err = adb.install(apk_file, serial="R5CT1234ABCD")
assert err is None
def test_install_failure(self, apk_file: Path):
adb = sideload.AdbManager(binary="fake-adb")
with mock.patch(
"sideload._run",
return_value=subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="",
stderr="INSTALL_FAILED_INSUFFICIENT_STORAGE",
),
):
err = adb.install(apk_file)
assert err is not None
assert "INSTALL_FAILED" in err
# ── _make_source tests ────────────────────────────────────────────────────────
class TestMakeSource:
def test_fdroid(self, app_entry: sideload.AppEntry):
s = sideload._make_source(app_entry)
assert isinstance(s, sideload.FdroidSource)
def test_github(self, github_entry: sideload.AppEntry):
s = sideload._make_source(github_entry)
assert isinstance(s, sideload.GitHubSource)
def test_gitlab(self, gitlab_entry: sideload.AppEntry):
s = sideload._make_source(gitlab_entry)
assert isinstance(s, sideload.GitLabSource)
def test_codeberg(self, codeberg_entry: sideload.AppEntry):
s = sideload._make_source(codeberg_entry)
assert isinstance(s, sideload.CodebergSource)
def test_generic(self):
entry = sideload.AppEntry(
name="X", source="generic", app_id="x",
config={"url": "https://example.test/", "asset_pattern": r"x\.apk"},
)
assert isinstance(sideload._make_source(entry), sideload.GenericSource)
def test_unknown_source_raises(self):
entry = sideload.AppEntry(name="X", source="unknown", app_id="x", config={})
with pytest.raises(ValueError, match="unknown source type"):
sideload._make_source(entry)
# ── sideload orchestration tests ──────────────────────────────────────────────
class TestSideload:
def test_dry_run_success(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
"""Full dry-run flow: resolve, download, validate, skip install."""
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
def mock_run(*args, **kwargs):
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with _mock_download_opener(mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config, dry_run=True,
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
)
assert result == 0
def test_resolve_error(self, app_entry: sideload.AppEntry, config: sideload.Config):
with mock.patch("sideload._http_get", side_effect=RuntimeError("network down")):
result = sideload.sideload(app_entry, config, dry_run=True)
assert result == 1
def test_validation_fails(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
def mock_run(*args, **kwargs):
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="com.wrong.id\n", stderr=""
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with _mock_download_opener(mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config, dry_run=True,
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
)
assert result == 1
def test_no_devices(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
call_count = 0
def mock_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="List of devices attached\n",
stderr="",
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with _mock_download_opener(mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config,
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
adb_manager=sideload.AdbManager(binary="fake-adb"),
)
assert result == 1
def test_device_not_found_by_serial(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
call_count = 0
def mock_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="List of devices attached\nABCD1234 device model:X\n",
stderr="",
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with _mock_download_opener(mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config,
device_serial="NONEXISTENT",
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
adb_manager=sideload.AdbManager(binary="fake-adb"),
)
assert result == 1
def test_full_install_success(
self, app_entry: sideload.AppEntry, config: sideload.Config, apk_file: Path
):
fdroid_index = {
"packages": {
"org.example.test": {
"1": {
"file": {
"name": "/repo/org.example.test_1.apk",
"sha256": _sha256(apk_file.read_bytes()),
}
}
}
}
}
mock_resp = _mock_urlopen_response(apk_file.read_bytes())
call_count = 0
def mock_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return subprocess.CompletedProcess(
args=list(args), returncode=0, stdout="org.example.test\n", stderr=""
)
if call_count == 2:
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="List of devices attached\nABCD1234 device model:Pixel\n",
stderr="",
)
return subprocess.CompletedProcess(
args=list(args),
returncode=0,
stdout="Performing Streamed Install\nSuccess\n",
stderr="",
)
with mock.patch(
"sideload._http_get", return_value=json.dumps(fdroid_index).encode()
):
with _mock_download_opener(mock_resp):
with mock.patch("sideload._run", side_effect=mock_run):
result = sideload.sideload(
app_entry, config,
device_serial="ABCD1234",
validator=sideload.ApkValidator(aapt2_bin="fake-aapt2", aapt_bin="fake-aapt"),
adb_manager=sideload.AdbManager(binary="fake-adb"),
)
assert result == 0
# ── CLI tests ─────────────────────────────────────────────────────────────────
class TestCLI:
def test_list(self, tmp_config: Path, capsys):
result = sideload.main(["--config", str(tmp_config), "--list"])
assert result == 0
captured = capsys.readouterr()
assert "TestApp" in captured.out
def test_config_not_found(self, capsys):
result = sideload.main(["--config", "/nonexistent/config.toml"])
assert result == 1
captured = capsys.readouterr()
assert "Config not found" in captured.err
def test_app_not_found(self, tmp_config: Path, capsys):
result = sideload.main(["--config", str(tmp_config), "NonExistent"])
assert result == 1
captured = capsys.readouterr()
assert "not found" in captured.err
def test_ambiguous_name(self, tmp_config_multi: Path, capsys):
apk_data = b"fake"
real_sha = _sha256(apk_data)
fdroid_index = {
"packages": {
"org.mozilla.fennec_fdroid": {
"1": {"file": {"name": "/repo/fennec.apk", "sha256": real_sha}}
}
}
}
mock_resp = _mock_urlopen_response(apk_data)
with mock.patch("sideload._http_get", return_value=json.dumps(fdroid_index).encode()):
with _mock_download_opener(mock_resp):
with mock.patch("sideload._run", return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="org.mozilla.fennec_fdroid\n", stderr=""
)):
result = sideload.main(
["--config", str(tmp_config_multi), "fennec", "--dry-run"]
)
assert result == 0
def test_version(self, capsys):
with pytest.raises(SystemExit):
sideload.main(["--version"])
def test_build_parser(self):
parser = sideload.build_parser()
args = parser.parse_args(["--list"])
assert args.list is True
args = parser.parse_args(["MyApp", "--dry-run"])
assert args.app == "MyApp"
assert args.dry_run is True
# ── utility tests ─────────────────────────────────────────────────────────────
class TestUtilities:
def test_sha256_file(self, tmp_path: Path):
p = tmp_path / "test.bin"
p.write_bytes(b"hello world")
expected = hashlib.sha256(b"hello world").hexdigest()
assert sideload._sha256_file(p) == expected
def test_make_apk_stem(self, app_entry: sideload.AppEntry):
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="1.2.3",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
stem = sideload._make_apk_stem(release)
assert stem == "org.example.test_1.2.3"
def test_make_apk_stem_sanitizes_slashes(self, app_entry: sideload.AppEntry):
release = sideload.ReleaseInfo(
app_entry=app_entry,
version="feature/branch",
download_url="https://example.com/x.apk",
expected_sha256=None,
)
stem = sideload._make_apk_stem(release)
assert "/" not in stem
def test_atomic_download(self, tmp_path: Path):
dest = tmp_path / "sub" / "file.apk"
data = b"downloaded-content"
mock_resp = _mock_urlopen_response(data)
with _mock_download_opener(mock_resp):
result = sideload._atomic_download("https://example.com/x.apk", dest)
assert result == dest
assert dest.read_bytes() == data
assert not dest.with_suffix(".apk.tmp").exists()
def test_atomic_download_reports_content_length_progress(self, tmp_path: Path):
data = b"downloaded-content"
response = _mock_urlopen_response(data)
response.headers = {"Content-Length": str(len(data))}
updates = []
with _mock_download_opener(response):
result = sideload._atomic_download(
"https://example.com/x.apk",
tmp_path / "test.apk",
progress=lambda downloaded, total: updates.append((downloaded, total)),
)
assert result.read_bytes() == data
assert updates[0] == (0, len(data))
assert updates[-1] == (len(data), len(data))
def test_atomic_download_reports_indeterminate_progress(self, tmp_path: Path):
data = b"downloaded-content"
response = _mock_urlopen_response(data)
response.headers = {}
updates = []
with _mock_download_opener(response):
result = sideload._atomic_download(
"https://example.com/x.apk",
tmp_path / "test.apk",
expected_size=None,
progress=lambda downloaded, total: updates.append((downloaded, total)),
)
assert result.read_bytes() == data
assert updates[0] == (0, None)
assert updates[-1] == (len(data), None)
def test_atomic_download_uses_expected_size_without_content_length(self, tmp_path: Path):
data = b"downloaded-content"
response = _mock_urlopen_response(data)
response.headers = {}
updates = []
with _mock_download_opener(response):
sideload._atomic_download(
"https://example.com/x.apk",
tmp_path / "test.apk",
expected_size=len(data),
progress=lambda downloaded, total: updates.append((downloaded, total)),
)
assert updates[0] == (0, len(data))
assert updates[-1] == (len(data), len(data))
def test_atomic_download_cleans_up_on_error(self, tmp_path: Path):
dest = tmp_path / "file.apk"
with _mock_download_opener(error=RuntimeError("fail")):
with pytest.raises(RuntimeError):
sideload._atomic_download("https://example.com/x.apk", dest)
assert not dest.exists()
assert not dest.with_suffix(".apk.tmp").exists()
def test_http_get_error(self):
with mock.patch(
"urllib.request.urlopen",
side_effect=urllib.error.HTTPError(
"https://example.com", 404, "Not Found", {}, None
),
):
with pytest.raises(RuntimeError, match="HTTP 404"):
sideload._http_get("https://example.com")
def test_http_get_network_error(self):
with mock.patch(
"urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused"),
):
with pytest.raises(RuntimeError, match="network error"):
sideload._http_get("https://example.com")
def test_run_passes_args_correctly(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["echo", "hello"], returncode=0, stdout="hello\n", stderr=""
)
sideload._run("echo", "hello")
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args == ["echo", "hello"]
# ── AdbDevice dataclass ───────────────────────────────────────────────────────
class TestAdbDevice:
def test_creation(self):
d = sideload.AdbDevice(serial="ABC123", model="Pixel", transport="usb")
assert d.serial == "ABC123"
assert d.model == "Pixel"
assert d.transport == "usb"
# ── ReleaseInfo dataclass ─────────────────────────────────────────────────────
class TestReleaseInfo:
def test_creation(self, app_entry: sideload.AppEntry):
r = sideload.ReleaseInfo(
app_entry=app_entry,
version="1.0",
download_url="https://example.com/x.apk",
expected_sha256="abc",
file_size=1000,
)
assert r.version == "1.0"
assert r.file_size == 1000