Files
2026-05-23 13:21:03 +02:00

1024 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"
)