Files
FabledSteward/tests/mcp/test_monitoring_writes.py
bvandeusenandClaude Opus 5.5 5ef18c992c
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m9s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m6s
feat(alerts): Send test on a rule checks that its alerts reach someone (#5466)
#5466 "Send test: check a rule's delivery from the rule". A rule's page gets
a Send test button (operator role), and MCP gets send_test_alert. Each sends
a TEST alert for that rule now, by email to everyone who gets alerts and by
webhook. The alert carries the rule's name and condition, at the latest
reading of what it watches; a rule covering hosts uses its first host. The
result for each channel shows under the button, or "Nothing is set up to
send to". Nothing fires, no event is written, and the test is audited as
alert_rule.tested. An alert with no reading now says "has no reading yet"
instead of "is None".

Docs: alerting.md also gains the burst email from #5465 "One email per burst
of alerts".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:33:32 -04:00

105 lines
4.7 KiB
Python

"""The monitoring-config write tools: their validation refusals and their roles.
Monitor-config validation is covered in tests/core/test_monitors.py (the form
and the tools share it). The writes themselves, their audit rows and the
rename warning run against Postgres in
tests/integration/test_mcp_monitoring_writes.py.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from steward.alerts import service as alert_svc
from steward.mcp.tools import alerts
from steward.models.alerts import AlertOperator
from steward.models.users import UserRole
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
OPERATOR_TOOLS = {"create_monitor", "update_monitor", "set_monitor_enabled", "create_alert_rule",
"update_alert_rule", "acknowledge_alert", "send_test_alert", "create_maintenance_window",
"end_maintenance_window"}
ADMIN_TOOLS = {"delete_monitor", "delete_alert_rule"}
@pytest.fixture
def known(monkeypatch):
"""A source with one resource and its catalog metrics, without a database:
the host_agent plugin is loaded (its get_alert_metrics hook writes the
catalog) and has reported one host."""
import plugins.host_agent
from steward.core import plugin_manager
monkeypatch.setattr(plugin_manager, "get_loaded_plugin_modules",
lambda: {"host_agent": plugins.host_agent})
async def resources(db, src):
return ["nas"] if src == "host_agent" else []
monkeypatch.setattr(alert_svc, "resources_for", resources)
async def _fields(**over):
args = dict(name="cpu hot", source_module="host_agent", resource_name="nas",
metric_name="cpu_pct", operator=">", threshold="90", consecutive_failures_required=2)
args.update(over)
return await alert_svc.rule_fields(None, **args)
async def test_rule_fields_accepts_the_form_choices(known):
f = await _fields()
assert f["operator"] is AlertOperator.gt and f["threshold"] == 90.0
assert f["consecutive_failures_required"] == 2
@pytest.mark.parametrize("over, why", [
({"name": " "}, "needs a name"),
({"source_module": "zabbix"}, "'zabbix' is not one of icmp, tcp"),
({"resource_name": "web01"}, "host_agent has no resource 'web01'. Known: nas"),
({"metric_name": "cpu"}, "does not report 'cpu'. Its metrics: cpu_pct"),
({"operator": "=>"}, "'=>' is not one of >, <"),
({"threshold": "high"}, "threshold must be a number"),
({"consecutive_failures_required": 0}, "at least 1"),
])
async def test_rule_fields_refuses_with_a_reason(known, over, why):
with pytest.raises(ValueError, match=why):
await _fields(**over)
def test_window_fields():
f = alert_svc.window_fields(name="patching", start_at=NOW, end_at=NOW + timedelta(hours=1),
scope_source="", scope_resource="nas")
assert f["scope_source"] is None and f["scope_resource"] is None # resource needs a source
with pytest.raises(ValueError, match="must end after it starts"):
alert_svc.window_fields(name="x", start_at=NOW, end_at=NOW, scope_source=None,
scope_resource=None)
with pytest.raises(ValueError, match="'nope' is not one of"):
alert_svc.window_fields(name="x", start_at=NOW, end_at=NOW + timedelta(minutes=1),
scope_source="nope", scope_resource=None)
async def test_maintenance_window_needs_exactly_one_end():
with pytest.raises(ValueError, match="exactly one of duration_minutes or end_at"):
await alerts.create_maintenance_window("x")
with pytest.raises(ValueError, match="exactly one of duration_minutes or end_at"):
await alerts.create_maintenance_window("x", duration_minutes=30, end_at="2026-10-06T13:00:00Z")
with pytest.raises(ValueError, match="not an ISO-8601 time"):
await alerts.create_maintenance_window("x", end_at="tonight")
def test_when_reads_a_zoneless_time_as_utc():
assert alerts._when("2026-10-06T22:00:00", "end_at") == datetime(2026, 10, 6, 22, tzinfo=timezone.utc)
assert alerts._when("2026-10-06T22:00:00Z", "end_at").utcoffset() == timedelta(0)
def test_write_tools_are_registered_with_their_roles(app):
mcp = app.mcp_server
for name in OPERATOR_TOOLS:
assert mcp.tool_roles[name] is UserRole.operator, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is False, name
for name in ADMIN_TOOLS:
assert mcp.tool_roles[name] is UserRole.admin, name
assert mcp.tool_roles["get_alert_rule_options"] is UserRole.viewer
for name in OPERATOR_TOOLS | ADMIN_TOOLS | {"get_alert_rule_options"}:
assert (mcp._tool_manager.get_tool(name).description or "").strip(), name