feat: add source pass fail rollups

This commit is contained in:
Christian Krakau-Louis
2026-05-22 20:35:56 +02:00
parent 2165fb1054
commit 14f9f6defd
12 changed files with 503 additions and 65 deletions
+32
View File
@@ -14,6 +14,7 @@ import pytest
from fastapi.testclient import TestClient
from app.api.api_v1.endpoints import domains as domains_endpoint
from app.api.api_v1.endpoints.domains import _spf_fix_hint
from app.models.domain import Domain
from app.services.dns_resolver import DomainDNSResult
from app.services.report_store import ReportStore
@@ -189,6 +190,32 @@ def test_get_selectors_includes_report_selectors(client: TestClient):
assert "google" in data["report_selectors"]
def test_get_selectors_ignores_missing_dkim_detail_lists(client: TestClient):
"""Missing or malformed DKIM auth-detail arrays should not break selectors."""
ReportStore.get_instance().add_report(
{
**MINIMAL_REPORT,
"report_id": "missing-dkim-details",
"records": [
{
"source_ip": "203.0.113.10",
"count": 1,
"disposition": "none",
"dkim_result": "pass",
"spf_result": "pass",
"dkim": ["not-a-dict", {"selector": "mail"}],
"spf": None,
}
],
}
)
response = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
assert response.status_code == 200
assert "mail" in response.json()["report_selectors"]
def test_get_selectors_report_selector_moves_to_manual_when_added(client: TestClient):
"""A selector discovered from reports should appear only in 'selectors' once added manually."""
# Confirm it's in report_selectors before adding
@@ -425,3 +452,8 @@ def test_sources_endpoint_no_fix_hint_when_spf_passes(client: TestClient):
passing = next((s for s in sources if s["ip"] == "1.2.3.4"), None)
if passing is not None:
assert passing["spf_fix_hint"] is None
def test_spf_fix_hint_returns_none_for_invalid_ip_with_failures():
"""Invalid source IP values should not generate SPF snippets."""
assert _spf_fix_hint("not-an-ip", "mixed", failed_count=3) is None
@@ -132,6 +132,51 @@ def test_get_domain_sources_returns_200(seeded_client: TestClient):
assert source["dmarc"] == "pass"
def test_get_domain_sources_returns_rollup_counts(client: TestClient):
"""Endpoint reports pass/fail totals instead of only the latest IP result."""
report = {
**REPORT_DICT_POLICY,
"report_id": "rpt-mixed-source",
"records": [
{
"source_ip": "209.85.220.9",
"count": 4,
"disposition": "none",
"dkim_result": "pass",
"spf_result": "fail",
"header_from": DOMAIN,
},
{
"source_ip": "209.85.220.9",
"count": 6,
"disposition": "quarantine",
"dkim_result": "fail",
"spf_result": "fail",
"header_from": DOMAIN,
},
],
"summary": {"total_count": 10, "passed_count": 4, "failed_count": 6},
}
ReportStore.get_instance().add_report(report)
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
assert response.status_code == 200
source = response.json()["sources"][0]
assert source["ip"] == "209.85.220.9"
assert source["count"] == 10
assert source["spf"] == "fail"
assert source["dkim"] == "mixed"
assert source["dmarc"] == "mixed"
assert source["spf_pass_count"] == 0
assert source["spf_fail_count"] == 10
assert source["dkim_pass_count"] == 4
assert source["dkim_fail_count"] == 6
assert source["dmarc_pass_count"] == 4
assert source["dmarc_fail_count"] == 6
assert source["disposition_counts"] == {"none": 4, "quarantine": 6}
def test_get_domain_sources_days_param_accepted(seeded_client: TestClient):
"""The 'days' query parameter is accepted without raising a TypeError."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/sources?days=7")
+76 -1
View File
@@ -1,4 +1,4 @@
from app.services.report_store import ReportStore
from app.services.report_store import ReportStore, _auth_status_from_counts, _dominant_result
def _sample_report(domain: str = "example.com") -> dict:
@@ -91,6 +91,81 @@ class TestReportStore:
assert [source["source_ip"] for source in sources] == ["203.0.113.2", "203.0.113.1"]
assert [source["count"] for source in sources] == [12, 5]
def test_get_domain_sources_rolls_up_pass_fail_counts_per_ip(self):
store = ReportStore.get_instance()
report = _sample_report("test.com")
report["records"] = [
{
"source_ip": "203.0.113.9",
"count": 7,
"disposition": "none",
"dkim_result": "pass",
"spf_result": "fail",
"header_from": "test.com",
},
{
"source_ip": "203.0.113.9",
"count": 3,
"disposition": "quarantine",
"dkim_result": "fail",
"spf_result": "pass",
"header_from": "test.com",
},
{
"source_ip": "203.0.113.9",
"count": 2,
"disposition": "reject",
"dkim_result": "fail",
"spf_result": "fail",
"header_from": "test.com",
},
]
store.add_report(report)
source = store.get_domain_sources("test.com")[0]
assert source["source_ip"] == "203.0.113.9"
assert source["count"] == 12
assert source["spf_result"] == "mixed"
assert source["dkim_result"] == "mixed"
assert source["dmarc_result"] == "mixed"
assert source["spf_pass_count"] == 3
assert source["spf_fail_count"] == 9
assert source["dkim_pass_count"] == 7
assert source["dkim_fail_count"] == 5
assert source["dmarc_pass_count"] == 10
assert source["dmarc_fail_count"] == 2
assert source["disposition_counts"] == {"none": 7, "quarantine": 3, "reject": 2}
def test_get_domain_sources_rolls_up_unknown_auth_results(self):
store = ReportStore.get_instance()
report = _sample_report("test.com")
report["records"] = [
{
"source_ip": "203.0.113.10",
"count": 4,
"disposition": "none",
"dkim_result": "temperror",
"spf_result": "neutral",
"header_from": "test.com",
}
]
store.add_report(report)
source = store.get_domain_sources("test.com")[0]
assert source["spf_result"] == "unknown"
assert source["dkim_result"] == "unknown"
assert source["spf_unknown_count"] == 4
assert source["dkim_unknown_count"] == 4
assert source["dmarc_result"] == "fail"
def test_dominant_result_returns_default_for_empty_counts(self):
assert _dominant_result({}) == "none"
def test_auth_status_returns_none_without_counts(self):
assert _auth_status_from_counts(0, 0) == "none"
def test_clear(self):
store = ReportStore.get_instance()
store.add_report(_sample_report("test.com"))
+82 -1
View File
@@ -13,7 +13,7 @@ import app.models.user # noqa: F401
from app.core.database import Base
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.utils.stats_summarizer import StatsSummarizer
from app.utils.stats_summarizer import StatsSummarizer, _auth_status_from_counts
@pytest.fixture()
@@ -81,6 +81,51 @@ def _seed_domain_and_reports(db, domain_name="example.com"):
return domain
def _seed_mixed_source_records(db, domain_name="example.com"):
"""Insert multiple auth outcomes for one source IP."""
domain = Domain(name=domain_name)
db.add(domain)
db.flush()
report = DMARCReport(
domain_id=domain.id,
report_id="rpt-mixed",
org_name="google.com",
begin_date=1597449600,
end_date=1597535999,
policy="none",
)
db.add(report)
db.flush()
db.add_all(
[
ReportRecord(
report_id=report.id,
source_ip="203.0.113.55",
count=8,
disposition="none",
dkim="pass",
spf="fail",
),
ReportRecord(
report_id=report.id,
source_ip="203.0.113.55",
count=2,
disposition="reject",
dkim="fail",
spf="fail",
),
]
)
db.flush()
return domain
def test_auth_status_from_counts_returns_none_without_results():
assert _auth_status_from_counts(0, 0) == "none"
class TestStatsSummarizerGlobal:
"""Tests for global statistics."""
@@ -114,6 +159,24 @@ class TestStatsSummarizerGlobal:
assert stats["top_sources"][0]["ip"] == "203.0.113.1"
assert stats["top_sources"][0]["count"] == 5
def test_global_top_sources_include_pass_fail_rollups(self, db_session, summarizer):
_seed_mixed_source_records(db_session)
db_session.commit()
stats = summarizer.calculate_summary_statistics(db_session)
source = stats["top_sources"][0]
assert source["ip"] == "203.0.113.55"
assert source["count"] == 10
assert source["spf_pass_count"] == 0
assert source["spf_fail_count"] == 10
assert source["dkim_pass_count"] == 8
assert source["dkim_fail_count"] == 2
assert source["dmarc_pass_count"] == 8
assert source["dmarc_fail_count"] == 2
assert source["spf"] == "fail"
assert source["dkim"] == "mixed"
assert source["dmarc"] == "mixed"
def test_multiple_domains(self, db_session, summarizer):
_seed_domain_and_reports(db_session, "example.com")
_seed_domain_and_reports(db_session, "test.org")
@@ -155,6 +218,24 @@ class TestStatsSummarizerDomain:
assert stats["sources"][0]["ip"] == "203.0.113.1"
assert stats["sources"][0]["count"] == 5
def test_domain_sources_group_by_ip_with_pass_fail_rollups(self, db_session, summarizer):
_seed_mixed_source_records(db_session, "example.com")
db_session.commit()
stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com")
assert len(stats["sources"]) == 1
source = stats["sources"][0]
assert source["ip"] == "203.0.113.55"
assert source["count"] == 10
assert source["spf_fail_count"] == 10
assert source["dkim_pass_count"] == 8
assert source["dkim_fail_count"] == 2
assert source["dmarc_pass_count"] == 8
assert source["dmarc_fail_count"] == 2
assert source["spf"] == "fail"
assert source["dkim"] == "mixed"
assert source["dmarc"] == "mixed"
def test_domain_isolation(self, db_session, summarizer):
"""Stats for one domain should not include data from another."""
_seed_domain_and_reports(db_session, "example.com")