feat: add source pass fail rollups
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user