feat: add notification alert rules

This commit is contained in:
Christian Krakau-Louis
2026-05-22 22:55:49 +02:00
parent 9d0375569b
commit 05b687b525
9 changed files with 703 additions and 10 deletions
+197
View File
@@ -2,10 +2,61 @@
Tests for the Settings model and /api/v1/settings endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.notifications import NotificationResult
def _timestamp_days_ago(days: int) -> int:
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
def _add_domain(db_session: Session, name: str) -> Domain:
domain = Domain(name=name)
db_session.add(domain)
db_session.flush()
return domain
def _add_report_record(
db_session: Session,
domain: Domain,
*,
report_id: str,
days_ago: int,
source_ip: str,
count: int,
dkim: str = "pass",
spf: str = "pass",
) -> None:
begin_date = _timestamp_days_ago(days_ago)
report = DMARCReport(
domain_id=domain.id,
report_id=report_id,
org_name="receiver.example",
begin_date=begin_date,
end_date=begin_date + 3600,
policy="none",
)
db_session.add(report)
db_session.flush()
db_session.add(
ReportRecord(
report_id=report.id,
source_ip=source_ip,
count=count,
disposition="none",
dkim=dkim,
spf=spf,
header_from=domain.name,
)
)
class TestSettingModel:
@@ -51,6 +102,10 @@ class TestSettingsAPI:
assert "cloudflare.api_token" in keys
assert "notifications.apprise_enabled" in keys
assert "notifications.apprise_urls" in keys
assert "notifications.alert_new_sources_enabled" in keys
assert "notifications.alert_compliance_drop_points" in keys
assert "notifications.alert_failure_threshold_count" in keys
assert "notifications.alert_missing_reports_days" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
@@ -213,3 +268,145 @@ class TestSettingsAPI:
detail = res.json()["detail"]
assert detail["success"] is False
assert detail["message"] == "No notification targets are configured."
def test_notification_alert_rules_detect_new_source_and_failures(
self,
authed_client: TestClient,
db_session: Session,
):
"""GET /settings/notifications/alerts evaluates source and failure alerts."""
domain = _add_domain(db_session, "alerts.example")
_add_report_record(
db_session,
domain,
report_id="alerts-old-source",
days_ago=10,
source_ip="203.0.113.10",
count=12,
)
_add_report_record(
db_session,
domain,
report_id="alerts-new-source",
days_ago=0,
source_ip="203.0.113.20",
count=150,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
rules = {alert["rule"] for alert in alerts}
assert "new_sender_source" in rules
assert "dmarc_failures_above_threshold" in rules
new_source = next(alert for alert in alerts if alert["rule"] == "new_sender_source")
assert new_source["source_ip"] == "203.0.113.20"
def test_notification_alert_rules_detect_missing_reports(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert rules flag active monitored domains without recent reports."""
_add_domain(db_session, "missing.example")
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
assert any(
alert["rule"] == "missing_reports" and alert["domain"] == "missing.example"
for alert in alerts
)
def test_notification_alert_rules_detect_compliance_drop(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert rules compare recent compliance rates and flag large drops."""
domain = _add_domain(db_session, "drop.example")
_add_report_record(
db_session,
domain,
report_id="drop-known-source",
days_ago=10,
source_ip="203.0.113.30",
count=3,
)
_add_report_record(
db_session,
domain,
report_id="drop-passing-day",
days_ago=1,
source_ip="203.0.113.30",
count=10,
)
_add_report_record(
db_session,
domain,
report_id="drop-failing-day",
days_ago=0,
source_ip="203.0.113.30",
count=10,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
compliance_alert = next(alert for alert in alerts if alert["rule"] == "compliance_drop")
assert compliance_alert["domain"] == "drop.example"
assert compliance_alert["previous_rate"] == 100.0
assert compliance_alert["current_rate"] == 0.0
def test_notification_alert_send_uses_alert_summary(
self,
authed_client: TestClient,
db_session: Session,
monkeypatch,
):
"""POST /settings/notifications/alerts/send sends one alert summary."""
sent_messages = []
def fake_send_notification(
db, *, title, body, force=False
): # pylint: disable=unused-argument
sent_messages.append({"title": title, "body": body})
return NotificationResult(
success=True,
message="Notification sent.",
configured_targets=1,
)
monkeypatch.setattr("app.services.alert_rules.send_notification", fake_send_notification)
domain = _add_domain(db_session, "send.example")
_add_report_record(
db_session,
domain,
report_id="send-failing-day",
days_ago=0,
source_ip="203.0.113.40",
count=200,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
assert res.status_code == 200
data = res.json()
assert data["notification"]["success"] is True
assert data["alerts"]
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
assert "DMARC failures above threshold" in sent_messages[0]["body"]