604 lines
20 KiB
Python
604 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from ptlinux.paths import AppPaths
|
|
from ptlinux.self_update import (
|
|
LauncherRemoteHistory,
|
|
LauncherRemoteRelease,
|
|
LauncherUpdateError,
|
|
ManifestFile,
|
|
UpdateManifest,
|
|
_activate_release,
|
|
_fetch_historical_manifest,
|
|
_recover_interrupted_activation,
|
|
_validate_release_directory,
|
|
launcher_rollback_candidates,
|
|
parse_launcher_release_history,
|
|
rollback_launcher_update,
|
|
)
|
|
|
|
|
|
def history_release(
|
|
version: str,
|
|
channel: str,
|
|
*,
|
|
status: str = "active",
|
|
) -> dict[str, object]:
|
|
tag = f"v{version}"
|
|
base = f"https://updates.example.test/releases/download/{tag}"
|
|
return {
|
|
"channel": channel,
|
|
"compatibility": {"config_schema": 1, "minimum_config_schema": 1},
|
|
"manifest_signature_url": f"{base}/manifest.json.sig",
|
|
"manifest_url": f"{base}/manifest.json",
|
|
"release_date": "2026-09-08T12:00:00Z",
|
|
"release_id": 42,
|
|
"release_notes_url": f"https://updates.example.test/releases/tag/{tag}",
|
|
"status": status,
|
|
"tag": tag,
|
|
"title": f"Launcher {version}",
|
|
"version": version,
|
|
}
|
|
|
|
|
|
def history_bytes(releases: list[dict[str, object]]) -> bytes:
|
|
return (
|
|
json.dumps(
|
|
{
|
|
"application": "PristonTale-EU",
|
|
"generated_at": "2026-09-08T12:01:00Z",
|
|
"releases": releases,
|
|
"schema": 1,
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
|
|
|
|
def app_paths(root: Path) -> AppPaths:
|
|
return AppPaths.from_environment({"HOME": str(root / "home")})
|
|
|
|
|
|
def make_release(path: Path, version: str, *, extra_file: bool = False) -> None:
|
|
files = {
|
|
"release.json": (
|
|
json.dumps(
|
|
{
|
|
"channel": "prerelease" if "-" in version else "stable",
|
|
"config_schema": 1,
|
|
"minimum_config_schema": 1,
|
|
"schema": 1,
|
|
"tag": f"v{version}",
|
|
"version": version,
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
).encode(),
|
|
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
|
|
"ptlinux/__main__.py": b"fixture\n",
|
|
"ptlinux/cli.py": b"fixture\n",
|
|
"ptlinux/self_update.py": b"fixture\n",
|
|
"ptlinux/launcher-update-public.pem": b"fixture\n",
|
|
"ptdiag/__init__.py": b"fixture\n",
|
|
"ptdiag/assbin.py": b"fixture\n",
|
|
"ptdiag/logs.py": b"fixture\n",
|
|
}
|
|
if extra_file:
|
|
files["ptlinux/new_feature.py"] = b"only in the newer release\n"
|
|
for relative, data in files.items():
|
|
target = path / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(data)
|
|
target.chmod(0o644)
|
|
for directory in (path, *path.rglob("*")):
|
|
if directory.is_dir():
|
|
directory.chmod(0o755)
|
|
|
|
|
|
def archived_release(root: Path, version: str) -> tuple[Path, tuple[ManifestFile, ...]]:
|
|
release = root / "payload"
|
|
make_release(release, version)
|
|
archive = root / f"PristonTale-EU-{version}.tar.gz"
|
|
with tarfile.open(archive, "w:gz") as bundle:
|
|
for path in sorted(release.rglob("*")):
|
|
relative = path.relative_to(release).as_posix()
|
|
info = tarfile.TarInfo(relative)
|
|
info.mode = 0o755 if path.is_dir() else 0o644
|
|
if path.is_dir():
|
|
info.type = tarfile.DIRTYPE
|
|
bundle.addfile(info)
|
|
else:
|
|
data = path.read_bytes()
|
|
info.size = len(data)
|
|
bundle.addfile(info, io.BytesIO(data))
|
|
files = tuple(
|
|
ManifestFile(
|
|
path=path.relative_to(release).as_posix(),
|
|
size=path.stat().st_size,
|
|
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
)
|
|
for path in sorted(release.rglob("*"))
|
|
if path.is_file()
|
|
)
|
|
return archive, files
|
|
|
|
|
|
class RemoteHistoryTests(unittest.TestCase):
|
|
def test_release_validation_removes_generated_bytecode_caches(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
release = Path(temporary) / "1.8.0"
|
|
make_release(release, "1.8.0")
|
|
cache = release / "ptlinux/__pycache__"
|
|
cache.mkdir(mode=0o775)
|
|
(cache / "cli.cpython-314.pyc").write_bytes(b"generated bytecode")
|
|
cache.chmod(0o775)
|
|
|
|
self.assertEqual(_validate_release_directory(release), "1.8.0")
|
|
self.assertFalse(cache.exists())
|
|
|
|
def test_release_validation_rejects_unexpected_cache_content(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
release = Path(temporary) / "1.8.0"
|
|
make_release(release, "1.8.0")
|
|
cache = release / "ptlinux/__pycache__"
|
|
cache.mkdir()
|
|
unexpected = cache / "payload.txt"
|
|
unexpected.write_text("not bytecode\n", encoding="utf-8")
|
|
|
|
with self.assertRaisesRegex(LauncherUpdateError, "unexpected path"):
|
|
_validate_release_directory(release)
|
|
self.assertTrue(unexpected.is_file())
|
|
|
|
def test_history_is_strict_and_drives_channel_aware_rollback(self) -> None:
|
|
data = history_bytes(
|
|
[
|
|
history_release("1.9.0-beta.2", "prerelease"),
|
|
history_release("1.9.0-beta.1", "prerelease", status="withdrawn"),
|
|
history_release("1.8.0", "stable"),
|
|
history_release("1.7.4", "stable"),
|
|
]
|
|
)
|
|
history = parse_launcher_release_history(
|
|
data,
|
|
history_url="https://updates.example.test/updates/history.json",
|
|
)
|
|
|
|
stable = launcher_rollback_candidates(
|
|
history,
|
|
installed_version="1.9.0-beta.3",
|
|
selected_channel="stable",
|
|
)
|
|
prerelease = launcher_rollback_candidates(
|
|
history,
|
|
installed_version="1.9.0-beta.3",
|
|
selected_channel="prerelease",
|
|
)
|
|
|
|
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
|
|
self.assertEqual(
|
|
[item.version for item in prerelease],
|
|
["1.9.0-beta.2", "1.8.0", "1.7.4"],
|
|
)
|
|
|
|
def test_history_rejects_unsorted_and_detached_signature_urls(self) -> None:
|
|
with self.assertRaisesRegex(LauncherUpdateError, "descending"):
|
|
parse_launcher_release_history(
|
|
history_bytes(
|
|
[
|
|
history_release("1.8.0", "stable"),
|
|
history_release("1.9.0", "stable"),
|
|
]
|
|
),
|
|
history_url="https://updates.example.test/updates/history.json",
|
|
)
|
|
detached = history_release("1.8.0", "stable")
|
|
detached["manifest_signature_url"] = "https://attacker.test/manifest.sig"
|
|
with self.assertRaisesRegex(LauncherUpdateError, "signature URL"):
|
|
parse_launcher_release_history(
|
|
history_bytes([detached]),
|
|
history_url="https://updates.example.test/updates/history.json",
|
|
)
|
|
|
|
def test_signed_history_compatibility_overrides_immutable_manifest_defaults(self) -> None:
|
|
record = LauncherRemoteRelease(
|
|
version="1.8.0",
|
|
channel="stable",
|
|
tag="v1.8.0",
|
|
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
|
|
manifest_signature_url=(
|
|
"https://updates.example.test/v1.8.0/manifest.json.sig"
|
|
),
|
|
release_date="2026-09-08T12:00:00Z",
|
|
release_notes_url="https://updates.example.test/releases/v1.8.0",
|
|
status="active",
|
|
title="Launcher 1.8.0",
|
|
config_schema=3,
|
|
minimum_config_schema=2,
|
|
)
|
|
manifest = UpdateManifest(
|
|
schema=1,
|
|
channel="stable",
|
|
version="1.8.0",
|
|
minimum_updater_version="0.5.0",
|
|
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
|
|
archive_sha256="a" * 64,
|
|
archive_size=123,
|
|
published_at="2026-09-08T12:00:00Z",
|
|
notes_url=record.release_notes_url,
|
|
tag="v1.8.0",
|
|
config_schema=1,
|
|
minimum_config_schema=1,
|
|
)
|
|
with (
|
|
patch(
|
|
"ptlinux.self_update._fetch_small_file",
|
|
side_effect=[b"manifest", b"signature"],
|
|
),
|
|
patch("ptlinux.self_update._verify_manifest_signature"),
|
|
patch("ptlinux.self_update.parse_update_manifest", return_value=manifest),
|
|
):
|
|
result = _fetch_historical_manifest(
|
|
record,
|
|
Path("public.pem"),
|
|
timeout_seconds=1,
|
|
)
|
|
|
|
self.assertEqual(result.config_schema, 3)
|
|
self.assertEqual(result.minimum_config_schema, 2)
|
|
|
|
|
|
class RemoteRollbackTests(unittest.TestCase):
|
|
def test_remote_rollback_verifies_and_activates_a_complete_target_release(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.9.0-beta.3"
|
|
make_release(current, current.name, extra_file=True)
|
|
paths.launcher_current.symlink_to(Path("releases") / current.name)
|
|
archive, files = archived_release(root, "1.8.0")
|
|
archive_bytes = archive.read_bytes()
|
|
manifest = UpdateManifest(
|
|
schema=1,
|
|
channel="stable",
|
|
version="1.8.0",
|
|
minimum_updater_version="0.5.0",
|
|
archive_url="https://updates.example.test/PristonTale-EU-1.8.0.tar.gz",
|
|
archive_sha256=hashlib.sha256(archive_bytes).hexdigest(),
|
|
archive_size=len(archive_bytes),
|
|
published_at="2026-09-08T12:00:00Z",
|
|
notes_url="https://updates.example.test/releases/tag/v1.8.0",
|
|
tag="v1.8.0",
|
|
files=files,
|
|
manifest_sha256="a" * 64,
|
|
)
|
|
record_value = history_release("1.8.0", "stable")
|
|
record = LauncherRemoteRelease(
|
|
version="1.8.0",
|
|
channel="stable",
|
|
tag="v1.8.0",
|
|
manifest_url=str(record_value["manifest_url"]),
|
|
manifest_signature_url=str(record_value["manifest_signature_url"]),
|
|
release_date=str(record_value["release_date"]),
|
|
release_notes_url=str(record_value["release_notes_url"]),
|
|
status="active",
|
|
title="Launcher 1.8.0",
|
|
)
|
|
history = LauncherRemoteHistory(
|
|
generated_at="2026-09-08T12:01:00Z",
|
|
history_url="https://updates.example.test/updates/history.json",
|
|
releases=(record,),
|
|
history_sha256="b" * 64,
|
|
)
|
|
paths.config.mkdir(parents=True)
|
|
paths.settings_file.write_text(
|
|
json.dumps({"schema": 1, "user_choice": "preserve me"}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
def copy_archive(_manifest: object, destination: Path, **_kwargs: object) -> None:
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(archive_bytes)
|
|
|
|
with (
|
|
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
|
|
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
|
|
patch("ptlinux.self_update._download_archive", side_effect=copy_archive),
|
|
patch("ptlinux.self_update._self_check_release"),
|
|
):
|
|
result = rollback_launcher_update(
|
|
paths,
|
|
target_version="1.8.0",
|
|
selected_channel="prerelease",
|
|
)
|
|
|
|
self.assertEqual(result.from_version, "1.9.0-beta.3")
|
|
self.assertEqual(result.to_version, "1.8.0")
|
|
self.assertEqual(paths.launcher_current.resolve().name, "1.8.0")
|
|
self.assertEqual(paths.launcher_previous.resolve().name, "1.9.0-beta.3")
|
|
self.assertFalse((paths.launcher_current / "ptlinux/new_feature.py").exists())
|
|
self.assertEqual(
|
|
json.loads(paths.settings_file.read_text(encoding="utf-8"))["user_choice"],
|
|
"preserve me",
|
|
)
|
|
operations = json.loads(
|
|
paths.launcher_update_operations.read_text(encoding="utf-8")
|
|
)["operations"]
|
|
self.assertEqual(operations[-1]["reason"], "manual")
|
|
self.assertTrue(operations[-1]["rollback"])
|
|
integrity_record = paths.launcher_release_integrity / "1.8.0.json"
|
|
self.assertTrue(integrity_record.is_file())
|
|
|
|
(paths.launcher_current / "ptlinux/__init__.py").write_text(
|
|
"locally modified after verification\n",
|
|
encoding="utf-8",
|
|
)
|
|
_activate_release(paths, current)
|
|
with self.assertRaisesRegex(LauncherUpdateError, "signed manifest"):
|
|
rollback_launcher_update(paths)
|
|
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
|
|
|
|
def test_remote_rollback_rejects_incompatible_configuration_before_download(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.9.0"
|
|
make_release(current, current.name)
|
|
paths.launcher_current.symlink_to(Path("releases") / current.name)
|
|
paths.config.mkdir(parents=True)
|
|
paths.settings_file.write_text('{"schema":1}\n', encoding="utf-8")
|
|
record = LauncherRemoteRelease(
|
|
version="1.8.0",
|
|
channel="stable",
|
|
tag="v1.8.0",
|
|
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
|
|
manifest_signature_url=(
|
|
"https://updates.example.test/v1.8.0/manifest.json.sig"
|
|
),
|
|
release_date="2026-09-08T12:00:00Z",
|
|
release_notes_url="https://updates.example.test/releases/v1.8.0",
|
|
status="active",
|
|
title="Launcher 1.8.0",
|
|
)
|
|
history = LauncherRemoteHistory(
|
|
generated_at="2026-09-08T12:01:00Z",
|
|
history_url="https://updates.example.test/history.json",
|
|
releases=(record,),
|
|
history_sha256="b" * 64,
|
|
)
|
|
manifest = UpdateManifest(
|
|
schema=1,
|
|
channel="stable",
|
|
version="1.8.0",
|
|
minimum_updater_version="0.5.0",
|
|
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
|
|
archive_sha256="a" * 64,
|
|
archive_size=123,
|
|
published_at="2026-09-08T12:00:00Z",
|
|
notes_url=record.release_notes_url,
|
|
tag="v1.8.0",
|
|
config_schema=3,
|
|
minimum_config_schema=2,
|
|
)
|
|
with (
|
|
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
|
|
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
|
|
patch("ptlinux.self_update._download_archive") as download,
|
|
self.assertRaisesRegex(LauncherUpdateError, "config schemas 2 through 3"),
|
|
):
|
|
rollback_launcher_update(paths, target_version="1.8.0")
|
|
|
|
download.assert_not_called()
|
|
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
|
|
|
|
def test_rollback_refuses_to_mutate_while_a_managed_game_is_active(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.8.0"
|
|
previous = paths.launcher_releases / "1.7.4"
|
|
for release in (current, previous):
|
|
make_release(release, release.name)
|
|
paths.launcher_current.symlink_to(Path("releases") / current.name)
|
|
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
|
|
|
|
with (
|
|
patch("ptlinux.self_update.managed_game_process_is_active", return_value=True),
|
|
self.assertRaisesRegex(RuntimeError, "active managed game client"),
|
|
):
|
|
rollback_launcher_update(paths)
|
|
|
|
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
|
|
|
|
def test_precheck_rejects_insufficient_disk_space(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.9.0"
|
|
make_release(current, current.name)
|
|
paths.launcher_current.symlink_to(Path("releases") / current.name)
|
|
record = LauncherRemoteRelease(
|
|
version="1.8.0",
|
|
channel="stable",
|
|
tag="v1.8.0",
|
|
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
|
|
manifest_signature_url=(
|
|
"https://updates.example.test/v1.8.0/manifest.json.sig"
|
|
),
|
|
release_date="2026-09-08T12:00:00Z",
|
|
release_notes_url="https://updates.example.test/releases/v1.8.0",
|
|
status="active",
|
|
title="Launcher 1.8.0",
|
|
)
|
|
history = LauncherRemoteHistory(
|
|
generated_at="2026-09-08T12:01:00Z",
|
|
history_url="https://updates.example.test/history.json",
|
|
releases=(record,),
|
|
history_sha256="b" * 64,
|
|
)
|
|
manifest = UpdateManifest(
|
|
schema=1,
|
|
channel="stable",
|
|
version="1.8.0",
|
|
minimum_updater_version="0.5.0",
|
|
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
|
|
archive_sha256="a" * 64,
|
|
archive_size=123,
|
|
published_at="2026-09-08T12:00:00Z",
|
|
notes_url=record.release_notes_url,
|
|
tag="v1.8.0",
|
|
)
|
|
with (
|
|
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
|
|
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
|
|
patch(
|
|
"ptlinux.self_update.shutil.disk_usage",
|
|
return_value=SimpleNamespace(free=0),
|
|
),
|
|
patch("ptlinux.self_update._download_archive") as download,
|
|
self.assertRaisesRegex(LauncherUpdateError, "insufficient free disk space"),
|
|
):
|
|
rollback_launcher_update(paths, target_version="1.8.0")
|
|
|
|
download.assert_not_called()
|
|
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
|
|
|
|
def test_failed_link_activation_restores_the_original_snapshot(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.8.0"
|
|
previous = paths.launcher_releases / "1.7.4"
|
|
target = paths.launcher_releases / "1.9.0"
|
|
for release in (current, previous, target):
|
|
make_release(release, release.name)
|
|
paths.launcher_current.symlink_to(Path("releases") / current.name)
|
|
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
|
|
from ptlinux import self_update
|
|
|
|
real_replace = self_update._replace_release_link
|
|
calls = 0
|
|
|
|
def fail_current_once(link: Path, release: Path, releases: Path) -> None:
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 2:
|
|
raise OSError("simulated current-link failure")
|
|
real_replace(link, release, releases)
|
|
|
|
with (
|
|
patch(
|
|
"ptlinux.self_update._replace_release_link",
|
|
side_effect=fail_current_once,
|
|
),
|
|
self.assertRaisesRegex(OSError, "simulated current-link failure"),
|
|
):
|
|
_activate_release(paths, target)
|
|
|
|
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
|
|
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
|
|
self.assertFalse(paths.launcher_update_transaction.exists())
|
|
|
|
def test_crash_journal_restores_original_links_even_if_target_is_missing(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.8.0"
|
|
previous = paths.launcher_releases / "1.7.4"
|
|
for release in (current, previous):
|
|
make_release(release, release.name)
|
|
paths.launcher_current.symlink_to(Path("releases/1.9.0"))
|
|
paths.launcher_previous.symlink_to(Path("releases") / current.name)
|
|
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
|
|
paths.launcher_update_transaction.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": 1,
|
|
"state": "applying",
|
|
"current": current.name,
|
|
"previous": previous.name,
|
|
"target": "1.9.0",
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with patch("ptlinux.self_update._send_launcher_release_event") as send:
|
|
_recover_interrupted_activation(paths)
|
|
|
|
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
|
|
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
|
|
self.assertFalse(paths.launcher_update_transaction.exists())
|
|
self.assertEqual(
|
|
[call.args[0] for call in send.call_args_list],
|
|
["rollback_restore_started", "rollback_restore_success"],
|
|
)
|
|
|
|
def test_completed_activation_journal_is_cleaned_without_rolling_back(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.8.0"
|
|
previous = paths.launcher_releases / "1.7.4"
|
|
target = paths.launcher_releases / "1.9.0"
|
|
for release in (current, previous, target):
|
|
make_release(release, release.name)
|
|
paths.launcher_current.symlink_to(Path("releases") / target.name)
|
|
paths.launcher_previous.symlink_to(Path("releases") / current.name)
|
|
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
|
|
paths.launcher_update_transaction.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema": 1,
|
|
"state": "activated",
|
|
"current": current.name,
|
|
"previous": previous.name,
|
|
"target": target.name,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with patch("ptlinux.self_update._send_launcher_release_event") as send:
|
|
_recover_interrupted_activation(paths)
|
|
|
|
self.assertEqual(paths.launcher_current.resolve(), target.resolve())
|
|
self.assertEqual(paths.launcher_previous.resolve(), current.resolve())
|
|
self.assertFalse(paths.launcher_update_transaction.exists())
|
|
send.assert_not_called()
|
|
|
|
def test_diagnostic_write_failure_does_not_reverse_completed_rollback(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = app_paths(root)
|
|
current = paths.launcher_releases / "1.8.0"
|
|
previous = paths.launcher_releases / "1.7.4"
|
|
for release in (current, previous):
|
|
make_release(release, release.name)
|
|
paths.launcher_current.symlink_to(Path("releases") / current.name)
|
|
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
|
|
|
|
with patch(
|
|
"ptlinux.self_update._write_json",
|
|
side_effect=OSError("read-only diagnostics directory"),
|
|
):
|
|
result = rollback_launcher_update(paths)
|
|
|
|
self.assertEqual(paths.launcher_current.resolve(), previous.resolve())
|
|
self.assertFalse(result.state_recorded)
|
|
self.assertIn("could not be saved", result.warning or "")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|