"""Integration: the monitoring-config write tools against real Postgres. A monitor is created on a host, edited (config merged, a rename warning for the rule that watches it by name), paused and deleted; a rule is created on that monitor, edited, acknowledged once firing, and deleted; a maintenance window in progress is ended and an upcoming one cancelled. Every write leaves an audit row as " via MCP". """ from __future__ import annotations import asyncio import os import uuid import pytest pytestmark = pytest.mark.integration _NEEDS_DB = pytest.mark.skipif( not os.environ.get("STEWARD_DATABASE_URL"), reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)", ) @pytest.fixture def app(): if not os.environ.get("STEWARD_DATABASE_URL"): pytest.skip("needs Postgres") from steward.app import create_app return create_app(testing=False) @_NEEDS_DB def test_monitor_rule_ack_and_maintenance_writes(app): from sqlalchemy import select from steward.core.api_tokens import Caller from steward.mcp import _context from steward.mcp.tools import alerts as a from steward.mcp.tools import monitors as m from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, MaintenanceWindow from steward.models.audit import AuditEvent from steward.models.hosts import Host from steward.models.monitors import Monitor from steward.models.users import User, UserRole tag = uuid.uuid4().hex[:8] uid, username, host_name = str(uuid.uuid4()), f"mcp-cfg-{tag}", f"cfg-host-{tag}" mon_name, rule_name = f"web-{tag}", f"web-down-{tag}" async def _seed(): async with app.db_sessionmaker() as s: async with s.begin(): s.add(User(id=uid, username=username, email=f"{tag}@e.test", password_hash="x", role=UserRole.admin, is_active=True)) s.add(Host(id=str(uuid.uuid4()), name=host_name, address="10.7.7.7")) async def _actions(): async with app.db_sessionmaker() as s: rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid))).scalars() return [(e.action, e.username) for e in rows] async def _go(): await _seed() _context._app_ctx.set(app) _context._caller_ctx.set(Caller(user_id=uid, username=username, role=UserRole.admin, token_id=7)) # ── monitors ─────────────────────────────────────────────────────── with pytest.raises(ValueError, match="does not take port"): await m.create_monitor(mon_name, "http", "nas.lan", config={"port": 80}) with pytest.raises(ValueError, match="no host named"): await m.create_monitor(mon_name, "http", "nas.lan", host=f"ghost-{tag}") made = await m.create_monitor(mon_name, "http", "nas.lan", host=host_name, config={"expected_status": 204, "headers": {"Authorization": "Bearer s3cret"}}) assert made["target"] == "https://nas.lan" and made["host_name"] == host_name assert made["config"]["headers"] == {"Authorization": ""} assert made["config"]["verify_ssl"] is True # ── rules ────────────────────────────────────────────────────────── opts = await a.get_alert_rule_options("http") assert mon_name in opts["resources"] and "is_up" in opts["metrics"] with pytest.raises(ValueError, match="does not report 'up'"): await a.create_alert_rule(rule_name, "http", mon_name, "up", "<", 1) rule = await a.create_alert_rule(rule_name, "http", mon_name, "is_up", "<", 1, consecutive_checks=3) assert rule["condition"] == "is_up < 1 for 3 consecutive checks" rule = await a.update_alert_rule(rule_name, threshold=0.5, enabled=False) assert rule["condition"].startswith("is_up < 0.5") and rule["enabled"] is False # Editing the config merges: the stored header survives a timeout change. upd = await m.update_monitor(mon_name, config={"timeout_seconds": 5}) assert upd["config"]["timeout_seconds"] == 5 and upd["config"]["expected_status"] == 204 async with app.db_sessionmaker() as s: row = await s.get(Monitor, made["monitor_id"]) assert row.config["headers"] == {"Authorization": "Bearer s3cret"} # A rename leaves the rule on the old name, and says so. renamed = await m.update_monitor(mon_name, name=f"{mon_name}-2") assert rule_name in renamed["warning"] await a.update_alert_rule(rule_name, resource=f"{mon_name}-2") assert "warning" not in await m.update_monitor(f"{mon_name}-2", check_interval_s=30) assert (await m.set_monitor_enabled(f"{mon_name}-2", False))["enabled"] is False # ── acknowledge ──────────────────────────────────────────────────── with pytest.raises(ValueError, match="not firing"): await a.acknowledge_alert(rule_name) async with app.db_sessionmaker() as s: async with s.begin(): st = (await s.execute(select(AlertState).where( AlertState.rule_id == rule["rule_id"]))).scalar_one() st.state = AlertStateEnum.firing ack = await a.acknowledge_alert(rule_name) assert ack["state"] == "acknowledged" # ── maintenance ──────────────────────────────────────────────────── win = await a.create_maintenance_window(f"patch-{tag}", duration_minutes=30, source_module="http", resource=f"{mon_name}-2") assert win["active"] is True ended = await a.end_maintenance_window(f"patch-{tag}") assert ended["outcome"] == "ended" and ended["active"] is True with pytest.raises(ValueError, match="already ended"): await a.end_maintenance_window(win["window_id"]) later = await a.create_maintenance_window(f"later-{tag}", start_at="2099-01-01T00:00:00Z", end_at="2099-01-01T02:00:00Z") assert (await a.end_maintenance_window(later["window_id"]))["outcome"] == "cancelled" async with app.db_sessionmaker() as s: assert await s.get(MaintenanceWindow, later["window_id"]) is None assert await s.get(MaintenanceWindow, win["window_id"]) is not None # ── deletes ──────────────────────────────────────────────────────── await a.delete_alert_rule(rule_name) await m.delete_monitor(f"{mon_name}-2") async with app.db_sessionmaker() as s: assert await s.get(AlertRule, rule["rule_id"]) is None assert await s.get(Monitor, made["monitor_id"]) is None actions = await _actions() assert all(user == f"{username} via MCP" for _, user in actions) assert sorted({act for act, _ in actions}) == sorted({ "monitor.created", "monitor.updated", "monitor.disabled", "monitor.deleted", "alert_rule.created", "alert_rule.updated", "alert_rule.acknowledged", "alert_rule.deleted", "maintenance_window.created", "maintenance_window.ended", "maintenance_window.cancelled"}) asyncio.run(_go())