1010 lines
38 KiB
Python
1010 lines
38 KiB
Python
"""Unit tests for sideload module — no network, ADB, or apkanalyzer required."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
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
|
|
|
|
|
|
# ── 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 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/")
|
|
|
|
|
|
# ── 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": "/repo/org.example.test_12345.apk",
|
|
"sha256": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
"size": 1234567,
|
|
}
|
|
},
|
|
"12344": {
|
|
"file": {
|
|
"name": "/repo/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_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,
|
|
)
|
|
result = source.download_apk(release, cache)
|
|
assert result == cache.cached_path(app_entry.app_id, sha)
|
|
|
|
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.patch("urllib.request.urlopen", return_value=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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
result = source.download_apk(release, cache)
|
|
assert result.is_file()
|
|
assert result.read_bytes() == apk_data
|
|
|
|
|
|
# ── 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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
result = source.download_apk(release, cache)
|
|
assert result.is_file()
|
|
assert result.read_bytes() == apk_data
|
|
|
|
|
|
# ── ApkValidator tests ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestApkValidator:
|
|
def test_validate_success(self, apk_file: Path):
|
|
validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer")
|
|
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(apkanalyzer_bin="fake-apkanalyzer")
|
|
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_apkanalyzer_fails(self, apk_file: Path):
|
|
validator = sideload.ApkValidator(apkanalyzer_bin="fake-apkanalyzer")
|
|
with mock.patch(
|
|
"sideload._run",
|
|
return_value=subprocess.CompletedProcess(
|
|
args=[], returncode=1, stdout="", stderr="command not found"
|
|
),
|
|
):
|
|
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_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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
with mock.patch("sideload._run", side_effect=mock_run):
|
|
result = sideload.sideload(
|
|
app_entry, config, dry_run=True,
|
|
validator=sideload.ApkValidator(apkanalyzer_bin="fake"),
|
|
)
|
|
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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
with mock.patch("sideload._run", side_effect=mock_run):
|
|
result = sideload.sideload(
|
|
app_entry, config, dry_run=True,
|
|
validator=sideload.ApkValidator(apkanalyzer_bin="fake"),
|
|
)
|
|
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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
with mock.patch("sideload._run", side_effect=mock_run):
|
|
result = sideload.sideload(
|
|
app_entry, config,
|
|
validator=sideload.ApkValidator(apkanalyzer_bin="fake"),
|
|
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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
with mock.patch("sideload._run", side_effect=mock_run):
|
|
result = sideload.sideload(
|
|
app_entry, config,
|
|
device_serial="NONEXISTENT",
|
|
validator=sideload.ApkValidator(apkanalyzer_bin="fake"),
|
|
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.patch("urllib.request.urlopen", return_value=mock_resp):
|
|
with mock.patch("sideload._run", side_effect=mock_run):
|
|
result = sideload.sideload(
|
|
app_entry, config,
|
|
device_serial="ABCD1234",
|
|
validator=sideload.ApkValidator(apkanalyzer_bin="fake"),
|
|
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.patch("urllib.request.urlopen", return_value=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.patch("urllib.request.urlopen", return_value=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_cleans_up_on_error(self, tmp_path: Path):
|
|
dest = tmp_path / "file.apk"
|
|
with mock.patch("urllib.request.urlopen", side_effect=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
|