1024 lines
36 KiB
Python
1024 lines
36 KiB
Python
"""
|
||
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.core.credential_encryption import decrypt_secret, is_encrypted_secret
|
||
from app.models.alert import AlertConfigurationAudit, AlertHistory
|
||
from app.models.domain import Domain
|
||
from app.models.report import DMARCReport, ReportRecord
|
||
from app.models.setting import Setting
|
||
from app.services.alert_history import list_alert_config_audit, record_alert_config_change
|
||
from app.services.notifications import NotificationResult, send_notification
|
||
from app.services.summary_notifications import send_due_scheduled_summaries
|
||
|
||
|
||
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:
|
||
"""Unit tests for the Setting ORM model."""
|
||
|
||
def test_create_setting(self, db_session: Session):
|
||
row = Setting(
|
||
key="general.app_name",
|
||
value="TestApp",
|
||
description="App name",
|
||
value_type="string",
|
||
category="general",
|
||
)
|
||
db_session.add(row)
|
||
db_session.commit()
|
||
db_session.refresh(row)
|
||
|
||
assert row.key == "general.app_name"
|
||
assert row.value == "TestApp"
|
||
assert row.category == "general"
|
||
assert row.value_type == "string"
|
||
|
||
def test_repr(self, db_session: Session):
|
||
row = Setting(key="dns.resolver", value="system", category="dns")
|
||
db_session.add(row)
|
||
db_session.commit()
|
||
assert "dns.resolver" in repr(row)
|
||
assert "dns" in repr(row)
|
||
|
||
def test_alert_reprs(self):
|
||
alert = AlertHistory(rule="missing_reports", is_active=True)
|
||
audit = AlertConfigurationAudit(key="notifications.apprise_enabled")
|
||
|
||
assert "missing_reports" in repr(alert)
|
||
assert "notifications.apprise_enabled" in repr(audit)
|
||
|
||
|
||
class TestSettingsAPI:
|
||
"""Integration tests for /api/v1/settings endpoints."""
|
||
|
||
def test_list_settings_seeds_defaults(self, authed_client: TestClient):
|
||
"""GET /api/v1/settings returns seeded defaults on first call."""
|
||
res = authed_client.get("/api/v1/settings")
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
assert isinstance(data, list)
|
||
keys = {row["key"] for row in data}
|
||
assert "general.app_name" in keys
|
||
assert "dmarc.default_policy" in keys
|
||
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
|
||
assert "notifications.summary_daily_enabled" in keys
|
||
assert "notifications.summary_weekly_enabled" in keys
|
||
assert "notifications.summary_send_hour_utc" in keys
|
||
assert "notifications.summary_weekday_utc" in keys
|
||
assert "notifications.min_send_interval_minutes" in keys
|
||
assert "notifications.redact_pii_enabled" in keys
|
||
assert "forensics.redaction_mode" in keys
|
||
assert "forensics.redact_long_tokens_enabled" in keys
|
||
|
||
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
||
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
||
res = authed_client.get("/api/v1/settings?category=dmarc")
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
for row in data:
|
||
assert row["category"] == "dmarc"
|
||
|
||
def test_get_single_setting(self, authed_client: TestClient):
|
||
"""GET /api/v1/settings/{key} returns a single setting."""
|
||
# Seed defaults first
|
||
authed_client.get("/api/v1/settings")
|
||
res = authed_client.get("/api/v1/settings/general.app_name")
|
||
assert res.status_code == 200
|
||
assert res.json()["key"] == "general.app_name"
|
||
assert res.json()["value"] == "DMARQ"
|
||
|
||
def test_get_missing_setting_returns_404(self, authed_client: TestClient):
|
||
"""GET /api/v1/settings/{key} returns 404 for unknown keys."""
|
||
authed_client.get("/api/v1/settings") # seed
|
||
res = authed_client.get("/api/v1/settings/nonexistent.key")
|
||
assert res.status_code == 404
|
||
|
||
def test_update_setting(self, authed_client: TestClient):
|
||
"""PUT /api/v1/settings/{key} updates a setting value."""
|
||
authed_client.get("/api/v1/settings") # seed
|
||
res = authed_client.put(
|
||
"/api/v1/settings/general.app_name",
|
||
json={"value": "MyDMARQ"},
|
||
)
|
||
assert res.status_code == 200
|
||
assert res.json()["value"] == "MyDMARQ"
|
||
|
||
# Verify persistence
|
||
res2 = authed_client.get("/api/v1/settings/general.app_name")
|
||
assert res2.json()["value"] == "MyDMARQ"
|
||
|
||
def test_update_setting_upserts(self, authed_client: TestClient):
|
||
"""PUT /api/v1/settings/{key} creates the row if it doesn't exist yet."""
|
||
res = authed_client.put(
|
||
"/api/v1/settings/general.custom_key",
|
||
json={"value": "hello"},
|
||
)
|
||
assert res.status_code == 200
|
||
assert res.json()["value"] == "hello"
|
||
|
||
def test_bulk_update(self, authed_client: TestClient):
|
||
"""POST /api/v1/settings/bulk updates multiple settings at once."""
|
||
authed_client.get("/api/v1/settings") # seed
|
||
res = authed_client.post(
|
||
"/api/v1/settings/bulk",
|
||
json={
|
||
"settings": {
|
||
"dmarc.default_policy": "quarantine",
|
||
"dmarc.default_percentage": "80",
|
||
}
|
||
},
|
||
)
|
||
assert res.status_code == 200
|
||
data = {row["key"]: row["value"] for row in res.json()}
|
||
assert data["dmarc.default_policy"] == "quarantine"
|
||
assert data["dmarc.default_percentage"] == "80"
|
||
|
||
def test_secret_is_redacted_in_response(self, authed_client: TestClient):
|
||
"""cloudflare.api_token value is redacted in GET responses."""
|
||
authed_client.get("/api/v1/settings") # seed
|
||
# Store a real token
|
||
authed_client.put(
|
||
"/api/v1/settings/cloudflare.api_token",
|
||
json={"value": "super-secret-token"},
|
||
)
|
||
res = authed_client.get("/api/v1/settings/cloudflare.api_token")
|
||
assert res.status_code == 200
|
||
assert res.json()["value"] == "**redacted**"
|
||
|
||
def test_apprise_urls_are_redacted_in_response(self, authed_client: TestClient):
|
||
"""Apprise target URLs are treated as notification secrets."""
|
||
authed_client.get("/api/v1/settings")
|
||
authed_client.put(
|
||
"/api/v1/settings/notifications.apprise_urls",
|
||
json={"value": "mailto://user:password@example.com"},
|
||
)
|
||
res = authed_client.get("/api/v1/settings/notifications.apprise_urls")
|
||
assert res.status_code == 200
|
||
assert res.json()["value"] == "**redacted**"
|
||
|
||
def test_apprise_urls_are_encrypted_at_rest(
|
||
self,
|
||
authed_client: TestClient,
|
||
db_session: Session,
|
||
):
|
||
"""Apprise target URLs are encrypted in the settings table."""
|
||
target = "mailto://user:password@example.com"
|
||
authed_client.get("/api/v1/settings")
|
||
|
||
res = authed_client.put(
|
||
"/api/v1/settings/notifications.apprise_urls",
|
||
json={"value": target},
|
||
)
|
||
|
||
assert res.status_code == 200
|
||
row = db_session.query(Setting).filter(Setting.key == "notifications.apprise_urls").first()
|
||
assert row is not None
|
||
assert row.value != target
|
||
assert is_encrypted_secret(row.value)
|
||
assert decrypt_secret(row.value) == target
|
||
|
||
def test_legacy_plaintext_secret_setting_is_migrated_on_read(
|
||
self,
|
||
authed_client: TestClient,
|
||
db_session: Session,
|
||
):
|
||
"""Plaintext secret settings are encrypted the next time defaults are seeded."""
|
||
db_session.add(
|
||
Setting(
|
||
key="notifications.apprise_urls",
|
||
value="mailto://user:password@example.com",
|
||
category="notifications",
|
||
)
|
||
)
|
||
db_session.commit()
|
||
|
||
res = authed_client.get("/api/v1/settings")
|
||
|
||
assert res.status_code == 200
|
||
row = db_session.query(Setting).filter(Setting.key == "notifications.apprise_urls").first()
|
||
assert is_encrypted_secret(row.value)
|
||
|
||
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
|
||
"""Sending **redacted** back to PUT should not overwrite the stored value."""
|
||
authed_client.get("/api/v1/settings")
|
||
authed_client.put(
|
||
"/api/v1/settings/cloudflare.api_token",
|
||
json={"value": "real-token-value"},
|
||
)
|
||
# Simulate round-trip with redacted placeholder
|
||
authed_client.put(
|
||
"/api/v1/settings/cloudflare.api_token",
|
||
json={"value": "**redacted**"},
|
||
)
|
||
# Direct DB check via a fresh GET – the value should still be "real-token-value"
|
||
# (GET always redacts, so we check via the list endpoint's category filter)
|
||
res = authed_client.get("/api/v1/settings?category=cloudflare")
|
||
cf = {row["key"]: row["value"] for row in res.json()}
|
||
# Value should remain redacted (which means the underlying value is still set)
|
||
assert cf["cloudflare.api_token"] == "**redacted**"
|
||
|
||
def test_unauthenticated_returns_403(self, client: TestClient):
|
||
"""Unauthenticated requests to settings endpoints return 403."""
|
||
res = client.get("/api/v1/settings")
|
||
assert res.status_code in (401, 403)
|
||
|
||
def test_test_notification_sends_via_apprise(self, authed_client: TestClient, monkeypatch):
|
||
"""POST /settings/notifications/test sends a sanitized Apprise test notification."""
|
||
|
||
class FakeApprise:
|
||
instances = []
|
||
|
||
def __init__(self):
|
||
self.urls = []
|
||
self.messages = []
|
||
FakeApprise.instances.append(self)
|
||
|
||
def add(self, url):
|
||
self.urls.append(url)
|
||
return True
|
||
|
||
def notify(self, *, title, body):
|
||
self.messages.append({"title": title, "body": body})
|
||
return True
|
||
|
||
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
|
||
|
||
authed_client.get("/api/v1/settings")
|
||
authed_client.post(
|
||
"/api/v1/settings/bulk",
|
||
json={
|
||
"settings": {
|
||
"notifications.apprise_enabled": "true",
|
||
"notifications.apprise_urls": "mailto://user:password@example.com",
|
||
}
|
||
},
|
||
)
|
||
|
||
res = authed_client.post("/api/v1/settings/notifications/test")
|
||
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
assert data["success"] is True
|
||
assert data["configured_targets"] == 1
|
||
assert "password" not in str(data)
|
||
assert FakeApprise.instances[0].messages[0]["title"] == "DMARQ test notification"
|
||
|
||
def test_notification_delivery_is_rate_limited(
|
||
self,
|
||
db_session: Session,
|
||
monkeypatch,
|
||
):
|
||
"""Non-forced notification sends respect the configured cooldown."""
|
||
|
||
class FakeApprise:
|
||
instances = []
|
||
|
||
def __init__(self):
|
||
self.messages = []
|
||
FakeApprise.instances.append(self)
|
||
|
||
def add(self, url):
|
||
return True
|
||
|
||
def notify(self, *, title, body):
|
||
self.messages.append({"title": title, "body": body})
|
||
return True
|
||
|
||
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
|
||
db_session.add_all(
|
||
[
|
||
Setting(
|
||
key="notifications.apprise_enabled",
|
||
value="true",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.apprise_urls",
|
||
value="mailto://user:password@example.com",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.min_send_interval_minutes",
|
||
value="15",
|
||
category="notifications",
|
||
),
|
||
]
|
||
)
|
||
db_session.commit()
|
||
|
||
first = send_notification(db_session, title="First", body="First body")
|
||
second = send_notification(db_session, title="Second", body="Second body")
|
||
|
||
assert first.success is True
|
||
assert second.success is False
|
||
assert second.rate_limited is True
|
||
assert second.error == "rate_limited"
|
||
assert sum(len(instance.messages) for instance in FakeApprise.instances) == 1
|
||
|
||
def test_notification_delivery_edges_are_sanitized(
|
||
self,
|
||
db_session: Session,
|
||
monkeypatch,
|
||
):
|
||
"""Notification delivery handles disabled, invalid, and failed target paths."""
|
||
|
||
class FalseApprise:
|
||
def add(self, url):
|
||
return False
|
||
|
||
class RaisingApprise:
|
||
def add(self, url):
|
||
return True
|
||
|
||
def notify(self, *, title, body):
|
||
raise RuntimeError("delivery failed")
|
||
|
||
class NotDeliveredApprise:
|
||
def add(self, url):
|
||
return True
|
||
|
||
def notify(self, *, title, body):
|
||
return False
|
||
|
||
assert send_notification(db_session, title="Off", body="Body").skipped is True
|
||
|
||
db_session.add_all(
|
||
[
|
||
Setting(
|
||
key="notifications.apprise_enabled",
|
||
value="true",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.apprise_urls",
|
||
value="mailto://user:password@example.com",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.min_send_interval_minutes",
|
||
value="not-an-integer",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.last_sent_at",
|
||
value="not-a-date",
|
||
category="notifications",
|
||
),
|
||
]
|
||
)
|
||
db_session.commit()
|
||
|
||
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FalseApprise)
|
||
invalid = send_notification(db_session, title="Invalid", body="Body")
|
||
assert invalid.success is False
|
||
assert invalid.invalid_targets == 1
|
||
|
||
monkeypatch.setattr("app.services.notifications.apprise.Apprise", RaisingApprise)
|
||
failed = send_notification(db_session, title="Raises", body="Body")
|
||
assert failed.error == "delivery_failed"
|
||
|
||
monkeypatch.setattr("app.services.notifications.apprise.Apprise", NotDeliveredApprise)
|
||
not_delivered = send_notification(db_session, title="No", body="Body")
|
||
assert not_delivered.error == "not_delivered"
|
||
|
||
def test_notification_decrypt_error_returns_no_targets(
|
||
self,
|
||
db_session: Session,
|
||
monkeypatch,
|
||
):
|
||
"""Unreadable encrypted target settings fail closed without exposing secrets."""
|
||
db_session.add_all(
|
||
[
|
||
Setting(
|
||
key="notifications.apprise_enabled",
|
||
value="true",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.apprise_urls",
|
||
value="enc:v1:bad-token",
|
||
category="notifications",
|
||
),
|
||
]
|
||
)
|
||
db_session.commit()
|
||
|
||
def raise_value_error(value): # pylint: disable=unused-argument
|
||
raise ValueError("bad token")
|
||
|
||
monkeypatch.setattr("app.services.notifications.decrypt_secret", raise_value_error)
|
||
|
||
result = send_notification(db_session, title="Bad secret", body="Body")
|
||
|
||
assert result.success is False
|
||
assert result.message == "No notification targets are configured."
|
||
|
||
def test_notification_delivery_redacts_email_addresses(
|
||
self,
|
||
db_session: Session,
|
||
monkeypatch,
|
||
):
|
||
"""Outbound notification text redacts email addresses by default."""
|
||
|
||
class FakeApprise:
|
||
messages = []
|
||
|
||
def add(self, url):
|
||
return True
|
||
|
||
def notify(self, *, title, body):
|
||
self.messages.append({"title": title, "body": body})
|
||
return True
|
||
|
||
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
|
||
db_session.add_all(
|
||
[
|
||
Setting(
|
||
key="notifications.apprise_enabled",
|
||
value="true",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.apprise_urls",
|
||
value="mailto://user:password@example.com",
|
||
category="notifications",
|
||
),
|
||
]
|
||
)
|
||
db_session.commit()
|
||
|
||
result = send_notification(
|
||
db_session,
|
||
title="Failure for admin@example.com",
|
||
body="Sample from alice@example.com failed DMARC.",
|
||
)
|
||
|
||
assert result.success is True
|
||
assert "admin@example.com" not in FakeApprise.messages[0]["title"]
|
||
assert "alice@example.com" not in FakeApprise.messages[0]["body"]
|
||
assert "[redacted-email]@example.com" in FakeApprise.messages[0]["body"]
|
||
redact_text = send_notification.__globals__["redact_notification_text"]
|
||
assert redact_text('"admin@example.com!"').strip('"') == "[redacted-email]@example.com!"
|
||
assert redact_text("not-an-email") == "not-an-email"
|
||
|
||
def test_test_notification_without_targets_returns_400(self, authed_client: TestClient):
|
||
"""Test notification returns a useful error when no target is configured."""
|
||
authed_client.get("/api/v1/settings")
|
||
|
||
res = authed_client.post("/api/v1/settings/notifications/test")
|
||
|
||
assert res.status_code == 400
|
||
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"]
|
||
|
||
def test_notification_alert_history_records_and_resolves_alerts(
|
||
self,
|
||
authed_client: TestClient,
|
||
db_session: Session,
|
||
):
|
||
"""Alert evaluations persist active history and resolve missing alerts."""
|
||
domain = _add_domain(db_session, "history.example")
|
||
_add_report_record(
|
||
db_session,
|
||
domain,
|
||
report_id="history-failing-day",
|
||
days_ago=0,
|
||
source_ip="203.0.113.45",
|
||
count=200,
|
||
dkim="fail",
|
||
spf="fail",
|
||
)
|
||
db_session.commit()
|
||
|
||
res = authed_client.get("/api/v1/settings/notifications/alerts")
|
||
|
||
assert res.status_code == 200
|
||
history_res = authed_client.get("/api/v1/settings/notifications/alerts/history")
|
||
assert history_res.status_code == 200
|
||
history = history_res.json()["history"]
|
||
assert history
|
||
assert any(item["is_active"] for item in history)
|
||
assert db_session.query(AlertHistory).count() == len(history)
|
||
|
||
authed_client.post(
|
||
"/api/v1/settings/bulk",
|
||
json={
|
||
"settings": {
|
||
"notifications.alert_new_sources_enabled": "false",
|
||
"notifications.alert_failure_threshold_enabled": "false",
|
||
"notifications.alert_missing_reports_enabled": "false",
|
||
"notifications.alert_compliance_drop_enabled": "false",
|
||
}
|
||
},
|
||
)
|
||
|
||
res = authed_client.get("/api/v1/settings/notifications/alerts")
|
||
|
||
assert res.status_code == 200
|
||
assert res.json()["alerts"] == []
|
||
resolved_res = authed_client.get(
|
||
"/api/v1/settings/notifications/alerts/history?active=false"
|
||
)
|
||
resolved = resolved_res.json()["history"]
|
||
assert resolved
|
||
assert all(item["is_active"] is False for item in resolved)
|
||
|
||
def test_notification_alert_send_failure_returns_400(
|
||
self,
|
||
authed_client: TestClient,
|
||
monkeypatch,
|
||
):
|
||
"""Alert send endpoint surfaces notification delivery failures."""
|
||
|
||
def fake_send_current_alerts(db): # pylint: disable=unused-argument
|
||
return {
|
||
"alerts": [{"title": "Alert", "detail": "Detail"}],
|
||
"notification": {
|
||
"success": False,
|
||
"message": "No valid notification targets are configured.",
|
||
},
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
"app.api.api_v1.endpoints.settings.send_current_alerts",
|
||
fake_send_current_alerts,
|
||
)
|
||
|
||
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
|
||
|
||
assert res.status_code == 400
|
||
assert res.json()["detail"]["notification"]["success"] is False
|
||
|
||
def test_notification_config_audit_records_sanitized_changes(
|
||
self,
|
||
authed_client: TestClient,
|
||
):
|
||
"""Notification setting changes create an audit trail without secret values."""
|
||
authed_client.get("/api/v1/settings")
|
||
|
||
res = authed_client.post(
|
||
"/api/v1/settings/bulk",
|
||
json={
|
||
"settings": {
|
||
"notifications.apprise_enabled": "true",
|
||
"notifications.apprise_urls": "mailto://user:password@example.com",
|
||
"notifications.alert_failure_threshold_count": "250",
|
||
}
|
||
},
|
||
)
|
||
|
||
assert res.status_code == 200
|
||
audit_res = authed_client.get("/api/v1/settings/notifications/config-audit")
|
||
assert audit_res.status_code == 200
|
||
audit = audit_res.json()["audit"]
|
||
keys = {item["key"] for item in audit}
|
||
assert "notifications.apprise_enabled" in keys
|
||
assert "notifications.apprise_urls" in keys
|
||
assert "notifications.alert_failure_threshold_count" in keys
|
||
secret_row = next(item for item in audit if item["key"] == "notifications.apprise_urls")
|
||
assert secret_row["new_value"] == "[redacted]"
|
||
assert "password" not in str(audit)
|
||
|
||
def test_notification_config_audit_actor_variants(
|
||
self,
|
||
db_session: Session,
|
||
):
|
||
"""Config audit actor detection covers session and JWT auth contexts."""
|
||
record_alert_config_change(
|
||
db_session,
|
||
key="notifications.apprise_enabled",
|
||
old_value="false",
|
||
new_value="true",
|
||
auth_context={"auth_type": "session", "user_id": 123},
|
||
)
|
||
record_alert_config_change(
|
||
db_session,
|
||
key="notifications.apprise_enabled",
|
||
old_value="true",
|
||
new_value="false",
|
||
auth_context={"auth_type": "jwt", "payload": {"sub": "admin@example.com"}},
|
||
)
|
||
db_session.commit()
|
||
|
||
audit = list_alert_config_audit(db_session, limit=2)
|
||
|
||
assert {row["changed_by"] for row in audit} == {"123", "admin@example.com"}
|
||
|
||
def test_bulk_update_upserts_and_preserves_redacted_secret(
|
||
self,
|
||
authed_client: TestClient,
|
||
db_session: Session,
|
||
):
|
||
"""Bulk settings handles new rows and redacted secret placeholders."""
|
||
authed_client.get("/api/v1/settings")
|
||
authed_client.put(
|
||
"/api/v1/settings/notifications.apprise_urls",
|
||
json={"value": "mailto://user:password@example.com"},
|
||
)
|
||
before = (
|
||
db_session.query(Setting)
|
||
.filter(Setting.key == "notifications.apprise_urls")
|
||
.first()
|
||
.value
|
||
)
|
||
|
||
res = authed_client.post(
|
||
"/api/v1/settings/bulk",
|
||
json={
|
||
"settings": {
|
||
"notifications.custom_notice": "enabled",
|
||
"notifications.apprise_urls": "**redacted**",
|
||
}
|
||
},
|
||
)
|
||
|
||
assert res.status_code == 200
|
||
after = (
|
||
db_session.query(Setting)
|
||
.filter(Setting.key == "notifications.apprise_urls")
|
||
.first()
|
||
.value
|
||
)
|
||
custom = (
|
||
db_session.query(Setting).filter(Setting.key == "notifications.custom_notice").first()
|
||
)
|
||
assert after == before
|
||
assert custom.value == "enabled"
|
||
|
||
def test_notification_summary_preview_returns_recent_activity(
|
||
self,
|
||
authed_client: TestClient,
|
||
db_session: Session,
|
||
):
|
||
"""GET /settings/notifications/summary returns a daily summary preview."""
|
||
domain = _add_domain(db_session, "summary.example")
|
||
_add_report_record(
|
||
db_session,
|
||
domain,
|
||
report_id="summary-recent",
|
||
days_ago=0,
|
||
source_ip="203.0.113.50",
|
||
count=25,
|
||
)
|
||
_add_report_record(
|
||
db_session,
|
||
domain,
|
||
report_id="summary-old",
|
||
days_ago=3,
|
||
source_ip="203.0.113.51",
|
||
count=99,
|
||
)
|
||
db_session.commit()
|
||
|
||
res = authed_client.get("/api/v1/settings/notifications/summary?period=daily")
|
||
|
||
assert res.status_code == 200
|
||
summary = res.json()["summary"]
|
||
assert summary["period"] == "daily"
|
||
assert summary["total_messages"] == 25
|
||
assert summary["reports_processed"] == 1
|
||
assert summary["top_domains"][0]["domain"] == "summary.example"
|
||
|
||
def test_notification_summary_send_uses_apprise_summary(
|
||
self,
|
||
authed_client: TestClient,
|
||
db_session: Session,
|
||
monkeypatch,
|
||
):
|
||
"""POST /settings/notifications/summary/send sends the selected 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.summary_notifications.send_notification",
|
||
fake_send_notification,
|
||
)
|
||
|
||
domain = _add_domain(db_session, "weekly.example")
|
||
_add_report_record(
|
||
db_session,
|
||
domain,
|
||
report_id="weekly-recent",
|
||
days_ago=2,
|
||
source_ip="203.0.113.60",
|
||
count=40,
|
||
)
|
||
db_session.commit()
|
||
|
||
res = authed_client.post("/api/v1/settings/notifications/summary/send?period=weekly")
|
||
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
assert data["notification"]["success"] is True
|
||
assert data["summary"]["period"] == "weekly"
|
||
assert sent_messages[0]["title"].startswith("DMARQ weekly summary")
|
||
assert "Weekly DMARC summary" in sent_messages[0]["body"]
|
||
|
||
def test_notification_summary_invalid_period_and_failure_paths(
|
||
self,
|
||
authed_client: TestClient,
|
||
monkeypatch,
|
||
):
|
||
"""Summary endpoints return useful 400 responses for invalid or failed sends."""
|
||
preview_res = authed_client.get("/api/v1/settings/notifications/summary?period=monthly")
|
||
assert preview_res.status_code == 400
|
||
|
||
send_res = authed_client.post("/api/v1/settings/notifications/summary/send?period=monthly")
|
||
assert send_res.status_code == 400
|
||
|
||
def fake_send_summary_notification(db, period): # pylint: disable=unused-argument
|
||
return {
|
||
"summary": {"period": period},
|
||
"notification": {"success": False, "message": "Not delivered."},
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
"app.api.api_v1.endpoints.settings.send_summary_notification",
|
||
fake_send_summary_notification,
|
||
)
|
||
|
||
failed_res = authed_client.post("/api/v1/settings/notifications/summary/send?period=daily")
|
||
assert failed_res.status_code == 400
|
||
assert failed_res.json()["detail"]["notification"]["success"] is False
|
||
|
||
def test_due_scheduled_summaries_send_once_per_period(
|
||
self,
|
||
db_session: Session,
|
||
monkeypatch,
|
||
):
|
||
"""Scheduled summaries respect enabled settings and last-sent markers."""
|
||
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.summary_notifications.send_notification",
|
||
fake_send_notification,
|
||
)
|
||
|
||
db_session.add_all(
|
||
[
|
||
Setting(
|
||
key="notifications.summary_daily_enabled",
|
||
value="true",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.summary_weekly_enabled",
|
||
value="true",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.summary_send_hour_utc",
|
||
value="8",
|
||
category="notifications",
|
||
),
|
||
Setting(
|
||
key="notifications.summary_weekday_utc",
|
||
value="0",
|
||
category="notifications",
|
||
),
|
||
]
|
||
)
|
||
domain = _add_domain(db_session, "scheduled.example")
|
||
_add_report_record(
|
||
db_session,
|
||
domain,
|
||
report_id="scheduled-recent",
|
||
days_ago=0,
|
||
source_ip="203.0.113.70",
|
||
count=12,
|
||
)
|
||
db_session.commit()
|
||
now = datetime(2026, 5, 18, 8, 30, tzinfo=timezone.utc)
|
||
|
||
first = send_due_scheduled_summaries(db_session, now=now)
|
||
second = send_due_scheduled_summaries(db_session, now=now)
|
||
|
||
assert set(first) == {"daily", "weekly"}
|
||
assert second == {}
|
||
assert len(sent_messages) == 2
|
||
assert (
|
||
db_session.query(Setting)
|
||
.filter(Setting.key == "notifications.summary_daily_last_sent_date")
|
||
.first()
|
||
.value
|
||
== "2026-05-18"
|
||
)
|