DNS monitors take an optional Resolver, a server's IP. Set, the check asks that server directly for A and AAAA over UDP and records how long it took to answer; empty keeps today's lookup through the container's resolver. "Does my local DNS still answer for public names" and "does 1.1.1.1" are now separate monitors. No new dependency. On a host's page the Ansible card moves above the Docker list, which can run to dozens of containers and hid it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.5 KiB
Python
156 lines
6.5 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, resolver=None):
|
|
seen["address"], seen["expected_ip"], seen["resolver"] = address, expected_ip, resolver
|
|
return {"is_up": True, "resolved_ip": "192.0.2.1", "response_ms": 12.5}
|
|
|
|
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", "resolver": None}
|
|
assert res["resolved_ip"] == "192.0.2.1"
|
|
asyncio.run(runner_mod.run_monitor(_monitor("dns", "host.test", {"resolver": "1.1.1.1"})))
|
|
assert seen["resolver"] == "1.1.1.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", "resolver": None}
|
|
assert _from_form(_Form(resolver=" 1.1.1.1 "), "dns") == {"expected_ip": None, "resolver": "1.1.1.1"}
|
|
assert _from_form(_Form(), "dns") == {"expected_ip": None, "resolver": 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"),
|
|
("dns", {"resolver": "dns.google"}, "resolver must be a DNS server's IP address"),
|
|
("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)
|