zapretgui/tests/test_updater_handoff_state.py
loop-uh 5b6ad4c32a
Some checks failed
Zapret source guards / validate (push) Successful in 4s
Windows-сборка / Dev Ошибка: Сборка Nuitka (21.1.5.79)
Переписать установку обновления: наблюдатель больше не зависает и не ставит старую версию
Установка обновления вынесена в отдельную папку src/updater/install/
(пути, запись о ходе установки, наблюдатель, запуск установщика,
разбор прошлого обновления, починка) и одно сравнение версий versions.py.

- Наблюдатель ждёт только установщик (WaitForExit), а не запущенную им
  новую программу: раньше Start-Process -Wait висел до её закрытия.
- Аргументы установщика собираются с кавычками: путь установки с
  пробелом больше не распадается на два аргумента.
- handoff.json читается с учётом BOM и пишется без него: сорвавшееся
  обновление снова распознаётся, битая запись удаляется.
- Восстановление после перезагрузки доводит только начатую установку и
  не ставит старую версию поверх новой.
- Опоздавший наблюдатель видит отмену и не запускает установщик.
- Перед запуском сверяется SHA-256 установщика; каталог обновления в
  ProgramData закрыт от записи обычными пользователями, временные файлы
  создаются со случайными именами.
- Зависший наблюдатель прежних версий останавливается при первом запуске.
- Починка поставки идёт через того же наблюдателя.
2026-09-27 16:49:28 +03:00

166 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
"""Состояние передачи управления установщику.
Запись переживает закрытие приложения и читается наблюдателем, поэтому
проверяются два свойства: она никогда не видна наполовину записанной и
никогда не «выздоравливает» из битого файла в правдоподобное состояние.
"""
import json
from pathlib import Path
import tempfile
import unittest
from updater.install.handoff import (
HandoffState,
SCHEMA_VERSION,
UpdateHandoffRecord,
clear_record,
read_record,
write_record,
)
def _record(**overrides) -> UpdateHandoffRecord:
payload = {
"state": HandoffState.PREPARED,
"version": "21.1.1.4",
"target_root": r"C:\Zapret\Stable",
"installer_path": r"C:\ProgramData\Zapret\update\stable\Zapret2Setup.exe",
"arguments": ("/AUTOUPDATE", "/SILENT"),
"gui_pid": 4242,
}
payload.update(overrides)
return UpdateHandoffRecord(**payload)
class HandoffRecordRoundTripTests(unittest.TestCase):
def test_record_survives_write_and_read(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
self.assertTrue(write_record(_record(), path, now=1700000000.0))
restored = read_record(path)
self.assertIsNotNone(restored)
self.assertEqual(restored.state, HandoffState.PREPARED)
self.assertEqual(restored.version, "21.1.1.4")
self.assertEqual(restored.arguments, ("/AUTOUPDATE", "/SILENT"))
self.assertEqual(restored.gui_pid, 4242)
self.assertEqual(restored.updated_at, 1700000000.0)
self.assertEqual(restored.schema_version, SCHEMA_VERSION)
def test_write_is_atomic_and_leaves_no_temporary_file(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
write_record(_record(), path)
write_record(_record(state=HandoffState.LAUNCHED), path)
leftovers = sorted(item.name for item in Path(temp_dir).iterdir())
self.assertEqual(leftovers, ["handoff.json"])
def test_partial_file_is_not_mistaken_for_state_and_is_removed(self) -> None:
"""Битая запись удаляется, иначе предупреждение повторялось бы вечно."""
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
path.write_text('{"state": "launc', encoding="utf-8")
self.assertIsNone(read_record(path))
self.assertFalse(path.exists())
def test_record_written_by_windows_powershell_with_bom_is_read(self) -> None:
"""Windows PowerShell 5.1 пишет UTF-8 с BOM — так выглядела живая запись."""
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
payload = {
"schema_version": 1,
"state": "succeeded",
"version": "21.1.5.67",
"target_root": "C:\\Zapret\\Dev",
"installer_path": "C:\\ProgramData\\Zapret\\update\\dev\\Zapret2Setup.exe",
"arguments": ["/AUTOUPDATE", "/SILENT"],
"gui_pid": 23544,
"installer_exit_code": None,
"installed_version": "21.1.5.67",
"error": "",
"updated_at": 1787348465.317,
}
path.write_bytes(b"\xef\xbb\xbf" + json.dumps(payload, indent=4).encode("utf-8"))
restored = read_record(path)
self.assertIsNotNone(restored)
self.assertEqual(restored.state, HandoffState.SUCCEEDED)
self.assertEqual(restored.installed_version, "21.1.5.67")
self.assertEqual(restored.schema_version, 1)
self.assertEqual(restored.installer_sha256, "")
def test_installer_checksum_travels_with_the_record(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
write_record(_record(installer_sha256="ab" * 32), path)
restored = read_record(path)
self.assertEqual(restored.installer_sha256, "ab" * 32)
def test_planted_temporary_file_does_not_capture_the_write(self) -> None:
"""Временный файл с предсказуемым именем мог подложить обычный пользователь."""
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
planted = Path(temp_dir) / "handoff.json.new"
planted.write_text("planted", encoding="utf-8")
self.assertTrue(write_record(_record(), path))
self.assertEqual(planted.read_text(encoding="utf-8"), "planted")
self.assertEqual(read_record(path).version, "21.1.1.4")
def test_unknown_state_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
path.write_text(json.dumps({"state": "installing"}), encoding="utf-8")
self.assertIsNone(read_record(path))
def test_missing_file_reads_as_no_state(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
self.assertIsNone(read_record(Path(temp_dir) / "absent.json"))
def test_broken_numbers_do_not_break_reading(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
path.write_text(
json.dumps(
{
"state": "failed",
"version": "21.1.1.4",
"gui_pid": "не число",
"installer_exit_code": "нет",
"updated_at": None,
}
),
encoding="utf-8",
)
restored = read_record(path)
self.assertIsNotNone(restored)
self.assertEqual(restored.gui_pid, 0)
self.assertIsNone(restored.installer_exit_code)
self.assertEqual(restored.updated_at, 0.0)
def test_clear_removes_state_and_tolerates_absence(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "handoff.json"
write_record(_record(), path)
clear_record(path)
clear_record(path)
self.assertFalse(path.exists())
if __name__ == "__main__":
unittest.main()