Files
FabledSteward/tests/core/test_monitors.py
T
bvandeusenandClaude Opus 5.5 9abe0beecc
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 41s
CI / unit (push) Successful in 1m0s
CI / publish (push) Successful in 58s
feat(mcp): monitoring-config write tools — monitors, alert rules, acks, maintenance
MCP-7 #4971 "Monitoring-config write tools: monitors, alert rules, acks,
maintenance".

The validation the forms did inline (or not at all) moves to the services,
and both the routes and the tools call it, so bad input is refused the same
way with the same reason:
- monitors/service: monitor_fields, build_config, normalise_target. Port
  1-65535, status 100-599, timeout 1-60, method GET/HEAD/POST, headers an
  object of strings, a key the type does not take is named. The forms now
  answer 400 with the reason instead of redirecting silently or a 500.
- alerts/service: METRIC_CATALOG and SOURCE_MODULES, resources_for,
  metrics_for, rule_fields (source, resource and metric from the same
  choices the rule form offers), add_rule, acknowledge, window_fields.

Tools: create_monitor, update_monitor (config merged over the stored one;
a rename warns about rules still watching the old name),
set_monitor_enabled, create_alert_rule, update_alert_rule,
acknowledge_alert, create_maintenance_window (duration or end),
end_maintenance_window (ends one in progress, cancels an upcoming one) —
operator. delete_monitor, delete_alert_rule — admin. get_alert_rule_options
— viewer. Every write is audited as "<user> via MCP".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:19:54 -04:00

152 lines
6.1 KiB
Python

"""Unit tests for the unified Monitor: dispatch, config plumbing, form parsing.
No DB / no network — the per-type probes are monkeypatched so we exercise the
run_monitor dispatcher and the route helpers in isolation. The DB-backed status
source + migration are covered in the integration lane.
"""
import asyncio
from steward.models.monitors import Monitor, MONITOR_TYPE_VALUES
from steward.monitors import runner as runner_mod
import pytest
from steward.monitors.routes import _form_opts
from steward.monitors.service import build_config, monitor_fields, normalise_target
def _monitor(mtype, target="example.com", cfg=None):
m = Monitor(id="m1", name="t", type=mtype, target=target)
m.set_config(cfg or {})
return m
def test_monitor_type_values():
assert MONITOR_TYPE_VALUES == ("icmp", "tcp", "dns", "http")
def test_config_json_roundtrip():
m = _monitor("tcp", cfg={"port": 8443})
assert m.config == {"port": 8443}
m.set_config({"port": 22})
assert m.config["port"] == 22
def test_config_handles_bad_json():
m = Monitor(id="x", name="t", type="tcp", target="h", config_json="not json")
assert m.config == {}
def test_run_monitor_unknown_type_is_safe():
m = _monitor("bogus")
res = asyncio.run(runner_mod.run_monitor(m))
assert res["is_up"] is False
assert "bogus" in res["error_msg"]
def test_run_monitor_tcp_uses_config_port(monkeypatch):
seen = {}
async def fake_tcp(address, port):
seen["address"], seen["port"] = address, port
return {"is_up": True, "response_ms": 12.0}
monkeypatch.setattr(runner_mod, "tcp_check", fake_tcp)
res = asyncio.run(runner_mod.run_monitor(_monitor("tcp", "db.local", {"port": 5432})))
assert seen == {"address": "db.local", "port": 5432}
assert res["is_up"] is True and res["response_ms"] == 12.0
# Untouched fields stay None (full MonitorResult superset is always present).
assert res["status_code"] is None and res["resolved_ip"] is None
def test_run_monitor_http_passes_config(monkeypatch):
seen = {}
async def fake_http(**kwargs):
seen.update(kwargs)
return {"is_up": True, "status_code": 200, "response_ms": 5.0}
monkeypatch.setattr(runner_mod, "http_check", fake_http)
cfg = {"method": "HEAD", "expected_status": 204, "content_match": "ok",
"headers": {"X": "1"}, "timeout_seconds": 3,
"follow_redirects": False, "verify_ssl": False}
res = asyncio.run(runner_mod.run_monitor(_monitor("http", "https://x.test", cfg)))
assert seen["method"] == "HEAD" and seen["expected_status"] == 204
assert seen["headers"] == {"X": "1"} and seen["verify_ssl"] is False
assert res["status_code"] == 200
def test_run_monitor_dns_passes_expected_ip(monkeypatch):
seen = {}
async def fake_dns(address, expected_ip=None):
seen["address"], seen["expected_ip"] = address, expected_ip
return {"is_up": True, "resolved_ip": "192.0.2.1"}
monkeypatch.setattr(runner_mod, "dns_check", fake_dns)
res = asyncio.run(runner_mod.run_monitor(_monitor("dns", "host.test", {"expected_ip": "192.0.2.1"})))
assert seen == {"address": "host.test", "expected_ip": "192.0.2.1"}
assert res["resolved_ip"] == "192.0.2.1"
# ── config validation (shared by the form and the MCP tools) ─────────────────
def test_normalise_target_adds_https_for_bare_http_host():
assert normalise_target("http", "example.com") == "https://example.com"
assert normalise_target("http", "http://x.com") == "http://x.com"
assert normalise_target("tcp", "example.com") == "example.com"
class _Form(dict):
"""Minimal stand-in for a Quart form (dict + 'in' membership for checkboxes)."""
def _from_form(form, mtype):
return build_config(mtype, _form_opts(form, mtype))
def test_build_config_from_the_form_per_type():
assert _from_form(_Form(port="8080"), "tcp") == {"port": 8080}
assert _from_form(_Form(), "tcp") == {"port": 80}
assert _from_form(_Form(expected_ip="10.0.0.1"), "dns") == {"expected_ip": "10.0.0.1"}
assert _from_form(_Form(), "dns") == {"expected_ip": None}
assert _from_form(_Form(), "icmp") == {}
http = _from_form(_Form(method="get", expected_status="201", follow_redirects="on"), "http")
assert http["method"] == "GET" and http["expected_status"] == 201
assert http["follow_redirects"] is True # present in form → checked
assert http["verify_ssl"] is False # absent from form → unchecked
def test_build_config_from_typed_values_defaults_to_safe_checks():
http = build_config("http", {"headers": {"X-Key": "v"}})
assert http["follow_redirects"] is True and http["verify_ssl"] is True
assert http["headers"] == {"X-Key": "v"} and http["timeout_seconds"] == 10
@pytest.mark.parametrize("mtype, opts, why", [
("tcp", {"port": 70000}, "port must be between 1 and 65535"),
("tcp", {"port": "http"}, "port must be a whole number"),
("tcp", {"expected_ip": "1.2.3.4"}, "does not take expected_ip; it takes port"),
("icmp", {"port": 22}, "takes no config"),
("http", {"method": "DELETE"}, "not one of GET, HEAD, POST"),
("http", {"expected_status": 42}, "between 100 and 599"),
("http", {"timeout_seconds": 0}, "between 1 and 60"),
("http", {"headers": "{not json"}, "JSON object"),
("http", {"headers": {"X": 1}}, "name: value strings"),
])
def test_build_config_refuses_with_a_reason(mtype, opts, why):
with pytest.raises(ValueError, match=why):
build_config(mtype, opts)
def test_monitor_fields():
f = monitor_fields("HTTP", target="nas.lan", opts={"expected_status": 204})
assert (f["type"], f["target"], f["name"]) == ("http", "https://nas.lan", "https://nas.lan")
assert f["check_interval_seconds"] == 0
with pytest.raises(ValueError, match="'smtp' is not one of icmp, tcp, dns, http"):
monitor_fields("smtp", target="x")
with pytest.raises(ValueError, match="needs a target"):
monitor_fields("icmp", target=" ")
with pytest.raises(ValueError, match="check_interval_seconds must be at least 0"):
monitor_fields("icmp", target="x", check_interval_seconds=-5)