import os import tempfile import unittest from mudfish_decky.client import AuthError, SigninError from mudfish_decky.controller import (MSG_INSTALLED, MSG_LOGGED_IN, MSG_MISSING, MSG_NO_LAN, MSG_NO_RESPONSE, MSG_RELOGIN, MSG_REMOVED, MSG_RTT, MSG_SLEEP, MSG_STILL_UP, MSG_TIMEOUT, MSG_UP_TO_DATE, MSG_UPDATE_NEED_OFF, MSG_UPDATED, Controller) from mudfish_decky.installer import InstallError from mudfish_decky.store import Store from tests.test_nodes import STATIC BEFORE = "default via 192.168.1.1 dev wlan0 proto dhcp src 192.168.1.50 metric 600\n" NEW_ROUTES = "1.201.174.247 via 192.168.1.1 dev wlan0\n2.58.243.110 via 192.168.1.1 dev wlan0\n" class FakeSystem: def __init__(self): self.installed = True self.tap = False self.routes = BEFORE self.deleted = [] self.daemon_calls = 0 self.ip = {"ip": "13.73.234.162", "country": "JP"} self.lan = "192.168.1.50" self.version = "6.6.0" self.daemon_bins = [] self.daemon_stops = 0 self.nm_removed = 0 def lan_ip(self): return self.lan def find_mudfish(self): if not self.installed: return None return (f"/opt/mudfish/{self.version}/bin/mudrun-headless", f"/opt/mudfish/{self.version}/var") def version_of(self, bin_path): return bin_path.split("/")[3] def ensure_daemon(self, bin_path, is_up): self.daemon_calls += 1 self.daemon_bins.append(bin_path) def stop_daemon(self, wait=True): self.daemon_stops += 1 def remove_nm_connection(self): self.nm_removed += 1 def tap_up(self): return self.tap def route_text(self): return self.routes def delete_routes(self, lines): self.deleted.extend(lines) return len(lines) def public_ip(self): return self.ip class FakeInstaller: MUDFISH_ROOT = "/opt/mudfish" def __init__(self, system): self.system = system self.latest = "6.7.0" self.calls = [] def latest_version(self): self.calls.append("latest") return self.latest def download(self, version): self.calls.append(("download", version)) return b"pkg" def install_package(self, data, root): self.calls.append(("install", root)) self.system.installed = True self.system.version = self.latest return self.latest def carry_login(self, old_var, new_var): self.calls.append(("carry", old_var, new_var)) def prune_versions(self, root, keep): self.calls.append(("prune", sorted(keep))) def remove_all(self, root): self.calls.append(("remove_all", root)) self.system.installed = False class FakeRelay: token = "tok" expires_at = 1300.0 def __init__(self): self.active = False self.started = False self.start_calls = 0 def start(self, host="0.0.0.0"): self.started = True self.active = True self.start_calls += 1 return 40123 def stop(self): self.active = False class FakeClient: def __init__(self, system): self.system = system self.st = {"status": 200, "isSignedIn": True, "core_running": False, "core_ready": False, "client_ready": False, "jwt": "JWT", "api_server": "api.mudfish.net"} self.signin_error = None self.rtt_map = {9: 38.5, 11: 31.0} self.conf = {"mudfishFullvpnSid": 0, "mudfishFullvpnVid": 0, "mudfishFullvpnExceptions": "", "mudfishMtu": 1350} self.saved = [] self.start_calls = 0 self.stop_calls = 0 self.refresh_calls = [] self.need_restart = True self.ready_on_start = True def status(self): return dict(self.st) def ensure_signed_in(self, force=False): if self.signin_error: raise self.signin_error return dict(self.st) def start(self): self.start_calls += 1 self.st["core_running"] = True if self.ready_on_start: self.st.update(core_ready=True, client_ready=True) self.system.tap = True self.system.routes = BEFORE + NEW_ROUTES def stop(self): self.stop_calls += 1 self.st.update(core_running=False, core_ready=False, client_ready=False) self.system.tap = False def rtts(self): return dict(self.rtt_map) def static_nodes(self, jwt, api): return STATIC def prog_conf(self, jwt, api): return dict(self.conf) def save_prog_conf(self, jwt, api, conf): self.saved.append(dict(conf)) self.conf = dict(conf) return {"status": 200, "need_restart": self.need_restart} def fullvpn_refresh(self, sid, exceptions): self.refresh_calls.append((sid, exceptions)) class ControllerTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.path = os.path.join(self.tmp.name, "settings.json") self.system = FakeSystem() self.client = FakeClient(self.system) self.now = 1000.0 def tearDown(self): self.tmp.cleanup() def make(self): return Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, sleep=lambda s: None) def test_state_missing_when_not_installed(self): self.system.installed = False st = self.make().state() self.assertEqual(st["phase"], "missing") self.assertEqual(st["message"], MSG_MISSING) def test_state_off_never_starts_core(self): st = self.make().state() self.assertEqual(st["phase"], "off") self.assertEqual(self.client.start_calls, 0) def test_state_off_shows_auto_pick(self): st = self.make().state() self.assertEqual(st["node_mode"], "auto") self.assertEqual(st["node"], {"sid": 11, "label": "Osaka - SakuraNet 4", "rtt": 31.0}) def test_connect_auto_picks_lowest_and_saves_sid(self): st = self.make().connect() self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11) self.assertEqual(self.client.conf["mudfishFullvpnVid"], 0) self.assertEqual(self.client.conf["mudfishMtu"], 1350) self.assertEqual(st["phase"], "on") self.assertEqual(st["connected_since"], 1000.0) self.assertEqual((st["ip"], st["country"]), ("13.73.234.162", "JP")) def test_connect_skips_save_when_sid_unchanged(self): self.client.conf["mudfishFullvpnSid"] = 11 self.make().connect() self.assertEqual(self.client.saved, []) def test_connect_auto_without_rtts_does_not_start(self): self.client.rtt_map = {} st = self.make().connect() self.assertEqual(st["phase"], "error") self.assertEqual(st["message"], MSG_RTT) self.assertEqual(self.client.start_calls, 0) def test_connect_uses_cached_rtts_when_measuring(self): c = self.make() c.state() # caches rtts self.client.rtt_map = {} # mid-round: endpoint has no rtts self.assertEqual(self.make().connect()["phase"], "on") def test_connect_signin_failure_shows_relogin(self): self.client.signin_error = SigninError("401") st = self.make().connect() self.assertEqual(st["phase"], "error") self.assertEqual(st["message"], MSG_RELOGIN) self.assertEqual(self.client.start_calls, 0) def test_connect_timeout_stops_core(self): self.client.ready_on_start = False st = self.make().connect() self.assertEqual(self.client.stop_calls, 1) self.assertEqual(st["phase"], "error") self.assertEqual(st["message"], MSG_TIMEOUT) def test_connect_manual_uses_saved_sid(self): c = self.make() c.set_node("9") c.connect() self.assertEqual(self.client.conf["mudfishFullvpnSid"], 9) def test_disconnect_deletes_only_new_routes_and_keeps_sid(self): c = self.make() c.connect() saves = len(self.client.saved) st = c.disconnect() self.assertEqual(self.system.deleted, ["1.201.174.247 via 192.168.1.1 dev wlan0", "2.58.243.110 via 192.168.1.1 dev wlan0"]) self.assertEqual(len(self.client.saved), saves) self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11) self.assertEqual(st["phase"], "off") self.assertIsNone(Store(self.path).load().get("connected_since")) def test_disconnect_without_snapshot_deletes_nothing(self): self.client.st.update(core_running=True, client_ready=True) self.system.tap = True self.system.routes = BEFORE + NEW_ROUTES self.make().disconnect() self.assertEqual(self.system.deleted, []) def test_state_on_survives_restart_uses_saved_since(self): Store(self.path).save({"connected_since": 100.0}) self.client.st.update(core_running=True, core_ready=True, client_ready=True) self.system.tap = True st = self.make().state() self.assertEqual(st["phase"], "on") self.assertEqual(st["connected_since"], 100.0) self.assertEqual(self.client.start_calls, 0) def test_state_on_without_saved_since_starts_clock_now(self): self.client.st.update(core_running=True, core_ready=True, client_ready=True) self.system.tap = True self.assertEqual(self.make().state()["connected_since"], 1000.0) def test_state_connecting_when_core_running_not_ready(self): self.client.st.update(core_running=True) self.assertEqual(self.make().state()["phase"], "connecting") def test_set_node_while_off_only_saves_choice(self): st = self.make().set_node("9") self.assertEqual(self.client.saved, []) self.assertEqual(Store(self.path).load()["manual_sid"], 9) self.assertEqual(st["node_mode"], "manual") self.assertEqual(st["node"]["sid"], 9) def test_set_node_while_on_refreshes(self): c = self.make() c.connect() self.client.need_restart = False st = c.set_node("9") self.assertEqual(self.client.refresh_calls, [(9, "")]) self.assertEqual(self.client.start_calls, 1) self.assertEqual(st["node"]["sid"], 9) def test_set_node_while_on_restarts_when_needed(self): c = self.make() c.connect() self.client.need_restart = True c.set_node("9") self.assertEqual((self.client.stop_calls, self.client.start_calls), (1, 2)) def test_set_node_back_to_auto(self): c = self.make() c.set_node("9") st = c.set_node("auto") self.assertEqual(st["node_mode"], "auto") self.assertEqual(st["node"]["sid"], 11) # ---- final review fixes ------------------------------------------------------------------ def test_connect_status_error_after_start_tears_down(self): real_start = self.client.start def start(): real_start() self.client.status = lambda: (_ for _ in ()).throw(OSError("launcher busy")) self.client.start = start st = self.make().connect() self.assertEqual(self.client.stop_calls, 1) self.assertEqual(st["phase"], "error") self.assertEqual(len(self.system.deleted), 2) def test_connect_timeout_cleans_routes_and_store(self): self.client.ready_on_start = False self.client.start = self._start_adding_routes_not_ready() self.make().connect() self.assertEqual(len(self.system.deleted), 2) data = Store(self.path).load() self.assertIsNone(data.get("routes_before")) self.assertIsNone(data.get("active_sid")) def _start_adding_routes_not_ready(self): def start(): self.client.start_calls += 1 self.client.st["core_running"] = True self.system.routes = BEFORE + NEW_ROUTES return start def test_disconnect_keeps_snapshot_when_tap_stays_up(self): c = self.make() c.connect() real_stop = self.client.stop def stop(): real_stop() self.system.tap = True # tunnel did not go away self.client.stop = stop st = c.disconnect() self.assertEqual(st["phase"], "error") self.assertEqual(st["message"], MSG_STILL_UP) self.assertTrue(st["tunnel"]) data = Store(self.path).load() self.assertIsNotNone(data.get("routes_before")) self.assertEqual(data.get("connected_since"), 1000.0) self.assertEqual(self.system.deleted, []) def test_state_reports_tunnel_when_core_stuck(self): self.client.st.update(core_running=True) st = self.make().state() self.assertEqual(st["phase"], "connecting") self.assertTrue(st["tunnel"]) def test_state_tunnel_false_when_off(self): self.assertFalse(self.make().state()["tunnel"]) def test_state_status_failure_with_tap_up_is_error_not_off(self): Store(self.path).save({"connected_since": 100.0}) self.system.tap = True self.client.status = lambda: (_ for _ in ()).throw(OSError("no answer")) st = self.make().state() self.assertEqual(st["phase"], "error") self.assertEqual(st["message"], MSG_NO_RESPONSE) self.assertTrue(st["tunnel"]) self.assertEqual(Store(self.path).load()["connected_since"], 100.0) def test_connect_resigns_once_when_cloud_rejects_jwt(self): calls = [] real_prog_conf = self.client.prog_conf def prog_conf(jwt, api): calls.append(jwt) if len(calls) == 1: raise AuthError("graphql returned no user") return real_prog_conf(jwt, api) forced = [] self.client.prog_conf = prog_conf self.client.ensure_signed_in = lambda force=False: forced.append(force) or dict(self.client.st) st = self.make().connect() self.assertEqual(st["phase"], "on") self.assertIn(True, forced) self.assertEqual(len(calls), 2) def test_connect_stale_rtt_cache_is_ignored(self): Store(self.path).save({"rtts": {"11": 31.0}, "rtts_at": 1000.0 - 601}) self.client.rtt_map = {} st = self.make().connect() self.assertEqual(st["message"], MSG_RTT) self.assertEqual(self.client.start_calls, 0) # ---- phone (QR) login -------------------------------------------------------------------- def make_with_relay(self): self.relay = FakeRelay() return Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, sleep=lambda s: None, relay_factory=lambda: self.relay) def test_connect_signin_failure_flags_need_login(self): self.client.signin_error = SigninError("401") self.assertTrue(self.make().connect()["need_login"]) def test_prepare_signin_failure_flags_need_login(self): self.client.signin_error = SigninError("401") c = self.make() c.prepare() st = c.state() self.assertTrue(st["need_login"]) self.assertEqual(st["message"], MSG_RELOGIN) def test_prepare_network_failure_does_not_flag_need_login(self): self.client.signin_error = OSError("name resolution") c = self.make() c.prepare() self.assertFalse(c.state()["need_login"]) def test_need_login_false_by_default(self): self.assertFalse(self.make().state()["need_login"]) def test_start_login_exposes_qr_url(self): c = self.make_with_relay() st = c.start_login() self.assertEqual(st["login"], {"url": "http://192.168.1.50:40123/_mfd/tok", "expires_at": 1300.0}) self.assertTrue(self.relay.started) self.assertEqual(self.client.start_calls, 0) def test_start_login_reuses_active_relay(self): c = self.make_with_relay() c.start_login() c.start_login() self.assertEqual(self.relay.start_calls, 1) def test_start_login_without_lan_ip_shows_message(self): self.system.lan = None st = self.make_with_relay().start_login() self.assertIsNone(st["login"]) self.assertEqual(st["message"], MSG_NO_LAN) self.assertFalse(self.relay.started) def test_state_closes_relay_after_signin(self): self.client.signin_error = SigninError("401") self.client.st["isSignedIn"] = False c = self.make_with_relay() c.connect() c.start_login() self.client.st["isSignedIn"] = True # user logged in from the phone st = c.state() self.assertFalse(self.relay.active) self.assertIsNone(st["login"]) self.assertFalse(st["need_login"]) self.assertEqual(st["phase"], "off") self.assertEqual(st["message"], MSG_LOGGED_IN) def test_stop_login_closes_relay(self): c = self.make_with_relay() c.start_login() self.assertIsNone(c.stop_login()["login"]) self.assertFalse(self.relay.active) def test_expired_relay_drops_from_state(self): c = self.make_with_relay() c.start_login() self.relay.active = False self.assertIsNone(c.state()["login"]) def test_shutdown_closes_relay(self): c = self.make_with_relay() c.start_login() c.shutdown() self.assertFalse(self.relay.active) # ---- install / update / uninstall -------------------------------------------------------- def make_with_installer(self): self.installer = FakeInstaller(self.system) return Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, sleep=lambda s: None, installer=self.installer) def test_state_reports_versions(self): Store(self.path).save({"latest_version": "6.7.0"}) st = self.make_with_installer().state() self.assertEqual(st["mudfish"], {"version": "6.6.0", "latest": "6.7.0"}) def test_state_missing_reports_no_version(self): self.system.installed = False st = self.make_with_installer().state() self.assertEqual(st["phase"], "missing") self.assertIsNone(st["mudfish"]["version"]) def test_install_downloads_latest_and_starts_daemon_without_connecting(self): self.system.installed = False st = self.make_with_installer().install() self.assertIn(("download", "6.7.0"), self.installer.calls) self.assertIn(("install", "/opt/mudfish"), self.installer.calls) self.assertEqual(self.system.daemon_bins[-1], "/opt/mudfish/6.7.0/bin/mudrun-headless") self.assertEqual(self.client.start_calls, 0) self.assertEqual(st["phase"], "off") self.assertEqual(st["message"], MSG_INSTALLED.format("6.7.0")) self.assertEqual(st["mudfish"]["version"], "6.7.0") def test_install_failure_shows_message(self): self.system.installed = False c = self.make_with_installer() self.installer.install_package = lambda data, root: (_ for _ in ()).throw(InstallError("checksum")) st = c.install() self.assertEqual(st["phase"], "missing") self.assertIn("checksum", st["message"]) def test_update_refused_while_tunnel_up(self): c = self.make_with_installer() c.connect() st = c.update() self.assertEqual(st["message"], MSG_UPDATE_NEED_OFF) self.assertNotIn(("download", "6.7.0"), self.installer.calls) self.assertEqual(st["phase"], "on") def test_update_carries_login_restarts_daemon_and_prunes(self): c = self.make_with_installer() st = c.update() self.assertIn(("carry", "/opt/mudfish/6.6.0/var", "/opt/mudfish/6.7.0/var"), self.installer.calls) self.assertEqual(self.system.daemon_stops, 1) self.assertEqual(self.system.daemon_bins[-1], "/opt/mudfish/6.7.0/bin/mudrun-headless") self.assertIn(("prune", ["6.6.0", "6.7.0"]), self.installer.calls) self.assertEqual(self.client.start_calls, 0) self.assertEqual(st["message"], MSG_UPDATED.format("6.7.0")) def test_update_when_already_latest_does_nothing(self): self.make_with_installer() self.installer.latest = "6.6.0" st = Controller(self.client, self.system, Store(self.path), clock=lambda: self.now, sleep=lambda s: None, installer=self.installer).update() self.assertEqual(st["message"], MSG_UP_TO_DATE) self.assertNotIn(("download", "6.6.0"), self.installer.calls) def test_refresh_latest_checks_at_most_daily(self): c = self.make_with_installer() c.refresh_latest() c.refresh_latest() self.assertEqual(self.installer.calls.count("latest"), 1) self.now += 86401 c.refresh_latest() self.assertEqual(self.installer.calls.count("latest"), 2) self.assertEqual(Store(self.path).load()["latest_version"], "6.7.0") def test_uninstall_turns_off_and_removes_everything(self): c = self.make_with_installer() self.relay = FakeRelay() c.relay_factory = lambda: self.relay c.connect() c.start_login() st = c.uninstall() self.assertEqual(self.client.stop_calls, 1) self.assertFalse(self.relay.active) self.assertEqual(self.system.daemon_stops, 1) self.assertEqual(self.system.nm_removed, 1) self.assertIn(("remove_all", "/opt/mudfish"), self.installer.calls) self.assertEqual(Store(self.path).load(), {}) self.assertEqual(st["phase"], "missing") self.assertEqual(st["message"], MSG_REMOVED) def test_uninstall_when_missing_still_cleans_up(self): self.system.installed = False c = self.make_with_installer() c.uninstall() self.assertIn(("remove_all", "/opt/mudfish"), self.installer.calls) self.assertEqual(self.client.start_calls, 0) # ---- sleep / shutdown -------------------------------------------------------------------- def test_resume_jump_turns_tunnel_off_with_notice(self): c = self.make() c.connect() self.assertFalse(c.check_resume(5.0)) # first sample only sets the baseline self.assertFalse(c.check_resume(5.5)) # small drift: not a resume self.assertTrue(c.check_resume(65.5)) # suspended ~60s st = c.state() self.assertEqual(self.client.stop_calls, 1) self.assertEqual(st["phase"], "off") self.assertEqual(st["message"], MSG_SLEEP) self.assertEqual(len(self.system.deleted), 2) def test_resume_while_off_does_nothing(self): c = self.make() c.check_resume(5.0) c.check_resume(100.0) self.assertEqual(self.client.stop_calls, 0) self.assertIsNone(c.state()["message"]) def test_resume_never_starts_core(self): c = self.make() c.check_resume(5.0) c.check_resume(100.0) self.assertEqual(self.client.start_calls, 0) def test_notice_cleared_by_next_connect(self): c = self.make() c.connect() c.check_resume(5.0) c.check_resume(100.0) self.assertIsNone(c.connect()["message"]) def test_shutdown_turns_tunnel_off(self): c = self.make() c.connect() c.shutdown() self.assertEqual(self.client.stop_calls, 1) self.assertEqual(len(self.system.deleted), 2) self.assertIsNone(Store(self.path).load().get("connected_since")) def test_shutdown_while_off_does_not_call_stop(self): self.make().shutdown() self.assertEqual(self.client.stop_calls, 0) def test_prepare_signs_in_and_caches_nodes_without_starting(self): self.client.st["isSignedIn"] = False signed = [] self.client.ensure_signed_in = lambda: signed.append(1) or dict(self.client.st, isSignedIn=True) c = self.make() c.prepare() self.assertEqual(self.system.daemon_calls, 1) self.assertEqual(signed, [1]) self.assertEqual(self.client.start_calls, 0) self.assertEqual([n["sid"] for n in Store(self.path).load()["static_nodes"]], [9, 11, 46, 300]) def test_prepare_tolerates_signin_failure(self): self.client.signin_error = SigninError("401") self.make().prepare() self.assertEqual(self.client.start_calls, 0) def test_nodes_lists_japan_by_ping(self): self.assertEqual([n["sid"] for n in self.make().nodes()], [11, 9, 46]) if __name__ == "__main__": unittest.main()