feat: Uptime Kuma – Einzel-Endpunkte, ausführliches /healthz, Push-Monitor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b8b9f2f25a
commit
a59ce46933
@@ -0,0 +1,218 @@
|
||||
"""Uptime Kuma: /healthz, Einzel-Endpunkte, Push; Hilfe-Seite und Menü."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from netmover.db import session_scope
|
||||
from netmover.health import Check, Health
|
||||
from netmover.kuma import push, push_url
|
||||
from netmover.models import Mount, MountState, MountType, Rule, utcnow
|
||||
from netmover.settings_store import AppSettings, load_settings, save_settings
|
||||
from tests.webutil import post
|
||||
|
||||
# -- push_url / push ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _health(ok: bool, *problems: str) -> Health:
|
||||
h = Health()
|
||||
h.add(Check("scheduler", "Scheduler", True, "läuft"))
|
||||
for p in problems:
|
||||
h.add(Check("mount", "x", False, p))
|
||||
assert h.ok is ok
|
||||
return h
|
||||
|
||||
|
||||
def test_push_url_replaces_kuma_defaults() -> None:
|
||||
url = push_url(
|
||||
"https://kuma.lan/api/push/AbC?status=up&msg=OK&ping=", _health(False, "NAS weg")
|
||||
)
|
||||
parsed = httpx.URL(url)
|
||||
assert parsed.path == "/api/push/AbC"
|
||||
assert parsed.params["status"] == "down" and parsed.params["msg"] == "NAS weg"
|
||||
assert push_url("https://kuma.lan/api/push/AbC", _health(True)).endswith(
|
||||
"status=up&msg=OK&ping="
|
||||
)
|
||||
|
||||
|
||||
def test_push_message_is_truncated() -> None:
|
||||
url = push_url("https://k/api/push/x", _health(False, "x" * 1000))
|
||||
assert len(httpx.URL(url).params["msg"]) == 250
|
||||
|
||||
|
||||
def test_push_swallows_errors_and_reports_result() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def ok_get(url: str, **kw: Any) -> httpx.Response:
|
||||
calls.append(url)
|
||||
return httpx.Response(200, request=httpx.Request("GET", url))
|
||||
|
||||
def bad_get(url: str, **kw: Any) -> httpx.Response:
|
||||
raise httpx.ConnectError("weg")
|
||||
|
||||
assert push("https://k/api/push/x", _health(True), getter=ok_get) is True
|
||||
assert calls and "status=up" in calls[0]
|
||||
assert push("https://k/api/push/x", _health(True), getter=bad_get) is False
|
||||
|
||||
|
||||
# -- Endpunkte ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_mount(
|
||||
state: Any, name: str, slug: str, mstate: MountState, error: str | None = None
|
||||
) -> int:
|
||||
with session_scope(state.session_factory) as s:
|
||||
m = Mount(
|
||||
name=name,
|
||||
slug=slug,
|
||||
type=MountType.CIFS,
|
||||
server="nas",
|
||||
share=slug,
|
||||
mountpoint=f"/mnt/netmover/{slug}",
|
||||
state=mstate,
|
||||
last_error=error,
|
||||
)
|
||||
s.add(m)
|
||||
s.flush()
|
||||
return m.id
|
||||
|
||||
|
||||
def test_healthz_details_and_single_endpoints(client: TestClient, state: Any) -> None:
|
||||
ok_id = _add_mount(state, "NAS", "nas", MountState.MOUNTED)
|
||||
_add_mount(state, "Archiv", "archiv", MountState.ERROR, "Host is down")
|
||||
with session_scope(state.session_factory) as s:
|
||||
r = Rule(name="Scans", src_mount_id=ok_id, dst_path="/tmp", consecutive_failures=3)
|
||||
s.add(r)
|
||||
s.flush()
|
||||
rid = r.id
|
||||
|
||||
r = client.get("/healthz")
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert body["status"] == "error" and body["version"]
|
||||
assert body["scheduler"]["running"] is True
|
||||
kinds = {(c["kind"], c["name"], c["ok"]) for c in body["checks"]}
|
||||
assert ("mount", "NAS", True) in kinds and ("mount", "Archiv", False) in kinds
|
||||
assert ("rule", "Scans", False) in kinds # drei Fehlschläge in Folge
|
||||
|
||||
assert client.get("/healthz/mount/nas").status_code == 200
|
||||
r = client.get("/healthz/mount/archiv")
|
||||
assert r.status_code == 503 and "Host is down" in r.json()["message"]
|
||||
r = client.get(f"/healthz/rule/{rid}")
|
||||
assert r.status_code == 503 and "3 fehlgeschlagene Läufe" in r.json()["message"]
|
||||
assert client.get("/healthz/mount/gibtsnicht").status_code == 404
|
||||
assert client.get("/healthz/rule/4711").status_code == 404
|
||||
|
||||
|
||||
def test_healthz_needs_no_login(raw_client: TestClient) -> None:
|
||||
assert raw_client.get("/healthz").status_code == 200
|
||||
assert raw_client.get("/healthz/mount/x").status_code == 404
|
||||
|
||||
|
||||
def test_disabled_rule_and_degraded_mount_are_not_alarms(client: TestClient, state: Any) -> None:
|
||||
mid = _add_mount(state, "NAS", "nas", MountState.DEGRADED)
|
||||
with session_scope(state.session_factory) as s:
|
||||
s.add(
|
||||
Rule(
|
||||
name="aus",
|
||||
src_mount_id=mid,
|
||||
dst_path="/tmp",
|
||||
enabled=False,
|
||||
created_at=utcnow() - timedelta(days=3),
|
||||
)
|
||||
)
|
||||
assert client.get("/healthz").status_code == 200
|
||||
|
||||
|
||||
def test_kuma_push_job_uses_saved_url(state: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"netmover.kuma.httpx.get",
|
||||
lambda url, **kw: (
|
||||
sent.append(url),
|
||||
httpx.Response(200, request=httpx.Request("GET", url)),
|
||||
)[1],
|
||||
)
|
||||
state.push_health() # ohne URL: nichts
|
||||
assert sent == []
|
||||
save_settings(
|
||||
state.session_factory, AppSettings(kuma_push_url="https://kuma.lan/api/push/T0k3n")
|
||||
)
|
||||
state.push_health()
|
||||
assert len(sent) == 1 and sent[0].startswith("https://kuma.lan/api/push/T0k3n?status=")
|
||||
|
||||
|
||||
def test_settings_kuma_url_masked_and_monitor_urls_listed(client: TestClient, state: Any) -> None:
|
||||
_add_mount(state, "NAS", "nas", MountState.MOUNTED)
|
||||
post(
|
||||
client,
|
||||
"/settings",
|
||||
{
|
||||
"retention_days": "30",
|
||||
"notify_mode": "off",
|
||||
"notify_after": "3",
|
||||
"webhook_url": "",
|
||||
"webhook_content_type": "application/json",
|
||||
"webhook_template": "$message",
|
||||
"kuma_push_url": "https://kuma.lan/api/push/GeHeIm?status=up",
|
||||
},
|
||||
)
|
||||
assert load_settings(state.session_factory).kuma_push_url.endswith("GeHeIm?status=up")
|
||||
page = client.get("/settings").text
|
||||
assert "GeHeIm" not in page and "kuma.lan" in page
|
||||
assert "/healthz/mount/nas" in page and "/healthz" in page
|
||||
|
||||
|
||||
# -- Hilfe und Menü ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_help_page_renders_guide_with_anchors(client: TestClient) -> None:
|
||||
html = client.get("/help").text
|
||||
for anchor in (
|
||||
"begriffe",
|
||||
"anbindungen",
|
||||
"schluessel",
|
||||
"regeln",
|
||||
"regel-sicherheit",
|
||||
"probelauf",
|
||||
"status",
|
||||
"protokoll",
|
||||
"uptime-kuma",
|
||||
"fragen",
|
||||
):
|
||||
assert f'id="{anchor}"' in html, anchor
|
||||
assert "<table>" in html and "{#" not in html
|
||||
assert 'src="/help/img/' in html
|
||||
|
||||
|
||||
def test_help_images_and_path_safety(client: TestClient) -> None:
|
||||
from netmover.web.app import _help_dir
|
||||
|
||||
if (_help_dir() / "img" / "status.png").is_file():
|
||||
r = client.get("/help/img/status.png")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||
for bad in ("../ANLEITUNG.md", "..%2F..%2Fpyproject.toml", "x.svg"):
|
||||
assert client.get(f"/help/img/{bad}").status_code == 404
|
||||
|
||||
|
||||
def test_navigation_groups_health_and_help_links(client: TestClient, state: Any) -> None:
|
||||
_add_mount(state, "Archiv", "archiv", MountState.ERROR, "weg")
|
||||
html = client.get("/mounts").text
|
||||
for group in ("Übersicht", "Verbindungen", "Automatisierung", "System"):
|
||||
assert group in html
|
||||
assert 'class="health bad"' in html and "1 Problem" in html
|
||||
assert "1 gestört" in html
|
||||
assert 'href="/help#anbindungen"' in html # Hilfe zu dieser Seite
|
||||
assert 'aria-current="page"' in html and 'class="menu-toggle"' in html
|
||||
form = client.get("/rules/new").text
|
||||
assert 'href="/help#regel-sicherheit"' in form
|
||||
|
||||
|
||||
def test_logged_out_pages_have_no_menu(raw_client: TestClient) -> None:
|
||||
html = raw_client.get("/setup").text
|
||||
assert 'id="sidebar"' not in html
|
||||
Reference in New Issue
Block a user