更新
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user