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
370 lines
12 KiB
Python
370 lines
12 KiB
Python
# SPDX-License-Identifier: MIT
|
|
"""Tests for winpodx.core.checks — health probes feed `winpodx check` + GUI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from winpodx.core import checks
|
|
from winpodx.core.checks import Probe
|
|
|
|
|
|
def test_probe_dataclass_is_immutable():
|
|
p = Probe(name="x", status="ok", detail="d", duration_ms=1)
|
|
with pytest.raises(Exception):
|
|
p.status = "fail" # type: ignore[misc]
|
|
|
|
|
|
def test_probe_is_ok_property():
|
|
assert Probe("x", "ok", "", 0).is_ok
|
|
assert not Probe("x", "warn", "", 0).is_ok
|
|
assert not Probe("x", "fail", "", 0).is_ok
|
|
assert not Probe("x", "skip", "", 0).is_ok
|
|
|
|
|
|
def test_overall_all_ok():
|
|
probes = [Probe("a", "ok", "", 0), Probe("b", "ok", "", 0)]
|
|
assert checks.overall(probes) == "ok"
|
|
|
|
|
|
def test_overall_warn_wins_over_ok():
|
|
probes = [Probe("a", "ok", "", 0), Probe("b", "warn", "", 0)]
|
|
assert checks.overall(probes) == "warn"
|
|
|
|
|
|
def test_overall_fail_wins_over_everything():
|
|
probes = [Probe("a", "ok", "", 0), Probe("b", "warn", "", 0), Probe("c", "fail", "", 0)]
|
|
assert checks.overall(probes) == "fail"
|
|
|
|
|
|
def test_overall_skip_treated_as_neutral():
|
|
"""A skip should not pull the verdict away from ok."""
|
|
probes = [Probe("a", "ok", "", 0), Probe("b", "skip", "", 0)]
|
|
assert checks.overall(probes) == "ok"
|
|
|
|
|
|
def test_overall_empty_iterable():
|
|
assert checks.overall([]) == "ok"
|
|
|
|
|
|
def test_probe_never_raises_on_internal_exception():
|
|
"""The _timed wrapper must catch any exception and surface as fail.
|
|
|
|
A buggy probe should NOT take down `winpodx check` for the whole
|
|
user — kernalix7's UI freezing on a transient AgentClient bug was
|
|
exactly this class of regression.
|
|
"""
|
|
|
|
def boom(_cfg) -> Probe:
|
|
raise RuntimeError("simulated")
|
|
|
|
# The probe wrapper inside checks.py wraps each function; for an
|
|
# external function we apply _timed directly to verify the contract.
|
|
status, detail, ms = checks._timed(lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
assert status == "fail"
|
|
assert "boom" in detail or "RuntimeError" in detail
|
|
assert ms >= 0
|
|
|
|
|
|
def test_run_all_returns_one_probe_per_registered_function(monkeypatch):
|
|
"""run_all must call every probe in PROBES and return a Probe for each."""
|
|
fake_cfg = object()
|
|
fake_probes = [Probe(f"p{i}", "ok", f"detail-{i}", i) for i, _ in enumerate(checks.PROBES)]
|
|
|
|
monkeypatch.setattr(checks, "PROBES", tuple(lambda _cfg, n=p: n for p in fake_probes))
|
|
out = checks.run_all(fake_cfg)
|
|
assert len(out) == len(fake_probes)
|
|
assert all(isinstance(p, Probe) for p in out)
|
|
|
|
|
|
def test_run_all_skips_exec_probes_when_agent_health_not_ok(monkeypatch):
|
|
fake_cfg = object()
|
|
called: list[str] = []
|
|
|
|
def pod(_cfg):
|
|
return Probe("pod_running", "ok", "running", 1)
|
|
|
|
def rdp(_cfg):
|
|
return Probe("rdp_port", "ok", "rdp", 1)
|
|
|
|
def agent(_cfg):
|
|
return Probe("agent_health", "warn", "agent warming up", 1)
|
|
|
|
def auth(_cfg):
|
|
called.append("auth")
|
|
return Probe("agent_auth_ready", "ok", "token", 1)
|
|
|
|
def guest_exec(_cfg):
|
|
called.append("exec")
|
|
return Probe("guest_exec", "ok", "exec", 1)
|
|
|
|
def guest_summary(_cfg):
|
|
called.append("summary")
|
|
return Probe("guest_summary", "ok", "summary", 1)
|
|
|
|
monkeypatch.setattr(checks, "probe_agent_auth_ready", auth)
|
|
monkeypatch.setattr(checks, "probe_guest_exec", guest_exec)
|
|
monkeypatch.setattr(checks, "probe_guest_summary", guest_summary)
|
|
monkeypatch.setattr(
|
|
checks,
|
|
"PROBES",
|
|
(
|
|
pod,
|
|
rdp,
|
|
agent,
|
|
checks.probe_agent_auth_ready,
|
|
checks.probe_guest_exec,
|
|
checks.probe_guest_summary,
|
|
),
|
|
)
|
|
|
|
out = checks.run_all(fake_cfg)
|
|
|
|
by_name = {p.name: p for p in out}
|
|
assert by_name["agent_auth_ready"].status == "skip"
|
|
assert by_name["guest_exec"].status == "skip"
|
|
assert by_name["guest_summary"].status == "skip"
|
|
assert called == []
|
|
|
|
|
|
def test_run_all_skips_exec_probes_when_auth_not_ready(monkeypatch):
|
|
fake_cfg = object()
|
|
called: list[str] = []
|
|
|
|
def pod(_cfg):
|
|
return Probe("pod_running", "ok", "running", 1)
|
|
|
|
def rdp(_cfg):
|
|
return Probe("rdp_port", "ok", "rdp", 1)
|
|
|
|
def agent(_cfg):
|
|
return Probe("agent_health", "ok", "version=x", 1)
|
|
|
|
def auth(_cfg):
|
|
return Probe("agent_auth_ready", "fail", "token missing", 1)
|
|
|
|
def guest_exec(_cfg):
|
|
called.append("exec")
|
|
return Probe("guest_exec", "ok", "exec", 1)
|
|
|
|
monkeypatch.setattr(checks, "probe_agent_auth_ready", auth)
|
|
monkeypatch.setattr(checks, "probe_guest_exec", guest_exec)
|
|
monkeypatch.setattr(
|
|
checks,
|
|
"PROBES",
|
|
(pod, rdp, agent, checks.probe_agent_auth_ready, checks.probe_guest_exec),
|
|
)
|
|
|
|
out = checks.run_all(fake_cfg)
|
|
|
|
by_name = {p.name: p for p in out}
|
|
assert by_name["agent_auth_ready"].status == "fail"
|
|
assert by_name["guest_exec"].status == "skip"
|
|
assert called == []
|
|
|
|
|
|
def test_probe_pod_running_handles_pod_status_failure(monkeypatch):
|
|
"""If pod_status raises, the probe must return fail, not propagate."""
|
|
import winpodx.core.pod as pod_mod
|
|
|
|
def boom(_cfg):
|
|
raise RuntimeError("backend gone")
|
|
|
|
monkeypatch.setattr(pod_mod, "pod_status", boom)
|
|
out = checks.probe_pod_running(_FakeCfg())
|
|
assert out.name == "pod_running"
|
|
assert out.status == "fail"
|
|
assert "backend gone" in out.detail or "RuntimeError" in out.detail
|
|
|
|
|
|
def test_probe_password_age_skip_when_disabled():
|
|
cfg = _FakeCfg()
|
|
cfg.rdp.password_max_age = 0
|
|
out = checks.probe_password_age(cfg)
|
|
assert out.status == "skip"
|
|
|
|
|
|
def test_probe_password_age_warn_when_no_timestamp():
|
|
cfg = _FakeCfg()
|
|
cfg.rdp.password_max_age = 7
|
|
cfg.rdp.password_updated = ""
|
|
out = checks.probe_password_age(cfg)
|
|
assert out.status == "warn"
|
|
|
|
|
|
def test_probe_password_age_warn_when_overdue():
|
|
cfg = _FakeCfg()
|
|
cfg.rdp.password_max_age = 1
|
|
cfg.rdp.password_updated = "2020-01-01T00:00:00+00:00"
|
|
out = checks.probe_password_age(cfg)
|
|
assert out.status == "warn"
|
|
assert "overdue" in out.detail
|
|
|
|
|
|
def test_probe_guest_exec_skip_when_pod_not_running(monkeypatch):
|
|
"""When the pod isn't running, the round-trip probe must skip rather than
|
|
fail — every guest probe would red-X otherwise and drown out the actual
|
|
"pod is down" signal."""
|
|
import winpodx.core.pod as pod_mod
|
|
from winpodx.core.pod import PodState, PodStatus
|
|
|
|
monkeypatch.setattr(pod_mod, "pod_status", lambda _cfg: PodStatus(state=PodState.STOPPED))
|
|
out = checks.probe_guest_exec(_FakeCfg())
|
|
assert out.status == "skip"
|
|
assert "pod" in out.detail.lower()
|
|
|
|
|
|
def test_probe_guest_summary_skip_when_pod_not_running(monkeypatch):
|
|
import winpodx.core.pod as pod_mod
|
|
from winpodx.core.pod import PodState, PodStatus
|
|
|
|
monkeypatch.setattr(pod_mod, "pod_status", lambda _cfg: PodStatus(state=PodState.STOPPED))
|
|
out = checks.probe_guest_summary(_FakeCfg())
|
|
assert out.status == "skip"
|
|
|
|
|
|
def test_probe_guest_exec_fail_when_agent_unreachable(monkeypatch):
|
|
"""When the pod IS running but agent is down, the probe must fail with
|
|
a useful detail — proves the round-trip channel is broken even though
|
|
the container is up."""
|
|
import winpodx.core.pod as pod_mod
|
|
from winpodx.core.agent import AgentUnavailableError
|
|
from winpodx.core.pod import PodState, PodStatus
|
|
|
|
monkeypatch.setattr(pod_mod, "pod_status", lambda _cfg: PodStatus(state=PodState.RUNNING))
|
|
|
|
class _FakeClient:
|
|
def __init__(self, _cfg) -> None:
|
|
pass
|
|
|
|
def exec(self, *_a, **_k):
|
|
raise AgentUnavailableError("connection refused")
|
|
|
|
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
|
|
out = checks.probe_guest_exec(_FakeCfg())
|
|
assert out.status == "fail"
|
|
assert "connection refused" in out.detail
|
|
|
|
|
|
def test_probe_agent_auth_ready_ok(monkeypatch):
|
|
class _FakeClient:
|
|
def __init__(self, _cfg) -> None:
|
|
pass
|
|
|
|
def auth_ready(self):
|
|
return True, ""
|
|
|
|
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
|
|
out = checks.probe_agent_auth_ready(_FakeCfg())
|
|
assert out.status == "ok"
|
|
|
|
|
|
def test_probe_agent_auth_ready_fail(monkeypatch):
|
|
class _FakeClient:
|
|
def __init__(self, _cfg) -> None:
|
|
pass
|
|
|
|
def auth_ready(self):
|
|
return False, "agent token file missing"
|
|
|
|
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
|
|
out = checks.probe_agent_auth_ready(_FakeCfg())
|
|
assert out.status == "fail"
|
|
assert "token" in out.detail
|
|
|
|
|
|
def test_probe_agent_health_timeout_is_warmup_warn(monkeypatch):
|
|
from winpodx.core.agent import AgentTimeoutError
|
|
|
|
class _FakeClient:
|
|
def __init__(self, _cfg) -> None:
|
|
pass
|
|
|
|
def health(self):
|
|
raise AgentTimeoutError("/health timed out after 2.0s")
|
|
|
|
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
|
|
out = checks.probe_agent_health(_FakeCfg())
|
|
assert out.status == "warn"
|
|
assert "warming up" in out.detail
|
|
|
|
|
|
def test_probe_guest_exec_timeout_is_warmup_warn(monkeypatch):
|
|
import winpodx.core.pod as pod_mod
|
|
from winpodx.core.agent import AgentTimeoutError
|
|
from winpodx.core.pod import PodState, PodStatus
|
|
|
|
monkeypatch.setattr(pod_mod, "pod_status", lambda _cfg: PodStatus(state=PodState.RUNNING))
|
|
|
|
class _FakeClient:
|
|
def __init__(self, _cfg) -> None:
|
|
pass
|
|
|
|
def exec(self, *_a, **_k):
|
|
raise AgentTimeoutError("/exec timed out after 9.0s")
|
|
|
|
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
|
|
out = checks.probe_guest_exec(_FakeCfg())
|
|
assert out.status == "warn"
|
|
assert "warming up" in out.detail
|
|
|
|
|
|
def test_probe_guest_summary_timeout_is_warmup_warn(monkeypatch):
|
|
import winpodx.core.pod as pod_mod
|
|
from winpodx.core.agent import AgentTimeoutError
|
|
from winpodx.core.pod import PodState, PodStatus
|
|
|
|
monkeypatch.setattr(pod_mod, "pod_status", lambda _cfg: PodStatus(state=PodState.RUNNING))
|
|
|
|
class _FakeClient:
|
|
def __init__(self, _cfg) -> None:
|
|
pass
|
|
|
|
def exec(self, *_a, **_k):
|
|
raise AgentTimeoutError("/exec timed out after 14.0s")
|
|
|
|
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
|
|
out = checks.probe_guest_summary(_FakeCfg())
|
|
assert out.status == "warn"
|
|
assert "warming up" in out.detail
|
|
|
|
|
|
def test_probe_apps_discovered_warn_on_missing_dir(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("winpodx.core.discovery.discovered_apps_dir", lambda: tmp_path / "missing")
|
|
out = checks.probe_apps_discovered(_FakeCfg())
|
|
assert out.status == "warn"
|
|
assert "no apps" in out.detail.lower()
|
|
|
|
|
|
def test_probe_apps_discovered_ok_with_app_subdirs(tmp_path, monkeypatch):
|
|
apps_dir = tmp_path / "discovered"
|
|
apps_dir.mkdir()
|
|
for slug in ("notepad", "msedge"):
|
|
(apps_dir / slug).mkdir()
|
|
(apps_dir / slug / "app.toml").write_text("name = '" + slug + "'\n")
|
|
monkeypatch.setattr("winpodx.core.discovery.discovered_apps_dir", lambda: apps_dir)
|
|
out = checks.probe_apps_discovered(_FakeCfg())
|
|
assert out.status == "ok"
|
|
assert "2 app(s)" in out.detail
|
|
|
|
|
|
# --- Helpers ---
|
|
|
|
|
|
class _FakeCfg:
|
|
"""Minimal stand-in for Config — only the fields probes touch."""
|
|
|
|
class _Pod:
|
|
backend = "podman"
|
|
|
|
class _Rdp:
|
|
ip = "127.0.0.1"
|
|
port = 3390
|
|
password = "x"
|
|
password_max_age = 0
|
|
password_updated = ""
|
|
|
|
def __init__(self):
|
|
self.pod = self._Pod()
|
|
self.rdp = self._Rdp()
|