# SPDX-License-Identifier: MIT """Tests for CLI issues 7-22 (4th audit) and H2, H7, M3, M6, M7 (5th audit).""" from __future__ import annotations import argparse import logging import string from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Issue 22: '$' removed from password alphabet class TestPasswordAlphabet: def test_dollar_sign_never_generated(self): from winpodx.cli.setup_cmd import _generate_password for _ in range(500): pw = _generate_password() assert "$" not in pw, f"'$' found in generated password: {pw!r}" def test_password_contains_required_character_classes(self): from winpodx.cli.setup_cmd import _generate_password for _ in range(50): pw = _generate_password() assert any(c in string.ascii_uppercase for c in pw) assert any(c in string.ascii_lowercase for c in pw) assert any(c in string.digits for c in pw) assert any(c in "!@#%&*" for c in pw) def test_password_default_length(self): from winpodx.cli.setup_cmd import _generate_password assert len(_generate_password()) == 20 def test_allowed_specials_present_in_alphabet(self): from winpodx.cli.setup_cmd import _generate_password seen_specials: set[str] = set() for _ in range(2000): pw = _generate_password() for ch in pw: if ch not in string.ascii_letters and ch not in string.digits: seen_specials.add(ch) assert seen_specials == set("!@#%&*"), f"Unexpected specials: {seen_specials}" # Issue 7: EOFError handling in setup wizard class TestAskHelper: def test_returns_input_on_normal_read(self, monkeypatch): from winpodx.cli.setup_cmd import _ask monkeypatch.setattr("builtins.input", lambda _: "hello") assert _ask("prompt: ") == "hello" def test_returns_default_on_eof(self, monkeypatch): from winpodx.cli.setup_cmd import _ask def _raise(_prompt): raise EOFError monkeypatch.setattr("builtins.input", _raise) assert _ask("prompt: ", default="mydefault") == "mydefault" def test_returns_empty_string_default_on_eof(self, monkeypatch): from winpodx.cli.setup_cmd import _ask def _raise(_prompt): raise EOFError monkeypatch.setattr("builtins.input", _raise) assert _ask("prompt: ") == "" def test_non_tty_forces_non_interactive(self, tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: False)) args = argparse.Namespace(backend="manual", non_interactive=False) freerdp_dep = MagicMock() freerdp_dep.found = True freerdp_dep.note = "" with ( patch( "winpodx.cli.setup_cmd.check_all", return_value={"freerdp": freerdp_dep}, ), patch( "winpodx.cli.setup_cmd.import_winapps_config", return_value=None, ), patch("winpodx.cli.setup_cmd._generate_compose"), patch("winpodx.cli.setup_cmd._recreate_container"), patch("winpodx.cli.setup_cmd._register_all_desktop_entries"), patch( "winpodx.display.scaling.detect_scale_factor", return_value=100, ), patch( "winpodx.display.scaling.detect_raw_scale", return_value=1.0, ), ): from winpodx.cli.setup_cmd import handle_setup handle_setup(args) def test_setup_stamps_install_marker_on_fresh_run(self, tmp_path, monkeypatch): """v0.2.0.2: setup must write installed_version.txt so a follow-up `winpodx migrate` doesn't misclassify a fresh --purge install as a pre-tracker upgrade from 0.1.7.""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: False)) args = argparse.Namespace(backend="manual", non_interactive=True) freerdp_dep = MagicMock() freerdp_dep.found = True freerdp_dep.note = "" with ( patch( "winpodx.cli.setup_cmd.check_all", return_value={"freerdp": freerdp_dep}, ), patch("winpodx.cli.setup_cmd.import_winapps_config", return_value=None), patch("winpodx.cli.setup_cmd._generate_compose"), patch("winpodx.cli.setup_cmd._recreate_container"), patch("winpodx.cli.setup_cmd._register_all_desktop_entries"), patch("winpodx.display.scaling.detect_scale_factor", return_value=100), patch("winpodx.display.scaling.detect_raw_scale", return_value=1.0), ): from winpodx.cli.setup_cmd import handle_setup handle_setup(args) from winpodx import __version__ marker = tmp_path / "winpodx" / "installed_version.txt" assert marker.exists(), "setup must stamp installed_version.txt" assert marker.read_text(encoding="utf-8").strip() == __version__ def test_setup_does_not_overwrite_existing_marker(self, tmp_path, monkeypatch): """An older marker (e.g. mid-upgrade) must not be silently bumped to the current version by re-running setup. Migrate is the only thing that should advance the marker.""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: False)) cfg_dir = tmp_path / "winpodx" cfg_dir.mkdir(parents=True) marker = cfg_dir / "installed_version.txt" marker.write_text("0.1.8\n", encoding="utf-8") args = argparse.Namespace(backend="manual", non_interactive=True) freerdp_dep = MagicMock() freerdp_dep.found = True freerdp_dep.note = "" with ( patch( "winpodx.cli.setup_cmd.check_all", return_value={"freerdp": freerdp_dep}, ), patch("winpodx.cli.setup_cmd.import_winapps_config", return_value=None), patch("winpodx.cli.setup_cmd._generate_compose"), patch("winpodx.cli.setup_cmd._recreate_container"), patch("winpodx.cli.setup_cmd._register_all_desktop_entries"), patch("winpodx.display.scaling.detect_scale_factor", return_value=100), patch("winpodx.display.scaling.detect_raw_scale", return_value=1.0), ): from winpodx.cli.setup_cmd import handle_setup handle_setup(args) assert marker.read_text(encoding="utf-8").strip() == "0.1.8" def test_setup_applies_win_version_arg_on_fresh_install(self, tmp_path, monkeypatch): """`--win-version ltsc11` must land in cfg.pod.win_version when no existing winpodx.toml is present. #178 follow-up.""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: False)) args = argparse.Namespace( backend="manual", non_interactive=True, win_version="ltsc11", ) freerdp_dep = MagicMock() freerdp_dep.found = True freerdp_dep.note = "" with ( patch( "winpodx.cli.setup_cmd.check_all", return_value={"freerdp": freerdp_dep}, ), patch("winpodx.cli.setup_cmd.import_winapps_config", return_value=None), patch("winpodx.cli.setup_cmd._generate_compose"), patch("winpodx.cli.setup_cmd._recreate_container"), patch("winpodx.cli.setup_cmd._register_all_desktop_entries"), patch("winpodx.display.scaling.detect_scale_factor", return_value=100), patch("winpodx.display.scaling.detect_raw_scale", return_value=1.0), ): from winpodx.cli.setup_cmd import handle_setup handle_setup(args) from winpodx.core.config import Config loaded = Config.load() assert loaded.pod.win_version == "ltsc11" # Issue 8: password rotation split-brain class TestRotatePasswordAtomicity: def _make_cfg(self, tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) from winpodx.cli.setup_cmd import _generate_compose from winpodx.core.config import Config cfg = Config() cfg.rdp.password = "oldpassword1" cfg.rdp.user = "User" cfg.pod.backend = "podman" cfg.save() _generate_compose(cfg) def test_compose_failure_does_not_corrupt_config(self, tmp_path, monkeypatch): self._make_cfg(tmp_path, monkeypatch) from winpodx.core.config import Config original_password = Config.load().rdp.password args = argparse.Namespace() with ( patch("winpodx.core.pod.pod_status") as mock_status, patch( "winpodx.core.provisioner._change_windows_password", return_value=True, ), patch( "winpodx.cli.setup_cmd._generate_compose_to", side_effect=OSError("disk full"), ), ): from winpodx.core.pod import PodState, PodStatus mock_status.return_value = PodStatus(state=PodState.RUNNING) from winpodx.cli.setup_cmd import handle_rotate_password try: handle_rotate_password(args) except (OSError, SystemExit): pass reloaded = Config.load() assert reloaded.rdp.password == original_password def test_successful_rotation_updates_both(self, tmp_path, monkeypatch): self._make_cfg(tmp_path, monkeypatch) from winpodx.core.config import Config old_password = Config.load().rdp.password args = argparse.Namespace() with ( patch("winpodx.core.pod.pod_status") as mock_status, patch( "winpodx.core.provisioner._change_windows_password", return_value=True, ), ): from winpodx.core.pod import PodState, PodStatus mock_status.return_value = PodStatus(state=PodState.RUNNING) from winpodx.cli.setup_cmd import handle_rotate_password handle_rotate_password(args) reloaded = Config.load() assert reloaded.rdp.password != old_password compose_path = tmp_path / "winpodx" / "compose.yaml" assert compose_path.exists() assert reloaded.rdp.password in compose_path.read_text() # Issue 9: install-all icon cache refresh class TestInstallAllIconCache: def test_update_icon_cache_called_after_install_all(self): fake_apps = [ MagicMock(full_name="Notepad"), MagicMock(full_name="WordPad"), ] update_called: list[bool] = [] with ( patch( "winpodx.core.app.list_available_apps", return_value=fake_apps, ), patch( "winpodx.desktop.entry.install_desktop_entry", return_value=Path("/tmp/x.desktop"), ), patch( "winpodx.desktop.icons.update_icon_cache", side_effect=lambda: update_called.append(True), ), ): from winpodx.cli.app import _install_all _install_all() assert update_called, "update_icon_cache() was not called by _install_all()" def test_update_icon_cache_not_called_when_no_apps(self): update_called: list[bool] = [] with ( patch("winpodx.core.app.list_available_apps", return_value=[]), patch( "winpodx.desktop.icons.update_icon_cache", side_effect=lambda: update_called.append(True), ), ): from winpodx.cli.app import _install_all _install_all() assert not update_called # Issue 10: _run_app catches RuntimeError from ensure_ready class TestRunAppExceptionHandling: def test_runtime_error_from_ensure_ready_exits_cleanly(self, capsys): with ( patch( "winpodx.core.provisioner.ensure_ready", side_effect=RuntimeError("compose failed"), ), patch("winpodx.desktop.notify.notify_error"), pytest.raises(SystemExit) as exc_info, ): from winpodx.cli.app import _run_app _run_app("notepad", None, False) assert exc_info.value.code == 1 assert "compose failed" in capsys.readouterr().err def test_provision_error_exits_cleanly(self, capsys): from winpodx.core.provisioner import ProvisionError with ( patch( "winpodx.core.provisioner.ensure_ready", side_effect=ProvisionError("pod broken"), ), patch("winpodx.desktop.notify.notify_error"), pytest.raises(SystemExit) as exc_info, ): from winpodx.cli.app import _run_app _run_app("notepad", None, False) assert exc_info.value.code == 1 # Issue 19: RDP_FLAGS filtered at winapps import time class TestWinappsRDPFlagsFilter: def _write_conf(self, path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) def test_safe_flags_preserved(self, tmp_path): conf = tmp_path / "winapps.conf" self._write_conf(conf, 'RDP_FLAGS="/scale:200 /sound:sys:alsa"\n') with patch("winpodx.utils.compat.find_winapps_conf", return_value=conf): from winpodx.utils.compat import import_winapps_config cfg = import_winapps_config() assert cfg is not None assert "/scale:200" in cfg.rdp.extra_flags assert "/sound:sys:alsa" in cfg.rdp.extra_flags def test_dangerous_flags_refused_entirely(self, tmp_path): # When any flag is blocked, extra_flags must be empty (all-or-nothing). conf = tmp_path / "winapps.conf" self._write_conf(conf, 'RDP_FLAGS="/exec:whoami /shell:sh /scale:100"\n') with patch("winpodx.utils.compat.find_winapps_conf", return_value=conf): from winpodx.utils.compat import import_winapps_config cfg = import_winapps_config() assert cfg is not None assert cfg.rdp.extra_flags == "" def test_all_dangerous_flags_blocked(self, tmp_path): conf = tmp_path / "winapps.conf" self._write_conf(conf, 'RDP_FLAGS="/exec:cmd /app:evil.exe /cert:ignore"\n') with patch("winpodx.utils.compat.find_winapps_conf", return_value=conf): from winpodx.utils.compat import import_winapps_config cfg = import_winapps_config() assert cfg is not None assert cfg.rdp.extra_flags == "" def test_warning_logged_when_flags_removed(self, tmp_path, caplog): conf = tmp_path / "winapps.conf" self._write_conf(conf, 'RDP_FLAGS="/exec:pwned /scale:100"\n') with ( patch("winpodx.utils.compat.find_winapps_conf", return_value=conf), caplog.at_level(logging.WARNING, logger="winpodx.utils.compat"), ): from winpodx.utils.compat import import_winapps_config import_winapps_config() assert any( "blocked" in r.message.lower() or "allowlist" in r.message.lower() for r in caplog.records ) def test_empty_rdp_flags_no_error(self, tmp_path): conf = tmp_path / "winapps.conf" self._write_conf(conf, "RDP_USER=User\n") with patch("winpodx.utils.compat.find_winapps_conf", return_value=conf): from winpodx.utils.compat import import_winapps_config cfg = import_winapps_config() assert cfg is not None assert cfg.rdp.extra_flags == "" # H2: adversarial username in COMPOSE_TEMPLATE.format() class TestComposeTemplateFormatInjection: """Usernames/passwords with { } must not cause IndexError or field leakage.""" def _make_cfg(self, tmp_path, monkeypatch, username: str, password: str = "S3cur3!pw"): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) from winpodx.core.config import Config cfg = Config() cfg.rdp.user = username cfg.rdp.password = password cfg.pod.backend = "podman" return cfg def test_username_with_format_index_placeholder(self, tmp_path, monkeypatch): cfg = self._make_cfg(tmp_path, monkeypatch, username="{0}") from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) compose_path = tmp_path / "winpodx" / "compose.yaml" assert compose_path.exists() def test_username_with_named_placeholder_does_not_leak_password(self, tmp_path, monkeypatch): secret = "SuperSecret!99" cfg = self._make_cfg(tmp_path, monkeypatch, username="{password}", password=secret) from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) compose_path = tmp_path / "winpodx" / "compose.yaml" content = compose_path.read_text() lines = [ln for ln in content.splitlines() if "USERNAME:" in ln] assert lines, "USERNAME field missing from compose output" username_line = lines[0] assert secret not in username_line, ( f"Password leaked into USERNAME field: {username_line!r}" ) def test_username_with_arbitrary_braces_does_not_raise(self, tmp_path, monkeypatch): cfg = self._make_cfg(tmp_path, monkeypatch, username="a{b}c") from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) compose_path = tmp_path / "winpodx" / "compose.yaml" assert compose_path.exists() def test_password_with_braces_does_not_raise(self, tmp_path, monkeypatch): cfg = self._make_cfg(tmp_path, monkeypatch, username="User", password="P@ss{word}1!") from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) compose_path = tmp_path / "winpodx" / "compose.yaml" assert compose_path.exists() # M3: container_name routed through cfg.pod.container_name class TestContainerNameFromConfig: def test_compose_uses_cfg_container_name(self, tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) from winpodx.core.config import Config cfg = Config() cfg.rdp.user = "User" cfg.rdp.password = "Test123!pw" cfg.pod.backend = "podman" cfg.pod.container_name = "my-custom-windows" from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) compose_path = tmp_path / "winpodx" / "compose.yaml" content = compose_path.read_text() assert "my-custom-windows" in content assert "winpodx-windows" not in content # M6: podman-specific keys absent from Docker compose output class TestComposeBackendSpecificKeys: def _generate(self, tmp_path, monkeypatch, backend: str) -> str: monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) from winpodx.core.config import Config cfg = Config() cfg.rdp.user = "User" cfg.rdp.password = "Test123!pw" cfg.pod.backend = backend from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) return (tmp_path / "winpodx" / "compose.yaml").read_text() def test_podman_backend_includes_keep_groups(self, tmp_path, monkeypatch): content = self._generate(tmp_path, monkeypatch, "podman") assert "keep-groups" in content assert "run.oci.keep_original_groups" in content def test_docker_backend_excludes_keep_groups(self, tmp_path, monkeypatch): content = self._generate(tmp_path, monkeypatch, "docker") assert "keep-groups" not in content assert "run.oci.keep_original_groups" not in content # M7: NETWORK: "slirp" removed from compose output class TestComposeNetworkKey: def test_network_pins_user_mode_not_slirp(self, tmp_path, monkeypatch): # The compose pins dockur user-mode networking (#269 / #387) so # USER_PORTS (the agent port) is always forwarded. It must be the # "user" mode, never "slirp" (slirp is dockur's fallback-of-last-resort # if passt fails to start, not something we should request directly). monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) from winpodx.core.config import Config cfg = Config() cfg.rdp.user = "User" cfg.rdp.password = "Test123!pw" cfg.pod.backend = "podman" from winpodx.cli.setup_cmd import _generate_compose _generate_compose(cfg) content = (tmp_path / "winpodx" / "compose.yaml").read_text() assert 'NETWORK: "user"' in content assert "slirp" not in content # --- v0.1.8 audit I1/I2: winpodx app refresh cfg-loading + kind routing --- class TestRefreshAppsCli: def _import(self): from winpodx.cli.app import _refresh_apps return _refresh_apps def test_refresh_passes_cfg_to_discover_apps(self): from winpodx.core.config import Config fake_cfg = Config() with ( patch("winpodx.core.config.Config.load", return_value=fake_cfg), patch("winpodx.core.discovery.discover_apps", return_value=[]) as mock_da, patch("winpodx.core.discovery.persist_discovered", return_value=[]), ): refresh = self._import() refresh(as_json=False, timeout=30) mock_da.assert_called_once() args, kwargs = mock_da.call_args # cfg must be first positional arg (matches discover_apps signature). assert args[0] is fake_cfg assert kwargs.get("timeout", None) == 30 def test_refresh_default_timeout_is_generous(self): # #619: `winpodx app refresh` with no --timeout must use the generous # discovery default (300s), NOT a tight 30s cap that made discovery # time out with "/exec timed out after 29.0s" on a slow guest while a # fresh install (which uses the library default) discovered fine. from winpodx.cli.main import cli as cli_entry from winpodx.core.config import Config from winpodx.core.discovery import DEFAULT_DISCOVERY_TIMEOUT assert DEFAULT_DISCOVERY_TIMEOUT == 300 fake_cfg = Config() with ( patch("winpodx.cli.main._maybe_resume_pending", lambda *a, **k: None), patch("winpodx.core.config.Config.load", return_value=fake_cfg), patch("winpodx.core.discovery.discover_apps", return_value=[]) as mock_da, patch("winpodx.core.discovery.persist_discovered", return_value=[]), ): try: cli_entry(["app", "refresh"]) except SystemExit: pass mock_da.assert_called_once() _args, kwargs = mock_da.call_args assert kwargs.get("timeout") == DEFAULT_DISCOVERY_TIMEOUT def test_refresh_pod_not_running_exits_2(self): from winpodx.core.config import Config from winpodx.core.discovery import DiscoveryError with ( patch("winpodx.core.config.Config.load", return_value=Config()), patch( "winpodx.core.discovery.discover_apps", side_effect=DiscoveryError("pod is down", kind="pod_not_running"), ), pytest.raises(SystemExit) as excinfo, ): refresh = self._import() refresh(as_json=False, timeout=30) assert excinfo.value.code == 2 def test_refresh_unsupported_backend_exits_2(self): from winpodx.core.config import Config from winpodx.core.discovery import DiscoveryError with ( patch("winpodx.core.config.Config.load", return_value=Config()), patch( "winpodx.core.discovery.discover_apps", side_effect=DiscoveryError( "manual backend not supported", kind="unsupported_backend" ), ), pytest.raises(SystemExit) as excinfo, ): refresh = self._import() refresh(as_json=False, timeout=30) assert excinfo.value.code == 2 def test_refresh_timeout_exits_4(self): from winpodx.core.config import Config from winpodx.core.discovery import DiscoveryError with ( patch("winpodx.core.config.Config.load", return_value=Config()), patch( "winpodx.core.discovery.discover_apps", side_effect=DiscoveryError("timed out", kind="timeout"), ), pytest.raises(SystemExit) as excinfo, ): refresh = self._import() refresh(as_json=False, timeout=30) assert excinfo.value.code == 4 def test_refresh_unknown_kind_defaults_to_3(self): from winpodx.core.config import Config from winpodx.core.discovery import DiscoveryError with ( patch("winpodx.core.config.Config.load", return_value=Config()), patch( "winpodx.core.discovery.discover_apps", side_effect=DiscoveryError("weird thing happened"), ), pytest.raises(SystemExit) as excinfo, ): refresh = self._import() refresh(as_json=False, timeout=30) assert excinfo.value.code == 3 def test_refresh_json_emits_slug_and_icon_path(self, capsys): import json from winpodx.core.config import Config from winpodx.core.discovery import DiscoveredApp app = DiscoveredApp( name="myapp", full_name="My App", executable="C:\\\\App\\\\my.exe", source="win32", slug="myapp", icon_path="/home/u/.local/share/icons/hicolor/32x32/apps/winpodx-myapp.png", ) with ( patch("winpodx.core.config.Config.load", return_value=Config()), patch("winpodx.core.discovery.discover_apps", return_value=[app]), patch("winpodx.core.discovery.persist_discovered", return_value=[app]), ): refresh = self._import() refresh(as_json=True, timeout=30) captured = capsys.readouterr() data = json.loads(captured.out) assert len(data) == 1 assert data[0]["name"] == "myapp" assert data[0]["slug"] == "myapp" assert data[0]["icon_path"].endswith("winpodx-myapp.png") assert "discovered 1 app" in captured.err