"""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