mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 10:42:23 +01:00
`_gather_inputs()` fails soft: a source that raises leaves its inventory as
`[]`. Every probe reads an empty inventory as "not configured" and answers
`disabled`, so a broken integrations file, email store, or endpoint database is
indistinguishable from a subsystem the operator deliberately turned off. With
the remaining subsystems disabled, `GET /api/diagnostics/services` then reports
`overall: "ok"` while three of its inputs failed to load — the exceptions appear
only in DEBUG logs.
Record each failure alongside the empty value (`failed`: source -> controlled
category) and report the affected subsystem through `_source_unavailable()`
rather than probing it with an inventory that is empty for the wrong reason.
- `degraded`, not `disabled`: the configured state is unknown, not known-off.
Not `down` either — nothing reached the subsystem and failed; the inventory
could not be read. Severity is non-zero either way, so a report containing a
failed source can no longer roll up to `ok`.
- A source that loads and is genuinely empty still reports `disabled`.
- One broken source degrades one entry; the others are still probed.
- `meta` carries the source name and the controlled `_classify_error()`
category only, never exception text, credentials, or database detail.
- `inputs.get("failed")` so an inputs payload without the key still probes
normally.
Verified with the reporter's harness: settings loading with
`search_provider: "disabled"` while the other three sources raise now yields
`overall: "degraded"` with `searxng` still `disabled`; before, all five read
`disabled` and `overall` was `ok`.
307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""Tests for rollup logic, aggregate collection, and shared utility helpers (_safe_url, _classify_error)."""
|
|
import pytest
|
|
|
|
from src import service_health as sh
|
|
|
|
|
|
class _Store:
|
|
def __init__(self, healthy):
|
|
self.healthy = healthy
|
|
|
|
|
|
# ── rollup ──
|
|
|
|
def test_rollup_picks_worst_non_disabled():
|
|
services = [
|
|
{"status": sh.OK}, {"status": sh.DISABLED},
|
|
{"status": sh.DEGRADED}, {"status": sh.OK},
|
|
]
|
|
assert sh._rollup(services) == sh.DEGRADED
|
|
|
|
|
|
def test_rollup_down_beats_degraded():
|
|
assert sh._rollup([{"status": sh.DEGRADED}, {"status": sh.DOWN}]) == sh.DOWN
|
|
|
|
|
|
def test_rollup_all_disabled_is_ok():
|
|
assert sh._rollup([{"status": sh.DISABLED}, {"status": sh.DISABLED}]) == sh.OK
|
|
|
|
|
|
# ── collect_service_health (async aggregate) ──
|
|
|
|
def test_collect_service_health_shape(monkeypatch):
|
|
import asyncio
|
|
|
|
# Avoid touching real data sources / network.
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {"search_provider": "disabled"},
|
|
"integrations": [],
|
|
"accounts": [],
|
|
"endpoints": [],
|
|
})
|
|
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
|
|
assert set(out) == {"overall", "services", "timestamp"}
|
|
names = {s["name"] for s in out["services"]}
|
|
assert names == {"chromadb", "searxng", "ntfy", "email", "providers"}
|
|
# Chroma healthy, everything else disabled → overall ok.
|
|
assert out["overall"] == sh.OK
|
|
|
|
|
|
# ── _safe_url: strip userinfo / query / fragment ──
|
|
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
("http://user:pass@host:8080/path?api_key=secret#frag", "http://host:8080/path"),
|
|
("https://admin:hunter2@searx.example.com/", "https://searx.example.com"),
|
|
("http://ntfy.local:80?token=abc", "http://ntfy.local:80"),
|
|
("host:8080", "host:8080"),
|
|
("", ""),
|
|
(None, ""),
|
|
])
|
|
def test_safe_url_strips_secrets(raw, expected):
|
|
out = sh._safe_url(raw)
|
|
assert out == expected
|
|
for bad in ("pass", "secret", "hunter2", "abc", "token", "@"):
|
|
if raw and bad in raw and bad not in expected:
|
|
assert bad not in out
|
|
|
|
|
|
# ── _classify_error: controlled categories, never raw text ──
|
|
|
|
def test_classify_error_categories():
|
|
import socket
|
|
assert sh._classify_error(TimeoutError()) == "timeout"
|
|
assert sh._classify_error(socket.timeout()) == "timeout"
|
|
assert sh._classify_error(socket.gaierror()) == "dns_error"
|
|
assert sh._classify_error(ConnectionRefusedError()) == "connection_refused"
|
|
assert sh._classify_error(OSError("boom")) == "network_error"
|
|
assert sh._classify_error(ValueError("x")) == "error"
|
|
|
|
|
|
# ── Concurrent collection and aggregate deadline ──
|
|
|
|
def test_collect_runs_subsystems_concurrently(monkeypatch):
|
|
# The aggregate is bounded by running the (internally-bounded) subsystems
|
|
# concurrently, so total wall-clock ≈ max(subsystem), not the sum. Each of
|
|
# the four network subsystems here sleeps ~0.6s; sequential would be ~2.4s.
|
|
import asyncio
|
|
import time
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
|
|
})
|
|
|
|
def slow(name):
|
|
def _fn(*_a, **_k):
|
|
time.sleep(0.6)
|
|
return {"name": name, "status": sh.OK, "detail": "", "meta": {}}
|
|
return _fn
|
|
|
|
monkeypatch.setattr(sh, "searxng_health", slow("searxng"))
|
|
monkeypatch.setattr(sh, "ntfy_health", slow("ntfy"))
|
|
monkeypatch.setattr(sh, "email_health", slow("email"))
|
|
monkeypatch.setattr(sh, "providers_health", slow("providers"))
|
|
|
|
t0 = time.monotonic()
|
|
out = asyncio.run(sh.collect_service_health(None, None))
|
|
elapsed = time.monotonic() - t0
|
|
assert elapsed < 1.5, f"subsystems not concurrent: took {elapsed:.1f}s"
|
|
assert {s["name"] for s in out["services"]} == {
|
|
"chromadb", "searxng", "ntfy", "email", "providers"}
|
|
|
|
|
|
def test_collect_aggregate_deadline_yields_controlled_result(monkeypatch):
|
|
# If the gather overruns the aggregate ceiling, the response is still a
|
|
# controlled {overall, services, timestamp} with each network subsystem
|
|
# marked down/timeout — never a hang or a raised exception.
|
|
import asyncio
|
|
import time
|
|
monkeypatch.setattr(sh, "_AGGREGATE_DEADLINE", 0.5)
|
|
monkeypatch.setattr(sh, "_SUBSYSTEM_DEADLINE", 0.4)
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
|
|
})
|
|
|
|
async def _slow_gather(*coros, **_k):
|
|
for c in coros: # close unawaited coros to avoid warnings
|
|
close = getattr(c, "close", None)
|
|
if close:
|
|
close()
|
|
await asyncio.sleep(5)
|
|
|
|
# Force the outer wait_for to trip by making gather itself slow.
|
|
monkeypatch.setattr(sh.asyncio, "gather", _slow_gather)
|
|
t0 = time.monotonic()
|
|
out = asyncio.run(sh.collect_service_health(None, None))
|
|
elapsed = time.monotonic() - t0
|
|
assert elapsed < 2, f"aggregate deadline did not bound: {elapsed:.1f}s"
|
|
assert set(out) == {"overall", "services", "timestamp"}
|
|
net = [s for s in out["services"] if s["name"] != "chromadb"]
|
|
assert all(s["status"] == sh.DOWN and s["meta"].get("error") == "timeout"
|
|
for s in net)
|
|
|
|
|
|
# ── Discovery failure is not "disabled" (#6154) ──
|
|
#
|
|
# `_gather_inputs()` fails soft to an empty inventory, and every probe reads an
|
|
# empty inventory as `disabled`. Without a record of *why* it is empty, a broken
|
|
# integrations file / email store / endpoint DB is reported as an intentionally
|
|
# unconfigured subsystem — and, with the rest disabled, `overall: "ok"`.
|
|
|
|
def _boom(*_a, **_k):
|
|
raise RuntimeError("secret-bearing detail: postgres://u:p@db/x")
|
|
|
|
|
|
def test_gather_inputs_records_each_failed_source(monkeypatch):
|
|
import core.database
|
|
import routes.email_helpers
|
|
import src.integrations
|
|
import src.settings
|
|
|
|
monkeypatch.setattr(src.settings, "load_settings", lambda: {"a": 1})
|
|
monkeypatch.setattr(src.integrations, "load_integrations", _boom)
|
|
monkeypatch.setattr(routes.email_helpers, "_list_email_accounts", _boom)
|
|
monkeypatch.setattr(core.database, "SessionLocal", _boom)
|
|
|
|
out = sh._gather_inputs()
|
|
assert out["settings"] == {"a": 1}
|
|
assert set(out["failed"]) == {"integrations", "accounts", "endpoints"}
|
|
# Controlled categories only — never the exception text.
|
|
assert set(out["failed"].values()) <= set(sh._ERROR_DETAIL)
|
|
assert "postgres" not in repr(out)
|
|
|
|
|
|
def test_gather_inputs_reports_no_failures_when_sources_load(monkeypatch):
|
|
import core.database
|
|
import routes.email_helpers
|
|
import src.integrations
|
|
import src.settings
|
|
|
|
class _DB:
|
|
def query(self, *_a):
|
|
return self
|
|
|
|
def filter(self, *_a):
|
|
return self
|
|
|
|
def all(self):
|
|
return []
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(src.settings, "load_settings", lambda: {})
|
|
monkeypatch.setattr(src.integrations, "load_integrations", lambda: [])
|
|
monkeypatch.setattr(routes.email_helpers, "_list_email_accounts", lambda: [])
|
|
monkeypatch.setattr(core.database, "SessionLocal", lambda: _DB())
|
|
|
|
assert sh._gather_inputs()["failed"] == {}
|
|
|
|
|
|
@pytest.mark.parametrize("source,service", [
|
|
("settings", "searxng"),
|
|
("integrations", "ntfy"),
|
|
("accounts", "email"),
|
|
("endpoints", "providers"),
|
|
])
|
|
def test_failed_source_is_not_reported_as_disabled(monkeypatch, source, service):
|
|
import asyncio
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {"search_provider": "disabled"},
|
|
"integrations": [], "accounts": [], "endpoints": [],
|
|
"failed": {source: "network_error"},
|
|
})
|
|
out = asyncio.run(sh.collect_service_health(None, None))
|
|
entry = next(s for s in out["services"] if s["name"] == service)
|
|
assert entry["status"] != sh.DISABLED
|
|
assert entry["meta"]["error"] == sh.CONFIG_SOURCE_ERROR
|
|
assert entry["meta"]["source"] == source
|
|
assert entry["meta"]["source_error"] == "network_error"
|
|
# A failed source must reach the aggregate verdict.
|
|
assert out["overall"] != sh.OK
|
|
assert set(out) == {"overall", "services", "timestamp"}
|
|
|
|
|
|
def test_all_sources_failing_cannot_report_overall_ok(monkeypatch):
|
|
# The exact shape from the report: settings loads and says search is off,
|
|
# the other three sources raise. Before the fix this was overall "ok" with
|
|
# five "disabled" entries.
|
|
import asyncio
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {"search_provider": "disabled"},
|
|
"integrations": [], "accounts": [], "endpoints": [],
|
|
"failed": {"integrations": "error", "accounts": "error",
|
|
"endpoints": "error"},
|
|
})
|
|
out = asyncio.run(sh.collect_service_health(None, None))
|
|
by_name = {s["name"]: s for s in out["services"]}
|
|
assert out["overall"] == sh.DEGRADED
|
|
# Loaded-and-genuinely-empty still reads as disabled.
|
|
assert by_name["searxng"]["status"] == sh.DISABLED
|
|
for name in ("ntfy", "email", "providers"):
|
|
assert by_name[name]["status"] == sh.DEGRADED
|
|
|
|
|
|
def test_empty_inventory_without_failure_stays_disabled(monkeypatch):
|
|
import asyncio
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {"search_provider": "disabled"},
|
|
"integrations": [], "accounts": [], "endpoints": [], "failed": {},
|
|
})
|
|
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
|
|
assert out["overall"] == sh.OK
|
|
assert all(s["status"] == sh.DISABLED
|
|
for s in out["services"] if s["name"] != "chromadb")
|
|
|
|
|
|
def test_one_failed_source_still_probes_the_others(monkeypatch):
|
|
# A broken endpoint DB must not stop ntfy/email from being probed.
|
|
import asyncio
|
|
probed = []
|
|
|
|
def _probe(name):
|
|
def _fn(*_a, **_k):
|
|
probed.append(name)
|
|
return sh._svc(name, sh.OK, "probed")
|
|
return _fn
|
|
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
|
|
"failed": {"endpoints": "timeout"},
|
|
})
|
|
monkeypatch.setattr(sh, "searxng_health", _probe("searxng"))
|
|
monkeypatch.setattr(sh, "ntfy_health", _probe("ntfy"))
|
|
monkeypatch.setattr(sh, "email_health", _probe("email"))
|
|
monkeypatch.setattr(sh, "providers_health", _probe("providers"))
|
|
|
|
out = asyncio.run(sh.collect_service_health(None, None))
|
|
assert sorted(probed) == ["email", "ntfy", "searxng"]
|
|
assert "providers" not in probed
|
|
by_name = {s["name"]: s for s in out["services"]}
|
|
assert by_name["providers"]["status"] == sh.DEGRADED
|
|
assert by_name["ntfy"]["status"] == sh.OK
|
|
|
|
|
|
def test_source_failure_entry_carries_no_raw_detail(monkeypatch):
|
|
import asyncio
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {}, "integrations": [], "accounts": [], "endpoints": [],
|
|
"failed": {"accounts": "auth_or_protocol_error"},
|
|
})
|
|
out = asyncio.run(sh.collect_service_health(None, None))
|
|
entry = next(s for s in out["services"] if s["name"] == "email")
|
|
assert entry["detail"] == (
|
|
"Could not read configuration (configuration source could not be read).")
|
|
assert set(entry["meta"]) == {"error", "source", "source_error"}
|
|
|
|
|
|
def test_collect_tolerates_inputs_without_a_failed_key(monkeypatch):
|
|
# `_gather_inputs()` is monkeypatched in a number of existing tests (and by
|
|
# anything calling the collector with a stub); a payload predating `failed`
|
|
# must still probe normally rather than raise.
|
|
import asyncio
|
|
monkeypatch.setattr(sh, "_gather_inputs", lambda: {
|
|
"settings": {"search_provider": "disabled"},
|
|
"integrations": [], "accounts": [], "endpoints": [],
|
|
})
|
|
out = asyncio.run(sh.collect_service_health(_Store(True), _Store(True)))
|
|
assert out["overall"] == sh.OK
|