305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Regression coverage for #693 live VPS host resource health panel."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
|
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
|
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
|
AUTH_PY = (REPO_ROOT / "api" / "auth.py").read_text(encoding="utf-8")
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.sent_headers = []
|
|
self.body = bytearray()
|
|
self.wfile = self
|
|
self.headers = {}
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, name, value):
|
|
self.sent_headers.append((name, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
def write(self, data):
|
|
self.body.extend(data)
|
|
|
|
def json_body(self):
|
|
return json.loads(bytes(self.body).decode("utf-8"))
|
|
|
|
|
|
def test_system_health_payload_normalizes_safe_aggregate_metrics(monkeypatch):
|
|
from api import system_health
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 17.345)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_memory_usage",
|
|
lambda: {"used_bytes": 4_000, "total_bytes": 10_000, "percent": 40.0},
|
|
)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_disk_usage",
|
|
lambda: {"used_bytes": 55_500, "total_bytes": 100_000, "percent": 55.5},
|
|
)
|
|
|
|
payload = system_health.build_system_health_payload()
|
|
|
|
assert payload["status"] == "ok"
|
|
assert payload["available"] is True
|
|
assert payload["cpu"] == {"percent": 17.3}
|
|
assert payload["memory"] == {"used_bytes": 4000, "total_bytes": 10000, "percent": 40.0}
|
|
assert payload["disk"] == {"used_bytes": 55500, "total_bytes": 100000, "percent": 55.5}
|
|
assert payload["checked_at"]
|
|
rendered = repr(payload)
|
|
for private_fragment in ("/home/", "/Users/", "mount", "path", "argv", "command", "env", "token"):
|
|
assert private_fragment not in rendered
|
|
|
|
|
|
def test_system_health_payload_partial_and_unavailable_are_graceful(monkeypatch):
|
|
from api import system_health
|
|
|
|
def boom():
|
|
raise RuntimeError("private /home/user/path should not leak")
|
|
|
|
monkeypatch.setattr(system_health, "_cpu_percent", boom)
|
|
monkeypatch.setattr(system_health, "_memory_usage", boom)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_disk_usage",
|
|
lambda: {"used_bytes": 1, "total_bytes": 4, "percent": 25.0},
|
|
)
|
|
|
|
partial = system_health.build_system_health_payload()
|
|
assert partial["status"] == "partial"
|
|
assert partial["available"] is True
|
|
assert partial["disk"]["percent"] == 25.0
|
|
assert partial["cpu"] is None
|
|
assert partial["memory"] is None
|
|
assert {e["metric"] for e in partial["errors"]} == {"cpu", "memory"}
|
|
assert "/home/user" not in repr(partial)
|
|
|
|
monkeypatch.setattr(system_health, "_disk_usage", boom)
|
|
unavailable = system_health.build_system_health_payload()
|
|
assert unavailable["status"] == "unavailable"
|
|
assert unavailable["available"] is False
|
|
assert unavailable["cpu"] is None
|
|
assert unavailable["memory"] is None
|
|
assert unavailable["disk"] is None
|
|
assert "/home/user" not in repr(unavailable)
|
|
|
|
|
|
def test_system_health_falls_back_to_psutil_when_procfs_is_unavailable(monkeypatch):
|
|
from api import system_health
|
|
|
|
class _MissingProcPath:
|
|
def open(self, *args, **kwargs):
|
|
raise FileNotFoundError("/private/proc/path")
|
|
|
|
class _FakeMemory:
|
|
total = 1000
|
|
available = 250
|
|
percent = 75.0
|
|
|
|
fake_psutil = SimpleNamespace(
|
|
cpu_percent=lambda interval=0.0: 42.25,
|
|
virtual_memory=lambda: _FakeMemory(),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
monkeypatch.setattr(system_health, "_PROC_STAT", _MissingProcPath())
|
|
monkeypatch.setattr(system_health, "_PROC_MEMINFO", _MissingProcPath())
|
|
|
|
assert system_health._cpu_percent() == 42.2
|
|
assert system_health._memory_usage() == {
|
|
"used_bytes": 750,
|
|
"total_bytes": 1000,
|
|
"percent": 75.0,
|
|
}
|
|
|
|
|
|
def test_system_health_missing_optional_psutil_is_safe_unavailable(monkeypatch):
|
|
from api import system_health
|
|
|
|
class _MissingProcPath:
|
|
def open(self, *args, **kwargs):
|
|
raise FileNotFoundError("/private/proc/path")
|
|
|
|
def missing_psutil(name):
|
|
if name == "psutil":
|
|
raise ModuleNotFoundError("No module named 'psutil'")
|
|
raise AssertionError(f"unexpected import: {name}")
|
|
|
|
monkeypatch.setattr(system_health, "_PROC_STAT", _MissingProcPath())
|
|
monkeypatch.setattr(system_health, "_PROC_MEMINFO", _MissingProcPath())
|
|
monkeypatch.setattr(system_health, "import_module", missing_psutil)
|
|
|
|
for collect in (system_health._cpu_percent, system_health._memory_usage):
|
|
try:
|
|
collect()
|
|
except RuntimeError as exc:
|
|
assert str(exc) == "psutil_unavailable"
|
|
else: # pragma: no cover - defensive regression clarity
|
|
raise AssertionError("missing optional psutil should surface a safe unavailable error")
|
|
|
|
|
|
def test_system_health_procfs_parse_errors_remain_visible(monkeypatch):
|
|
from api import system_health
|
|
|
|
fake_psutil = SimpleNamespace(
|
|
cpu_percent=lambda interval=0.0: 42.25,
|
|
virtual_memory=lambda: SimpleNamespace(total=1000, available=250),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
monkeypatch.setattr(
|
|
system_health,
|
|
"_read_proc_stat_cpu",
|
|
lambda: (_ for _ in ()).throw(RuntimeError("proc_stat_unavailable")),
|
|
)
|
|
monkeypatch.setattr(system_health, "_read_meminfo_kib", lambda: {})
|
|
|
|
try:
|
|
system_health._cpu_percent()
|
|
except RuntimeError as exc:
|
|
assert str(exc) == "proc_stat_unavailable"
|
|
else: # pragma: no cover - defensive regression clarity
|
|
raise AssertionError("procfs parse RuntimeError should not fall back to psutil")
|
|
|
|
try:
|
|
system_health._memory_usage()
|
|
except RuntimeError as exc:
|
|
assert str(exc) == "meminfo_unavailable"
|
|
else: # pragma: no cover - defensive regression clarity
|
|
raise AssertionError("meminfo invariant RuntimeError should not fall back to psutil")
|
|
|
|
|
|
def test_system_health_cpu_second_procfs_read_fallback_does_not_sleep_twice(monkeypatch):
|
|
from api import system_health
|
|
|
|
calls = []
|
|
|
|
def fake_read_proc_stat_cpu():
|
|
calls.append("proc")
|
|
if len(calls) == 1:
|
|
return (10, 100)
|
|
raise FileNotFoundError("/proc/stat disappeared")
|
|
|
|
def fake_sleep(seconds):
|
|
calls.append(("sleep", seconds))
|
|
|
|
def fake_cpu_percent(interval=0.0):
|
|
calls.append(("psutil", interval))
|
|
return 12.34
|
|
|
|
monkeypatch.setattr(system_health, "_read_proc_stat_cpu", fake_read_proc_stat_cpu)
|
|
monkeypatch.setattr(system_health.time, "sleep", fake_sleep)
|
|
monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(cpu_percent=fake_cpu_percent))
|
|
|
|
assert system_health._cpu_percent() == 12.3
|
|
assert calls == ["proc", ("sleep", system_health._CPU_SAMPLE_SECONDS), "proc", ("psutil", 0.0)]
|
|
|
|
|
|
def test_system_health_route_registered_and_auth_gated(monkeypatch):
|
|
assert 'parsed.path == "/api/system/health"' in ROUTES_PY
|
|
assert "build_system_health_payload()" in ROUTES_PY
|
|
assert '"/api/system/health"' not in AUTH_PY, "system metrics must not be public"
|
|
|
|
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
|
|
from api import auth as _auth
|
|
from api.auth import check_auth
|
|
|
|
# The password hash is cached process-wide (PBKDF2 is ~1s). A prior test may
|
|
# have populated the cache with "no password" (None), so the env var we just
|
|
# set would be ignored on the fast path. Invalidate before AND after so this
|
|
# test sees its own password and doesn't leak the test-password cache to the
|
|
# next test — required for order-independence under sharded/random runs.
|
|
_auth._invalidate_password_hash_cache()
|
|
|
|
handler = _FakeHandler()
|
|
try:
|
|
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
|
|
assert handler.status in (302, 401)
|
|
finally:
|
|
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
|
|
_auth._invalidate_password_hash_cache()
|
|
|
|
|
|
def test_system_health_route_returns_only_sanitized_payload(monkeypatch):
|
|
from api import routes
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"build_system_health_payload",
|
|
lambda: {
|
|
"status": "ok",
|
|
"available": True,
|
|
"checked_at": "2026-05-05T00:00:00+00:00",
|
|
"cpu": {"percent": 12.0},
|
|
"memory": {"used_bytes": 1, "total_bytes": 2, "percent": 50.0},
|
|
"disk": {"used_bytes": 3, "total_bytes": 4, "percent": 75.0},
|
|
"errors": [],
|
|
},
|
|
)
|
|
handler = _FakeHandler()
|
|
assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True
|
|
payload = handler.json_body()
|
|
assert payload["cpu"]["percent"] == 12.0
|
|
assert set(payload) == {"status", "available", "checked_at", "cpu", "memory", "disk", "errors"}
|
|
|
|
|
|
def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrome():
|
|
top_shell = INDEX_HTML[: INDEX_HTML.index('<div class="layout">')]
|
|
assert 'id="systemHealthPanel"' not in top_shell
|
|
assert 'aria-label="Host resource health"' not in top_shell
|
|
assert 'function _renderSystemHealthPanel()' in PANELS_JS
|
|
assert 'id="systemHealthPanel"' in PANELS_JS
|
|
assert 'aria-label="Host resource health"' in PANELS_JS
|
|
assert 'System health' in PANELS_JS
|
|
assert 'Current VPS resource usage' in PANELS_JS
|
|
assert PANELS_JS.index('_renderSystemHealthPanel()') < PANELS_JS.index('_renderLlmWikiStatus(wikiStatus)')
|
|
assert 'data-system-health-metric="cpu"' in PANELS_JS
|
|
assert 'data-system-health-metric="memory"' in PANELS_JS
|
|
assert 'data-system-health-metric="disk"' in PANELS_JS
|
|
assert ".system-health-panel.insights-card" in STYLE_CSS
|
|
assert ".system-health-bar-fill" in STYLE_CSS
|
|
assert ".system-health-panel.unavailable" in STYLE_CSS
|
|
assert "@media(max-width:640px)" in STYLE_CSS and ".system-health-panel.insights-card" in STYLE_CSS
|
|
|
|
|
|
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
|
|
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
|
|
assert "api('/api/system/health',{timeoutToast:false})" in UI_JS
|
|
assert "document.visibilityState !== 'visible'" in UI_JS
|
|
assert "document.querySelector('main.main.showing-insights')" in UI_JS
|
|
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
|
|
assert "typeof _syncSystemHealthMonitorVisibility === 'function'" in PANELS_JS
|
|
assert "function renderSystemHealth(payload)" in UI_JS
|
|
assert "setSystemHealthUnavailable" in UI_JS
|
|
assert "data-system-health-metric" in PANELS_JS
|
|
assert "CPU" in PANELS_JS and "RAM" in PANELS_JS and "Disk" in PANELS_JS
|
|
assert "aria-valuenow" in UI_JS
|
|
assert "style.width=`${percent}%`" in UI_JS
|
|
|
|
|
|
def test_system_health_backend_uses_no_shell_or_private_process_sources():
|
|
src = (REPO_ROOT / "api" / "system_health.py").read_text(encoding="utf-8")
|
|
assert "import subprocess" not in src
|
|
assert "os.environ" not in src
|
|
assert "ps aux" not in src
|
|
assert "/proc/self/environ" not in src
|
|
for private_field in ("argv", "cmdline", "username", "mountpoint"):
|
|
assert private_field not in src
|