"""Тесты автообновления. Обновление трогает работающий сервис, поэтому проверяется главное: не ставить старое поверх нового, не ставить битый архив и уметь откатиться. """ import hashlib import io import zipfile import pytest from app.updater import ( UpdateError, install_update, is_newer, parse_version, verify_sha256, ) def make_zip(files: dict[str, str]) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as z: for name, text in files.items(): z.writestr(name, text) return buf.getvalue() class TestVersions: def test_parses_plain_version(self): assert parse_version("1.2.3") == (1, 2, 3) def test_parses_with_v_prefix(self): assert parse_version("v1.2.3") == (1, 2, 3) def test_missing_parts_are_zero(self): assert parse_version("2") == (2, 0, 0) def test_garbage_is_lowest(self): assert parse_version("не версия") == (0, 0, 0) @pytest.mark.parametrize("remote,local", [("1.0.1", "1.0.0"), ("1.1.0", "1.0.9"), ("2.0.0", "1.99.99")]) def test_newer_versions_detected(self, remote, local): assert is_newer(remote, local) is True @pytest.mark.parametrize("remote,local", [("1.0.0", "1.0.0"), ("0.9.0", "1.0.0")]) def test_same_or_older_not_installed(self, remote, local): """Откат по времени - тоже ошибка: старое не должно затирать новое.""" assert is_newer(remote, local) is False class TestChecksum: def test_matching_checksum_passes(self): data = b"payload" verify_sha256(data, hashlib.sha256(data).hexdigest()) def test_wrong_checksum_raises(self): with pytest.raises(UpdateError, match="контрольная сумма"): verify_sha256(b"payload", "0" * 64) def test_checksum_is_case_insensitive(self): data = b"payload" verify_sha256(data, hashlib.sha256(data).hexdigest().upper()) class TestInstall: def test_replaces_app_directory(self, tmp_path): base = tmp_path (base / "app").mkdir() (base / "app" / "main.py").write_text("старый", encoding="utf-8") archive = make_zip({"app/main.py": "новый", "app/version.py": '__version__ = "9.9.9"'}) install_update(archive, base, verify=lambda _: True) assert (base / "app" / "main.py").read_text(encoding="utf-8") == "новый" def test_keeps_user_files(self, tmp_path): """Конфиг и словарь замен принадлежат пользователю, их трогать нельзя.""" base = tmp_path (base / "app").mkdir() (base / "config.toml").write_text("мои настройки", encoding="utf-8") (base / "replacements.txt").write_text("мои замены", encoding="utf-8") archive = make_zip({"app/main.py": "новый", "config.toml": "чужие настройки", "replacements.txt": "чужие замены"}) install_update(archive, base, verify=lambda _: True) assert (base / "config.toml").read_text(encoding="utf-8") == "мои настройки" assert (base / "replacements.txt").read_text(encoding="utf-8") == "мои замены" def test_rolls_back_when_new_code_is_broken(self, tmp_path): """Если новая версия не запускается, возвращаем предыдущую.""" base = tmp_path (base / "app").mkdir() (base / "app" / "main.py").write_text("рабочий код", encoding="utf-8") archive = make_zip({"app/main.py": "сломанный код"}) with pytest.raises(UpdateError, match="проверк"): install_update(archive, base, verify=lambda _: False) assert (base / "app" / "main.py").read_text(encoding="utf-8") == "рабочий код" def test_rejects_archive_without_app(self, tmp_path): base = tmp_path (base / "app").mkdir() archive = make_zip({"readme.txt": "мусор"}) with pytest.raises(UpdateError, match="нет папки app"): install_update(archive, base, verify=lambda _: True) def test_rejects_path_traversal(self, tmp_path): """Архив не должен уметь писать за пределы папки сервиса.""" base = tmp_path (base / "app").mkdir() archive = make_zip({"app/../../evil.py": "вредонос"}) with pytest.raises(UpdateError, match="путь"): install_update(archive, base, verify=lambda _: True) def test_rejects_absolute_paths(self, tmp_path): base = tmp_path (base / "app").mkdir() buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as z: z.writestr("/etc/passwd", "вредонос") z.writestr("app/main.py", "код") with pytest.raises(UpdateError, match="путь"): install_update(buf.getvalue(), base, verify=lambda _: True)