"""Unit tests for local VocaGateway embed helpers (no real containers).""" from __future__ import annotations import io import json import unittest from pathlib import Path from unittest.mock import MagicMock, patch from vocalinux.gateway_embed.pairing import ( MAX_QR_SVG_BYTES, decode_pairing_payload, ) from vocalinux.gateway_embed.preset import ( GATEWAY_TRANSCRIPTIONS_ENDPOINT, remote_api_preset_from_pairing, ) from vocalinux.gateway_embed.runtime import ContainerRuntime, detect_container_runtime from vocalinux.gateway_embed.sandbox import detect_sandbox from vocalinux.gateway_embed.status import GatewayStatus from vocalinux.gateway_embed.urls import is_loopback_url, reject_loopback_url from vocalinux.ui.config_manager import DEFAULT_CONFIG class TestLoopbackRejection(unittest.TestCase): def test_rejects_localhost_variants(self): for url in ( "http://127.0.0.1:8765", "http://localhost:8765", "http://[::1]:8765", "https://127.0.0.1/", "", None, ): self.assertTrue(is_loopback_url(url), url) self.assertIsNone(reject_loopback_url(url)) def test_rejects_short_form_loopback_and_localhost_star(self): # Contract / gateway policy: 127.1 and localhost.* must never be Pairable. for url in ( "http://127.1:8765", "http://127.0.1:8765", "http://localhost.localdomain:8765", "http://localhost.foo:8765", ): self.assertTrue(is_loopback_url(url), url) self.assertIsNone(reject_loopback_url(url), url) def test_rejects_link_local_for_qr(self): for url in ( "http://169.254.10.20:8765", "http://[fe80::1]:8765", ): self.assertIsNone(reject_loopback_url(url), url) def test_rejects_default_container_bridge(self): for url in ( "http://172.17.0.1:8765", "http://172.17.0.2:8765", "http://10.88.0.1:8765", ): self.assertIsNone(reject_loopback_url(url), url) def test_accepts_lan(self): url = "http://192.168.1.20:8765" self.assertFalse(is_loopback_url(url)) self.assertEqual(reject_loopback_url(url), url) class TestRuntimeDetector(unittest.TestCase): def test_prefers_podman(self): def lookup(name): return { "podman": "/usr/bin/podman", "docker": "/usr/bin/docker", }.get(name) def probe(argv, **_kwargs): return argv[0] in {"/usr/bin/podman", "/usr/bin/docker"} and argv[1] in { "info", "version", "compose", } info = detect_container_runtime(path_lookup=lookup, probe=probe) self.assertEqual(info.kind, ContainerRuntime.PODMAN) self.assertEqual(info.binary, "/usr/bin/podman") def test_falls_back_to_docker(self): def lookup(name): return {"docker": "/usr/bin/docker"}.get(name) def probe(argv, **_kwargs): return argv[0] == "/usr/bin/docker" info = detect_container_runtime(path_lookup=lookup, probe=probe) self.assertEqual(info.kind, ContainerRuntime.DOCKER) def test_none_with_hint(self): info = detect_container_runtime(path_lookup=lambda _n: None, probe=lambda *_a, **_k: False) self.assertEqual(info.kind, ContainerRuntime.NONE) self.assertIn("podman", info.hint.lower()) def test_engine_without_compose_is_unavailable(self): def lookup(name): return {"podman": "/usr/bin/podman"}.get(name) def probe(argv, **_kwargs): # Engine probes succeed; the compose plugin probe (argv[1]=="compose") fails. return ( bool(argv) and argv[0] == "/usr/bin/podman" and len(argv) > 1 and argv[1] in {"info", "version"} ) info = detect_container_runtime(path_lookup=lookup, probe=probe) self.assertEqual(info.kind, ContainerRuntime.PODMAN) self.assertEqual(info.compose_args, ()) self.assertIn("compose", info.hint.lower()) from vocalinux.gateway_embed.runner import GatewayRunner runner = GatewayRunner( runtime=info, sandbox=detect_sandbox({}), run=MagicMock(), ) self.assertFalse(runner.available) self.assertEqual(runner.unavailable_hint, info.hint) self.assertNotIn("No container runtime", runner.unavailable_hint) class TestStatusMachine(unittest.TestCase): def test_ready_beats_pairable(self): self.assertEqual( GatewayStatus.from_health(live=True, ready=True, pairable=True, running=True), GatewayStatus.READY, ) def test_pairable_before_ready(self): self.assertEqual( GatewayStatus.from_health(live=True, ready=False, pairable=True, running=True), GatewayStatus.PAIRABLE, ) def test_live_only(self): self.assertEqual( GatewayStatus.from_health(live=True, ready=False, pairable=False, running=True), GatewayStatus.LIVE, ) def test_starting(self): self.assertEqual( GatewayStatus.from_health( live=False, ready=False, pairable=False, running=True, starting=True ), GatewayStatus.STARTING, ) def test_stopped(self): self.assertEqual( GatewayStatus.from_health(live=False, ready=False, pairable=False, running=False), GatewayStatus.STOPPED, ) def test_error(self): self.assertEqual( GatewayStatus.from_health( live=False, ready=False, pairable=False, error=True, running=False ), GatewayStatus.ERROR, ) class TestRemoteApiPreset(unittest.TestCase): def test_maps_openai_path(self): preset = remote_api_preset_from_pairing( url="http://192.168.1.20:8765", token="a" * 32, ) self.assertEqual(preset["engine"], "remote_api") self.assertEqual(preset["remote_api_url"], "http://192.168.1.20:8765") self.assertEqual(preset["remote_api_key"], "a" * 32) self.assertEqual(preset["remote_api_endpoint"], GATEWAY_TRANSCRIPTIONS_ENDPOINT) def test_rejects_empty(self): with self.assertRaises(ValueError): remote_api_preset_from_pairing(url="", token="x" * 32) class TestPairingDecode(unittest.TestCase): def test_payload_object(self): info = decode_pairing_payload( { "payload": { "v": 1, "url": "http://10.0.0.5:8765", "token": "tokentokentokentokentokentoken12", } } ) self.assertEqual(info.display_url, "http://10.0.0.5:8765") self.assertTrue(info.pairable) def test_payload_string_and_loopback_hidden(self): raw = json.dumps({"v": 1, "url": "http://127.0.0.1:8765", "token": "t" * 32}) info = decode_pairing_payload({"payload": raw}) self.assertIsNone(info.display_url) self.assertFalse(info.pairable) def test_docker_bridge_not_pairable(self): info = decode_pairing_payload( { "payload": { "v": 1, "url": "http://172.17.0.2:8765", "token": "tokentokentokentokentokentoken12", } } ) self.assertIsNone(info.display_url) self.assertFalse(info.pairable) def test_link_local_not_pairable(self): info = decode_pairing_payload( { "payload": { "v": 1, "url": "http://169.254.1.2:8765", "token": "tokentokentokentokentokentoken12", } } ) self.assertIsNone(info.display_url) self.assertFalse(info.pairable) def test_short_form_loopback_not_pairable(self): info = decode_pairing_payload( { "payload": { "v": 1, "url": "http://127.1:8765", "token": "tokentokentokentokentokentoken12", } } ) self.assertIsNone(info.display_url) self.assertFalse(info.pairable) def test_qr_download_capped(self): from vocalinux.gateway_embed.pairing import _read_capped class FakeResp: def __init__(self, payload: bytes): self._buf = io.BytesIO(payload) def read(self, n=-1): return self._buf.read(n if n is not None else -1) huge = b"x" * (MAX_QR_SVG_BYTES + 10) with self.assertRaises(ValueError): _read_capped(FakeResp(huge), MAX_QR_SVG_BYTES) class TestSandbox(unittest.TestCase): def test_flatpak_fails_closed(self): state = detect_sandbox({"FLATPAK_ID": "com.vocahq.Vocalinux"}) self.assertTrue(state.blocked) self.assertEqual(state.kind, "flatpak") self.assertIn("Flatpak", state.hint) def test_host_ok(self): state = detect_sandbox({}) self.assertFalse(state.blocked) class TestDefaultEngineUnchanged(unittest.TestCase): def test_default_still_whisper_cpp(self): self.assertEqual(DEFAULT_CONFIG["speech_recognition"]["engine"], "whisper_cpp") self.assertIn("gateway_embed", DEFAULT_CONFIG) self.assertFalse(DEFAULT_CONFIG["gateway_embed"]["lan_publish"]) class TestRunnerNoVolumeWipe(unittest.TestCase): def test_stop_refuses_wipe_flag(self): from vocalinux.gateway_embed.runner import GatewayRunner from vocalinux.gateway_embed.runtime import RuntimeInfo runner = GatewayRunner( runtime=RuntimeInfo( kind=ContainerRuntime.PODMAN, binary="/usr/bin/podman", compose_args=("/usr/bin/podman", "compose"), ), sandbox=detect_sandbox({}), run=MagicMock(), ) with self.assertRaises(RuntimeError): runner.stop(wipe_volumes=True) class TestComposeCpuProfile(unittest.TestCase): def test_start_uses_profile_cpu(self): from vocalinux.gateway_embed.runner import GatewayRunner from vocalinux.gateway_embed.runtime import RuntimeInfo calls = [] def fake_run(argv, **kwargs): calls.append(list(argv)) completed = MagicMock() completed.returncode = 0 completed.stdout = b"" completed.stderr = b"" return completed runner = GatewayRunner( runtime=RuntimeInfo( kind=ContainerRuntime.PODMAN, binary="/usr/bin/podman", compose_args=("/usr/bin/podman", "compose"), ), sandbox=detect_sandbox({}), run=fake_run, ) with patch.object(runner, "prepare", return_value=("/tmp/gw", "t" * 32, "/tmp/.env")): result = runner.start(lan_publish=False) self.assertTrue(result.ok) compose_calls = [c for c in calls if c[:2] == ["/usr/bin/podman", "compose"]] self.assertTrue(compose_calls) argv = compose_calls[0] self.assertIn("--profile", argv) self.assertEqual(argv[argv.index("--profile") + 1], "cpu") self.assertIn("up", argv) self.assertIn("-d", argv) self.assertIn("gateway", argv) class TestImagePin(unittest.TestCase): def test_rejects_latest_override(self): import tempfile from vocalinux.gateway_embed.runner import write_env_file with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" with patch.dict("os.environ", {"VOCAGATEWAY_IMAGE": "vocagateway:latest"}): write_env_file(token="a" * 32, lan_publish=False, path=env_path) body = Path(env_path).read_text(encoding="utf-8") self.assertIn("VOCAGATEWAY_IMAGE=vocagateway:v0.1.0", body) self.assertNotIn(":latest", body) def test_rejects_newline_image_injection(self): import tempfile from vocalinux.gateway_embed.runner import write_env_file with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" evil = "vocagateway:v0.1.0\nEVIL=1" with patch.dict("os.environ", {"VOCAGATEWAY_IMAGE": evil}): write_env_file(token="a" * 32, lan_publish=False, path=env_path) body = Path(env_path).read_text(encoding="utf-8") self.assertIn("VOCAGATEWAY_IMAGE=vocagateway:v0.1.0\n", body) self.assertNotIn("EVIL=", body) def test_omits_loopback_public_url(self): import tempfile from vocalinux.gateway_embed.runner import write_env_file with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" write_env_file( token="a" * 32, lan_publish=True, public_url="http://127.0.0.1:8765", path=env_path, ) body = Path(env_path).read_text(encoding="utf-8") self.assertNotIn("VOCAGATEWAY_PUBLIC_URL=", body) def test_read_lan_publish_from_env_matches_write(self): import tempfile from vocalinux.gateway_embed.runner import ( read_lan_publish_from_env, write_env_file, ) with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" write_env_file(token="a" * 32, lan_publish=True, path=env_path) self.assertTrue(read_lan_publish_from_env(env_path)) write_env_file(token="a" * 32, lan_publish=False, path=env_path) self.assertFalse(read_lan_publish_from_env(env_path)) self.assertFalse(read_lan_publish_from_env(f"{tmp}/missing.env")) class TestTokenFileCap(unittest.TestCase): def test_oversized_token_file_regenerated(self): import tempfile from vocalinux.gateway_embed.paths_embed import ensure_token_file with tempfile.TemporaryDirectory() as tmp: path = f"{tmp}/token" with open(path, "wb") as handle: handle.write(b"a" * 9000) token = ensure_token_file(path) self.assertEqual(len(token), 64) self.assertLess(Path(path).stat().st_size, 200) class TestLanPublishGate(unittest.TestCase): def _manager(self): from vocalinux.gateway_embed.manager import GatewayEmbedManager from vocalinux.gateway_embed.runner import GatewayRunner from vocalinux.gateway_embed.runtime import RuntimeInfo runner = GatewayRunner( runtime=RuntimeInfo( kind=ContainerRuntime.PODMAN, binary="/usr/bin/podman", compose_args=("/usr/bin/podman", "compose"), ), sandbox=detect_sandbox({}), run=MagicMock(), ) return GatewayEmbedManager(runner=runner), runner def test_lan_toggle_without_republish_not_pairable(self): """Desired LAN alone must not advertise Pairable on a loopback bind.""" from vocalinux.gateway_embed.pairing import PairingInfo manager, runner = self._manager() runner.managed_by_us = True manager.lan_publish = True manager._compose_lan_publish = False manager._token = "t" * 32 self.assertFalse(manager._effective_lan_for_pairing()) captured = {} def fake_fetch(base_url, token, *, public_url=None, fetch_qr=True, timeout=3.0): captured["public_url"] = public_url return PairingInfo( version=1, url="http://127.0.0.1:8765", token=token, display_url=None, raw_payload={"v": 1, "url": "http://127.0.0.1:8765", "token": token}, ) with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch("vocalinux.gateway_embed.manager.fetch_pairing", side_effect=fake_fetch): status = manager.refresh_status() self.assertIsNone(captured.get("public_url")) self.assertEqual(status, GatewayStatus.LIVE) self.assertIsNotNone(manager.pairing) self.assertFalse(manager.pairing.pairable) def test_apply_lan_publish_republishes_when_managed(self): from vocalinux.gateway_embed.pairing import PairingInfo from vocalinux.gateway_embed.runner import RunnerResult manager, runner = self._manager() runner.managed_by_us = True manager.lan_publish = False manager._compose_lan_publish = False manager._pairing = PairingInfo( version=1, url="http://127.0.0.1:8765", token="t" * 32, display_url=None, raw_payload={}, ) calls = {"n": 0} def fake_republish(*, lan_publish, public_url=None): calls["n"] += 1 self.assertTrue(lan_publish) return RunnerResult(ok=True, message="republished") with patch("threading.Thread") as fake_thread: fake_thread.return_value = MagicMock() with patch.object(runner, "republish", side_effect=fake_republish): with patch.object(manager, "refresh_status", return_value=GatewayStatus.LIVE): manager.apply_lan_publish(True) self.assertIsNone(manager.pairing) self.assertTrue(manager._republish_started) # Run worker synchronously (Thread.start was mocked). manager._republish_worker() self.assertEqual(calls["n"], 1) self.assertTrue(manager._compose_lan_publish) self.assertTrue(manager.lan_publish) self.assertFalse(manager._republish_started) def test_start_worker_snapshots_lan_if_switch_flips_mid_start(self): from vocalinux.gateway_embed.runner import RunnerResult manager, runner = self._manager() manager.lan_publish = True manager._compose_lan_publish = None def fake_start(*, lan_publish, public_url=None): self.assertTrue(lan_publish) manager.lan_publish = False runner.managed_by_us = True return RunnerResult(ok=True, message="started") with patch("threading.Thread") as fake_thread: fake_thread.return_value = MagicMock() with patch( "vocalinux.gateway_embed.paths_embed.ensure_token_file", return_value="t" * 32, ): with patch.object(runner, "ensure_runtime"): with patch.object(runner, "start", side_effect=fake_start): with patch.object(manager, "_start_polling"): with patch.object(manager, "refresh_status"): manager._start_worker() self.assertIs(manager._compose_lan_publish, True) self.assertFalse(manager.lan_publish) self.assertTrue(manager._republish_started) def test_republish_worker_snapshots_lan_if_switch_flips_mid_republish(self): from vocalinux.gateway_embed.runner import RunnerResult manager, runner = self._manager() runner.managed_by_us = True manager.lan_publish = True manager._compose_lan_publish = False manager._republish_started = True def fake_republish(*, lan_publish, public_url=None): self.assertTrue(lan_publish) manager.lan_publish = False return RunnerResult(ok=True, message="republished") with patch("threading.Thread") as fake_thread: fake_thread.return_value = MagicMock() with patch.object(runner, "republish", side_effect=fake_republish): with patch.object(manager, "refresh_status"): manager._republish_worker() self.assertIs(manager._compose_lan_publish, True) self.assertFalse(manager.lan_publish) self.assertTrue(manager._republish_started) def test_live_detail_warns_when_lan_off_but_compose_still_open(self): from vocalinux.gateway_embed.pairing import PairingInfo manager, runner = self._manager() runner.managed_by_us = True manager.lan_publish = False manager._compose_lan_publish = True manager._token = "t" * 32 def fake_fetch(base_url, token, *, public_url=None, fetch_qr=True, timeout=3.0): return PairingInfo( version=1, url="http://127.0.0.1:8765", token=token, display_url=None, raw_payload={"v": 1, "url": "http://127.0.0.1:8765", "token": token}, ) with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch("vocalinux.gateway_embed.manager.fetch_pairing", side_effect=fake_fetch): status = manager.refresh_status() self.assertEqual(status, GatewayStatus.LIVE) self.assertIn("still open on the LAN", manager.status_detail) self.assertFalse(manager._effective_lan_for_pairing()) def test_omits_bridge_public_url_from_env(self): import tempfile from vocalinux.gateway_embed.runner import write_env_file with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" write_env_file( token="a" * 32, lan_publish=True, public_url="http://172.17.0.2:8765", path=env_path, ) body = Path(env_path).read_text(encoding="utf-8") self.assertNotIn("VOCAGATEWAY_PUBLIC_URL=", body) class TestOrphanComposeDetect(unittest.TestCase): """Leftover compose after Quit must not look Stopped.""" def _manager(self): from vocalinux.gateway_embed.manager import GatewayEmbedManager from vocalinux.gateway_embed.runner import GatewayRunner from vocalinux.gateway_embed.runtime import RuntimeInfo runner = GatewayRunner( runtime=RuntimeInfo( kind=ContainerRuntime.PODMAN, binary="/usr/bin/podman", compose_args=("/usr/bin/podman", "compose"), ), sandbox=detect_sandbox({}), run=MagicMock(), ) runner.managed_by_us = False return GatewayEmbedManager(runner=runner), runner def test_refresh_status_does_not_stay_stopped_for_orphan(self): manager, runner = self._manager() self.assertFalse(manager.managed_by_us) self.assertEqual(manager.status, GatewayStatus.STOPPED) with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") status = manager.refresh_status() self.assertEqual(status, GatewayStatus.LIVE) self.assertNotEqual(status, GatewayStatus.STOPPED) self.assertFalse(manager.managed_by_us) stoppable = { GatewayStatus.STARTING, GatewayStatus.LIVE, GatewayStatus.PAIRABLE, GatewayStatus.READY, GatewayStatus.ERROR, } self.assertIn(status, stoppable) def test_refresh_status_starting_when_compose_up_but_not_live(self): manager, runner = self._manager() with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=False, ready=False, error="") status = manager.refresh_status() self.assertEqual(status, GatewayStatus.STARTING) self.assertNotEqual(status, GatewayStatus.STOPPED) def test_runtime_ready_probe_adopts_orphan_and_starts_polling(self): manager, runner = self._manager() self.assertTrue(manager.runtime_ready) self.assertFalse(manager._runtime_detect_started) with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch.object(manager, "_start_polling") as poll: manager.begin_runtime_detection() self.assertEqual(manager.status, GatewayStatus.LIVE) poll.assert_called_once() def test_orphan_probe_loads_existing_token(self): import tempfile manager, runner = self._manager() with tempfile.TemporaryDirectory() as tmp: token_path = f"{tmp}/token" existing = "ab" * 32 with open(token_path, "w", encoding="utf-8") as handle: handle.write(existing + "\n") with patch( "vocalinux.gateway_embed.paths_embed.token_file_path", return_value=token_path, ): with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=False, ready=False, error="") with patch.object(manager, "_start_polling"): manager.begin_runtime_detection() self.assertEqual(manager._token, existing) def test_stop_worker_stops_orphan_without_managed_by_us(self): from vocalinux.gateway_embed.runner import RunnerResult manager, runner = self._manager() self.assertFalse(manager.managed_by_us) with patch.object( runner, "stop", return_value=RunnerResult(ok=True, message="stopped") ) as stop: manager._stop_worker() stop.assert_called_once_with(wipe_volumes=False) self.assertEqual(manager.status, GatewayStatus.STOPPED) self.assertFalse(manager.managed_by_us) def test_stop_async_starts_worker_for_orphan(self): manager, runner = self._manager() self.assertFalse(manager.managed_by_us) with patch("threading.Thread") as fake_thread: started = MagicMock() fake_thread.return_value = started manager.stop_async() fake_thread.assert_called_once() kwargs = fake_thread.call_args.kwargs self.assertEqual(kwargs.get("name"), "vocalinux-gateway-stop") self.assertEqual(kwargs.get("target").__func__, manager._stop_worker.__func__) started.start.assert_called_once() def test_orphan_adopt_restores_lan_on_from_env(self): import tempfile from vocalinux.gateway_embed.runner import write_env_file manager, runner = self._manager() self.assertFalse(runner.managed_by_us) manager.lan_publish = False manager._compose_lan_publish = None with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" write_env_file(token="a" * 32, lan_publish=True, path=env_path) with patch( "vocalinux.gateway_embed.runner.env_file_path", return_value=env_path, ): with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch.object(manager, "_start_polling"): manager.begin_runtime_detection() self.assertIs(manager._compose_lan_publish, True) self.assertTrue(manager.lan_publish) self.assertTrue(runner.managed_by_us) self.assertTrue(manager.managed_by_us) def test_orphan_adopt_restores_lan_off_from_loopback_env(self): import tempfile from vocalinux.gateway_embed.runner import write_env_file manager, runner = self._manager() manager.lan_publish = True manager._compose_lan_publish = None with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" write_env_file(token="a" * 32, lan_publish=False, path=env_path) with patch( "vocalinux.gateway_embed.runner.env_file_path", return_value=env_path, ): with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch.object(manager, "_start_polling"): manager.begin_runtime_detection() self.assertIs(manager._compose_lan_publish, False) self.assertFalse(manager.lan_publish) self.assertTrue(runner.managed_by_us) def test_apply_lan_publish_republishes_after_orphan_lan_adopt(self): import tempfile from vocalinux.gateway_embed.runner import RunnerResult, write_env_file manager, runner = self._manager() with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" write_env_file(token="a" * 32, lan_publish=True, path=env_path) with patch( "vocalinux.gateway_embed.runner.env_file_path", return_value=env_path, ): with patch.object(runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch.object(manager, "_start_polling"): manager.begin_runtime_detection() self.assertTrue(runner.managed_by_us) self.assertIs(manager._compose_lan_publish, True) calls = {"n": 0} def fake_republish(*, lan_publish, public_url=None): calls["n"] += 1 self.assertFalse(lan_publish) return RunnerResult(ok=True, message="republished") with patch("threading.Thread") as fake_thread: fake_thread.return_value = MagicMock() with patch.object(runner, "republish", side_effect=fake_republish): with patch.object(manager, "refresh_status", return_value=GatewayStatus.LIVE): manager.apply_lan_publish(False) self.assertTrue(manager._republish_started) manager._republish_worker() self.assertEqual(calls["n"], 1) self.assertIs(manager._compose_lan_publish, False) self.assertFalse(manager.lan_publish) class TestFailedRepublishEnvRestore(unittest.TestCase): """Failed force-recreate must not leave .env lying about the live bind.""" def _podman_runner(self, run): from vocalinux.gateway_embed.runner import GatewayRunner from vocalinux.gateway_embed.runtime import RuntimeInfo return GatewayRunner( runtime=RuntimeInfo( kind=ContainerRuntime.PODMAN, binary="/usr/bin/podman", compose_args=("/usr/bin/podman", "compose"), ), sandbox=detect_sandbox({}), run=run, ) def test_failed_republish_restores_previous_env_publish_host(self): import os import tempfile from vocalinux.gateway_embed.runner import ( read_lan_publish_from_env, write_env_file, ) with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" checkout = f"{tmp}/checkout" os.makedirs(checkout) Path(checkout, "compose.yaml").write_text("services: {}\n", encoding="utf-8") write_env_file(token="a" * 32, lan_publish=True, path=env_path) self.assertTrue(read_lan_publish_from_env(env_path)) def fake_run(argv, **_kwargs): completed = MagicMock() completed.returncode = 1 completed.stdout = b"" completed.stderr = b"force-recreate failed" return completed runner = self._podman_runner(fake_run) runner.managed_by_us = True with patch("vocalinux.gateway_embed.runner.env_file_path", return_value=env_path): with patch( "vocalinux.gateway_embed.runner.ensure_gateway_checkout", return_value=checkout, ): with patch( "vocalinux.gateway_embed.runner.ensure_token_file", return_value="a" * 32, ): result = runner.republish(lan_publish=False) self.assertFalse(result.ok) self.assertTrue(read_lan_publish_from_env(env_path)) body = Path(env_path).read_text(encoding="utf-8") self.assertIn("VOCAGATEWAY_PUBLISH_HOST=0.0.0.0", body) self.assertNotIn("VOCAGATEWAY_PUBLISH_HOST=127.0.0.1", body) def test_failed_republish_reverts_manager_lan_publish_to_compose(self): from vocalinux.gateway_embed.manager import GatewayEmbedManager from vocalinux.gateway_embed.runner import RunnerResult runner = self._podman_runner(MagicMock()) runner.managed_by_us = True manager = GatewayEmbedManager(runner=runner) manager.lan_publish = False manager._compose_lan_publish = True manager._republish_started = True def fake_republish(*, lan_publish, public_url=None): self.assertFalse(lan_publish) return RunnerResult(ok=False, message="force-recreate failed") with patch("threading.Thread") as fake_thread: fake_thread.return_value = MagicMock() with patch.object(runner, "republish", side_effect=fake_republish): manager._republish_worker() self.assertIs(manager._compose_lan_publish, True) self.assertTrue(manager.lan_publish) self.assertEqual(manager.status, GatewayStatus.ERROR) self.assertIn("previous bind is still in use", manager.status_detail) self.assertIn("Stop and Run", manager.status_detail) self.assertFalse(manager._republish_started) def test_orphan_adopt_after_failed_republish_still_sees_lan_from_env(self): import os import tempfile from vocalinux.gateway_embed.manager import GatewayEmbedManager from vocalinux.gateway_embed.runner import ( read_lan_publish_from_env, write_env_file, ) with tempfile.TemporaryDirectory() as tmp: env_path = f"{tmp}/.env" checkout = f"{tmp}/checkout" os.makedirs(checkout) Path(checkout, "compose.yaml").write_text("services: {}\n", encoding="utf-8") write_env_file(token="a" * 32, lan_publish=True, path=env_path) def fake_run(argv, **_kwargs): completed = MagicMock() completed.returncode = 1 completed.stdout = b"" completed.stderr = b"force-recreate failed" return completed runner = self._podman_runner(fake_run) runner.managed_by_us = True with patch("vocalinux.gateway_embed.runner.env_file_path", return_value=env_path): with patch( "vocalinux.gateway_embed.runner.ensure_gateway_checkout", return_value=checkout, ): with patch( "vocalinux.gateway_embed.runner.ensure_token_file", return_value="a" * 32, ): result = runner.republish(lan_publish=False) self.assertFalse(result.ok) self.assertTrue(read_lan_publish_from_env(env_path)) next_runner = self._podman_runner(MagicMock()) manager = GatewayEmbedManager(runner=next_runner) manager.lan_publish = False manager._compose_lan_publish = None with patch.object(next_runner, "is_compose_running", return_value=True): with patch("vocalinux.gateway_embed.manager.probe_health") as health: health.return_value = MagicMock(live=True, ready=False, error="") with patch.object(manager, "_start_polling"): manager.begin_runtime_detection() self.assertTrue(manager.lan_publish) self.assertIs(manager._compose_lan_publish, True) self.assertTrue(next_runner.managed_by_us) class TestManagerListenerCleanup(unittest.TestCase): def test_remove_listener_stops_callbacks(self): """Settings destroy must be able to detach without further emits.""" from vocalinux.gateway_embed.manager import GatewayEmbedManager from vocalinux.gateway_embed.runner import GatewayRunner from vocalinux.gateway_embed.runtime import RuntimeInfo runner = GatewayRunner( runtime=RuntimeInfo( kind=ContainerRuntime.PODMAN, binary="/usr/bin/podman", compose_args=("/usr/bin/podman", "compose"), ), sandbox=detect_sandbox({}), run=MagicMock(), ) manager = GatewayEmbedManager(runner=runner) seen: list[str] = [] def listener(status, detail): seen.append(detail) manager.add_listener(listener) manager._emit(manager.status, "one") manager.remove_listener(listener) manager._emit(manager.status, "two") self.assertEqual(seen, ["one"]) if __name__ == "__main__": unittest.main()