"""Tests for the StatsSummarizer with real database queries.""" import shutil import tempfile from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import app.models.domain # noqa: F401 import app.models.report # noqa: F401 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, _auth_status_from_counts @pytest.fixture() def db_session(): """Create a fresh in-memory SQLite database session.""" engine = create_engine("sqlite://", connect_args={"check_same_thread": False}) Base.metadata.create_all(engine) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) db = TestingSessionLocal() try: yield db finally: db.close() Base.metadata.drop_all(engine) engine.dispose() @pytest.fixture() def summarizer(): """Create a StatsSummarizer with a temp cache directory.""" cache_dir = tempfile.mkdtemp() s = StatsSummarizer(cache_dir=cache_dir) yield s shutil.rmtree(cache_dir, ignore_errors=True) def _seed_domain_and_reports(db, domain_name="example.com"): """Insert a domain with reports and records into the database.""" domain = Domain(name=domain_name) db.add(domain) db.flush() # Report 1: 2 records, 1 fully passing, 1 failing report1 = DMARCReport( domain_id=domain.id, report_id="rpt-001", org_name="google.com", begin_date=1597449600, # 2020-08-15 end_date=1597535999, policy="none", ) db.add(report1) db.flush() # Record: 5 emails, both pass rec1 = ReportRecord( report_id=report1.id, source_ip="203.0.113.1", count=5, disposition="none", dkim="pass", spf="pass", ) # Record: 3 emails, both fail rec2 = ReportRecord( report_id=report1.id, source_ip="198.51.100.1", count=3, disposition="quarantine", dkim="fail", spf="fail", ) db.add_all([rec1, rec2]) db.flush() 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 _timestamp_days_ago(days): return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp()) def _seed_recent_trend_records(db, domain_name="example.com"): """Insert recent reports across multiple days for trend calculations.""" domain = Domain(name=domain_name) db.add(domain) db.flush() report1 = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-recent-1", org_name="google.com", begin_date=_timestamp_days_ago(2), end_date=_timestamp_days_ago(2) + 3600, policy="none", ) report2 = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-recent-2", org_name="google.com", begin_date=_timestamp_days_ago(0), end_date=_timestamp_days_ago(0) + 3600, policy="none", ) report3 = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-old", org_name="google.com", begin_date=_timestamp_days_ago(20), end_date=_timestamp_days_ago(20) + 3600, policy="none", ) db.add_all([report1, report2, report3]) db.flush() db.add_all( [ ReportRecord( report_id=report1.id, source_ip="203.0.113.10", count=6, disposition="none", dkim="pass", spf="fail", ), ReportRecord( report_id=report1.id, source_ip="203.0.113.11", count=4, disposition="reject", dkim="fail", spf="fail", ), ReportRecord( report_id=report2.id, source_ip="203.0.113.12", count=5, disposition="none", dkim="pass", spf="pass", ), ReportRecord( report_id=report3.id, source_ip="203.0.113.13", count=99, disposition="none", dkim="pass", spf="pass", ), ] ) db.flush() return domain def _seed_new_source_records(db, domain_name="example.com"): """Insert an old source and a current first-seen source.""" domain = Domain(name=domain_name) db.add(domain) db.flush() old_report = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-old-source", org_name="google.com", begin_date=_timestamp_days_ago(10), end_date=_timestamp_days_ago(10) + 3600, policy="none", ) current_report = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-new-source", org_name="google.com", begin_date=_timestamp_days_ago(1), end_date=_timestamp_days_ago(1) + 3600, policy="none", ) db.add_all([old_report, current_report]) db.flush() db.add_all( [ ReportRecord( report_id=old_report.id, source_ip="203.0.113.20", count=12, disposition="none", dkim="pass", spf="pass", ), ReportRecord( report_id=current_report.id, source_ip="203.0.113.21", count=7, disposition="none", dkim="pass", spf="fail", ), ] ) db.flush() return domain def _seed_compliance_drop_records(db, domain_name="example.com"): """Insert a recent compliance drop with a source that is not new.""" domain = Domain(name=domain_name) db.add(domain) db.flush() old_report = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-known-source", org_name="google.com", begin_date=_timestamp_days_ago(10), end_date=_timestamp_days_ago(10) + 3600, policy="none", ) previous_report = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-passing-day", org_name="google.com", begin_date=_timestamp_days_ago(2), end_date=_timestamp_days_ago(2) + 3600, policy="none", ) current_report = DMARCReport( domain_id=domain.id, report_id=f"{domain_name}-failing-day", org_name="google.com", begin_date=_timestamp_days_ago(0), end_date=_timestamp_days_ago(0) + 3600, policy="none", ) db.add_all([old_report, previous_report, current_report]) db.flush() db.add_all( [ ReportRecord( report_id=old_report.id, source_ip="203.0.113.30", count=3, disposition="none", dkim="pass", spf="pass", ), ReportRecord( report_id=previous_report.id, source_ip="203.0.113.30", count=10, disposition="none", dkim="pass", spf="pass", ), ReportRecord( report_id=current_report.id, source_ip="203.0.113.30", count=10, disposition="none", 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.""" def test_empty_database_returns_zeros(self, db_session, summarizer): stats = summarizer.calculate_summary_statistics(db_session) assert stats["total_domains"] == 0 assert stats["total_emails"] == 0 assert stats["compliance_rate"] == 0.0 assert stats["reports_processed"] == 0 assert stats["top_sources"] == [] assert stats["compliance_trend"] == [] def test_global_stats_with_data(self, db_session, summarizer): _seed_domain_and_reports(db_session, "example.com") db_session.commit() stats = summarizer.calculate_summary_statistics(db_session) assert stats["total_domains"] == 1 assert stats["total_emails"] == 8 # 5 + 3 assert stats["compliant_emails"] == 5 # only rec1 passes assert stats["compliance_rate"] == 62.5 # 5/8 * 100 assert stats["reports_processed"] == 1 def test_global_top_sources(self, db_session, summarizer): _seed_domain_and_reports(db_session) db_session.commit() stats = summarizer.calculate_summary_statistics(db_session) assert len(stats["top_sources"]) == 2 # Sorted by count descending 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") db_session.commit() stats = summarizer.calculate_summary_statistics(db_session) assert stats["total_domains"] == 2 assert stats["total_emails"] == 16 # 8 * 2 assert stats["reports_processed"] == 2 def test_global_trend_includes_volume_and_failure_rate(self, db_session, summarizer): _seed_recent_trend_records(db_session) db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, period_days=7) assert len(stats["compliance_trend"]) == 2 first_day = stats["compliance_trend"][0] assert first_day["total"] == 10 assert first_day["volume"] == 10 assert first_day["passed"] == 6 assert first_day["failed"] == 4 assert first_day["rate"] == 60.0 assert first_day["compliance_rate"] == 60.0 assert first_day["failure_rate"] == 40.0 def test_global_trend_respects_period_days(self, db_session, summarizer): _seed_recent_trend_records(db_session) db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, period_days=1) assert len(stats["compliance_trend"]) == 1 assert stats["compliance_trend"][0]["total"] == 5 def test_global_change_summary_detects_new_source(self, db_session, summarizer): _seed_new_source_records(db_session) db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, period_days=7) new_sources = [item for item in stats["change_summary"] if item["type"] == "new_source"] assert len(new_sources) == 1 assert new_sources[0]["domain"] == "example.com" assert new_sources[0]["source_ip"] == "203.0.113.21" assert new_sources[0]["message_count"] == 7 def test_global_change_summary_detects_compliance_drop(self, db_session, summarizer): _seed_compliance_drop_records(db_session) db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, period_days=7) drops = [item for item in stats["change_summary"] if item["type"] == "compliance_drop"] assert len(drops) == 1 assert drops[0]["previous_rate"] == 100.0 assert drops[0]["current_rate"] == 0.0 assert drops[0]["drop"] == 100.0 assert drops[0]["failed"] == 10 class TestStatsSummarizerDomain: """Tests for domain-specific statistics.""" def test_nonexistent_domain(self, db_session, summarizer): stats = summarizer.calculate_summary_statistics(db_session, domain_id="nope.com") assert stats["domain"] == "nope.com" assert stats["total_emails"] == 0 assert stats["compliance_rate"] == 0.0 def test_domain_stats_with_data(self, db_session, summarizer): _seed_domain_and_reports(db_session, "example.com") db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com") assert stats["domain"] == "example.com" assert stats["total_emails"] == 8 assert stats["compliant_emails"] == 5 assert stats["compliance_rate"] == 62.5 assert stats["reports_processed"] == 1 def test_domain_sources(self, db_session, summarizer): _seed_domain_and_reports(db_session, "example.com") db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com") assert len(stats["sources"]) == 2 # First source should be the highest count 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") _seed_domain_and_reports(db_session, "other.org") db_session.commit() stats = summarizer.calculate_summary_statistics(db_session, domain_id="example.com") assert stats["total_emails"] == 8 # Only example.com's data def test_domain_trend_isolation(self, db_session, summarizer): _seed_recent_trend_records(db_session, "example.com") _seed_recent_trend_records(db_session, "other.org") db_session.commit() stats = summarizer.calculate_summary_statistics( db_session, domain_id="example.com", period_days=7 ) assert [point["total"] for point in stats["compliance_trend"]] == [10, 5] def test_domain_change_summary_isolated_to_domain(self, db_session, summarizer): _seed_new_source_records(db_session, "example.com") _seed_new_source_records(db_session, "other.org") db_session.commit() stats = summarizer.calculate_summary_statistics( db_session, domain_id="example.com", period_days=7 ) new_sources = [item for item in stats["change_summary"] if item["type"] == "new_source"] assert len(new_sources) == 1 assert new_sources[0]["domain"] == "example.com" assert new_sources[0]["source_ip"] == "203.0.113.21" class TestStatsSummarizerCaching: """Tests for the caching layer.""" def test_caching_returns_same_data(self, db_session, summarizer): _seed_domain_and_reports(db_session) db_session.commit() stats1 = summarizer.calculate_summary_statistics(db_session) stats2 = summarizer.calculate_summary_statistics(db_session) assert stats1 == stats2 def test_invalidate_cache(self, db_session, summarizer): _seed_domain_and_reports(db_session) db_session.commit() summarizer.calculate_summary_statistics(db_session) summarizer.invalidate_cache() # Should recalculate after invalidation stats = summarizer.calculate_summary_statistics(db_session) assert stats["total_domains"] == 1 def test_period_days_uses_separate_cache_files(self, db_session, summarizer): _seed_recent_trend_records(db_session) db_session.commit() stats_7_days = summarizer.calculate_summary_statistics(db_session, period_days=7) stats_1_day = summarizer.calculate_summary_statistics(db_session, period_days=1) assert len(stats_7_days["compliance_trend"]) == 2 assert len(stats_1_day["compliance_trend"]) == 1 def test_old_cache_without_change_summary_is_refreshed(self, db_session, summarizer): _seed_new_source_records(db_session) db_session.commit() summarizer.save_summary({"total_domains": 99}, period_days=7) stats = summarizer.calculate_summary_statistics(db_session, period_days=7) assert stats["total_domains"] == 1 assert "change_summary" in stats