import base64 import hashlib import os from pathlib import Path import pytest import syncvito.updater as updater from syncvito.updater import GiteaUpdateService, UpdateFormatError, UpdateInfo, parse_version class FakeResponse: def __init__(self, payload: bytes, url: str = "https://gitea.example/asset") -> None: self.payload = payload self.offset = 0 self.headers = {"Content-Length": str(len(payload))} self.url = url def __enter__(self) -> "FakeResponse": return self def __exit__(self, *_args: object) -> bool: return False def read(self, size: int = -1) -> bytes: if size < 0: size = len(self.payload) - self.offset chunk = self.payload[self.offset : self.offset + size] self.offset += len(chunk) return chunk def geturl(self) -> str: return self.url def _release(version: str, *, prerelease: bool = False) -> dict[str, object]: installer = f"SyncVitoSetup-{version}.exe" return { "tag_name": f"v{version}", "name": f"SyncVito {version}", "body": "Änderungen", "html_url": f"https://gitea.example/releases/v{version}", "draft": False, "prerelease": prerelease, "assets": [ { "name": installer, "browser_download_url": f"https://gitea.example/download/{installer}", "size": 1234, }, { "name": f"{installer}.sha256", "browser_download_url": f"https://gitea.example/download/{installer}.sha256", "size": 96, }, ], } def _info(version: str = "1.1.0") -> UpdateInfo: installer = f"SyncVitoSetup-{version}.exe" return UpdateInfo( version=version, title=f"SyncVito {version}", notes="Änderungen", release_url=f"https://gitea.example/releases/v{version}", installer_name=installer, download_url=f"https://gitea.example/download/{installer}", checksum_url=f"https://gitea.example/download/{installer}.sha256", size=0, ) def test_parse_version_accepts_stable_tags() -> None: assert parse_version("v1.2.3") == (1, 2, 3) assert parse_version("10.20.30") == (10, 20, 30) def test_parse_version_rejects_prerelease() -> None: with pytest.raises(ValueError): parse_version("1.2.3-beta") def test_check_reads_latest_installer_release(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path) monkeypatch.setattr(service, "_read_json", lambda _url: _release("1.3.0")) update = service.check() assert update is not None assert update.version == "1.3.0" assert update.installer_name == "SyncVitoSetup-1.3.0.exe" def test_check_returns_none_for_current_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: service = GiteaUpdateService("1.3.0", base_url="https://gitea.example", update_directory=tmp_path) monkeypatch.setattr(service, "_read_json", lambda _url: _release("1.3.0")) assert service.check() is None def test_release_requires_matching_installer(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path) release = _release("1.1.0") release["assets"] = [] monkeypatch.setattr(service, "_read_json", lambda _url: release) with pytest.raises(UpdateFormatError, match="SyncVitoSetup-1.1.0.exe"): service.check() def test_download_verifies_checksum(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path) payload = b"MZ-syncvito-installer" digest = hashlib.sha256(payload).hexdigest() responses = iter( [ FakeResponse(f"{digest} *SyncVitoSetup-1.1.0.exe\n".encode()), FakeResponse(payload), ] ) monkeypatch.setattr(service, "_request", lambda *_args, **_kwargs: next(responses)) progress: list[tuple[int, str]] = [] result = service.download(_info(), lambda value, text: progress.append((value, text))) assert result.read_bytes() == payload assert progress[-1][0] == 100 def test_cleanup_removes_only_managed_installers(tmp_path: Path) -> None: service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path) managed = [tmp_path / "SyncVitoSetup-1.0.1.exe", tmp_path / "SyncVitoSetup-1.0.2.exe.part"] preserved = [tmp_path / "anderes-setup.exe", tmp_path / "SyncVitoSetup-neu.exe"] for path in managed + preserved: path.write_bytes(b"test") assert service.cleanup_installers() == (2, 0) assert all(not path.exists() for path in managed) assert all(path.exists() for path in preserved) def test_update_watcher_waits_before_installer(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: installer = tmp_path / "SyncVitoSetup-1.1.0.exe" bootstrap_log = tmp_path / "bootstrap.log" captured: dict[str, object] = {} def capture(executable: str, arguments: list[str]) -> None: captured["executable"] = executable captured["arguments"] = arguments monkeypatch.setattr(updater, "_start_detached", capture) monkeypatch.setattr(updater.os, "name", "nt") powershell = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe" monkeypatch.setattr(Path, "is_file", lambda self: self == powershell) updater._start_installer_after_exit(installer, ["/VERYSILENT"], [222, 111], bootstrap_log) arguments = captured["arguments"] assert isinstance(arguments, list) encoded = arguments[arguments.index("-EncodedCommand") + 1] script = base64.b64decode(encoded).decode("utf-16-le") assert "foreach ($processId in @(111, 222))" in script assert script.index("while (Get-Process") < script.index(str(installer)) assert "'/VERYSILENT'" in script