from __future__ import annotations import tempfile import unittest from pathlib import Path from zipfile import ZipFile from plugin_helper.checker import check_lock from plugin_helper.fsutil import sha256_file from plugin_helper.installer import apply_plan, uninstall_plugin from plugin_helper.instances import get_instance, list_instances from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin from plugin_helper.planner import create_plan from plugin_helper.scanner import scan_instance from plugin_helper.state import downloads_dir, load_installed_state from plugin_helper.userdata import backup_userdata class PluginHelperTests(unittest.TestCase): def test_instances_and_scan(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) inst = root / "1.40.8" (inst / "Beat Saber_Data").mkdir(parents=True) (inst / "Plugins").mkdir() (inst / "Libs").mkdir() (inst / "UserData").mkdir() (inst / "Plugins" / "Example.dll").write_bytes(b"dll") (root / "not-an-instance").mkdir() instances = list_instances(root) self.assertEqual([item.name for item in instances], ["1.40.8"]) self.assertEqual(get_instance(root, "1.40.8").path, inst) scan = scan_instance(inst, include_hashes=True) self.assertEqual(scan["counts"]["plugins"], 1) self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll") self.assertIn("sha256", scan["files"][0]) def test_plan_apply_and_uninstall_dll(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) instance = work / "instances" / "1.40.8" state = work / "state" instance.mkdir(parents=True) (instance / "Beat Saber_Data").mkdir() (instance / "Plugins").mkdir() asset = downloads_dir(state, "1.40.8") / "Example.dll" asset.write_bytes(b"managed dll") registry = Registry( { "example": RegistryPlugin( id="example", name="Example", repo="owner/example", asset_patterns=("*.dll",), install_strategy="dll-to-plugins", ) } ) lockfile = Lockfile( beat_saber_version="1.40.8", instance="1.40.8", plugins=( LockedPlugin( id="example", repo="owner/example", tag="v1.0.0", asset="Example.dll", sha256=sha256_file(asset), ), ), ) plan, plan_path = create_plan( instance="1.40.8", instance_path=instance, beat_saber_version="1.40.8", registry=registry, lockfile=lockfile, state_root=state, repo_root=work, ) self.assertTrue(plan_path.exists()) self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll") result = apply_plan(plan, state) self.assertEqual(len(result["applied"]), 1) self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") installed = load_installed_state(state, "1.40.8") self.assertIn("example", installed["plugins"]) removed = uninstall_plugin("1.40.8", instance, state, "example") self.assertEqual(removed["removed"], ["Plugins/Example.dll"]) self.assertFalse((instance / "Plugins" / "Example.dll").exists()) def test_zip_to_pending_targets_ipa_pending(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) instance = work / "instances" / "1.40.8" state = work / "state" instance.mkdir(parents=True) (instance / "Beat Saber_Data").mkdir() asset = downloads_dir(state, "1.40.8") / "Example.zip" with ZipFile(asset, "w") as archive: archive.writestr("Plugins/Example.dll", b"dll") registry = Registry( { "example": RegistryPlugin( id="example", name="Example", repo=None, install_strategy="zip-to-pending", ) } ) lockfile = Lockfile( beat_saber_version="1.40.8", instance="1.40.8", plugins=( LockedPlugin( id="example", repo=None, tag=None, asset="Example.zip", sha256=sha256_file(asset), ), ), ) plan, _ = create_plan( instance="1.40.8", instance_path=instance, beat_saber_version="1.40.8", registry=registry, lockfile=lockfile, state_root=state, repo_root=work, ) self.assertEqual(plan["changes"][0]["target"], "IPA/Pending/Plugins/Example.dll") apply_plan(plan, state) self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll") def test_zip_member_cannot_escape_instance(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) instance = work / "instances" / "1.40.8" state = work / "state" instance.mkdir(parents=True) (instance / "Beat Saber_Data").mkdir() asset = downloads_dir(state, "1.40.8") / "Bad.zip" with ZipFile(asset, "w") as archive: archive.writestr("../Bad.dll", b"dll") registry = Registry( { "bad": RegistryPlugin( id="bad", name="Bad", repo=None, install_strategy="zip-to-pending", ) } ) lockfile = Lockfile( beat_saber_version="1.40.8", instance="1.40.8", plugins=( LockedPlugin( id="bad", repo=None, tag=None, asset="Bad.zip", sha256=sha256_file(asset), ), ), ) with self.assertRaises(ValueError): create_plan( instance="1.40.8", instance_path=instance, beat_saber_version="1.40.8", registry=registry, lockfile=lockfile, state_root=state, repo_root=work, ) def test_userdata_backup_contains_manifest(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) instance = root / "1.40.8" state = root / "state" (instance / "UserData").mkdir(parents=True) (instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8") result = backup_userdata("1.40.8", instance, state) self.assertTrue(Path(result["archive"]).exists()) self.assertEqual(result["manifest"]["fileCount"], 1) def test_check_reports_missing_asset(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) state = work / "state" registry = Registry( { "example": RegistryPlugin( id="example", name="Example", repo=None, install_strategy="dll-to-plugins", ) } ) lockfile = Lockfile( beat_saber_version="1.40.8", instance="1.40.8", plugins=( LockedPlugin( id="example", repo=None, tag="v1.0.0", asset="Missing.dll", sha256=None, ), ), ) result = check_lock( instance="1.40.8", registry=registry, lockfile=lockfile, state_root=state, repo_root=work, ) self.assertEqual(result["summary"]["errors"], 1) self.assertEqual(result["plugins"][0]["status"], "error") if __name__ == "__main__": unittest.main()