#!/usr/bin/env python3 """Deterministic tests for the emulator sidecar control surface. `resolve_game_path` is the last defence between an authenticated control request and a process launch inside the sidecar, and `desktop_display` decides whether a launch reaches the live desktop at all. Both are covered here so a future sidecar (Azahar, xemu, Cemu, Vita3K) inherits proven behaviour instead of a copied one. Run with: python -m unittest discover -s tests/controllers -t . """ import json import os import sys import tempfile import threading import unittest import urllib.error import urllib.request from http.server import ThreadingHTTPServer from pathlib import Path from unittest import mock DEPLOY = Path(__file__).resolve().parents[2] / "deploy" sys.path.insert(0, str(DEPLOY)) import ludarium_sidecar as sidecar # noqa: E402 def load_controller(module_name, file_name, token_variable, token="test-token"): """Import a controller script under a controlled games root and control token.""" import importlib.util root = tempfile.mkdtemp(prefix=f"ludarium-{module_name}-") environment = {token_variable: token, "LUDARIUM_GAMES_ROOT": root} with mock.patch.dict(os.environ, environment, clear=False): spec = importlib.util.spec_from_file_location(module_name, DEPLOY / file_name) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module, Path(root) class ResolveGamePathTests(unittest.TestCase): def setUp(self): self.root = Path(tempfile.mkdtemp(prefix="ludarium-games-")) (self.root / "gamecube").mkdir() self.game = self.root / "gamecube" / "title.iso" self.game.write_bytes(b"\0" * 16) self.extensions = {".iso", ".rvz"} self.platforms = {"gamecube", "wii"} def resolve(self, value, platforms=None): return sidecar.resolve_game_path(value, self.root, self.extensions, platforms) def test_accepts_an_allowlisted_file_inside_the_mount(self): self.assertEqual(self.resolve("gamecube/title.iso", self.platforms), self.game.resolve()) def test_rejects_an_absolute_path(self): with self.assertRaises(ValueError): self.resolve("/etc/passwd") def test_rejects_an_empty_path(self): with self.assertRaises(ValueError): self.resolve("") def test_rejects_parent_traversal(self): with self.assertRaises(ValueError): self.resolve("gamecube/../../etc/passwd", self.platforms) def test_normalises_a_current_directory_segment(self): # PurePosixPath drops "." segments, so the path still resolves inside the mount. self.assertEqual(self.resolve("gamecube/./title.iso", self.platforms), self.game.resolve()) def test_rejects_a_leading_parent_segment(self): with self.assertRaises(ValueError): self.resolve("../gamecube/title.iso", self.platforms) def test_rejects_an_unlisted_platform_directory(self): with self.assertRaises(ValueError): self.resolve("switch/title.iso", self.platforms) def test_rejects_a_bare_file_when_a_platform_directory_is_required(self): with self.assertRaises(ValueError): self.resolve("title.iso", self.platforms) def test_rejects_a_disallowed_extension(self): (self.root / "gamecube" / "notes.txt").write_text("x") with self.assertRaises(ValueError): self.resolve("gamecube/notes.txt", self.platforms) def test_extension_matching_is_case_insensitive(self): upper = self.root / "gamecube" / "Upper.ISO" upper.write_bytes(b"\0" * 16) self.assertEqual(self.resolve("gamecube/Upper.ISO", self.platforms), upper.resolve()) def test_rejects_a_missing_file(self): with self.assertRaises(ValueError): self.resolve("gamecube/absent.iso", self.platforms) def test_rejects_a_directory(self): (self.root / "gamecube" / "disc.iso").mkdir() with self.assertRaises(ValueError): self.resolve("gamecube/disc.iso", self.platforms) @unittest.skipUnless(hasattr(os, "symlink") and sys.platform != "win32", "symlink creation needs privileges on Windows") def test_rejects_a_symlink_that_escapes_the_mount(self): outside = Path(tempfile.mkdtemp(prefix="ludarium-outside-")) / "secret.iso" outside.write_bytes(b"\0" * 16) os.symlink(outside, self.root / "gamecube" / "link.iso") with self.assertRaises(ValueError): self.resolve("gamecube/link.iso", self.platforms) def test_accepts_a_bare_file_when_no_platform_directory_is_required(self): game = self.root / "title.rvz" game.write_bytes(b"\0" * 16) self.assertEqual(self.resolve("title.rvz"), game.resolve()) class DesktopDisplayTests(unittest.TestCase): """The live Eden defect: after a restart the newest socket is not the active display.""" def test_prefers_the_display_published_by_the_running_process(self): def run(argv, **kwargs): if argv[0].endswith("pgrep"): return mock.Mock(returncode=0, stdout="4242") return mock.Mock(returncode=0, stdout=b"HOME=/config\0DISPLAY=:7\0LANG=C\0") with mock.patch.object(sidecar.subprocess, "run", side_effect=run): self.assertEqual(sidecar.desktop_display("eden"), ":7") def test_falls_back_to_the_newest_socket_when_no_process_is_running(self): with mock.patch.object(sidecar.subprocess, "run", return_value=mock.Mock(returncode=1, stdout="")), \ mock.patch.object(sidecar.Path, "glob", autospec=True) as glob: sockets = [] for number, mtime in ((0, 100), (9, 50)): socket = mock.Mock() socket.name = f"X{number}" socket.stat.return_value = mock.Mock(st_mtime_ns=mtime) sockets.append(socket) glob.return_value = sockets # The newest socket wins over the highest number, matching the live fallback. self.assertEqual(sidecar.desktop_display("eden"), ":0") def test_falls_back_to_the_environment_when_nothing_is_discoverable(self): with mock.patch.object(sidecar.subprocess, "run", return_value=mock.Mock(returncode=1, stdout="")), \ mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \ mock.patch.dict(os.environ, {"DISPLAY": ":3"}, clear=False): self.assertEqual(sidecar.desktop_display("eden"), ":3") def test_ignores_a_malformed_published_display(self): def run(argv, **kwargs): if argv[0].endswith("pgrep"): return mock.Mock(returncode=0, stdout="4242") return mock.Mock(returncode=0, stdout=b"DISPLAY=:not-a-number\0") with mock.patch.object(sidecar.subprocess, "run", side_effect=run), \ mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \ mock.patch.dict(os.environ, {"DISPLAY": ":1"}, clear=False): self.assertEqual(sidecar.desktop_display("eden"), ":1") def test_survives_a_failing_process_lookup(self): with mock.patch.object(sidecar.subprocess, "run", side_effect=OSError("no pgrep")), \ mock.patch.object(sidecar.Path, "glob", autospec=True, return_value=[]), \ mock.patch.dict(os.environ, {"DISPLAY": ":2"}, clear=False): self.assertEqual(sidecar.desktop_display("eden"), ":2") class VisibleWindowTests(unittest.TestCase): def test_prefers_the_launched_process_id(self): calls = [] def run(argv, **kwargs): calls.append(argv) return mock.Mock(returncode=0, stdout="123\n") with mock.patch.object(sidecar.subprocess, "run", side_effect=run): self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), ["123"]) self.assertEqual(len(calls), 1) self.assertIn("--pid", calls[0]) def test_falls_back_to_the_window_name(self): def run(argv, **kwargs): return mock.Mock(returncode=0, stdout="" if "--pid" in argv else "456\n") with mock.patch.object(sidecar.subprocess, "run", side_effect=run): self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), ["456"]) def test_returns_nothing_when_no_window_matches(self): with mock.patch.object(sidecar.subprocess, "run", return_value=mock.Mock(returncode=1, stdout="")): self.assertEqual(sidecar.visible_windows({}, pid=99, name="Dolphin"), []) def build_runtime(root, **overrides): options = dict( name="dolphin", process_name="dolphin-emu", launch_argv=lambda target: ["/usr/bin/dolphin-emu", "--batch", "--exec", str(target)], games=root, extensions={".iso"}, hotkeys={"pause-resume": "F10"}, save_mode="native-and-savestate-persistent", platforms={"gamecube"}, window_name="Dolphin", ready_seconds=0, save_paths=()) options.update(overrides) return sidecar.EmulatorRuntime(**options) class EmulatorRuntimeTests(unittest.TestCase): def setUp(self): self.root = Path(tempfile.mkdtemp(prefix="ludarium-runtime-")) (self.root / "gamecube").mkdir() (self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16) self.runtime = build_runtime(self.root) self.environment = mock.patch.object( sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}) self.environment.start() self.addCleanup(self.environment.stop) def test_reports_no_running_title_before_a_launch(self): self.assertEqual(self.runtime.status(), {"running": False, "pid": None, "saveRoot": None, "savePaths": [], "saveMode": "native-and-savestate-persistent", "saveData": False}) def test_launch_returns_the_process_id_and_reports_running(self): process = mock.Mock(pid=4321) process.poll.return_value = None with mock.patch.object(sidecar.subprocess, "Popen", return_value=process) as popen: self.assertEqual(self.runtime.launch(self.root / "gamecube" / "title.iso"), 4321) argv = popen.call_args.args[0] self.assertEqual(argv[:2], [sidecar.SETUIDGID, sidecar.RUNTIME_USER]) self.assertIn("--batch", argv) self.assertTrue(self.runtime.status()["running"]) def test_launch_reports_an_early_exit(self): process = mock.Mock(pid=4321, returncode=1) process.poll.return_value = 1 with mock.patch.object(sidecar.subprocess, "Popen", return_value=process): self.assertIsNone(self.runtime.launch(self.root / "gamecube" / "title.iso")) def test_launch_terminates_a_previously_running_title(self): first = mock.Mock(pid=1) first.poll.return_value = None second = mock.Mock(pid=2) second.poll.return_value = None with mock.patch.object(sidecar.subprocess, "Popen", side_effect=[first, second]): self.runtime.launch(self.root / "gamecube" / "title.iso") self.runtime.launch(self.root / "gamecube" / "title.iso") first.send_signal.assert_called_once_with(sidecar.signal.SIGTERM) def test_stop_reports_false_when_nothing_runs(self): self.assertFalse(self.runtime.stop()) def test_stop_kills_a_title_that_ignores_termination(self): process = mock.Mock(pid=7) process.poll.return_value = None process.wait.side_effect = [sidecar.subprocess.TimeoutExpired("dolphin-emu", 8), 0] with mock.patch.object(sidecar.subprocess, "Popen", return_value=process): self.runtime.launch(self.root / "gamecube" / "title.iso") self.assertTrue(self.runtime.stop()) process.kill.assert_called_once() self.assertFalse(self.runtime.status()["running"]) def test_hotkey_targets_the_launched_process(self): process = mock.Mock(pid=555) process.poll.return_value = None sent = [] def run(argv, **kwargs): sent.append(argv) return mock.Mock(returncode=0, stdout="900\n") with mock.patch.object(sidecar.subprocess, "Popen", return_value=process): self.runtime.launch(self.root / "gamecube" / "title.iso") with mock.patch.object(sidecar.subprocess, "run", side_effect=run): self.assertTrue(self.runtime.hotkey("F10")) self.assertIn("555", sent[0]) self.assertEqual(sent[-1][-3:], ["900", "key", "F10"]) def test_hotkey_reports_false_without_a_window(self): with mock.patch.object(sidecar.subprocess, "run", return_value=mock.Mock(returncode=1, stdout="")): self.assertFalse(self.runtime.hotkey("F10")) class ControlSurfaceTests(unittest.TestCase): """Covers the exact status codes the Ludarium API depends on.""" def setUp(self): self.root = Path(tempfile.mkdtemp(prefix="ludarium-surface-")) (self.root / "gamecube").mkdir() (self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16) self.runtime = build_runtime(self.root) self.server = ThreadingHTTPServer( ("127.0.0.1", 0), sidecar.build_handler(self.runtime, "secret-token")) threading.Thread(target=self.server.serve_forever, daemon=True).start() self.addCleanup(self.server.server_close) self.addCleanup(self.server.shutdown) self.base = f"http://127.0.0.1:{self.server.server_address[1]}" def call(self, path, method="GET", body=None, token="secret-token", raw=None): payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else None) request = urllib.request.Request(self.base + path, data=payload, method=method) if token: request.add_header("X-Ludarium-Control-Token", token) if payload is not None: request.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(request, timeout=5) as response: return response.status, json.loads(response.read() or b"{}") except urllib.error.HTTPError as error: return error.code, json.loads(error.read() or b"{}") def test_health_needs_no_token(self): self.assertEqual(self.call("/health", token=None), (200, {"status": "healthy"})) def test_status_needs_a_token(self): self.assertEqual(self.call("/v1/status", token=None)[0], 404) self.assertEqual(self.call("/v1/status", token="wrong")[0], 404) def test_status_reports_the_save_mode(self): status, body = self.call("/v1/status") self.assertEqual(status, 200) self.assertEqual(body, {"running": False, "pid": None, "saveRoot": None, "savePaths": [], "saveMode": "native-and-savestate-persistent", "saveData": False}) def test_unknown_get_route_is_not_found(self): self.assertEqual(self.call("/v1/anything")[0], 404) def test_unauthenticated_post_is_not_found(self): self.assertEqual(self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"}, token=None)[0], 404) def test_launch_accepts_an_allowlisted_game(self): process = mock.Mock(pid=31337) process.poll.return_value = None with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \ mock.patch.object(sidecar.subprocess, "Popen", return_value=process): status, body = self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"}) self.assertEqual(status, 202) self.assertEqual(body["pid"], 31337) self.assertEqual(body["saveMode"], "native-and-savestate-persistent") def test_launch_rejects_traversal(self): status, body = self.call("/v1/launch", "POST", {"path": "gamecube/../../etc/passwd"}) self.assertEqual(status, 400) self.assertIn("Unsafe", body["message"]) def test_launch_reports_an_early_exit_as_unavailable(self): process = mock.Mock(pid=1, returncode=3) process.poll.return_value = 3 with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \ mock.patch.object(sidecar.subprocess, "Popen", return_value=process): self.assertEqual(self.call("/v1/launch", "POST", {"path": "gamecube/title.iso"})[0], 503) def test_oversized_body_is_rejected(self): self.assertEqual(self.call("/v1/launch", "POST", raw=b"{" + b"a" * 9000)[0], 400) def test_empty_body_is_rejected(self): self.assertEqual(self.call("/v1/launch", "POST", raw=b"")[0], 400) def test_non_object_body_is_rejected(self): self.assertEqual(self.call("/v1/action", "POST", raw=b'"stop"')[0], 400) def test_unsupported_action_is_rejected(self): self.assertEqual(self.call("/v1/action", "POST", {"action": "self-destruct"})[0], 400) def test_stop_without_a_running_title_conflicts(self): self.assertEqual(self.call("/v1/action", "POST", {"action": "stop"})[0], 409) def test_hotkey_without_a_window_conflicts(self): with mock.patch.object(sidecar, "runtime_environment", return_value={"DISPLAY": ":1"}), \ mock.patch.object(sidecar.subprocess, "run", return_value=mock.Mock(returncode=1, stdout="")): self.assertEqual(self.call("/v1/action", "POST", {"action": "pause-resume"})[0], 409) class ControllerIdentityTests(unittest.TestCase): """Each shipped controller must keep its exact runtime contract.""" def test_dolphin_controller(self): module, root = load_controller("dolphin_controller", "dolphin-controller.py", "LUDARIUM_DOLPHIN_CONTROL_TOKEN") runtime = module.RUNTIME # Dolphin's profile also holds a shader cache; only save-bearing directories are captured. self.assertEqual(runtime.save_paths, ("GC", "Wii", "StateSaves")) self.assertEqual(runtime.name, "dolphin") self.assertEqual(runtime.process_name, "dolphin-emu") self.assertEqual(runtime.platforms, {"gamecube", "wii"}) self.assertEqual(runtime.extensions, {".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"}) self.assertEqual(set(runtime.hotkeys), {"pause-resume", "fullscreen", "save-state", "load-state"}) self.assertEqual(runtime.save_mode, "native-and-savestate-persistent") self.assertEqual(runtime.launch_argv("/games/wii/a.iso"), ["/usr/bin/dolphin-emu", "--batch", "--exec", "/games/wii/a.iso"]) self.assertEqual(Path(runtime.games), root) def test_eden_controller(self): module, root = load_controller("eden_controller", "eden-controller.py", "LUDARIUM_EDEN_CONTROL_TOKEN") runtime = module.RUNTIME self.assertEqual(runtime.save_paths, ("nand/user/save",)) self.assertEqual(runtime.name, "eden") self.assertEqual(runtime.process_name, "eden") self.assertIsNone(runtime.platforms) self.assertEqual(runtime.extensions, {".xci", ".nsp"}) self.assertEqual(set(runtime.hotkeys), {"pause-resume", "fullscreen"}) self.assertEqual(runtime.save_mode, "native-persistent") self.assertEqual(runtime.launch_argv("/games/a.xci"), ["/usr/bin/eden", "-f", "-g", "/games/a.xci"]) self.assertEqual(Path(runtime.games), root) def test_a_controller_refuses_to_serve_without_a_token(self): module, _ = load_controller("eden_untokened", "eden-controller.py", "LUDARIUM_EDEN_CONTROL_TOKEN", token="") with self.assertRaises(SystemExit): module.main() if __name__ == "__main__": unittest.main() class SaveDataTests(unittest.TestCase): """Round-tripping an emulator's app-owned save directories through the vault.""" SAVE_PATHS = ("nand/user/save", "StateSaves") def setUp(self): self.root = Path(tempfile.mkdtemp(prefix="ludarium-saves-")) (self.root / "nand" / "user" / "save").mkdir(parents=True) (self.root / "nand" / "user" / "save" / "save.bin").write_bytes(b"progress" * 64) (self.root / "StateSaves").mkdir() (self.root / "StateSaves" / "slot1.sav").write_bytes(b"state") # A profile also holds material a capture must never carry: on a real Dolphin install this # is a shader cache far larger than one bounded revision. (self.root / "Cache").mkdir() (self.root / "Cache" / "shaders.bin").write_bytes(b"x" * 4096) (self.root / "config.ini").write_text("[General]\n") def export(self): return sidecar.export_save_data(self.root, self.SAVE_PATHS) def test_export_packs_only_the_configured_save_directories(self): import io import tarfile with tarfile.open(fileobj=io.BytesIO(self.export()), mode="r:gz") as archive: names = sorted(member.name for member in archive) self.assertEqual(names, ["StateSaves/slot1.sav", "nand/user/save/save.bin"]) self.assertTrue(all(member.mode == 0o644 and member.uid == 0 for member in archive)) def test_export_packs_the_whole_root_when_no_paths_are_configured(self): import io import tarfile with tarfile.open(fileobj=io.BytesIO(sidecar.export_save_data(self.root)), mode="r:gz") as archive: self.assertIn("Cache/shaders.bin", [member.name for member in archive]) def test_export_ignores_a_configured_path_that_does_not_exist(self): import io import tarfile payload = sidecar.export_save_data(self.root, ("StateSaves", "GC", "Wii")) with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: self.assertEqual([member.name for member in archive], ["StateSaves/slot1.sav"]) def test_export_rejects_a_missing_directory(self): with self.assertRaises(ValueError): sidecar.export_save_data(self.root / "absent") def test_export_rejects_a_profile_with_no_save_directories(self): with self.assertRaises(ValueError): sidecar.export_save_data(self.root, ("GC", "Wii")) def test_export_rejects_an_escaping_configured_path(self): with self.assertRaises(ValueError): sidecar.export_save_data(self.root, ("../..",)) def test_a_round_trip_restores_the_exact_content(self): payload = self.export() (self.root / "nand" / "user" / "save" / "save.bin").write_bytes(b"overwritten") (self.root / "StateSaves" / "slot1.sav").unlink() self.assertEqual(sidecar.import_save_data(self.root, payload, self.SAVE_PATHS), 2) self.assertEqual((self.root / "nand" / "user" / "save" / "save.bin").read_bytes(), b"progress" * 64) self.assertEqual((self.root / "StateSaves" / "slot1.sav").read_bytes(), b"state") def test_a_restore_leaves_unrelated_profile_content_alone(self): payload = self.export() sidecar.import_save_data(self.root, payload, self.SAVE_PATHS) self.assertEqual((self.root / "config.ini").read_text(), "[General]\n") self.assertEqual((self.root / "Cache" / "shaders.bin").stat().st_size, 4096) def test_restore_rejects_a_member_outside_the_save_directories(self): import io import tarfile buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz") as archive: info = tarfile.TarInfo("config.ini") info.size = 5 archive.addfile(info, io.BytesIO(b"evil!")) # A stored revision must never be able to reach an emulator's configuration. with self.assertRaises(ValueError): sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS) self.assertEqual((self.root / "config.ini").read_text(), "[General]\n") def test_restore_rejects_an_archive_that_escapes_the_save_directory(self): import io import tarfile buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz") as archive: info = tarfile.TarInfo("../escaped.bin") info.size = 4 archive.addfile(info, io.BytesIO(b"evil")) with self.assertRaises(ValueError): sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS) self.assertFalse((self.root.parent / "escaped.bin").exists()) def test_restore_rejects_a_symlink_member(self): import io import tarfile buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz") as archive: info = tarfile.TarInfo("link") info.type = tarfile.SYMTYPE info.linkname = "/etc/passwd" archive.addfile(info) with self.assertRaises(ValueError): sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS) def test_restore_rejects_an_oversized_archive(self): with self.assertRaises(ValueError): sidecar.import_save_data(self.root, b"\0" * (sidecar.MAX_SAVE_DATA_BYTES + 1), self.SAVE_PATHS) def test_restore_prevalidates_the_expanded_size_before_writing(self): import io import tarfile buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz") as archive: info = tarfile.TarInfo("save/large.bin") info.size = 5 archive.addfile(info, io.BytesIO(b"12345")) with mock.patch.object(sidecar, "MAX_SAVE_DATA_EXPANDED_BYTES", 4), self.assertRaises(ValueError): sidecar.import_save_data(self.root, buffer.getvalue(), self.SAVE_PATHS) self.assertFalse((self.root / "save" / "large.bin").exists()) def test_status_reports_save_data_support_and_the_exact_directories(self): without = build_runtime(self.root) with_saves = build_runtime(self.root, save_root=str(self.root), save_paths=self.SAVE_PATHS) self.assertFalse(without.status()["saveData"]) status = with_saves.status() self.assertTrue(status["saveData"]) self.assertEqual(status["saveRoot"], str(self.root)) # A misconfigured path is visible here instead of surfacing at capture time. self.assertEqual(sorted(Path(path).name for path in status["savePaths"]), ["StateSaves", "save"]) self.assertFalse(build_runtime(self.root, save_root=str(self.root / "absent")).status()["saveData"]) self.assertFalse(build_runtime(self.root, save_root=str(self.root), save_paths=("GC",)).status()["saveData"]) class SaveDataSurfaceTests(unittest.TestCase): def setUp(self): self.root = Path(tempfile.mkdtemp(prefix="ludarium-savesurface-")) (self.root / "gamecube").mkdir() (self.root / "gamecube" / "title.iso").write_bytes(b"\0" * 16) self.saves = Path(tempfile.mkdtemp(prefix="ludarium-saveroot-")) (self.saves / "GC").mkdir() (self.saves / "GC" / "card.raw").write_bytes(b"memory-card") self.runtime = build_runtime(self.root, save_root=str(self.saves), save_paths=("GC",)) self.server = ThreadingHTTPServer( ("127.0.0.1", 0), sidecar.build_handler(self.runtime, "secret-token")) threading.Thread(target=self.server.serve_forever, daemon=True).start() self.addCleanup(self.server.server_close) self.addCleanup(self.server.shutdown) self.base = f"http://127.0.0.1:{self.server.server_address[1]}" def request(self, method, data=None, token="secret-token"): request = urllib.request.Request(self.base + "/v1/save-data", data=data, method=method) if token: request.add_header("X-Ludarium-Control-Token", token) try: with urllib.request.urlopen(request, timeout=5) as response: return response.status, response.read() except urllib.error.HTTPError as error: return error.code, error.read() def test_export_needs_a_token(self): self.assertEqual(self.request("GET", token=None)[0], 404) def test_export_and_restore_round_trip_over_http(self): status, payload = self.request("GET") self.assertEqual(status, 200) (self.saves / "GC" / "card.raw").write_bytes(b"lost") self.assertEqual(self.request("POST", payload)[0], 202) self.assertEqual((self.saves / "GC" / "card.raw").read_bytes(), b"memory-card") def test_restore_rejects_a_non_archive(self): self.assertEqual(self.request("POST", b"not-an-archive")[0], 400) def test_restore_rejects_an_empty_body(self): self.assertEqual(self.request("POST", b"")[0], 400) def test_a_runtime_without_a_save_directory_conflicts(self): runtime = build_runtime(self.root) server = ThreadingHTTPServer(("127.0.0.1", 0), sidecar.build_handler(runtime, "secret-token")) threading.Thread(target=server.serve_forever, daemon=True).start() self.addCleanup(server.server_close) self.addCleanup(server.shutdown) request = urllib.request.Request( f"http://127.0.0.1:{server.server_address[1]}/v1/save-data", method="GET") request.add_header("X-Ludarium-Control-Token", "secret-token") with self.assertRaises(urllib.error.HTTPError) as error: urllib.request.urlopen(request, timeout=5) self.assertEqual(error.exception.code, 409)