893 lines
32 KiB
Python
893 lines
32 KiB
Python
"""
|
|
Integration tests for the DKIM selector management API endpoints.
|
|
|
|
These tests use the in-memory SQLite test database via the ``client`` fixture
|
|
(which overrides ``get_db``) and populate the ``ReportStore`` singleton so
|
|
that the endpoints can find the test domain.
|
|
|
|
DNS lookups are mocked so no real network calls are made.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
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.dns_cache import DNSCache, DNSRecordChange
|
|
from app.models.domain import Domain
|
|
from app.services.bimi import BIMIResult
|
|
from app.services.dns_cache import _selectors_key, resolve_domain_dns_cached
|
|
from app.services.dns_resolver import DomainDNSResult
|
|
from app.services.mta_sts import MTAStsResult
|
|
from app.services.report_store import ReportStore
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DOMAIN = "example.com"
|
|
|
|
# A minimal parsed DMARC report that populates the ReportStore
|
|
MINIMAL_REPORT = {
|
|
"domain": DOMAIN,
|
|
"report_id": "test-001",
|
|
"org_name": "Test Org",
|
|
"policy": {"p": "none", "sp": "", "pct": "100"},
|
|
"records": [
|
|
{
|
|
"source_ip": "1.2.3.4",
|
|
"count": 5,
|
|
"disposition": "none",
|
|
"dkim_result": "pass",
|
|
"spf_result": "pass",
|
|
"dkim": [{"domain": DOMAIN, "result": "pass", "selector": "google"}],
|
|
"spf": [{"domain": DOMAIN, "result": "pass"}],
|
|
}
|
|
],
|
|
"summary": {"total_count": 5, "passed_count": 5, "failed_count": 0, "pass_rate": 100.0},
|
|
}
|
|
|
|
# DomainDNSResult returned by the mocked DNS provider
|
|
MOCK_DNS_RESULT = DomainDNSResult(
|
|
dmarc=True,
|
|
dmarc_record="v=DMARC1; p=none; rua=mailto:dmarc@example.com",
|
|
spf=True,
|
|
spf_record="v=spf1 include:_spf.google.com ~all",
|
|
dkim=True,
|
|
dkim_selectors=["google"],
|
|
dkim_record="v=DKIM1; k=rsa; p=ABC",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _seed_report_store():
|
|
"""Put a domain into the ReportStore for every test in this module."""
|
|
store = ReportStore.get_instance()
|
|
store.add_report(MINIMAL_REPORT)
|
|
yield
|
|
|
|
|
|
def _mock_dns(result: DomainDNSResult = MOCK_DNS_RESULT):
|
|
"""Return a context manager that patches the DNS provider's check_domain."""
|
|
provider = AsyncMock()
|
|
provider.check_domain = AsyncMock(return_value=result)
|
|
provider.lookup_txt = AsyncMock(side_effect=LookupError("MTA-STS not configured"))
|
|
return patch(
|
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
|
return_value=provider,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/v1/domains/{domain_id}/selectors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_selectors_empty(client: TestClient):
|
|
"""Returns an empty list when no selectors have been configured."""
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["selectors"] == []
|
|
assert "report_selectors" in data
|
|
|
|
|
|
def test_get_selectors_unknown_domain(client: TestClient):
|
|
"""Returns 404 for a domain not in the ReportStore."""
|
|
response = client.get("/api/v1/domains/unknown.example.com/selectors")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/v1/domains/{domain_id}/selectors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_add_selector(client: TestClient):
|
|
"""Adding a selector persists it and returns the updated list."""
|
|
response = client.post(
|
|
f"/api/v1/domains/{DOMAIN}/selectors",
|
|
json={"selector": "mysel"},
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert "mysel" in data["selectors"]
|
|
|
|
|
|
def test_add_selector_deduplication(client: TestClient):
|
|
"""Adding the same selector twice should not create duplicates."""
|
|
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
|
|
response = client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dup"})
|
|
assert response.status_code == 201
|
|
assert response.json()["selectors"].count("dup") == 1
|
|
|
|
|
|
def test_add_selector_invalid_empty(client: TestClient):
|
|
"""An empty selector string should be rejected."""
|
|
response = client.post(
|
|
f"/api/v1/domains/{DOMAIN}/selectors",
|
|
json={"selector": " "},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_add_selector_unknown_domain(client: TestClient):
|
|
"""Adding a selector to an unknown domain returns 404."""
|
|
response = client.post(
|
|
"/api/v1/domains/unknown.example.com/selectors",
|
|
json={"selector": "google"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_add_multiple_selectors(client: TestClient):
|
|
"""Multiple distinct selectors can be added and all are returned."""
|
|
for sel in ("sel1", "sel2", "sel3"):
|
|
r = client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": sel})
|
|
assert r.status_code == 201
|
|
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
|
|
assert response.status_code == 200
|
|
selectors = response.json()["selectors"]
|
|
assert "sel1" in selectors
|
|
assert "sel2" in selectors
|
|
assert "sel3" in selectors
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE /api/v1/domains/{domain_id}/selectors/{selector}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_selector(client: TestClient):
|
|
"""Deleting a selector removes it from the persisted list."""
|
|
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "todelete"})
|
|
response = client.delete(f"/api/v1/domains/{DOMAIN}/selectors/todelete")
|
|
assert response.status_code == 200
|
|
assert "todelete" not in response.json()["selectors"]
|
|
|
|
|
|
def test_delete_nonexistent_selector(client: TestClient):
|
|
"""Deleting a selector that was never added returns 404."""
|
|
# Ensure the domain exists in DB (via add then delete)
|
|
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "dummy"})
|
|
response = client.delete(f"/api/v1/domains/{DOMAIN}/selectors/ghost")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_delete_selector_unknown_domain(client: TestClient):
|
|
"""Deleting from an unknown domain returns 404."""
|
|
response = client.delete("/api/v1/domains/unknown.example.com/selectors/google")
|
|
assert response.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/v1/domains/{domain_id}/dns (real DNS replaced by mock)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_selectors_includes_report_selectors(client: TestClient):
|
|
"""Report selectors (from DMARC report records) are returned in report_selectors."""
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# The MINIMAL_REPORT has a record with selector "google" in its dkim auth results
|
|
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
|
|
r1 = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
|
|
assert "google" in r1.json()["report_selectors"]
|
|
|
|
# Add it as a manual selector
|
|
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "google"})
|
|
|
|
r2 = client.get(f"/api/v1/domains/{DOMAIN}/selectors")
|
|
data = r2.json()
|
|
assert "google" in data["selectors"]
|
|
# It must not appear in both lists
|
|
assert "google" not in data["report_selectors"]
|
|
|
|
|
|
def test_dns_endpoint_returns_dkim_selectors_as_list(client: TestClient):
|
|
"""The /dns endpoint should return dkimSelectors as a list."""
|
|
with _mock_dns():
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data["dkimSelectors"], list)
|
|
assert "google" in data["dkimSelectors"]
|
|
|
|
|
|
def test_dns_endpoint_returns_real_data(client: TestClient):
|
|
"""The /dns endpoint should return the mocked DNS check result."""
|
|
with _mock_dns():
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["dmarc"] is True
|
|
assert data["spf"] is True
|
|
assert data["dkim"] is True
|
|
assert "p=none" in data["dmarcRecord"]
|
|
assert data["cached"] is False
|
|
assert data["checkedAt"] is not None
|
|
|
|
|
|
def test_dns_endpoint_uses_cached_result(client: TestClient, db_session):
|
|
"""Repeated DNS checks reuse a fresh cached result."""
|
|
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
|
|
|
|
with patch(
|
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
|
return_value=mock_provider,
|
|
):
|
|
first = client.get(f"/api/v1/domains/{DOMAIN}/dns")
|
|
second = client.get(f"/api/v1/domains/{DOMAIN}/dns")
|
|
|
|
assert first.status_code == 200
|
|
assert second.status_code == 200
|
|
assert first.json()["cached"] is False
|
|
assert second.json()["cached"] is True
|
|
assert mock_provider.check_domain.await_count == 1
|
|
assert db_session.query(DNSCache).count() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dns_cache_recovers_from_concurrent_insert(db_session, monkeypatch):
|
|
"""Concurrent DNS widgets should not fail on a duplicate cache insert."""
|
|
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
|
|
selectors = ["google"]
|
|
original_commit = db_session.commit
|
|
original_rollback = db_session.rollback
|
|
commit_calls = 0
|
|
|
|
def fake_commit():
|
|
nonlocal commit_calls
|
|
commit_calls += 1
|
|
if commit_calls == 1:
|
|
raise IntegrityError("insert", {}, Exception("duplicate"))
|
|
original_commit()
|
|
|
|
def fake_rollback():
|
|
original_rollback()
|
|
db_session.add(
|
|
DNSCache(
|
|
domain=DOMAIN,
|
|
provider=mock_provider.__class__.__name__,
|
|
selectors_key=_selectors_key(selectors),
|
|
result_json=(
|
|
'{"dmarc":false,"spf":false,"dkim":false,'
|
|
'"dkim_selectors":[],"selectors_checked":[]}'
|
|
),
|
|
checked_at=datetime(2026, 5, 23, 12, 0, 0),
|
|
)
|
|
)
|
|
original_commit()
|
|
|
|
monkeypatch.setattr(db_session, "commit", fake_commit)
|
|
monkeypatch.setattr(db_session, "rollback", fake_rollback)
|
|
|
|
result, cached, _checked = await resolve_domain_dns_cached(
|
|
db_session,
|
|
mock_provider,
|
|
DOMAIN,
|
|
selectors=selectors,
|
|
)
|
|
|
|
assert result == MOCK_DNS_RESULT
|
|
assert cached is False
|
|
assert db_session.query(DNSCache).count() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dns_cache_reraises_when_conflict_row_missing(db_session, monkeypatch):
|
|
"""Unexpected cache collisions should still surface when no row can be recovered."""
|
|
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
|
|
|
|
def fake_commit():
|
|
raise IntegrityError("insert", {}, Exception("duplicate"))
|
|
|
|
monkeypatch.setattr(db_session, "commit", fake_commit)
|
|
|
|
with pytest.raises(IntegrityError):
|
|
await resolve_domain_dns_cached(
|
|
db_session,
|
|
mock_provider,
|
|
DOMAIN,
|
|
selectors=["google"],
|
|
)
|
|
|
|
|
|
def test_dns_endpoint_refresh_bypasses_cache(client: TestClient):
|
|
"""The refresh query parameter forces a new DNS lookup."""
|
|
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
|
|
|
|
with patch(
|
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
|
return_value=mock_provider,
|
|
):
|
|
client.get(f"/api/v1/domains/{DOMAIN}/dns")
|
|
refreshed = client.get(f"/api/v1/domains/{DOMAIN}/dns?refresh=true")
|
|
|
|
assert refreshed.status_code == 200
|
|
assert refreshed.json()["cached"] is False
|
|
assert mock_provider.check_domain.await_count == 2
|
|
|
|
|
|
def test_dns_endpoint_uses_manual_selectors(client: TestClient):
|
|
"""Manually added selectors should be forwarded to check_domain."""
|
|
# Add a custom selector
|
|
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "customsel"})
|
|
|
|
captured_selectors = []
|
|
|
|
async def _fake_check_domain(domain, selectors=None):
|
|
captured_selectors.extend(selectors or [])
|
|
return MOCK_DNS_RESULT
|
|
|
|
with patch(
|
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
|
return_value=AsyncMock(check_domain=_fake_check_domain),
|
|
):
|
|
client.get(f"/api/v1/domains/{DOMAIN}/dns")
|
|
|
|
assert "customsel" in captured_selectors
|
|
|
|
|
|
def test_dns_endpoint_404_for_unknown_domain(client: TestClient):
|
|
with _mock_dns():
|
|
response = client.get("/api/v1/domains/unknown.example.com/dns")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_dns_endpoint_supports_manually_configured_domain(client: TestClient, db_session):
|
|
"""A domain created before reports arrive can still run DNS checks."""
|
|
db_session.add(Domain(name="manual.example", active=True))
|
|
db_session.commit()
|
|
|
|
with _mock_dns():
|
|
response = client.get("/api/v1/domains/manual.example/dns")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["dmarc"] is True
|
|
|
|
|
|
def test_dns_health_404_for_unknown_domain(client: TestClient):
|
|
with _mock_dns():
|
|
response = client.get("/api/v1/domains/unknown.example.com/dns/health")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_dns_health_links_checks_to_evidence(client: TestClient):
|
|
"""DNS health returns provider-neutral checks, recommendations, and evidence links."""
|
|
missing_dkim = DomainDNSResult(
|
|
dmarc=True,
|
|
dmarc_record="v=DMARC1; p=none; rua=mailto:dmarc@example.com",
|
|
spf=True,
|
|
spf_record="v=spf1 include:_spf.google.com ~all",
|
|
dkim=False,
|
|
selectors_checked=["google"],
|
|
)
|
|
|
|
mta_sts = MTAStsResult(
|
|
status="pass",
|
|
dns_record="v=STSv1; id=20260523",
|
|
policy_url="https://mta-sts.example.com/.well-known/mta-sts.txt",
|
|
mode="enforce",
|
|
max_age=86400,
|
|
mx=["*.example.com"],
|
|
)
|
|
bimi = BIMIResult(
|
|
status="pass",
|
|
dns_record="v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
|
logo_url="https://example.com/logo.svg",
|
|
certificate_url="https://example.com/vmc.pem",
|
|
)
|
|
with (
|
|
_mock_dns(result=missing_dkim),
|
|
patch(
|
|
"app.api.api_v1.endpoints.domains.check_mta_sts_cached",
|
|
new=AsyncMock(return_value=(mta_sts, False, None)),
|
|
),
|
|
patch(
|
|
"app.api.api_v1.endpoints.domains.check_bimi_cached",
|
|
new=AsyncMock(return_value=(bimi, False, None)),
|
|
),
|
|
):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "degraded"
|
|
dkim_check = next(check for check in data["checks"] if check["key"] == "dkim")
|
|
mta_sts_check = next(check for check in data["checks"] if check["key"] == "mta_sts")
|
|
bimi_check = next(check for check in data["checks"] if check["key"] == "bimi")
|
|
assert dkim_check["status"] == "fail"
|
|
assert dkim_check["evidence"][0]["href"] == "#dns-records"
|
|
assert mta_sts_check["status"] == "pass"
|
|
assert mta_sts_check["evidence"][1]["href"] == "#mta-sts-posture"
|
|
assert bimi_check["status"] == "fail"
|
|
assert bimi_check["evidence"][0]["href"] == "#bimi-posture"
|
|
assert any(item["type"] == "bimi_dmarc_not_ready" for item in data["recommendations"])
|
|
assert any(item["type"] == "missing_dkim" for item in data["recommendations"])
|
|
|
|
|
|
def test_dns_health_recommends_enforcement_when_evidence_supports_it(client: TestClient):
|
|
"""High-volume p=none domains with strong compliance get plan-only guidance."""
|
|
store = ReportStore.get_instance()
|
|
store.clear()
|
|
store.add_report(
|
|
{
|
|
**MINIMAL_REPORT,
|
|
"summary": {"total_count": 500, "passed_count": 495, "failed_count": 5},
|
|
"records": [
|
|
{
|
|
**MINIMAL_REPORT["records"][0],
|
|
"count": 500,
|
|
"dkim_result": "pass",
|
|
"spf_result": "pass",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
with _mock_dns():
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
|
|
|
|
assert response.status_code == 200
|
|
recommendations = response.json()["recommendations"]
|
|
readiness = next(item for item in recommendations if item["type"] == "policy_enforcement_ready")
|
|
assert "low pct" in readiness["action"]
|
|
assert any(item["label"] == "Compliance" for item in readiness["evidence"])
|
|
|
|
|
|
def test_dns_health_marks_all_missing_records_critical(client: TestClient):
|
|
"""Missing DMARC, SPF, and DKIM produce specific repair recommendations."""
|
|
missing_all = DomainDNSResult(
|
|
dmarc=False,
|
|
spf=False,
|
|
dkim=False,
|
|
selectors_checked=["google"],
|
|
)
|
|
|
|
with _mock_dns(result=missing_all):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "critical"
|
|
recommendation_types = [item["type"] for item in data["recommendations"]]
|
|
assert {"missing_dmarc", "missing_spf", "missing_dkim"}.issubset(set(recommendation_types))
|
|
assert recommendation_types.count("missing_mta_sts") == 1
|
|
assert recommendation_types.count("missing_bimi") == 1
|
|
assert any(item["type"] == "policy_needs_more_data" for item in data["recommendations"])
|
|
|
|
|
|
def test_mta_sts_endpoint_returns_cached_posture(client: TestClient):
|
|
"""The domain detail page can fetch MTA-STS posture with cache metadata."""
|
|
checked_at = datetime(2026, 5, 23, 12, 0, 0)
|
|
result = MTAStsResult(
|
|
status="fail",
|
|
dns_record=None,
|
|
policy_url=f"https://mta-sts.{DOMAIN}/.well-known/mta-sts.txt",
|
|
errors=["No _mta-sts TXT record was found."],
|
|
)
|
|
|
|
with patch(
|
|
"app.api.api_v1.endpoints.domains.check_mta_sts_cached",
|
|
new=AsyncMock(return_value=(result, True, checked_at)),
|
|
):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/mta-sts")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "fail"
|
|
assert data["cached"] is True
|
|
assert data["checked_at"] == checked_at.isoformat()
|
|
assert data["errors"] == ["No _mta-sts TXT record was found."]
|
|
|
|
|
|
def test_mta_sts_endpoint_returns_404_for_unknown_domain(client: TestClient):
|
|
response = client.get("/api/v1/domains/unknown.example.com/dns/mta-sts")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_bimi_endpoint_returns_cached_posture(client: TestClient):
|
|
"""The domain detail page can fetch BIMI posture with cache metadata."""
|
|
checked_at = datetime(2026, 5, 23, 12, 0, 0)
|
|
result = BIMIResult(
|
|
status="pass",
|
|
selector="default",
|
|
query_name=f"default._bimi.{DOMAIN}",
|
|
dns_record="v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
|
logo_url="https://example.com/logo.svg",
|
|
certificate_url="https://example.com/vmc.pem",
|
|
)
|
|
|
|
with patch(
|
|
"app.api.api_v1.endpoints.domains.check_bimi_cached",
|
|
new=AsyncMock(return_value=(result, True, checked_at)),
|
|
):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/bimi")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "pass"
|
|
assert data["query_name"] == f"default._bimi.{DOMAIN}"
|
|
assert data["logo_url"] == "https://example.com/logo.svg"
|
|
assert data["cached"] is True
|
|
assert data["checked_at"] == checked_at.isoformat()
|
|
|
|
|
|
def test_bimi_endpoint_returns_404_for_unknown_domain(client: TestClient):
|
|
response = client.get("/api/v1/domains/unknown.example.com/dns/bimi")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_posture_dashboard_links_recommendations_changes_and_playbooks(
|
|
client: TestClient, db_session
|
|
):
|
|
"""The posture dashboard is actionable and links back to underlying evidence."""
|
|
db_session.add(
|
|
DNSRecordChange(
|
|
domain=DOMAIN,
|
|
provider="cloudflare",
|
|
zone_id="zone-1",
|
|
record_key="dmarc",
|
|
record_type="TXT",
|
|
record_name=f"_dmarc.{DOMAIN}",
|
|
change_type="modified",
|
|
previous_content="v=DMARC1; p=none",
|
|
current_content="v=DMARC1; p=quarantine; pct=100",
|
|
observed_at=datetime(2026, 5, 23, 12, 0, 0),
|
|
)
|
|
)
|
|
db_session.commit()
|
|
|
|
missing_spf = DomainDNSResult(
|
|
dmarc=True,
|
|
dmarc_record="v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@example.com",
|
|
spf=False,
|
|
dkim=True,
|
|
dkim_selectors=["google"],
|
|
dkim_record="v=DKIM1; k=rsa; p=ABC",
|
|
)
|
|
mta_sts = MTAStsResult(
|
|
status="pass",
|
|
dns_record="v=STSv1; id=20260523",
|
|
policy_url="https://mta-sts.example.com/.well-known/mta-sts.txt",
|
|
mode="enforce",
|
|
max_age=86400,
|
|
mx=["*.example.com"],
|
|
)
|
|
bimi = BIMIResult(
|
|
status="pass",
|
|
dns_record="v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
|
logo_url="https://example.com/logo.svg",
|
|
certificate_url="https://example.com/vmc.pem",
|
|
)
|
|
|
|
with (
|
|
_mock_dns(result=missing_spf),
|
|
patch(
|
|
"app.api.api_v1.endpoints.domains.check_mta_sts_cached",
|
|
new=AsyncMock(return_value=(mta_sts, False, None)),
|
|
),
|
|
patch(
|
|
"app.api.api_v1.endpoints.domains.check_bimi_cached",
|
|
new=AsyncMock(return_value=(bimi, False, None)),
|
|
),
|
|
):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/posture")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "degraded"
|
|
assert data["score"] == 80
|
|
assert any(item["key"] == "spf" and item["href"] == "#dns-records" for item in data["coverage"])
|
|
missing_spf_recommendation = next(
|
|
item for item in data["recommendations"] if item["type"] == "missing_spf"
|
|
)
|
|
assert missing_spf_recommendation["evidence"][0]["href"] == "#dns-records"
|
|
assert data["changes"][0]["title"] == f"TXT _dmarc.{DOMAIN} modified"
|
|
assert data["changes"][0]["evidence"][0]["value"] == "v=DMARC1; p=none"
|
|
assert any(playbook["key"] == "missing_spf" for playbook in data["playbooks"])
|
|
|
|
|
|
def test_posture_dashboard_returns_404_for_unknown_domain(client: TestClient):
|
|
response = client.get("/api/v1/domains/unknown.example.com/posture")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_domain_detail_data_endpoints_support_manually_configured_domain(
|
|
client: TestClient, db_session
|
|
):
|
|
"""Manually monitored domains should render empty detail data instead of 404s."""
|
|
db_session.add(Domain(name="manual.example", active=True))
|
|
db_session.commit()
|
|
|
|
reports = client.get("/api/v1/domains/manual.example/reports")
|
|
sources = client.get("/api/v1/domains/manual.example/sources")
|
|
selectors = client.get("/api/v1/domains/manual.example/selectors")
|
|
|
|
assert reports.status_code == 200
|
|
assert reports.json()["reports"] == []
|
|
assert sources.status_code == 200
|
|
assert sources.json()["sources"] == []
|
|
assert selectors.status_code == 200
|
|
assert selectors.json() == {"selectors": [], "report_selectors": []}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("policy", "summary", "expected_type", "expected_severity"),
|
|
[
|
|
(
|
|
"quarantine",
|
|
{"total_count": 1000, "failed_count": 50, "compliance_rate": 95.0},
|
|
"policy_already_enforced",
|
|
"info",
|
|
),
|
|
(
|
|
"none",
|
|
{"total_count": 50, "failed_count": 0, "compliance_rate": 100.0},
|
|
"policy_needs_more_data",
|
|
"warning",
|
|
),
|
|
(
|
|
"none",
|
|
{"total_count": 200, "failed_count": 15, "compliance_rate": 92.5},
|
|
"policy_enforcement_review",
|
|
"warning",
|
|
),
|
|
(
|
|
"none",
|
|
{"total_count": 200, "failed_count": 80, "compliance_rate": 60.0},
|
|
"policy_not_ready",
|
|
"error",
|
|
),
|
|
],
|
|
)
|
|
def test_enforcement_recommendation_common_states(
|
|
policy, summary, expected_type, expected_severity
|
|
):
|
|
"""Policy guidance covers enforced, low-volume, review, and not-ready states."""
|
|
recommendation = domains_endpoint._enforcement_recommendation(policy, summary)
|
|
|
|
assert recommendation.type == expected_type
|
|
assert recommendation.severity == expected_severity
|
|
assert recommendation.evidence[0].value == f"p={policy}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/v1/domains/summary (DNS fields included)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_summary_includes_dns_fields(client: TestClient):
|
|
"""The summary endpoint should include dmarc_status, spf_status, dkim_status."""
|
|
with _mock_dns():
|
|
response = client.get("/api/v1/domains/summary")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_domains"] == 1
|
|
domain = data["domains"][0]
|
|
assert "dmarc_status" in domain
|
|
assert "spf_status" in domain
|
|
assert "dkim_status" in domain
|
|
assert domain["dmarc_status"] is True
|
|
assert domain["spf_status"] is True
|
|
assert domain["dkim_status"] is True
|
|
assert domain["dmarc_policy"] == "none"
|
|
|
|
|
|
def test_summary_dns_failure_defaults_false(client: TestClient):
|
|
"""If DNS check fails, status fields default to False rather than crashing."""
|
|
empty_result = DomainDNSResult()
|
|
|
|
with _mock_dns(result=empty_result):
|
|
response = client.get("/api/v1/domains/summary")
|
|
|
|
assert response.status_code == 200
|
|
domain = response.json()["domains"][0]
|
|
assert domain["dmarc_status"] is False
|
|
assert domain["spf_status"] is False
|
|
assert domain["dkim_status"] is False
|
|
|
|
|
|
def test_summary_endpoint_uses_manual_selectors(client: TestClient):
|
|
"""Manually configured selectors are forwarded by the summary endpoint."""
|
|
client.post(f"/api/v1/domains/{DOMAIN}/selectors", json={"selector": "manualsel"})
|
|
captured_selectors = []
|
|
|
|
async def _fake_check_domain(domain, selectors=None):
|
|
captured_selectors.extend(selectors or [])
|
|
return MOCK_DNS_RESULT
|
|
|
|
with patch(
|
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
|
return_value=AsyncMock(check_domain=_fake_check_domain),
|
|
):
|
|
response = client.get("/api/v1/domains/summary")
|
|
|
|
assert response.status_code == 200
|
|
assert "manualsel" in captured_selectors
|
|
assert "google" in captured_selectors
|
|
|
|
|
|
def test_selector_map_lookup_chunks_domain_names(db_session, monkeypatch):
|
|
"""Large summary batches are split to avoid database parameter limits."""
|
|
monkeypatch.setattr(domains_endpoint, "DOMAIN_SELECTOR_LOOKUP_CHUNK_SIZE", 2)
|
|
db_session.add_all(
|
|
[
|
|
Domain(name="one.example", dkim_selectors="a,b"),
|
|
Domain(name="two.example", dkim_selectors="c"),
|
|
Domain(name="three.example", dkim_selectors="d"),
|
|
]
|
|
)
|
|
db_session.commit()
|
|
|
|
selectors = domains_endpoint._get_domain_selectors_map_from_db(
|
|
db_session,
|
|
["one.example", "two.example", "three.example", "one.example"],
|
|
)
|
|
|
|
assert selectors == {
|
|
"one.example": ["a", "b"],
|
|
"two.example": ["c"],
|
|
"three.example": ["d"],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/v1/domains/{domain_id}/sources (PTR + fix hints)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# A failing-source report used for sources tests
|
|
FAILING_SOURCE_REPORT = {
|
|
"domain": DOMAIN,
|
|
"report_id": "fail-src-001",
|
|
"org_name": "Fail Org",
|
|
"policy": {"p": "reject", "sp": "", "pct": "100"},
|
|
"records": [
|
|
{
|
|
"source_ip": "10.0.0.1",
|
|
"count": 3,
|
|
"disposition": "reject",
|
|
"dkim_result": "fail",
|
|
"spf_result": "fail",
|
|
"dkim": [],
|
|
"spf": [],
|
|
}
|
|
],
|
|
"summary": {"total_count": 3, "passed_count": 0, "failed_count": 3, "pass_rate": 0.0},
|
|
}
|
|
|
|
|
|
def _mock_provider(hostname=None):
|
|
"""Return a context manager that patches get_default_provider with a PTR mock."""
|
|
mock_prov = AsyncMock()
|
|
mock_prov.check_domain = AsyncMock(return_value=MOCK_DNS_RESULT)
|
|
mock_prov.lookup_ptr = AsyncMock(return_value=hostname)
|
|
return patch(
|
|
"app.api.api_v1.endpoints.domains.get_default_provider",
|
|
return_value=mock_prov,
|
|
)
|
|
|
|
|
|
def test_sources_endpoint_includes_hostname(client: TestClient):
|
|
"""The /sources endpoint should return the rDNS hostname when available."""
|
|
store = ReportStore.get_instance()
|
|
store.add_report(FAILING_SOURCE_REPORT)
|
|
|
|
with _mock_provider(hostname="mail.example.com"):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
|
|
|
assert response.status_code == 200
|
|
sources = response.json()["sources"]
|
|
# Find the failing source
|
|
failing = next((s for s in sources if s["ip"] == "10.0.0.1"), None)
|
|
assert failing is not None
|
|
assert failing["hostname"] == "mail.example.com"
|
|
|
|
|
|
def test_sources_endpoint_hostname_none_when_no_ptr(client: TestClient):
|
|
"""The /sources endpoint should return null hostname when no PTR record exists."""
|
|
with _mock_provider(hostname=None):
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
|
|
|
assert response.status_code == 200
|
|
sources = response.json()["sources"]
|
|
for source in sources:
|
|
# hostname may be null; it must not crash
|
|
assert "hostname" in source
|
|
|
|
|
|
def test_sources_endpoint_spf_fix_hint_for_failing_ip(client: TestClient):
|
|
"""A source with spf=fail should receive an spf_fix_hint containing its IP."""
|
|
store = ReportStore.get_instance()
|
|
store.add_report(FAILING_SOURCE_REPORT)
|
|
|
|
with _mock_provider():
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
|
|
|
assert response.status_code == 200
|
|
sources = response.json()["sources"]
|
|
failing = next((s for s in sources if s["ip"] == "10.0.0.1"), None)
|
|
assert failing is not None
|
|
assert failing["spf_fix_hint"] == "ip4:10.0.0.1"
|
|
|
|
|
|
def test_sources_endpoint_no_fix_hint_when_spf_passes(client: TestClient):
|
|
"""A source with spf=pass should not receive an spf_fix_hint."""
|
|
with _mock_provider():
|
|
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
|
|
|
assert response.status_code == 200
|
|
sources = response.json()["sources"]
|
|
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
|