From c696e8a0a873127fe7fd717e2d4cca038c4ced05 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 10:38:11 +0200 Subject: [PATCH] sync: tests/test_app.py from Butler 2.3 --- tests/test_app.py | 245 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 tests/test_app.py diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..c9f8e63 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,245 @@ +import os +import asyncio +import time +from datetime import datetime, timedelta, timezone + +os.environ.setdefault("BUTLER_TOKEN", "test-token") + +from fastapi.testclient import TestClient + +import app + + +def test_redact_response_recursively(): + source = { + "id": 6, + "hawserToken": "secret-value", + "nested": [{"webhookSecret": "also-secret", "name": "tdarr"}], + } + result = app._redact_response(source) + assert result["hawserToken"] == "[REDACTED]" + assert result["nested"][0]["webhookSecret"] == "[REDACTED]" + assert result["nested"][0]["name"] == "tdarr" + + +def test_inventory_defaults_node7_and_explicit_user(): + inventory = """ +[node] +node2 ansible_host=10.5.85.12 +[apps] +tdarr ansible_host=10.2.1.104 +emby-chris ansible_host=10.7.1.106 +special ansible_host=10.7.1.200 ansible_user=operator +""" + hosts = {item["name"]: item for item in app._inventory_hosts(inventory)} + assert hosts["node2"]["user"] == "root" + assert hosts["tdarr"]["user"] == "sascha" + assert hosts["emby-chris"]["user"] == "chris" + assert hosts["special"]["user"] == "operator" + + +def test_health_exposes_current_version(): + with TestClient(app.app) as client: + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["version"] == app.VERSION == "2.3.0" + + +def test_invalid_log_target_is_rejected_before_ssh(): + with TestClient(app.app) as client: + response = client.get( + "/logs/tdarr/fileflows;rm", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 400 + + +def test_inventory_rejects_invalid_ip_before_ssh(): + with TestClient(app.app) as client: + response = client.post( + "/inventory/host", + headers={"Authorization": "Bearer test-token"}, + json={"name": "bad-host", "ip": "not-an-ip", "group": "auto"}, + ) + assert response.status_code == 400 + + +def test_inventory_upsert_uses_base64_script(monkeypatch): + calls = [] + + def fake_ssh(host, command, timeout=600): + calls.append((host, command, timeout)) + return 0, "updated", "" + + monkeypatch.setattr(app, "_ssh", fake_ssh) + with TestClient(app.app) as client: + response = client.post( + "/inventory/host", + headers={"Authorization": "Bearer test-token"}, + json={"name": "pfannkuchen", "ip": "46.225.230.72", "group": "vps", "user": "root"}, + ) + assert response.status_code == 200 + assert "base64.b64decode" in calls[0][1] + assert "\\nname =" not in calls[0][1] + + +def test_docker_inspect_returns_sanitized_summary(monkeypatch): + raw = [{ + "Name": "/fileflows", + "Config": {"Image": "revenz/fileflows:26.06", "Env": ["API_TOKEN=secret", "TZ=Europe/Berlin"]}, + "State": {"Status": "running", "Running": True, "OOMKilled": False, "ExitCode": 0}, + "RestartCount": 0, + "HostConfig": {"Runtime": "nvidia", "Memory": 0, "MemoryReservation": 0, "NanoCpus": 0, + "DeviceRequests": [], "RestartPolicy": {"Name": "always"}, "LogConfig": {"Type": "json-file"}}, + "Mounts": [{"Type": "bind", "Source": "/app-config/fileflows-data", "Destination": "/app/Data", "RW": True}], + }] + monkeypatch.setattr(app, "_find_inventory_host", lambda _name: {"user": "sascha", "ip": "10.2.1.104"}) + monkeypatch.setattr(app, "_ssh", lambda *args, **kwargs: (0, __import__("json").dumps(raw), "")) + with TestClient(app.app) as client: + response = client.get( + "/docker/inspect/tdarr/fileflows", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["runtime"] == "nvidia" + assert result["environment_keys"] == ["API_TOKEN", "TZ"] + assert "secret" not in response.text + + +def test_classify_http_status_distinguishes_auth_and_route_errors(): + assert app._classify_http_status(200, {200}) == "healthy" + assert app._classify_http_status(401, {200}) == "auth_failed" + assert app._classify_http_status(403, {200}) == "auth_failed" + assert app._classify_http_status(404, {200}) == "misconfigured" + assert app._classify_http_status(429, {200}) == "degraded" + assert app._classify_http_status(503, {200}) == "degraded" + + +def test_service_auth_headers_support_lightweight_health_probes(monkeypatch): + monkeypatch.setattr(app, "_get_key", lambda _cfg: "test-secret") + assert app._service_auth({"auth": "bearer"})["headers"] == { + "Authorization": "Bearer test-secret" + } + assert app._service_auth({"auth": "apikey"})["headers"] == { + "X-Api-Key": "test-secret" + } + assert app._service_auth({"auth": "n8n"})["headers"] == { + "X-N8N-API-KEY": "test-secret" + } + + +def _archive_json(start, name="archive"): + return __import__("json").dumps([{"archives": [{"start": start, "name": name}]}]) + + +def test_backup_item_reports_age_and_severity(): + now = datetime.now(timezone.utc) + recent = (now - timedelta(hours=4)).isoformat() + warning = (now - timedelta(hours=36)).isoformat() + critical = (now - timedelta(hours=60)).isoformat() + + assert app._backup_item(0, _archive_json(recent), "")["state"] == "healthy" + assert app._backup_item(0, _archive_json(warning), "")["state"] == "warning" + assert app._backup_item(0, _archive_json(critical), "")["state"] == "critical" + assert app._backup_item(1, "", "timeout")["state"] == "unknown" + + +def test_ssh_timeout_is_normalized_instead_of_crashing_collection(monkeypatch): + def timeout(*_args, **_kwargs): + raise app._sp.TimeoutExpired(cmd=["ssh"], timeout=1) + + monkeypatch.setattr(app._sp, "run", timeout) + rc, out, err = app._ssh("sascha@example", "true", timeout=1) + assert (rc, out) == (124, "") + assert "timed out" in err.lower() + + +def test_service_login_failure_is_isolated(monkeypatch): + monkeypatch.setattr(app, "SERVICES", { + "dockhand": {"url": "http://dockhand.invalid", "auth": "session", "health_path": "/api/health"} + }) + + async def failed_login(_client): + raise RuntimeError("login failed") + + monkeypatch.setattr(app, "_dockhand_login", failed_login) + result = asyncio.run(app._collect_service_status()) + assert result["dockhand"]["status"] == "offline" + assert result["dockhand"]["reachable"] is False + + +def test_backup_collection_runs_hosts_concurrently(monkeypatch): + active = 0 + max_active = 0 + + monkeypatch.setattr(app, "_get_inventory_hosts", lambda: [ + {"name": f"vm-{index}", "user": "sascha", "ip": f"10.1.1.{index}"} + for index in range(1, 5) + ]) + + def fake_ssh(*_args, **_kwargs): + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + time.sleep(0.05) + active -= 1 + return 0, _archive_json(datetime.now(timezone.utc).isoformat()), "" + + monkeypatch.setattr(app, "_ssh", fake_ssh) + result = asyncio.run(app._collect_backup_status(concurrency=4)) + + assert max_active > 1 + assert result["summary"] == { + "total": 4, "healthy": 4, "warning": 0, "critical": 0, "unknown": 0 + } + + +def test_overview_openapi_has_stable_enums_and_schema(): + schema = app.app.openapi() + response_schema = schema["paths"]["/overview"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert response_schema["$ref"].endswith("/OverviewResponse") + overview_schema = schema["components"]["schemas"]["OverviewResponse"] + assert overview_schema["properties"]["overall_state"]["enum"] == ["healthy", "warning", "critical"] + finding_schema = schema["components"]["schemas"]["OverviewFinding"] + assert finding_schema["properties"]["severity"]["enum"] == ["healthy", "warning", "critical"] + + +def test_overview_is_compact_deterministic_and_light_model_friendly(monkeypatch): + async def service_data(): + return { + "ok": {"status": "healthy", "reachable": True, "http": 200}, + "bad-auth": {"status": "auth_failed", "reachable": True, "http": 401}, + } + + async def host_data(): + return { + "vm1": {"reachable": True, "containers": ["app: Up 1 hour (healthy)"]}, + "vm2": {"reachable": False, "containers": [], "error": "timeout"}, + } + + async def backup_data(): + return { + "summary": {"total": 1, "healthy": 0, "warning": 1, "critical": 0, "unknown": 0}, + "hosts": {"vm1": {"state": "warning", "age_hours": 36}}, + } + + async def disk_data(): + return {"vm1": {"pct": "85%"}} + + monkeypatch.setattr(app, "_collect_service_status", service_data) + monkeypatch.setattr(app, "_collect_health_all", host_data) + monkeypatch.setattr(app, "_collect_backup_status", backup_data) + monkeypatch.setattr(app, "_collect_disk_usage", disk_data) + + with TestClient(app.app) as client: + response = client.get("/overview", headers={"Authorization": "Bearer test-token"}) + + assert response.status_code == 200 + result = response.json() + assert result["schema_version"] == 1 + assert result["overall_state"] == "critical" + assert result["summary"] == {"critical": 2, "warning": 2, "healthy": 2} + assert [item["code"] for item in result["findings"]] == [ + "host_unreachable", "service_auth_failed", "backup_warning", "disk_high" + ]