This commit is contained in:
Your Name
2026-08-22 10:46:13 +08:00
parent c06d293424
commit 43e5411b6a
39 changed files with 1607 additions and 300 deletions
+261
View File
@@ -9,16 +9,25 @@ from pathlib import Path
import httpx
import pytest
from doctor_workstation.services import app_update
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_ARCHIVE,
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdatePackage,
apply_extracted_update,
apply_inno_setup_update,
compare_version,
discover_payload,
download_package,
fetch_update_offer,
normalize_version,
package_filename,
parse_update_offer,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
@@ -54,6 +63,106 @@ def test_parse_offer_requires_hash_before_install() -> None:
assert offer.package is None
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
offer = parse_update_offer(
{
"has_update": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"size": 123,
"filename": "DoctorWorkstation-Setup.exe",
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is True
assert offer.package is not None
assert offer.package.type == PACKAGE_TYPE_INNO_SETUP
def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "http://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
offer = parse_update_offer(
{
"has_update": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/update.bin",
"sha256": "a" * 64,
"type": package_type,
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
base = {
"has_update": True,
"latest_version": "0.1.0",
"platform": "windows",
"arch": "x64",
"can_install": False,
}
stale = parse_update_offer(
base,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
wrong_platform = parse_update_offer(
{**base, "latest_version": "0.2.0", "platform": "macos"},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert stale.has_update is False
assert wrong_platform.has_update is False
def test_fetch_update_offer_uses_check_endpoint() -> None:
requests: list[httpx.Request] = []
@@ -162,3 +271,155 @@ def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
payload = b"short"
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=payload)
destination = tmp_path / "pkg.exe"
with pytest.raises(AppUpdateError, match="文件大小"):
download_package(
"https://cdn.example.com/pkg.exe",
destination,
sha256=hashlib.sha256(payload).hexdigest(),
expected_size=len(payload) + 1,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
assert not (tmp_path / "pkg.exe.part").exists()
def test_windows_installer_download_policy_requires_verified_https() -> None:
with pytest.raises(AppUpdateError, match="HTTPS"):
validate_installer_download_policy(
"http://cdn.example.com/setup.exe",
verify_ssl=True,
)
with pytest.raises(AppUpdateError, match="证书校验"):
validate_installer_download_policy(
"https://cdn.example.com/setup.exe",
verify_ssl=False,
)
validate_installer_download_policy(
"http://127.0.0.1/setup.exe",
verify_ssl=True,
)
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
assert validate_windows_installer(installer) == installer.resolve()
invalid = tmp_path / "invalid.exe"
invalid.write_bytes(b"PK")
with pytest.raises(AppUpdateError, match="PE"):
validate_windows_installer(invalid)
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
installer = tmp_path / "DoctorWorkstation-Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
spawned: dict[str, Path] = {}
monkeypatch.setattr(app_update.sys, "platform", "win32")
def capture_spawn(
script: Path,
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
) -> None:
spawned.update(
script=script,
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
)
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
apply_inno_setup_update(installer, install_root=install_root)
script_text = spawned["script"].read_text(encoding="utf-8-sig")
assert spawned["installer"] == installer.resolve()
assert spawned["restart_exe"] == installed_exe
assert "/VERYSILENT" in script_text
assert "/RESTARTEXITCODE=3010" in script_text
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
assert "$HelperLogFile" in script_text
assert "$InstallerLogFile" in script_text
assert "Restart-Application" in script_text
def test_archive_applier_restarts_from_install_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = tmp_path / "payload"
payload.mkdir()
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
captured: dict[str, Path] = {}
script = tmp_path / "apply.ps1"
script.write_text("", encoding="utf-8")
def capture_script(**kwargs: Path) -> Path:
captured.update(kwargs)
return script
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
apply_extracted_update(payload, install_root=install_root)
assert captured["restart_exe"] == installed_exe
def test_inno_setup_applier_reports_helper_start_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ")
monkeypatch.setattr(app_update.sys, "platform", "win32")
def fail_spawn(*args: object, **kwargs: object) -> None:
del args, kwargs
raise OSError("blocked")
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", fail_spawn)
with pytest.raises(AppUpdateError, match="无法启动 Windows 更新助手"):
apply_inno_setup_update(installer, install_root=install_root)
def test_package_filename_defaults_match_package_type() -> None:
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
installer = UpdatePackage(
"https://cdn.example.com/",
"a" * 64,
0,
"",
type=PACKAGE_TYPE_INNO_SETUP,
)
assert package_filename(archive, "0.2.0").endswith(".zip")
assert package_filename(installer, "0.2.0").endswith(".exe")
assert archive.type == PACKAGE_TYPE_ARCHIVE
+34
View File
@@ -20,6 +20,9 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
run_script = read("scripts/run_windows.ps1")
package_script = read("scripts/package_windows.ps1")
compiler_script = read("scripts/ensure_inno_setup.ps1")
installer_definition = read("packaging/windows/doctor_workstation.iss")
installer_smoke = read("scripts/smoke_windows_installer.ps1")
release_launcher = read("packaging/windows/start_release.bat")
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
@@ -32,11 +35,42 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
assert "& $Npm ci --prefix" in package_script
assert "build_windows.ps1" in package_script
assert "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" in package_script
assert "DoctorWorkstation-Setup-Windows-x64-$ProjectVersion" in package_script
assert "Get-FileHash" in package_script
assert "ensure_inno_setup.ps1" in package_script
assert "doctor_workstation.iss" in package_script
assert package_script.index("& $BuildScript") < package_script.index("& $InnoCompiler")
assert "Start_DoctorWorkstation.bat" in package_script
assert "DoctorWorkstation\\DoctorWorkstation.exe" in release_launcher
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
assert "6.7.3" in compiler_script
assert "Get-FileHash" in compiler_script
assert "Get-AuthenticodeSignature" in compiler_script
assert "9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732" in (
compiler_script
)
assert "E0B0B350E2245F3C5E65586DFE43D574F6E7F06F2261149ABA284954B3FC9A8D" in (
compiler_script
)
assert "AppId=" in installer_definition
assert "MinVersion=10.0.17763" in installer_definition
assert "ArchitecturesAllowed=x64compatible" in installer_definition
assert "PrivilegesRequiredOverridesAllowed=dialog commandline" in installer_definition
assert "UsePreviousAppDir=yes" in installer_definition
assert "UsePreviousPrivileges=yes" in installer_definition
assert "recursesubdirs createallsubdirs" in installer_definition
assert "{autoprograms}" in installer_definition
assert "{autodesktop}" in installer_definition
assert "UninstallDisplayIcon=" in installer_definition
assert "ChineseMessagesFile" in installer_definition
assert '"/CURRENTUSER"' in installer_smoke
assert '-ArgumentList "--smoke-test"' in installer_smoke
assert "unins000.exe" in installer_smoke
assert "-WindowStyle Hidden" in installer_smoke
assert "Uninstaller left the installed executable behind" in installer_smoke
def test_debug_launcher_reuses_an_isolated_persistent_profile() -> None:
debug_script = read("Debug_DoctorWorkstation.bat")
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import hashlib
import struct
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
MASTER_SHA256 = "c76f19b9a1c89c23d9923a0903c673dc5127acecc640272e4974241021f1100e"
def _png_info(path: Path) -> tuple[int, int, int]:
data = path.read_bytes()
assert data.startswith(b"\x89PNG\r\n\x1a\n")
assert data[12:16] == b"IHDR"
width, height = struct.unpack(">II", data[16:24])
color_type = data[25]
return width, height, color_type
def _ico_sizes(path: Path) -> set[tuple[int, int]]:
data = path.read_bytes()
reserved, image_type, count = struct.unpack_from("<HHH", data)
assert reserved == 0
assert image_type == 1
sizes: set[tuple[int, int]] = set()
for index in range(count):
width, height = struct.unpack_from("BB", data, 6 + index * 16)
sizes.add((width or 256, height or 256))
return sizes
def test_brand_assets_are_complete_and_derived_from_the_approved_master() -> None:
master = BRANDING_ROOT / "brand-master.png"
lockup = BRANDING_ROOT / "brand-lockup.png"
icon_png = BRANDING_ROOT / "app-icon.png"
icon_ico = BRANDING_ROOT / "app-icon.ico"
icon_icns = BRANDING_ROOT / "app-icon.icns"
favicon = PROJECT_ROOT / "video_companion" / "public" / "favicon.png"
assert hashlib.sha256(master.read_bytes()).hexdigest() == MASTER_SHA256
assert _png_info(master)[:2] == (1254, 1254)
assert _png_info(lockup)[0] >= 800
assert _png_info(lockup)[1] >= 900
assert _png_info(icon_png) == (1024, 1024, 6)
assert _png_info(favicon)[:2] == (64, 64)
assert {(16, 16), (32, 32), (48, 48), (64, 64), (256, 256)} <= _ico_sizes(
icon_ico
)
icns = icon_icns.read_bytes()
assert icns.startswith(b"icns")
assert struct.unpack(">I", icns[4:8])[0] == len(icns)
def test_runtime_and_packaging_use_the_same_brand_icon() -> None:
app_source = (PROJECT_ROOT / "src" / "doctor_workstation" / "app.py").read_text(
encoding="utf-8"
)
resources_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "resources.py"
).read_text(encoding="utf-8")
login_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "login.py"
).read_text(encoding="utf-8")
shell_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
).read_text(encoding="utf-8")
spec = (PROJECT_ROOT / "packaging" / "doctor_workstation.spec").read_text(
encoding="utf-8"
)
installer = (
PROJECT_ROOT / "packaging" / "windows" / "doctor_workstation.iss"
).read_text(encoding="utf-8")
package_script = (PROJECT_ROOT / "scripts" / "package_windows.ps1").read_text(
encoding="utf-8"
)
companion_html = (PROJECT_ROOT / "video_companion" / "index.html").read_text(
encoding="utf-8"
)
assert 'resource_path("branding", "app-icon.png")' in resources_source
assert "app_icon_path()" in app_source
assert "brand_lockup_path()" in login_source
assert "return QIcon(str(app_icon_path()))" in login_source
assert "QPixmap(str(app_icon_path()))" in shell_source
assert 'icon=str(WINDOWS_ICON) if sys.platform == "win32" else None' in spec
assert "icon=str(MACOS_ICON)" in spec
assert "SetupIconFile={#AppIconFile}" in installer
assert installer.count('IconFilename: "{app}\\{#AppExecutableName}"') == 2
assert '"/DAppIconFile=$WindowsIcon"' in package_script
assert '<link rel="icon" type="image/png" href="/favicon.png" />' in companion_html
assert "icon.svg" not in app_source
assert "_draw_mark" not in login_source
assert not (PROJECT_ROOT / "resources" / "icon.svg").exists()