3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
743 lines
26 KiB
Python
743 lines
26 KiB
Python
# 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
|