Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

398 lines
13 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for daemon module (lock files, suspend, time sync)."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from winpodx.core import daemon
from winpodx.core.config import Config
from winpodx.core.daemon import (
cleanup_lock_files,
is_pod_paused,
resume_pod,
suspend_pod,
sync_windows_time,
)
def test_rail_window_classes_none_on_nonzero_exit(monkeypatch):
"""Security/robustness: a non-zero wmctrl exit (WM transitioning) must return
None, not an empty set -- an empty set would arm every live session for
reaping and kill them all on two transient scan failures."""
from winpodx.core.daemon import _rail_window_classes
class _R:
returncode = 1
stdout = "" # wmctrl printed nothing because it failed
monkeypatch.setattr("winpodx.core.daemon.subprocess.run", lambda *a, **k: _R())
assert _rail_window_classes("wmctrl") is None
def test_rail_window_classes_parses_on_success(monkeypatch):
from winpodx.core.daemon import _rail_window_classes
class _R:
returncode = 0
stdout = (
"0x01 0 RAIL.winword host Doc\n"
"0x02 0 RAIL.excel host Sheet\n"
"0x03 0 Navigator.firefox host Web\n"
)
monkeypatch.setattr("winpodx.core.daemon.subprocess.run", lambda *a, **k: _R())
assert _rail_window_classes("wmctrl") == {"winword", "excel"}
def test_cleanup_lock_files(tmp_path):
# Lock files should be removed, normal files preserved.
lock = tmp_path / "~$test.docx"
lock.write_text("x")
normal = tmp_path / "test.docx"
normal.write_text("real content")
removed = cleanup_lock_files([tmp_path])
assert len(removed) == 1
assert removed[0] == lock
assert not lock.exists()
assert normal.exists()
def test_cleanup_ignores_large_files(tmp_path):
# Files matching lock pattern but >1KB should not be removed.
lock = tmp_path / "~$big.docx"
lock.write_text("x" * 2000)
removed = cleanup_lock_files([tmp_path])
assert len(removed) == 0
assert lock.exists()
def test_cleanup_empty_dir(tmp_path):
removed = cleanup_lock_files([tmp_path])
assert removed == []
def test_cleanup_nonexistent_dir():
removed = cleanup_lock_files([Path("/nonexistent/path")])
assert removed == []
def _mock_run_ok(stdout: str = "") -> MagicMock:
r = MagicMock()
r.returncode = 0
r.stdout = stdout
r.stderr = ""
return r
def test_suspend_pod_uses_configured_container_name():
cfg = Config()
cfg.pod.backend = "podman"
cfg.pod.container_name = "alt-winpod"
with patch("winpodx.core.daemon.subprocess.run", return_value=_mock_run_ok()) as mr:
assert suspend_pod(cfg) is True
cmd = mr.call_args.args[0]
assert cmd == ["podman", "pause", "alt-winpod"]
def test_idle_action_default_pauses(monkeypatch):
# Default idle_action="pause": freeze the pod (keep RAM), never stop it.
cfg = Config()
assert cfg.pod.idle_action == "pause"
monkeypatch.setattr(daemon, "is_pod_paused", lambda _cfg: False)
monkeypatch.setattr(daemon, "suspend_pod", lambda _cfg: True)
monkeypatch.setattr(daemon, "cleanup_lock_files", lambda: None)
suspended = {"n": 0}
monkeypatch.setattr(daemon, "suspend_pod", lambda _cfg: suspended.__setitem__("n", 1))
daemon._apply_idle_action(cfg)
assert suspended["n"] == 1
def test_idle_action_stop_stops_running_pod(monkeypatch):
# idle_action="stop" (#622) frees RAM by stopping a *running* pod.
from winpodx.core.pod import PodState
cfg = Config()
cfg.pod.idle_action = "stop"
cfg.pod.__post_init__()
stopped = {"n": 0}
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: type("S", (), {"state": PodState.RUNNING})(),
)
monkeypatch.setattr("winpodx.core.pod.stop_pod", lambda _cfg: stopped.__setitem__("n", 1))
monkeypatch.setattr(daemon, "cleanup_lock_files", lambda: None)
monkeypatch.setattr(daemon, "suspend_pod", lambda _cfg: pytest.fail("must not pause"))
daemon._apply_idle_action(cfg)
assert stopped["n"] == 1
def test_idle_action_stop_skips_already_stopped(monkeypatch):
# Don't re-stop a pod that isn't running (avoids churn every idle tick).
from winpodx.core.pod import PodState
cfg = Config()
cfg.pod.idle_action = "stop"
cfg.pod.__post_init__()
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: type("S", (), {"state": PodState.STOPPED})(),
)
monkeypatch.setattr(
"winpodx.core.pod.stop_pod", lambda _cfg: pytest.fail("must not stop a stopped pod")
)
monkeypatch.setattr(daemon, "suspend_pod", lambda _cfg: pytest.fail("must not pause"))
daemon._apply_idle_action(cfg) # no raise = pass
def test_resume_pod_uses_configured_container_name():
cfg = Config()
cfg.pod.backend = "docker"
cfg.pod.container_name = "alt-winpod"
with patch("winpodx.core.daemon.subprocess.run", return_value=_mock_run_ok()) as mr:
assert resume_pod(cfg) is True
cmd = mr.call_args.args[0]
assert cmd == ["docker", "unpause", "alt-winpod"]
def test_is_pod_paused_uses_configured_container_name():
cfg = Config()
cfg.pod.backend = "podman"
cfg.pod.container_name = "alt-winpod"
with patch("winpodx.core.daemon.subprocess.run", return_value=_mock_run_ok("paused\n")) as mr:
assert is_pod_paused(cfg) is True
cmd = mr.call_args.args[0]
assert "alt-winpod" in cmd
assert "winpodx-windows" not in cmd
def test_sync_windows_time_uses_windows_exec_channel(monkeypatch):
"""v0.1.9.5: sync_windows_time migrated from podman exec to FreeRDP RemoteApp."""
from winpodx.core.windows_exec import WindowsExecResult
cfg = Config()
cfg.pod.backend = "podman"
cfg.pod.container_name = "alt-winpod"
cfg.rdp.password = "secret"
captured: dict[str, str] = {}
def fake(cfg_inner, payload, *, timeout=60, description="windows-exec"):
captured["payload"] = payload
captured["description"] = description
return WindowsExecResult(rc=0, stdout="time synced", stderr="")
# Force the FreeRDP fallback path so the run_in_windows stub is reachable.
# On a dev box where a real winpodx agent is listening on 127.0.0.1:8765,
# transport.dispatch picks AgentTransport and bypasses run_in_windows;
# making dispatch raise routes the call through the FreeRDP branch
# exactly as run_via_transport's contract documents.
def _no_agent(_cfg, **_kw):
raise RuntimeError("agent transport disabled for tests")
monkeypatch.setattr("winpodx.core.transport.dispatch", _no_agent)
monkeypatch.setattr("winpodx.core.windows_exec.run_in_windows", fake)
assert sync_windows_time(cfg) is True
assert "w32tm" in captured["payload"]
assert captured["description"] == "sync-time"
def test_cleanup_ignores_symlinks(tmp_path):
target = tmp_path / "important.txt"
target.write_text("important data")
symlink = tmp_path / "~$evil.docx"
symlink.symlink_to(target)
removed = cleanup_lock_files([tmp_path])
assert len(removed) == 0
assert target.exists()
assert symlink.is_symlink()
# -- periodic checksum-gated icon refresh ---------------------------------
def test_icon_refresh_once_skips_when_paused(monkeypatch):
from winpodx.core import daemon
monkeypatch.setattr(daemon, "is_pod_paused", lambda cfg: True)
hit = {"n": 0}
monkeypatch.setattr(
"winpodx.core.discovery.discover_apps", lambda cfg: hit.__setitem__("n", 1) or []
)
daemon._icon_refresh_once(Config())
assert hit["n"] == 0 # never discovered while paused
def test_icon_refresh_once_skips_when_not_running(monkeypatch):
from winpodx.core import daemon
from winpodx.core.pod import PodState, PodStatus
monkeypatch.setattr(daemon, "is_pod_paused", lambda cfg: False)
monkeypatch.setattr(
"winpodx.core.pod.pod_status", lambda cfg: PodStatus(state=PodState.STOPPED)
)
hit = {"n": 0}
monkeypatch.setattr(
"winpodx.core.discovery.discover_apps", lambda cfg: hit.__setitem__("n", 1) or []
)
daemon._icon_refresh_once(Config())
assert hit["n"] == 0 # pod not running -> skip
def test_icon_refresh_once_no_sync_when_nothing_changed(monkeypatch):
from winpodx.core import daemon
from winpodx.core.pod import PodState, PodStatus
monkeypatch.setattr(daemon, "is_pod_paused", lambda cfg: False)
monkeypatch.setattr(
"winpodx.core.pod.pod_status", lambda cfg: PodStatus(state=PodState.RUNNING)
)
seq = []
monkeypatch.setattr(
"winpodx.core.discovery.discover_apps", lambda cfg: seq.append("disc") or ["app"]
)
monkeypatch.setattr(
"winpodx.core.discovery.persist_discovered", lambda apps: seq.append("persist") or []
)
monkeypatch.setattr(
"winpodx.desktop.entry.install_desktop_entry",
lambda info: seq.append("install"), # must NOT be called
)
daemon._icon_refresh_once(Config())
assert seq == ["disc", "persist"] # checksum gate left nothing changed -> no resync
def test_icon_refresh_once_syncs_when_changed(monkeypatch):
from winpodx.core import daemon
from winpodx.core.pod import PodState, PodStatus
monkeypatch.setattr(daemon, "is_pod_paused", lambda cfg: False)
monkeypatch.setattr(
"winpodx.core.pod.pod_status", lambda cfg: PodStatus(state=PodState.RUNNING)
)
monkeypatch.setattr("winpodx.core.discovery.discover_apps", lambda cfg: ["app"])
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda apps: ["/p/app.toml"])
monkeypatch.setattr(
"winpodx.core.app.list_available_apps",
lambda: [
type("A", (), {"name": "word", "hidden": False})(),
type("A", (), {"name": "hiddenapp", "hidden": True})(),
],
)
installed = []
monkeypatch.setattr(
"winpodx.desktop.entry.install_desktop_entry", lambda info: installed.append(info.name)
)
monkeypatch.setattr(
"winpodx.desktop.icons.refresh_icon_cache", lambda: installed.append("cache")
)
daemon._icon_refresh_once(Config())
assert installed == ["word", "cache"] # visible app installed, hidden skipped, cache refreshed
# --- Session window-reaper (#680) --------------------------------------------
def test_rail_window_classes_parses_res_class(monkeypatch):
out = (
"0x01 0 RAIL.excel host Book1\n"
"0x02 0 RAIL.winword host Doc\n"
"0x03 0 firefox.Firefox host web\n"
)
monkeypatch.setattr(
daemon.subprocess,
"run",
lambda *a, **k: type("R", (), {"stdout": out, "returncode": 0})(),
)
assert daemon._rail_window_classes("wmctrl") == {"excel", "winword"}
def test_rail_window_classes_none_on_scan_error(monkeypatch):
def _boom(*a, **k):
raise OSError("wmctrl gone")
monkeypatch.setattr(daemon.subprocess, "run", _boom)
assert daemon._rail_window_classes("wmctrl") is None
def _reaper_env(monkeypatch, scans):
"""Wire a fast session window-reaper over a scripted sequence of window-class
scans. Returns (stop_event, killed_list)."""
import threading
from winpodx.core.process import TrackedProcess
monkeypatch.setattr(daemon, "_WINDOW_REAP_POLL_SECS", 0.001)
monkeypatch.setattr(daemon, "_WINDOW_REAP_DEBOUNCE_SECS", 0.0)
monkeypatch.setattr(daemon.shutil, "which", lambda _n: "/usr/bin/wmctrl")
monkeypatch.setattr(daemon, "list_active_sessions", lambda: [TrackedProcess("excel", 111)])
it = iter(scans)
monkeypatch.setattr(daemon, "_rail_window_classes", lambda _w: next(it, set()))
stop = threading.Event()
killed: list[str] = []
def _kill(name, expected_pid=None):
killed.append(name)
stop.set() # one reap is enough for the test
monkeypatch.setattr(daemon, "kill_session", _kill)
return stop, killed
def test_session_window_reaper_reaps_after_window_closes(monkeypatch):
# Window present on the first scan (arms), gone after -> reaped via kill_session.
stop, killed = _reaper_env(monkeypatch, [{"excel"}, set(), set()])
daemon.run_session_window_reaper(Config(), stop)
assert killed == ["excel"]
def test_session_window_reaper_never_reaps_unseen_window(monkeypatch):
# A session whose RAIL window never appears must never be reaped.
import threading
from winpodx.core.process import TrackedProcess
monkeypatch.setattr(daemon, "_WINDOW_REAP_POLL_SECS", 0.001)
monkeypatch.setattr(daemon, "_WINDOW_REAP_DEBOUNCE_SECS", 0.0)
monkeypatch.setattr(daemon.shutil, "which", lambda _n: "/usr/bin/wmctrl")
monkeypatch.setattr(daemon, "list_active_sessions", lambda: [TrackedProcess("excel", 111)])
stop = threading.Event()
calls = {"n": 0}
def _scan(_w):
calls["n"] += 1
if calls["n"] >= 4:
stop.set()
return set() # excel window never maps
monkeypatch.setattr(daemon, "_rail_window_classes", _scan)
killed: list[str] = []
monkeypatch.setattr(daemon, "kill_session", lambda name, expected_pid=None: killed.append(name))
daemon.run_session_window_reaper(Config(), stop)
assert killed == []
def test_session_window_reaper_noop_without_wmctrl(monkeypatch):
import threading
monkeypatch.setattr(daemon.shutil, "which", lambda _n: None)
called: list = []
monkeypatch.setattr(daemon, "list_active_sessions", lambda: called.append("x") or [])
daemon.run_session_window_reaper(Config(), threading.Event())
assert called == [] # returned before the loop