plate-config-app/tests/test_update_service.py
Danil Iarosh 35c9693740 fix: stabilize OTA updates in v2.0.2
- Исправлен перезапуск приложения после замены файлов и добавлено подтверждение запуска новой версии.
- Обеспечены откат при ошибке и корректное завершение фоновых задач.
- Исключены посторонние DLL из Windows-сборки, обновлены тесты и инструкция ручного перехода с v2.0.1.
2026-09-17 10:30:18 +03:00

717 lines
27 KiB
Python

from __future__ import annotations
import hashlib
import io
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
from app.services import update_service
from app.services.update_service import (
UpdateCancelled,
UpdateError,
UpdateInfo,
archive_executable_name,
build_download_url,
check_for_update,
download_update,
extract_release_tag,
launch_update,
normalize_version,
parse_version,
platform_asset_name,
platform_executable_name,
signal_update_ready,
)
class FakeResponse:
def __init__(
self,
url: str,
data: bytes = b"",
*,
content_length: int | None = None,
) -> None:
self._url = url
self._stream = io.BytesIO(data)
self.headers: dict[str, str] = {}
if content_length is not None:
self.headers["Content-Length"] = str(content_length)
def __enter__(self):
return self
def __exit__(self, *_args) -> None:
return None
def geturl(self) -> str:
return self._url
def read(self, size: int = -1) -> bytes:
return self._stream.read(size)
def make_update_archive(
executable_name: str,
executable_data: bytes,
*,
extra_files: dict[str, bytes] | None = None,
) -> bytes:
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr(executable_name, executable_data)
for name, data in (extra_files or {}).items():
archive.writestr(name, data)
return stream.getvalue()
class UpdateServiceTests(unittest.TestCase):
def test_normalizes_version_prefix_and_whitespace(self) -> None:
self.assertEqual("2.0.0", normalize_version(" v2.0.0 "))
self.assertEqual("2.0.0", normalize_version("V2.0.0"))
def test_parses_version_into_numeric_components(self) -> None:
self.assertEqual((12, 34, 56), parse_version("v12.34.56"))
self.assertEqual((2, 0, 0), parse_version(" 2.0.0 "))
def test_rejects_non_standard_versions(self) -> None:
invalid_versions = (
"v2.0",
"v2.0.0.1",
"v2.0.0-beta",
"v2.01.0",
"release-2.0.0",
)
for version in invalid_versions:
with self.subTest(version=version), self.assertRaises(UpdateError):
parse_version(version)
def test_extracts_tag_from_latest_redirect(self) -> None:
tag = extract_release_tag(
"https://git.robotail.pro/Robotail/plate-config-app/releases/tag/v2.1.0"
)
self.assertEqual("v2.1.0", tag)
def test_rejects_invalid_release_redirects(self) -> None:
invalid_urls = (
"https://git.robotail.pro/user/login",
"http://git.robotail.pro/Robotail/plate-config-app/releases/tag/v2.1.0",
"https://example.com/Robotail/plate-config-app/releases/tag/v2.1.0",
)
for url in invalid_urls:
with self.subTest(url=url), self.assertRaises(UpdateError):
extract_release_tag(url)
def test_build_download_url_escapes_tag_and_asset(self) -> None:
url = build_download_url("v2.1.0 rc/1", "plate config.zip")
self.assertTrue(url.endswith("/v2.1.0%20rc%2F1/plate%20config.zip"))
def test_equal_or_older_tag_does_not_offer_update(self) -> None:
cases = (
("2.0.0", "v2.0.0"),
("2.0.0", "v1.99.99"),
("2.5.0", "v2.4.99"),
("2.5.4", "v2.5.3"),
)
for current, remote in cases:
with self.subTest(current=current, remote=remote):
result = check_for_update(
current,
opener=self._release_opener(remote),
system="Windows",
machine="AMD64",
)
self.assertIsNone(result)
def test_newer_major_minor_and_patch_versions_offer_zip(self) -> None:
cases = (
("1.9.9", "v2.0.0"),
("2.4.9", "v2.5.0"),
("2.5.3", "v2.5.4"),
)
for current, remote in cases:
with self.subTest(current=current, remote=remote):
result = check_for_update(
current,
opener=self._release_opener(remote),
system="Windows",
machine="x86_64",
)
self.assertIsNotNone(result)
assert result is not None
self.assertEqual("plate_config_app-windows-x86_64.zip", result.asset_name)
self.assertTrue(result.download_url.endswith("/" + result.asset_name))
def test_rejects_invalid_current_or_remote_version(self) -> None:
for current, remote in (("development", "v2.1.0"), ("2.0.0", "v2.1")):
with self.subTest(current=current, remote=remote), self.assertRaises(
UpdateError
):
check_for_update(
current,
opener=self._release_opener(remote),
system="Linux",
machine="x86_64",
)
def test_wraps_network_error_during_update_check(self) -> None:
def opener(_request, timeout: float):
raise OSError("offline")
with self.assertRaisesRegex(UpdateError, "offline") as raised:
check_for_update("2.0.0", opener=opener)
self.assertIsInstance(raised.exception.__cause__, OSError)
def test_platform_names(self) -> None:
self.assertEqual(
"plate_config_app-windows-x86_64.exe",
platform_executable_name(system="Windows", machine="AMD64"),
)
self.assertEqual(
"plate_config_app-windows-x86_64.zip",
platform_asset_name(system="Windows", machine="AMD64"),
)
self.assertEqual(
"plate_config_app-linux-x86_64.zip",
platform_asset_name(system="Linux", machine="x86_64"),
)
self.assertEqual(
"plate_config_app-linux-x86_64",
archive_executable_name("plate_config_app-linux-x86_64.zip"),
)
def test_rejects_unsupported_platform_architecture_or_asset(self) -> None:
with self.assertRaisesRegex(UpdateError, "архитектуру"):
platform_asset_name(system="Linux", machine="arm64")
with self.assertRaisesRegex(UpdateError, "платформу"):
platform_asset_name(system="Darwin", machine="x86_64")
with self.assertRaisesRegex(UpdateError, "Неизвестное"):
archive_executable_name("update.zip")
def test_signals_restart_only_when_ready_file_is_requested(self) -> None:
with tempfile.TemporaryDirectory() as directory:
marker = Path(directory) / "ready"
with patch.dict(os.environ, {update_service.UPDATE_READY_ENV: str(marker)}):
signal_update_ready()
self.assertEqual(str(os.getpid()), marker.read_text(encoding="ascii"))
self.assertNotIn(update_service.UPDATE_READY_ENV, os.environ)
marker.unlink()
signal_update_ready()
self.assertFalse(marker.exists())
def test_downloads_and_validates_windows_zip(self) -> None:
info = self._update_info()
data = make_update_archive(
"plate_config_app-windows-x86_64.exe",
b"MZnew",
extra_files={"commands/default.csv": b"04;Update"},
)
progress_values: list[int] = []
with tempfile.TemporaryDirectory() as directory:
result = download_update(
info,
Path(directory),
opener=lambda _request, timeout: FakeResponse(
info.download_url,
data,
content_length=len(data),
),
progress=progress_values.append,
)
self.assertEqual(data, result.read_bytes())
self.assertEqual(100, progress_values[-1])
def test_downloads_and_validates_linux_zip(self) -> None:
info = self._update_info(system="linux")
data = make_update_archive("plate_config_app-linux-x86_64", b"\x7fELFnew")
with tempfile.TemporaryDirectory() as directory:
result = download_update(
info,
Path(directory),
opener=lambda _request, timeout: FakeResponse(info.download_url, data),
)
with zipfile.ZipFile(result) as archive:
self.assertEqual(
["plate_config_app-linux-x86_64"],
archive.namelist(),
)
def test_rejects_invalid_or_incomplete_download(self) -> None:
info = self._update_info()
valid_data = make_update_archive(
"plate_config_app-windows-x86_64.exe",
b"MZnew",
)
cases = (
(b"<html>login</html>", None),
(valid_data, len(valid_data) + 10),
(b"", None),
)
for data, content_length in cases:
with self.subTest(size=len(data)), tempfile.TemporaryDirectory() as directory:
target = Path(directory)
with self.assertRaises(UpdateError):
download_update(
info,
target,
opener=lambda _request, timeout, payload=data, length=content_length: (
FakeResponse(
info.download_url,
payload,
content_length=length,
)
),
)
self.assertEqual([], list(target.iterdir()))
def test_rejects_login_or_foreign_download_redirect(self) -> None:
info = self._update_info()
data = make_update_archive(
"plate_config_app-windows-x86_64.exe",
b"MZnew",
)
redirects = (
"https://git.robotail.pro/user/login",
"https://example.com/update.zip",
)
for redirect in redirects:
with self.subTest(redirect=redirect), tempfile.TemporaryDirectory() as directory:
with self.assertRaises(UpdateError):
download_update(
info,
Path(directory),
opener=lambda _request, timeout, url=redirect: FakeResponse(
url,
data,
),
)
def test_rejects_oversized_downloads(self) -> None:
info = self._update_info()
with tempfile.TemporaryDirectory() as directory, patch.object(
update_service,
"MAX_DOWNLOAD_BYTES",
4,
):
for content_length in (8, None):
with self.subTest(content_length=content_length), self.assertRaisesRegex(
UpdateError,
"превышает",
):
download_update(
info,
Path(directory),
opener=lambda _request, timeout, length=content_length: FakeResponse(
info.download_url,
b"12345678",
content_length=length,
),
)
def test_cancels_download_and_removes_partial_file(self) -> None:
info = self._update_info()
with tempfile.TemporaryDirectory() as directory:
target = Path(directory)
with self.assertRaises(UpdateCancelled):
download_update(
info,
target,
opener=lambda _request, timeout: FakeResponse(
info.download_url,
b"partial",
),
cancelled=lambda: True,
)
self.assertEqual([], list(target.iterdir()))
def test_rejects_missing_or_read_only_target_directory(self) -> None:
info = self._update_info()
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(UpdateError, "не найден"):
download_update(info, Path(directory) / "missing")
with patch("app.services.update_service.os.access", return_value=False):
with self.assertRaisesRegex(UpdateError, "Нет прав"):
download_update(info, Path(directory))
def test_rejects_archive_without_executable(self) -> None:
data = self._custom_archive({"commands/default.csv": b"04;Update"})
self._assert_invalid_archive(data, "исполняемый файл")
def test_rejects_archive_path_traversal(self) -> None:
data = self._custom_archive(
{
"plate_config_app-windows-x86_64.exe": b"MZnew",
"../outside.txt": b"unsafe",
}
)
self._assert_invalid_archive(data, "небезопасный путь")
def test_rejects_archive_symlink(self) -> None:
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w") as archive:
archive.writestr("plate_config_app-windows-x86_64.exe", b"MZnew")
link = zipfile.ZipInfo("commands/link")
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
archive.writestr(link, "../../outside")
self._assert_invalid_archive(stream.getvalue(), "ссылку")
def test_rejects_duplicate_archive_paths(self) -> None:
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w") as archive:
archive.writestr("plate_config_app-windows-x86_64.exe", b"MZnew")
archive.writestr("commands/Test.csv", b"one")
archive.writestr("commands/test.csv", b"two")
self._assert_invalid_archive(stream.getvalue(), "повторяющиеся")
def test_rejects_excessive_unpacked_size(self) -> None:
data = make_update_archive(
"plate_config_app-windows-x86_64.exe",
b"MZnew",
extra_files={"commands/large.csv": b"12345678"},
)
with patch.object(update_service, "MAX_UNPACKED_BYTES", 8):
self._assert_invalid_archive(data, "Распакованный размер")
def test_prepares_windows_package_replacement_helper(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app-windows-x86_64.exe"
archive = root / ".downloaded-update.zip"
target.write_bytes(b"MZold")
archive.write_bytes(
make_update_archive(
target.name,
b"MZnew",
extra_files={"commands/default.csv": b"new"},
)
)
with patch("app.services.update_service.subprocess.Popen") as popen:
launch_update(
archive,
executable_path=target,
process_id=123,
system="Windows",
)
scripts = list(root.glob(".plate_config_updater-*.ps1"))
staging = list(root.glob(".plate_config_update-*"))
self.assertEqual(1, len(scripts))
self.assertEqual(1, len(staging))
self.assertFalse(archive.exists())
self.assertEqual(b"new", (staging[0] / "commands/default.csv").read_bytes())
script = scripts[0].read_text(encoding="utf-8-sig")
self.assertIn("$Staging", script)
self.assertIn("$backedUp", script)
self.assertIn("AddSeconds(30)", script)
self.assertIn("$restarted.HasExited", script)
self.assertIn("$ReadyFile", script)
self.assertIn('$env:PYINSTALLER_RESET_ENVIRONMENT = "1"', script)
popen.assert_called_once()
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
def test_prepares_linux_package_replacement_helper(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app-linux-x86_64"
archive = root / ".downloaded-update.zip"
target.write_bytes(b"\x7fELFold")
archive.write_bytes(
make_update_archive(
target.name,
b"\x7fELFnew",
extra_files={"commands/default.csv": b"new"},
)
)
with patch("app.services.update_service.subprocess.Popen") as popen:
launch_update(
archive,
executable_path=target,
process_id=123,
system="Linux",
)
scripts = list(root.glob(".plate_config_updater-*.sh"))
staging = list(root.glob(".plate_config_update-*"))
self.assertEqual(1, len(scripts))
self.assertEqual(1, len(staging))
self.assertTrue((staging[0] / target.name).stat().st_mode & stat.S_IXUSR)
script = scripts[0].read_text(encoding="utf-8")
self.assertIn("staging_path", script)
self.assertIn("rollback", script)
self.assertIn("PLATE_CONFIG_UPDATE_READY_FILE", script)
self.assertIn("PYINSTALLER_RESET_ENVIRONMENT=1", script)
popen.assert_called_once()
def test_extracts_external_archive_next_to_executable(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
downloads = root / "downloads"
application = root / "application"
downloads.mkdir()
application.mkdir()
target = application / "plate_config_app-linux-x86_64"
archive = downloads / "update.zip"
target.write_bytes(b"\x7fELFold")
archive.write_bytes(make_update_archive(target.name, b"\x7fELFnew"))
with patch("app.services.update_service.subprocess.Popen") as popen:
launch_update(
archive,
executable_path=target,
process_id=123,
system="Linux",
)
staging = list(application.glob(".plate_config_update-*"))
self.assertEqual(1, len(staging))
self.assertFalse(archive.exists())
self.assertIn(str(staging[0]), popen.call_args.args[0])
def test_failed_helper_launch_removes_staging_and_script(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app-windows-x86_64.exe"
archive = root / "update.zip"
target.write_bytes(b"MZold")
archive.write_bytes(make_update_archive(target.name, b"MZnew"))
with patch(
"app.services.update_service.subprocess.Popen",
side_effect=OSError("helper failed"),
), self.assertRaisesRegex(OSError, "helper failed"):
launch_update(
archive,
executable_path=target,
process_id=123,
system="Windows",
)
self.assertEqual([], list(root.glob(".plate_config_update-*")))
self.assertEqual([], list(root.glob(".plate_config_updater-*")))
def test_rejects_missing_archive_or_unsupported_platform(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app"
archive = root / "update.zip"
target.write_bytes(b"old")
with self.assertRaisesRegex(UpdateError, "не найден"):
launch_update(archive, executable_path=target, system="Linux")
archive.write_bytes(b"not-a-zip")
with self.assertRaisesRegex(UpdateError, "платформу"):
launch_update(archive, executable_path=target, system="Darwin")
def _assert_invalid_archive(self, data: bytes, message: str) -> None:
info = self._update_info()
with tempfile.TemporaryDirectory() as directory:
target = Path(directory)
with self.assertRaisesRegex(UpdateError, message):
download_update(
info,
target,
opener=lambda _request, timeout: FakeResponse(
info.download_url,
data,
),
)
self.assertEqual([], list(target.iterdir()))
@staticmethod
def _custom_archive(files: dict[str, bytes]) -> bytes:
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
for name, data in files.items():
archive.writestr(name, data)
return stream.getvalue()
@staticmethod
def _update_info(*, system: str = "windows") -> UpdateInfo:
asset_name = f"plate_config_app-{system}-x86_64.zip"
return UpdateInfo(
tag="v2.1.0",
version="2.1.0",
asset_name=asset_name,
download_url=(
"https://git.robotail.pro/Robotail/plate-config-app/releases/"
f"download/v2.1.0/{asset_name}"
),
)
@staticmethod
def _release_opener(tag: str):
def opener(_request, timeout: float):
return FakeResponse(
"https://git.robotail.pro/Robotail/plate-config-app/"
f"releases/tag/{tag}"
)
return opener
class ReplacementIntegrationTests(unittest.TestCase):
@staticmethod
def _digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
@staticmethod
def _wait_for_rollback(
target: Path,
expected_digest: str,
replaced_resource: Path,
) -> None:
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if (
target.is_file()
and ReplacementIntegrationTests._digest(target) == expected_digest
and replaced_resource.is_file()
and replaced_resource.read_text(encoding="utf-8") == "old"
and not list(target.parent.glob(".plate_config_updater-*"))
and not list(target.parent.glob(".plate_config_update-*"))
and not list(target.parent.glob(".plate_config_backup-*"))
and (target.parent / "update-error.log").is_file()
):
return
time.sleep(0.1)
raise AssertionError("Внешний helper не откатил пакет за 20 секунд")
@unittest.skipUnless(sys.platform == "win32", "Тест предназначен для Windows")
def test_windows_helper_rolls_back_when_new_app_exits_early(self) -> None:
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app-windows-x86_64.exe"
archive = root / "update.zip"
new_executable = (system_root / "System32" / "whoami.exe").read_bytes()
shutil.copy2(system_root / "System32" / "where.exe", target)
self._prepare_old_files(root)
archive.write_bytes(
make_update_archive(
target.name,
new_executable,
extra_files={"commands/version.txt": b"new"},
)
)
expected_digest = self._digest(target)
processes = self._launch_and_capture(archive, target, "Windows")
self._wait_for_rollback(
target,
expected_digest,
root / "commands/version.txt",
)
processes[0].wait(timeout=5)
self._assert_package_preserved(root)
self._wait_for_windows_unlock(target)
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
def test_linux_helper_rolls_back_when_new_app_exits_early(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
target = root / "plate_config_app-linux-x86_64"
archive = root / "update.zip"
new_executable = Path("/bin/true").read_bytes()
shutil.copy2("/bin/false", target)
self._prepare_old_files(root)
archive.write_bytes(
make_update_archive(
target.name,
new_executable,
extra_files={"commands/version.txt": b"new"},
)
)
expected_digest = self._digest(target)
processes = self._launch_and_capture(archive, target, "Linux")
self._wait_for_rollback(
target,
expected_digest,
root / "commands/version.txt",
)
processes[0].wait(timeout=5)
self._assert_package_preserved(root)
self.assertTrue(target.stat().st_mode & stat.S_IXUSR)
@staticmethod
def _prepare_old_files(root: Path) -> None:
commands = root / "commands"
commands.mkdir()
(commands / "version.txt").write_text("old", encoding="utf-8")
(commands / "obsolete.txt").write_text("remove", encoding="utf-8")
(root / "settings.json").write_text("preserve", encoding="utf-8")
@staticmethod
def _assert_package_preserved(root: Path) -> None:
self_settings = (root / "settings.json").read_text(encoding="utf-8")
if self_settings != "preserve":
raise AssertionError("settings.json не сохранён")
if not (root / "commands/obsolete.txt").exists():
raise AssertionError("Старое содержимое каталога не восстановлено")
@staticmethod
def _launch_and_capture(
archive: Path,
target: Path,
system: str,
) -> list[subprocess.Popen]:
processes: list[subprocess.Popen] = []
real_popen = subprocess.Popen
def start_process(*args, **kwargs):
process = real_popen(*args, **kwargs)
processes.append(process)
return process
with patch(
"app.services.update_service.subprocess.Popen",
side_effect=start_process,
):
launch_update(
archive,
executable_path=target,
process_id=2_147_483_647,
system=system,
)
return processes
@staticmethod
def _wait_for_windows_unlock(target: Path) -> None:
deadline = time.monotonic() + 10
while True:
try:
target.unlink()
return
except PermissionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.1)
if __name__ == "__main__":
unittest.main()