DazedTL/tests/test_bundled_updates.py

358 lines
14 KiB
Python

"""Integration tests for bundled-only utility updates and tool-only auto-update checks.
These tests use temporary directories and mocks — no live network or GUI window.
They guard the security model: third-party utilities must not fetch upstream unless
a maintainer explicitly uses --force / --refresh-offline / --refresh-all.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from util.wolfdawn import WolfDawnError, bundled_binary_path, ensure_wolf_binary
class UpdateThreadInstallFilterTests(unittest.TestCase):
"""Tool update must refresh shipped data/ assets while protecting user state."""
def test_installs_translation_contexts_and_skills(self):
from gui.main import UpdateThread
for rel in (
"data/translation_contexts.json",
"data/skills/system.md",
"data/skills/project_setup.md",
"data/vocab_base.txt",
"gui/main.py",
"gameupdate/GameUpdate.bat",
"gameupdate/gameupdate/patch.ps1",
):
with self.subTest(rel=rel):
self.assertTrue(UpdateThread.should_install(Path(rel)))
def test_skips_user_local_data_and_workdir(self):
from gui.main import UpdateThread
for rel in (
"data/vocab.txt",
"data/last_update_sha.txt",
"data/wolf_speakers.json",
"data/wolf_safe_notes.json",
".env",
"venv/pyvenv.cfg",
"log/translations.txt",
"files/Map001.json",
"translated/Map001.json",
):
with self.subTest(rel=rel):
self.assertFalse(UpdateThread.should_install(Path(rel)))
class CheckToolUpdateTests(unittest.TestCase):
"""Tool update check compares remote SHA to data/last_update_sha.txt only."""
def test_returns_sha_when_remote_differs(self):
with tempfile.TemporaryDirectory() as raw:
sha_file = Path(raw) / "last_update_sha.txt"
sha_file.write_text("aaaaaaaa", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.object(UpdateThread, "SHA_FILE", str(sha_file)):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value="bbbbbbbb"
):
self.assertEqual(check_tool_update(), "bbbbbbbb")
def test_returns_none_when_up_to_date(self):
with tempfile.TemporaryDirectory() as raw:
sha_file = Path(raw) / "last_update_sha.txt"
sha_file.write_text("same1234", encoding="utf-8")
from gui.main import UpdateThread, check_tool_update
with patch.object(UpdateThread, "SHA_FILE", str(sha_file)):
with patch.object(
UpdateThread, "fetch_latest_sha", return_value="same1234"
):
self.assertIsNone(check_tool_update())
def test_returns_none_on_fetch_failure(self):
from gui.main import UpdateThread, check_tool_update
with patch.object(
UpdateThread, "fetch_latest_sha", side_effect=OSError("offline")
):
self.assertIsNone(check_tool_update())
class AceBundledOnlyTests(unittest.TestCase):
def _ace_paths(self, base: Path) -> dict:
offline = base / "offline"
offline.mkdir(parents=True, exist_ok=True)
return {
"OFFLINE_DIR": offline,
"RV2JSON_LOCAL": base / "RV2JSON.exe",
"DECRYPTER_LOCAL": base / "RPGMakerDecrypter-cli.exe",
"DECRYPTER_LEGACY": base / "RPGMakerDecrypter.exe",
"VERSION_FILE": base / ".tools_version.json",
}
def test_seed_overwrites_stale_runtime_copy(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
(paths["OFFLINE_DIR"] / "RV2JSON.exe").write_bytes(b"OFFLINE")
paths["RV2JSON_LOCAL"].write_bytes(b"STALE")
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
ace._seed_from_offline(paths["RV2JSON_LOCAL"], "RV2JSON.exe", None)
self.assertEqual(paths["RV2JSON_LOCAL"].read_bytes(), b"OFFLINE")
def test_ensure_rv2json_does_not_contact_upstream(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
(paths["OFFLINE_DIR"] / "RV2JSON.exe").write_bytes(b"OK")
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(
ace,
"_rv2json_upstream",
side_effect=AssertionError("must not fetch upstream"),
):
self.assertTrue(ace.ensure_rv2json(force=False))
self.assertEqual(paths["RV2JSON_LOCAL"].read_bytes(), b"OK")
def test_ensure_rv2json_fails_when_nothing_bundled(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(
ace,
"_rv2json_upstream",
side_effect=AssertionError("must not fetch upstream"),
):
self.assertFalse(ace.ensure_rv2json(force=False))
def test_force_delegates_to_upstream_fetch(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._ace_paths(base)
import util.ace.update_tools as ace
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(ace, "_fetch_rv2json_upstream", return_value=True) as fetch:
self.assertTrue(ace.ensure_rv2json(force=True))
fetch.assert_called_once()
class ForgeBundledOnlyTests(unittest.TestCase):
def _forge_paths(self, base: Path, *, with_plugins: bool) -> dict:
upstream = base / "upstream"
upstream.mkdir(parents=True, exist_ok=True)
plugin_body = b"// forge plugin\n"
paths = {
"_PKG_ROOT": base,
"UPSTREAM_DIR": upstream,
"VERSION_FILE": base / ".forge_version.json",
}
if with_plugins:
for name in ("Forge_MV.js", "Forge_MZ.js"):
(base / name).write_bytes(plugin_body)
(upstream / name).write_bytes(plugin_body)
return paths
def test_ensure_forge_plugins_does_not_contact_upstream(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=True)
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
with patch.object(
forge,
"_upstream_commit",
side_effect=AssertionError("must not fetch upstream"),
):
with patch.object(
forge,
"refresh_forge_plugins",
side_effect=AssertionError("must not refresh upstream"),
):
self.assertTrue(forge.ensure_forge_plugins(force=False))
def test_ensure_forge_plugins_seeds_from_upstream_folder(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=False)
(paths["UPSTREAM_DIR"] / "Forge_MV.js").write_bytes(b"// mv")
(paths["UPSTREAM_DIR"] / "Forge_MZ.js").write_bytes(b"// mz")
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
self.assertTrue(forge.ensure_forge_plugins(force=False))
self.assertTrue((base / "Forge_MV.js").is_file())
self.assertTrue((base / "Forge_MZ.js").is_file())
def test_ensure_forge_plugins_fails_when_missing_everywhere(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=False)
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
self.assertFalse(forge.ensure_forge_plugins(force=False))
def test_force_calls_refresh_forge_plugins(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
paths = self._forge_paths(base, with_plugins=True)
import util.forge.update_tools as forge
with patch.multiple("util.forge.update_tools", **paths):
with patch.object(forge, "refresh_forge_plugins", return_value=True) as refresh:
self.assertTrue(forge.ensure_forge_plugins(force=True))
refresh.assert_called_once()
class WolfBundledOnlyTests(unittest.TestCase):
def test_ensure_wolf_binary_uses_bundled_path(self):
with tempfile.TemporaryDirectory() as raw:
wolf = Path(raw) / "wolf"
wolf.write_bytes(b"\x7fELF")
with patch("util.wolfdawn.bundled_binary_path", return_value=wolf):
self.assertEqual(ensure_wolf_binary(), wolf)
def test_ensure_wolf_binary_makes_bundled_copy_executable(self):
with tempfile.TemporaryDirectory() as raw:
wolf = Path(raw) / "wolf"
wolf.write_bytes(b"\x7fELF")
wolf.chmod(0o644)
with patch("util.wolfdawn.bundled_binary_path", return_value=wolf):
with patch("util.wolfdawn._platform_dir", return_value="linux"):
ensure_wolf_binary()
self.assertTrue(wolf.stat().st_mode & 0o111)
def test_ensure_wolf_binary_raises_when_missing(self):
missing = Path("/nonexistent/wolf/missing")
with patch("util.wolfdawn.bundled_binary_path", return_value=missing):
with self.assertRaises(WolfDawnError) as ctx:
ensure_wolf_binary()
self.assertIn("Update DazedMTLTool", str(ctx.exception))
def test_ensure_wolf_binary_never_downloads(self):
missing = Path("/nonexistent/wolf/missing")
with patch("util.wolfdawn.bundled_binary_path", return_value=missing):
with patch(
"util.wolfdawn.download_wolf_binary",
side_effect=AssertionError("must not download at runtime"),
):
with self.assertRaises(WolfDawnError):
ensure_wolf_binary()
def test_ensure_wolfdawn_binary_does_not_refresh_by_default(self):
import util.wolfdawn.update_tools as wd_update
wolf = bundled_binary_path()
if not wolf.is_file():
self.skipTest(f"no bundled wolf binary at {wolf}")
with patch.object(
wd_update,
"refresh_wolfdawn_binary",
side_effect=AssertionError("must not refresh upstream"),
):
self.assertTrue(wd_update.ensure_wolfdawn_binary(force=False))
def test_ensure_wolfdawn_binary_force_calls_refresh(self):
import util.wolfdawn.update_tools as wd_update
with patch.object(wd_update, "refresh_wolfdawn_binary", return_value=True) as refresh:
with patch.object(wd_update, "bundled_binary_path", return_value=Path("/wolf")):
with patch("pathlib.Path.is_file", return_value=True):
self.assertTrue(wd_update.ensure_wolfdawn_binary(force=True))
refresh.assert_called_once()
class MaintainerRefreshTests(unittest.TestCase):
"""Maintainer CLI paths should still wire upstream fetch when explicitly invoked."""
def test_refresh_offline_bundle_downloads_into_offline_dir(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
offline = base / "offline"
offline.mkdir()
import util.ace.update_tools as ace
paths = {
"OFFLINE_DIR": offline,
"RV2JSON_LOCAL": base / "RV2JSON.exe",
"DECRYPTER_LOCAL": base / "RPGMakerDecrypter-cli.exe",
"DECRYPTER_LEGACY": base / "RPGMakerDecrypter.exe",
"VERSION_FILE": base / ".tools_version.json",
}
def fake_download(url: str, dest: Path) -> None:
dest.write_bytes(b"downloaded")
with patch.multiple("util.ace.update_tools", **paths):
with patch.object(
ace,
"_rv2json_upstream",
return_value=("sha1", "https://example/rv2json"),
):
with patch.object(
ace,
"_decrypter_upstream",
return_value=("v1", "https://example/dec"),
):
with patch.object(ace, "_download", side_effect=fake_download):
self.assertTrue(ace.refresh_offline_bundle(log_fn=None))
self.assertEqual((offline / "RV2JSON.exe").read_bytes(), b"downloaded")
self.assertEqual(
(offline / ace.DECRYPTER_ASSET).read_bytes(), b"downloaded"
)
def test_refresh_forge_plugins_installs_downloaded_bytes(self):
with tempfile.TemporaryDirectory() as raw:
base = Path(raw)
upstream = base / "upstream"
upstream.mkdir()
import util.forge.update_tools as forge
paths = {
"_PKG_ROOT": base,
"UPSTREAM_DIR": upstream,
"VERSION_FILE": base / ".forge_version.json",
}
plugin = b"// downloaded forge\n"
with patch.multiple("util.forge.update_tools", **paths):
with patch.object(forge, "_upstream_commit", return_value="abc123"):
with patch.object(forge, "_fetch_plugins", return_value=plugin):
self.assertTrue(forge.refresh_forge_plugins(log_fn=None))
self.assertEqual((base / "Forge_MV.js").read_bytes(), plugin)
self.assertEqual((upstream / "Forge_MZ.js").read_bytes(), plugin)
if __name__ == "__main__":
unittest.main(verbosity=2)