feat: 实现 Client 在线更新与安全回退 (#93)

This commit is contained in:
chengma
2026-08-10 12:32:39 +08:00
parent c24fac964e
commit 6d6829e374
14 changed files with 1609 additions and 18 deletions
+99 -1
View File
@@ -2,12 +2,20 @@
import hashlib
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from build_tools.launcher import app_executable, install_root
from build_tools.launcher import (
UpdateApplyError,
app_executable,
apply_pending_update,
finalize_applied_update,
install_root,
is_main_program_running,
)
from build_tools.release_manifest import MANIFEST_FILE_NAME, write_manifest
from src.db import data_dir
@@ -27,6 +35,96 @@ class LauncherPathTest(unittest.TestCase):
)
class LauncherUpdateTest(unittest.TestCase):
def setUp(self):
self.temporary_directory = tempfile.TemporaryDirectory()
self.root = Path(self.temporary_directory.name)
self.current_app = self.root / "app"
self.staged_app = self.root / "data" / "update" / "app.new"
self.current_app.mkdir(parents=True)
self.staged_app.mkdir(parents=True)
(self.current_app / "CMAutoBuy.exe").write_bytes(b"old")
(self.staged_app / "CMAutoBuy.exe").write_bytes(b"new")
self.pending_path = self.root / "data" / "update" / "pending.json"
self.pending_path.write_text(
json.dumps(
{
"schema_version": 1,
"state": "ready",
"version": "0.2.0",
}
),
encoding="utf-8",
)
def tearDown(self):
self.temporary_directory.cleanup()
def test_apply_keeps_old_app_until_health_is_confirmed(self):
version = apply_pending_update(self.root)
self.assertEqual(version, "0.2.0")
self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"new")
self.assertEqual((self.root / "app.old" / "CMAutoBuy.exe").read_bytes(), b"old")
pending = json.loads(self.pending_path.read_text(encoding="utf-8"))
self.assertEqual(pending["state"], "applied")
healthy_path = self.root / "data" / "update" / "healthy.json"
healthy_path.write_text('{"version":"0.2.0"}', encoding="utf-8")
finalize_applied_update(self.root)
self.assertFalse(self.pending_path.exists())
self.assertTrue((self.root / "app.old").exists())
def test_applied_without_health_rolls_back_on_next_launch(self):
apply_pending_update(self.root)
result = apply_pending_update(self.root)
self.assertIsNone(result)
self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"old")
self.assertFalse(self.pending_path.exists())
self.assertTrue((self.root / "data" / "update" / "app.failed").exists())
def test_move_failure_restores_current_app(self):
real_replace = os.replace
def fail_for_staged_app(source, destination):
if Path(source).name == "app.new":
raise OSError("simulated lock")
return real_replace(source, destination)
with patch("build_tools.launcher.os.replace", side_effect=fail_for_staged_app):
with self.assertRaises(UpdateApplyError):
apply_pending_update(self.root)
self.assertEqual((self.root / "app" / "CMAutoBuy.exe").read_bytes(), b"old")
self.assertEqual((self.staged_app / "CMAutoBuy.exe").read_bytes(), b"new")
def test_running_pid_must_belong_to_expected_executable(self):
running_path = self.root / "data" / "update" / "running.json"
running_path.write_text('{"pid":123}', encoding="utf-8")
self.assertTrue(
is_main_program_running(
self.root,
checker=lambda pid, executable: pid == 123
and executable == self.root / "app" / "CMAutoBuy.exe",
)
)
self.assertFalse(
is_main_program_running(self.root, checker=lambda _pid, _path: False)
)
self.assertFalse(running_path.exists())
def test_corrupt_pending_state_stops_update_instead_of_being_ignored(self):
self.pending_path.write_text("not-json", encoding="utf-8")
with self.assertRaises(UpdateApplyError):
apply_pending_update(self.root)
self.assertEqual((self.current_app / "CMAutoBuy.exe").read_bytes(), b"old")
class PackagedDataPathTest(unittest.TestCase):
def test_packaged_main_program_uses_root_data_directory(self):
with tempfile.TemporaryDirectory() as directory:
+203
View File
@@ -0,0 +1,203 @@
"""在线更新清单、下载校验和安全解压测试。"""
import hashlib
import io
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
from src.update_service import (
UnsafeUpdateArchiveError,
UpdateConfigurationError,
UpdateIntegrityError,
UpdateService,
mark_current_version_healthy,
parse_version,
validate_manifest_url,
)
class FakeResponse(io.BytesIO):
def __init__(self, content: bytes, url: str, declared_size=None):
super().__init__(content)
self._url = url
self.headers = {}
if declared_size is not None:
self.headers["Content-Length"] = str(declared_size)
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *_args):
self.close()
class FakeUrlOpen:
def __init__(self, responses):
self.responses = responses
self.requested_urls = []
def __call__(self, request, timeout):
self.requested_urls.append((request.full_url, timeout))
content, final_url = self.responses[request.full_url]
return FakeResponse(content, final_url, len(content))
def make_update_zip(version="0.2.0", extra_entries=None):
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("app/CMAutoBuy.exe", b"exe")
archive.writestr("app/version.txt", version.encode("utf-8"))
for name, content in extra_entries or []:
archive.writestr(name, content)
return output.getvalue()
def make_manifest(update_content, version="0.2.0"):
return json.dumps(
{
"schema_version": 1,
"product": "CMAutoBuy",
"version": version,
"update": {
"file": f"CMAutoBuy-{version}-update.zip",
"size": len(update_content),
"sha256": hashlib.sha256(update_content).hexdigest(),
},
}
).encode("utf-8")
class UpdateServiceTest(unittest.TestCase):
def setUp(self):
self.temporary_directory = tempfile.TemporaryDirectory()
self.update_directory = Path(self.temporary_directory.name) / "update"
self.manifest_url = "https://updates.example.test/releases/autobuy%E2%80%94%E2%80%94manifest.json"
def tearDown(self):
self.temporary_directory.cleanup()
def service_with(self, update_content, manifest_content=None):
file_name = "CMAutoBuy-0.2.0-update.zip"
update_url = f"https://updates.example.test/releases/{file_name}"
opener = FakeUrlOpen(
{
self.manifest_url: (
manifest_content or make_manifest(update_content),
self.manifest_url,
),
update_url: (update_content, update_url),
}
)
return UpdateService(self.update_directory, opener), opener
def test_strict_version_comparison(self):
self.assertLess(parse_version("1.2.3"), parse_version("1.2.4"))
self.assertEqual(parse_version("1.2.3"), parse_version("1.2.3.0"))
with self.assertRaises(UpdateConfigurationError):
parse_version("v1.2")
def test_only_https_without_credentials_is_allowed(self):
self.assertEqual(
validate_manifest_url(self.manifest_url),
self.manifest_url,
)
for invalid in (
"http://updates.example.test/manifest.json",
"https://user:pass@updates.example.test/manifest.json",
"https://updates.example.test/manifest.json?token=secret",
"not-a-url",
):
with self.subTest(invalid=invalid), self.assertRaises(
UpdateConfigurationError
):
validate_manifest_url(invalid)
def test_check_reports_newer_and_current_versions(self):
update_content = make_update_zip()
service, _opener = self.service_with(update_content)
newer = service.check(self.manifest_url, "0.1.0")
current = service.check(self.manifest_url, "0.2.0")
self.assertTrue(newer.available)
self.assertEqual(newer.latest_version, "0.2.0")
self.assertIsNotNone(newer.update)
self.assertFalse(current.available)
self.assertIsNone(current.update)
def test_download_stages_verified_app_and_pending_state(self):
update_content = make_update_zip()
service, _opener = self.service_with(update_content)
result = service.check(self.manifest_url, "0.1.0")
progress = []
staged = service.download_and_stage(result.update, on_progress=progress.append)
self.assertEqual(staged, self.update_directory / "app.new")
self.assertEqual((staged / "version.txt").read_text(), "0.2.0")
pending = json.loads(
(self.update_directory / "pending.json").read_text(encoding="utf-8")
)
self.assertEqual(pending["state"], "ready")
self.assertEqual(pending["version"], "0.2.0")
self.assertEqual(progress[-1], 100)
def test_hash_mismatch_never_creates_pending_state(self):
update_content = make_update_zip()
manifest = json.loads(make_manifest(update_content))
manifest["update"]["sha256"] = "0" * 64
service, _opener = self.service_with(
update_content,
json.dumps(manifest).encode("utf-8"),
)
result = service.check(self.manifest_url, "0.1.0")
with self.assertRaises(UpdateIntegrityError):
service.download_and_stage(result.update)
self.assertFalse((self.update_directory / "pending.json").exists())
self.assertFalse((self.update_directory / "app.new").exists())
def test_path_traversal_archive_is_rejected(self):
update_content = make_update_zip(
extra_entries=[("app/../../outside.txt", b"unsafe")]
)
service, _opener = self.service_with(update_content)
result = service.check(self.manifest_url, "0.1.0")
with self.assertRaises(UnsafeUpdateArchiveError):
service.download_and_stage(result.update)
self.assertFalse((self.update_directory.parent / "outside.txt").exists())
self.assertFalse((self.update_directory / "pending.json").exists())
def test_packaged_version_must_match_manifest(self):
update_content = make_update_zip(version="9.9.9")
service, _opener = self.service_with(update_content)
result = service.check(self.manifest_url, "0.1.0")
with self.assertRaises(UpdateIntegrityError):
service.download_and_stage(result.update)
def test_health_marker_is_only_written_for_pending_update(self):
mark_current_version_healthy(self.update_directory)
self.assertFalse((self.update_directory / "healthy.json").exists())
self.update_directory.mkdir(parents=True)
(self.update_directory / "pending.json").write_text("{}", encoding="utf-8")
mark_current_version_healthy(self.update_directory)
health = json.loads(
(self.update_directory / "healthy.json").read_text(encoding="utf-8")
)
self.assertEqual(health["version"], "0.2.0")
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
"""设置页在线更新 UI 和后台线程测试。"""
import os
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtCore import QTimer
from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication
from src.mock_admin_gateway import MockAdminGateway
from src.settings_repository import SettingsRepository
from src.settings_ui import SettingsPage
from src.update_service import (
UPDATE_MANIFEST_SETTING,
UpdateCheckResult,
UpdateInfo,
)
MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json"
class FakeUpdateService:
def __init__(self, result=None, delay=0.0, download_error=None):
self.result = result or UpdateCheckResult("0.2.0", "0.2.0", False)
self.delay = delay
self.download_error = download_error
self.check_count = 0
self.download_count = 0
def check(self, manifest_url, current_version="0.1.0", is_cancelled=None):
self.check_count += 1
self.manifest_url = manifest_url
if self.delay:
time.sleep(self.delay)
return self.result
def download_and_stage(self, update, is_cancelled=None, on_progress=None):
self.download_count += 1
if on_progress is not None:
on_progress(50)
if self.download_error is not None:
raise self.download_error
if on_progress is not None:
on_progress(100)
return Path("app.new")
class FakeButton:
def setText(self, _text):
pass
def setFocus(self):
pass
class AcceptDownloadMessageBox:
def __init__(self, _title, _content, _parent):
self.yesButton = FakeButton()
self.cancelButton = FakeButton()
def exec(self):
return True
class UpdateUiEventTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.temporary_directory = tempfile.TemporaryDirectory()
self.repository = SettingsRepository(
Path(self.temporary_directory.name) / "client.db"
)
def tearDown(self):
self.temporary_directory.cleanup()
def _page(self, service):
return SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
update_service=service,
)
def _wait_until(self, predicate, timeout_ms=2000):
elapsed = 0
while not predicate() and elapsed < timeout_ms:
QTest.qWait(10)
elapsed += 10
self.assertTrue(predicate(), "等待在线更新线程超时")
def test_update_card_loads_saved_url_and_current_version(self):
self.repository.set(UPDATE_MANIFEST_SETTING, MANIFEST_URL)
page = self._page(FakeUpdateService())
self.assertEqual(page.currentVersionLabel.text(), "0.2.0")
self.assertEqual(page.updateManifestUrlInput.text(), MANIFEST_URL)
self.assertTrue(page.updateCheckButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_http_url_is_rejected_before_starting_worker(self):
service = FakeUpdateService()
page = self._page(service)
page.updateManifestUrlInput.setText("http://updates.example.test/manifest.json")
page.updateCheckButton.click()
self.assertIn("必须是有效的 HTTPS", page.updateStatusLabel.text())
self.assertEqual(service.check_count, 0)
page.eventBinder.shutdown()
page.deleteLater()
def test_slow_check_does_not_block_or_start_twice(self):
service = FakeUpdateService(delay=0.08)
page = self._page(service)
page.updateManifestUrlInput.setText(MANIFEST_URL)
timer_fired = []
QTimer.singleShot(10, lambda: timer_fired.append(True))
page.updateCheckButton.click()
page.eventBinder.updateEventBinder.request_check()
self._wait_until(lambda: bool(timer_fired), timeout_ms=500)
self._wait_until(
lambda: page.eventBinder.updateEventBinder._check_thread is None
)
self.assertEqual(service.check_count, 1)
self.assertEqual(
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL
)
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
def test_confirmed_new_version_downloads_and_stages(self):
update = UpdateInfo(
version="0.2.0",
manifest_url=MANIFEST_URL,
update_url="https://updates.example.test/CMAutoBuy-0.2.0-update.zip",
file_name="CMAutoBuy-0.2.0-update.zip",
size=1024,
sha256="0" * 64,
)
service = FakeUpdateService(
UpdateCheckResult("0.1.0", "0.2.0", True, update)
)
page = self._page(service)
page.updateManifestUrlInput.setText(MANIFEST_URL)
with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox):
page.updateCheckButton.click()
self._wait_until(
lambda: page.eventBinder.updateEventBinder._check_thread is None
and page.eventBinder.updateEventBinder._download_thread is None
)
self.assertEqual(service.download_count, 1)
self.assertIn("已准备好", page.updateStatusLabel.text())
self.assertIn("重新启动", page.updateStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
if __name__ == "__main__":
unittest.main()