Files
FabledSteward/tests/integration/test_alert_send_test.py
T
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

117 lines
5.0 KiB
Python

"""Integration: Send test on an alert rule (#5466).
The button on a rule's page, and send_test_alert over MCP, send a TEST alert
for that rule now, by every channel a real alert of it would use, and say
what happened to each. Nothing fires and the rule's history is unchanged;
the audit log records the test. Only the delivery itself is replaced.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from zoneinfo import ZoneInfo
import pytest
pytestmark = pytest.mark.integration
@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)
def test_send_test_reaches_each_channel_and_leaves_the_rule_alone(app, monkeypatch):
from sqlalchemy import func, select
from steward.alerts import service as alert_svc
from steward.core import mail, notifications
from steward.core.alerts import record_metric
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools import alerts as mcp_alerts
from steward.models.alerts import AlertEvent, AlertState, AlertStateEnum
from steward.models.audit import AuditEvent
from steward.models.users import User, UserRole
emails, hooks = [], []
async def fake_email(cfg, alert, to):
emails.append((alert, [r.address for r in to]))
return {"sent": True, "error": None}
async def fake_hook(cfg, alert):
hooks.append(alert)
return {"sent": False, "error": "connection refused"}
async def fake_recipients(db, kind, smtp):
return [mail.Recipient("ann@example.com", ZoneInfo("UTC"), False, "ann")]
monkeypatch.setattr(notifications, "_send_email", fake_email)
monkeypatch.setattr(notifications, "_send_webhook", fake_hook)
monkeypatch.setattr(mail, "recipients", fake_recipients)
app.config["SMTP"] = {"host": "mx.example", "port": 587, "from": "alerts@example.com"}
app.config["WEBHOOK"] = {"url": "https://hook.example/api/1"}
async def _go():
tag = uuid.uuid4().hex[:8]
host = f"st-{tag}"
async with app.db_sessionmaker() as s:
async with s.begin():
user = User(username=f"st-{tag}", email=f"st-{tag}@test", password_hash="x",
role=UserRole.operator, is_active=True)
s.add(user)
await s.flush()
await record_metric(s, "host_agent", host, "cpu_pct", 42.0)
fields = await alert_svc.rule_fields(
s, name=f"Hot {tag}", source_module="host_agent", resource_name=host,
metric_name="cpu_pct", operator=">", threshold=90)
rule = await alert_svc.add_rule(s, fields, created_by=user.id)
uid, rule_id = user.id, rule.id
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="operator", username=f"st-{tag}",
_csrf="test-token", login_at=time.time())
resp = await client.post(f"/alerts/rules/{rule_id}/test",
headers={"X-CSRF-Token": "test-token"})
assert resp.status_code == 200
page = await resp.get_data(as_text=True)
assert "alert-error" in page
assert "Email sent to ann@example.com." in page
assert "Webhook to hook.example failed: connection refused" in page
# What went out is this rule, marked TEST, at its latest reading.
((alert, to),) = emails
assert to == ["ann@example.com"] and hooks == [alert]
assert (alert["rule_name"], alert["state"], alert["resource"], alert["value"]) == (
f"Hot {tag}", "TEST", host, 42.0)
# Nothing fired: no event, the alert still inactive. The audit log has it.
async with app.db_sessionmaker() as s:
events = (await s.execute(select(func.count()).select_from(AlertEvent)
.where(AlertEvent.rule_id == rule_id))).scalar_one()
state = (await s.execute(select(AlertState).where(
AlertState.rule_id == rule_id))).scalar_one()
audits = (await s.execute(select(AuditEvent).where(
AuditEvent.user_id == uid))).scalars().all()
assert events == 0 and state.state == AlertStateEnum.inactive
assert [a.action for a in audits] == ["alert_rule.tested"]
assert json.loads(audits[0].detail_json)["ok"] is False
# Over MCP, with nothing set up to send to, it says so.
app.config["SMTP"], app.config["WEBHOOK"] = {}, {}
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=f"st-{tag}",
role=UserRole.operator, token_id=7))
got = await mcp_alerts.send_test_alert(f"Hot {tag}")
assert got["ok"] is False and got["result"][0].startswith("Nothing is set up to send to")
assert len(emails) == 1 and len(hooks) == 1
asyncio.run(_go())