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>
152 lines
6.1 KiB
Python
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)
|