Files
gh-christianlouis-dmarq/backend/app/tests/test_domain_detail_endpoints.py
T
2026-05-23 14:17:09 +02:00

384 lines
13 KiB
Python

"""
Integration tests for the domain detail API endpoints:
GET /api/v1/domains/{domain_id}/reports
GET /api/v1/domains/{domain_id}/sources
These tests ensure that the ReportStore data is correctly projected into
the Pydantic response models, including the policy-dict extraction and
the use of begin_timestamp/end_timestamp integers for date fields.
"""
import csv
from io import StringIO
import pytest
from fastapi.testclient import TestClient
from app.api.api_v1.endpoints import domains as domains_endpoint
from app.services.report_store import ReportStore
# ---------------------------------------------------------------------------
# Helpers / constants
# ---------------------------------------------------------------------------
DOMAIN = "example.com"
# A minimal parsed DMARC report with policy stored as a dict (the real-world
# shape produced by DMARCParser) and Unix timestamps alongside ISO strings.
REPORT_DICT_POLICY = {
"domain": DOMAIN,
"report_id": "rpt-dict-policy",
"org_name": "Google LLC",
"begin_date": "2020-08-15T00:00:00",
"end_date": "2020-08-15T23:59:59",
"begin_timestamp": 1597449600,
"end_timestamp": 1597535999,
"policy": {
"p": "reject",
"sp": "reject",
"pct": "100",
"np": "none",
"adkim": "s",
"aspf": "r",
"fo": "1",
"testing": "y",
"discovery_method": "treewalk",
},
"schema_version": "1.0",
"variant": "rfc9990",
"generator": "ExampleRUA 2.0",
"records": [
{
"source_ip": "209.85.220.1",
"count": 10,
"disposition": "none",
"dkim_result": "pass",
"spf_result": "pass",
"header_from": DOMAIN,
}
],
"summary": {"total_count": 10, "passed_count": 10, "failed_count": 0},
}
# Same report but with policy already stored as a plain string.
REPORT_STR_POLICY = {
**REPORT_DICT_POLICY,
"report_id": "rpt-str-policy",
"policy": "quarantine",
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def seeded_client(client: TestClient):
"""Client with one report (dict-style policy) in the ReportStore."""
ReportStore.get_instance().add_report(REPORT_DICT_POLICY)
return client
# ---------------------------------------------------------------------------
# GET /api/v1/domains/{domain_id}/reports
# ---------------------------------------------------------------------------
def test_get_domain_reports_returns_200(seeded_client: TestClient):
"""Endpoint returns HTTP 200 and correct structure for a known domain."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/reports")
assert response.status_code == 200
data = response.json()
assert "reports" in data
assert "compliance_timeline" in data
def test_get_domain_reports_policy_dict_extracted(seeded_client: TestClient):
"""When the stored policy is a dict, the 'p' value should be surfaced."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/reports")
assert response.status_code == 200
reports = response.json()["reports"]
assert len(reports) == 1
assert reports[0]["policy"] == "reject"
def test_get_domain_reports_policy_string_preserved(client: TestClient):
"""When the stored policy is already a string it should be kept as-is."""
ReportStore.get_instance().add_report(REPORT_STR_POLICY)
response = client.get(f"/api/v1/domains/{DOMAIN}/reports")
assert response.status_code == 200
reports = response.json()["reports"]
assert len(reports) == 1
assert reports[0]["policy"] == "quarantine"
def test_get_domain_reports_timestamps_are_integers(seeded_client: TestClient):
"""begin_date and end_date in the response must be integers (Unix timestamps)."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/reports")
assert response.status_code == 200
report = response.json()["reports"][0]
assert isinstance(report["begin_date"], int)
assert isinstance(report["end_date"], int)
assert report["begin_date"] == 1597449600
assert report["end_date"] == 1597535999
def test_get_domain_reports_unknown_domain_returns_404(client: TestClient):
"""Returns 404 when the requested domain has no reports."""
response = client.get("/api/v1/domains/no-such-domain.example.com/reports")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# GET /api/v1/domains/{domain_id}/reports/export
# ---------------------------------------------------------------------------
def test_export_domain_reports_returns_csv(seeded_client: TestClient):
"""CSV export includes report summary rows for the requested domain."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/reports/export")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
assert "attachment" in response.headers["content-disposition"]
rows = list(csv.DictReader(StringIO(response.text)))
assert len(rows) == 1
assert rows[0]["domain"] == DOMAIN
assert rows[0]["report_id"] == "rpt-dict-policy"
assert rows[0]["begin_date"] == "2020-08-15"
assert rows[0]["total_emails"] == "10"
assert rows[0]["passed"] == "10"
assert rows[0]["failed"] == "0"
assert rows[0]["policy"] == "reject"
assert rows[0]["non_subdomain_policy"] == "none"
assert rows[0]["adkim"] == "s"
assert rows[0]["aspf"] == "r"
assert rows[0]["failure_options"] == "1"
assert rows[0]["testing"] == "y"
assert rows[0]["discovery_method"] == "treewalk"
assert rows[0]["schema_version"] == "1.0"
assert rows[0]["report_variant"] == "rfc9990"
assert rows[0]["generator"] == "ExampleRUA 2.0"
def test_export_domain_reports_filters_by_date_range(client: TestClient):
"""CSV export only includes reports inside the requested date range."""
ReportStore.get_instance().add_report(REPORT_DICT_POLICY)
ReportStore.get_instance().add_report(
{
**REPORT_STR_POLICY,
"report_id": "rpt-next-day",
"begin_date": "2020-08-16T00:00:00",
"end_date": "2020-08-16T23:59:59",
"begin_timestamp": 1597536000,
"end_timestamp": 1597622399,
}
)
response = client.get(
f"/api/v1/domains/{DOMAIN}/reports/export?start_date=2020-08-16&end_date=2020-08-16"
)
assert response.status_code == 200
rows = list(csv.DictReader(StringIO(response.text)))
assert [row["report_id"] for row in rows] == ["rpt-next-day"]
def test_export_domain_reports_rejects_invalid_date_order(seeded_client: TestClient):
"""CSV export validates that the start date is not after the end date."""
response = seeded_client.get(
f"/api/v1/domains/{DOMAIN}/reports/export?start_date=2020-08-17&end_date=2020-08-16"
)
assert response.status_code == 422
def test_export_domain_reports_unknown_domain_returns_404(client: TestClient):
"""Returns 404 when exporting a domain with no reports."""
response = client.get("/api/v1/domains/no-such-domain.example.com/reports/export")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# GET /api/v1/domains/{domain_id}/sources
# ---------------------------------------------------------------------------
def test_get_domain_sources_returns_200(seeded_client: TestClient):
"""Endpoint returns HTTP 200 and a sources list for a known domain."""
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/sources")
assert response.status_code == 200
data = response.json()
assert "sources" in data
assert len(data["sources"]) == 1
source = data["sources"][0]
assert source["ip"] == "209.85.220.1"
assert source["spf"] == "pass"
assert source["dkim"] == "pass"
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_returns_recommendations(
client: TestClient, monkeypatch: pytest.MonkeyPatch
):
"""Endpoint includes actionable guidance for common failure patterns."""
async def fake_ptr_lookup(_provider, _ip, timeout=3.0): # pylint: disable=unused-argument
return "sender.example.net"
monkeypatch.setattr(domains_endpoint, "_safe_ptr_lookup", fake_ptr_lookup)
report = {
**REPORT_DICT_POLICY,
"report_id": "rpt-full-fail",
"records": [
{
"source_ip": "192.0.2.10",
"count": 3,
"disposition": "none",
"dkim_result": "fail",
"spf_result": "fail",
"header_from": DOMAIN,
}
],
"summary": {"total_count": 3, "passed_count": 0, "failed_count": 3},
}
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]
recommendation_types = {item["type"] for item in source["recommendations"]}
assert recommendation_types == {"full_fail", "policy_not_enforced"}
assert source["spf_fix_hint"] == "ip4:192.0.2.10"
def test_source_recommendations_cover_common_cases():
"""Recommendation builder handles the milestone source patterns."""
cases = [
(
{
"source_ip": "192.0.2.20",
"spf_result": "pass",
"dkim_result": "fail",
"dmarc_result": "pass",
"dmarc_pass_count": 8,
"dmarc_fail_count": 0,
"disposition": "none",
},
"mail.example.net",
None,
{"spf_only_pass"},
),
(
{
"source_ip": "192.0.2.21",
"spf_result": "fail",
"dkim_result": "pass",
"dmarc_result": "pass",
"dmarc_pass_count": 8,
"dmarc_fail_count": 0,
"disposition": "none",
},
"mail.example.net",
"ip4:192.0.2.21",
{"dkim_only_pass"},
),
(
{
"source_ip": "192.0.2.22",
"spf_result": "fail",
"dkim_result": "fail",
"dmarc_result": "fail",
"dmarc_pass_count": 0,
"dmarc_fail_count": 8,
"disposition": "none",
"disposition_counts": {"none": 8},
},
"mail.example.net",
"ip4:192.0.2.22",
{"full_fail", "policy_not_enforced"},
),
(
{
"source_ip": "192.0.2.23",
"spf_result": "fail",
"dkim_result": "fail",
"dmarc_result": "fail",
"dmarc_pass_count": 0,
"dmarc_fail_count": 8,
"disposition": "quarantine",
},
None,
"ip4:192.0.2.23",
{"unknown_source", "full_fail"},
),
]
for source, hostname, spf_fix_hint, expected_types in cases:
recommendations = (
domains_endpoint._source_recommendations( # pylint: disable=protected-access
source["source_ip"], source, hostname, spf_fix_hint
)
)
assert {item.type for item in recommendations} == expected_types
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")
assert response.status_code == 200
def test_get_domain_sources_unknown_domain_returns_404(client: TestClient):
"""Returns 404 when the requested domain has no reports."""
response = client.get("/api/v1/domains/no-such-domain.example.com/sources")
assert response.status_code == 404