feat: add evidence-linked DNS health guidance
This commit is contained in:
@@ -88,6 +88,47 @@ class DNSRecordResponse(BaseModel):
|
|||||||
checkedAt: Optional[str] = None
|
checkedAt: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DNSHealthEvidence(BaseModel):
|
||||||
|
"""Evidence backing a DNS health recommendation."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
href: str
|
||||||
|
|
||||||
|
|
||||||
|
class DNSHealthCheck(BaseModel):
|
||||||
|
"""Single DNS health check result."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
evidence: List[DNSHealthEvidence] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DNSHealthRecommendation(BaseModel):
|
||||||
|
"""Actionable DNS or enforcement recommendation."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
action: str
|
||||||
|
evidence: List[DNSHealthEvidence] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DNSHealthResponse(BaseModel):
|
||||||
|
"""Evidence-linked DNS health summary for a domain."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
policy: str
|
||||||
|
compliance_rate: float
|
||||||
|
total_emails: int
|
||||||
|
failed_emails: int
|
||||||
|
checks: List[DNSHealthCheck]
|
||||||
|
recommendations: List[DNSHealthRecommendation]
|
||||||
|
|
||||||
|
|
||||||
class CloudflareZoneResponse(BaseModel):
|
class CloudflareZoneResponse(BaseModel):
|
||||||
"""Cloudflare zone available for import."""
|
"""Cloudflare zone available for import."""
|
||||||
|
|
||||||
@@ -310,6 +351,96 @@ def _domain_names_for_summary(db: Session, store: ReportStore) -> List[str]:
|
|||||||
return list(dict.fromkeys(stored_domains + report_domains))
|
return list(dict.fromkeys(stored_domains + report_domains))
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_exists(db: Session, store: ReportStore, domain_name: str) -> bool:
|
||||||
|
return domain_name in store.get_domains() or bool(
|
||||||
|
db.query(Domain.id).filter(Domain.name == domain_name).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_evidence(label: str, value: Optional[str], href: str = "#dns-records") -> DNSHealthEvidence:
|
||||||
|
return DNSHealthEvidence(label=label, value=value or "Not found", href=href)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_evidence(label: str, value: object, href: str = "#compliance-chart") -> DNSHealthEvidence:
|
||||||
|
return DNSHealthEvidence(label=label, value=str(value), href=href)
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_check(
|
||||||
|
key: str,
|
||||||
|
label: str,
|
||||||
|
present: bool,
|
||||||
|
present_message: str,
|
||||||
|
missing_message: str,
|
||||||
|
evidence: List[DNSHealthEvidence],
|
||||||
|
) -> DNSHealthCheck:
|
||||||
|
return DNSHealthCheck(
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
status="pass" if present else "fail",
|
||||||
|
message=present_message if present else missing_message,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforcement_recommendation(
|
||||||
|
policy: str,
|
||||||
|
summary: Dict[str, Any],
|
||||||
|
) -> DNSHealthRecommendation:
|
||||||
|
total = int(summary.get("total_count", 0) or 0)
|
||||||
|
failed = int(summary.get("failed_count", 0) or 0)
|
||||||
|
compliance = float(summary.get("compliance_rate", 0.0) or 0.0)
|
||||||
|
evidence = [
|
||||||
|
_summary_evidence("Policy", f"p={policy}", "#dns-records"),
|
||||||
|
_summary_evidence("Total messages", total),
|
||||||
|
_summary_evidence("Compliance", f"{compliance}%"),
|
||||||
|
_summary_evidence("Failed messages", failed, "#sending-sources"),
|
||||||
|
]
|
||||||
|
if policy != "none":
|
||||||
|
return DNSHealthRecommendation(
|
||||||
|
type="policy_already_enforced",
|
||||||
|
severity="info",
|
||||||
|
title="DMARC policy is already enforced",
|
||||||
|
detail="This domain is already beyond monitoring mode.",
|
||||||
|
action="Continue watching failure trends before tightening further.",
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
if total < 100:
|
||||||
|
return DNSHealthRecommendation(
|
||||||
|
type="policy_needs_more_data",
|
||||||
|
severity="warning",
|
||||||
|
title="Collect more report volume before enforcement",
|
||||||
|
detail="DMARQ needs at least 100 observed messages before recommending quarantine.",
|
||||||
|
action="Keep p=none until more aggregate reports arrive.",
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
if compliance >= 98.0 and failed <= max(2, int(total * 0.02)):
|
||||||
|
return DNSHealthRecommendation(
|
||||||
|
type="policy_enforcement_ready",
|
||||||
|
severity="info",
|
||||||
|
title="Ready to plan quarantine",
|
||||||
|
detail="Recent report volume is high and failures are low enough to plan enforcement.",
|
||||||
|
action="Move gradually: set p=quarantine with a low pct value, then watch failures.",
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
if compliance >= 90.0:
|
||||||
|
return DNSHealthRecommendation(
|
||||||
|
type="policy_enforcement_review",
|
||||||
|
severity="warning",
|
||||||
|
title="Close remaining failures before enforcement",
|
||||||
|
detail="Compliance is improving, but failures still need review before policy changes.",
|
||||||
|
action="Review failing sources and SPF/DKIM alignment before changing p=none.",
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
return DNSHealthRecommendation(
|
||||||
|
type="policy_not_ready",
|
||||||
|
severity="error",
|
||||||
|
title="Not ready for enforcement",
|
||||||
|
detail="Current DMARC compliance is too low for a safe policy change.",
|
||||||
|
action="Fix unauthenticated or unknown senders before moving beyond p=none.",
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=DomainSummaryResponse)
|
@router.get("/summary", response_model=DomainSummaryResponse)
|
||||||
async def get_domains_summary(db: Session = Depends(get_db)):
|
async def get_domains_summary(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
@@ -540,9 +671,8 @@ async def get_domain_stats(
|
|||||||
"""
|
"""
|
||||||
store = ReportStore.get_instance()
|
store = ReportStore.get_instance()
|
||||||
hydrate_report_store_from_db(db, store)
|
hydrate_report_store_from_db(db, store)
|
||||||
domains = store.get_domains()
|
domains = _domain_names_for_summary(db, store)
|
||||||
|
|
||||||
# For Milestone 1, domain_id is simply the domain name
|
|
||||||
if domain_id not in domains:
|
if domain_id not in domains:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -579,9 +709,8 @@ async def get_domain_dns_records(
|
|||||||
"""
|
"""
|
||||||
store = ReportStore.get_instance()
|
store = ReportStore.get_instance()
|
||||||
hydrate_report_store_from_db(db, store)
|
hydrate_report_store_from_db(db, store)
|
||||||
domains = store.get_domains()
|
|
||||||
|
|
||||||
if domain_id not in domains:
|
if not _domain_exists(db, store, domain_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Domain not found",
|
detail="Domain not found",
|
||||||
@@ -612,6 +741,95 @@ async def get_domain_dns_records(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{domain_id}/dns/health", response_model=DNSHealthResponse)
|
||||||
|
async def get_domain_dns_health(
|
||||||
|
domain_id: str = Path(..., title="The domain ID or name"),
|
||||||
|
refresh: bool = Query(False, title="Refresh cached DNS result"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return evidence-linked DNS health and enforcement readiness guidance."""
|
||||||
|
store = ReportStore.get_instance()
|
||||||
|
hydrate_report_store_from_db(db, store)
|
||||||
|
if not _domain_exists(db, store, domain_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Domain not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
manual_selectors = _get_domain_selectors_from_db(db, domain_id)
|
||||||
|
report_selectors = _get_selectors_from_reports(store, domain_id)
|
||||||
|
combined_selectors = list(dict.fromkeys(manual_selectors + report_selectors))
|
||||||
|
|
||||||
|
provider = get_default_provider(db)
|
||||||
|
result, _, _ = await resolve_domain_dns_cached(
|
||||||
|
db,
|
||||||
|
provider,
|
||||||
|
domain_id,
|
||||||
|
selectors=combined_selectors,
|
||||||
|
refresh=refresh,
|
||||||
|
)
|
||||||
|
summary = store.get_domain_summary(domain_id)
|
||||||
|
policy = extract_dmarc_policy(result.dmarc_record) or "none"
|
||||||
|
checks = [
|
||||||
|
_dns_check(
|
||||||
|
"dmarc",
|
||||||
|
"DMARC",
|
||||||
|
result.dmarc,
|
||||||
|
"DMARC record is published.",
|
||||||
|
"No DMARC record was found.",
|
||||||
|
[_record_evidence("DMARC TXT", result.dmarc_record)],
|
||||||
|
),
|
||||||
|
_dns_check(
|
||||||
|
"spf",
|
||||||
|
"SPF",
|
||||||
|
result.spf,
|
||||||
|
"SPF record is published.",
|
||||||
|
"No SPF record was found at the domain root.",
|
||||||
|
[_record_evidence("SPF TXT", result.spf_record)],
|
||||||
|
),
|
||||||
|
_dns_check(
|
||||||
|
"dkim",
|
||||||
|
"DKIM",
|
||||||
|
result.dkim,
|
||||||
|
"At least one DKIM selector resolved.",
|
||||||
|
"No DKIM record was found for configured or observed selectors.",
|
||||||
|
[
|
||||||
|
_record_evidence(
|
||||||
|
"Selectors checked",
|
||||||
|
", ".join(combined_selectors or result.selectors_checked or []),
|
||||||
|
),
|
||||||
|
_record_evidence("DKIM TXT", result.dkim_record),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
recommendations: List[DNSHealthRecommendation] = []
|
||||||
|
for check in checks:
|
||||||
|
if check.status == "fail":
|
||||||
|
recommendations.append(
|
||||||
|
DNSHealthRecommendation(
|
||||||
|
type=f"missing_{check.key}",
|
||||||
|
severity="error" if check.key == "dmarc" else "warning",
|
||||||
|
title=f"{check.label} needs attention",
|
||||||
|
detail=check.message,
|
||||||
|
action=f"Publish or repair the {check.label} DNS record, then refresh DNS health.",
|
||||||
|
evidence=check.evidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recommendations.append(_enforcement_recommendation(policy, summary))
|
||||||
|
|
||||||
|
failed_checks = sum(1 for check in checks if check.status == "fail")
|
||||||
|
health_status = "healthy" if failed_checks == 0 else "degraded" if failed_checks < 3 else "critical"
|
||||||
|
return DNSHealthResponse(
|
||||||
|
status=health_status,
|
||||||
|
policy=policy,
|
||||||
|
compliance_rate=float(summary.get("compliance_rate", 0.0) or 0.0),
|
||||||
|
total_emails=int(summary.get("total_count", 0) or 0),
|
||||||
|
failed_emails=int(summary.get("failed_count", 0) or 0),
|
||||||
|
checks=checks,
|
||||||
|
recommendations=recommendations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cloudflare/discover", response_model=List[CloudflareZoneResponse])
|
@router.get("/cloudflare/discover", response_model=List[CloudflareZoneResponse])
|
||||||
async def discover_cloudflare_domains(db: Session = Depends(get_db)):
|
async def discover_cloudflare_domains(db: Session = Depends(get_db)):
|
||||||
"""Discover active Cloudflare zones visible to the configured API token."""
|
"""Discover active Cloudflare zones visible to the configured API token."""
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compliance Chart -->
|
<!-- Compliance Chart -->
|
||||||
|
<section id="compliance-chart-section">
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
{% call card_title() %}Compliance Over Time{% endcall %}
|
{% call card_title() %}Compliance Over Time{% endcall %}
|
||||||
@@ -125,8 +126,74 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- DNS Health Summary -->
|
||||||
|
<section id="dns-health-summary">
|
||||||
|
{% call card() %}
|
||||||
|
{% call card_header() %}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
{% call card_title() %}DNS Health Summary{% endcall %}
|
||||||
|
<span class="inline-flex items-center rounded px-2 py-1 text-xs font-semibold capitalize"
|
||||||
|
:class="dnsHealthStatusClass"
|
||||||
|
x-text="dnsHealth.status || 'checking'"></span>
|
||||||
|
</div>
|
||||||
|
{% call card_description() %}
|
||||||
|
Evidence-linked authentication posture and enforcement readiness
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% call card_content() %}
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<template x-for="check in dnsHealth.checks" :key="check.key">
|
||||||
|
<div class="rounded border border-base-300 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold" x-text="check.label"></h3>
|
||||||
|
<span class="rounded px-2 py-1 text-xs font-semibold capitalize"
|
||||||
|
:class="check.status === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
x-text="check.status"></span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-base-content/70" x-text="check.message"></p>
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<template x-for="item in check.evidence" :key="item.label + item.value">
|
||||||
|
<a :href="item.href" class="block text-xs link link-primary">
|
||||||
|
<span x-text="item.label"></span>:
|
||||||
|
<span class="font-mono break-all" x-text="item.value"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-3">
|
||||||
|
<template x-for="recommendation in dnsHealth.recommendations" :key="recommendation.type">
|
||||||
|
<div class="rounded border border-base-300 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="mt-1 h-2.5 w-2.5 rounded-full"
|
||||||
|
:class="recommendationSeverityClass(recommendation.severity)"></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="font-semibold" x-text="recommendation.title"></h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70" x-text="recommendation.detail"></p>
|
||||||
|
<p class="mt-2 text-sm font-medium" x-text="recommendation.action"></p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<template x-for="item in recommendation.evidence" :key="item.label + item.value">
|
||||||
|
<a :href="item.href" class="badge badge-outline gap-1">
|
||||||
|
<span x-text="item.label"></span>
|
||||||
|
<span x-text="item.value"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- DNS Records -->
|
<!-- DNS Records -->
|
||||||
|
<section id="dns-records">
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
{% call card_title() %}DNS Records{% endcall %}
|
{% call card_title() %}DNS Records{% endcall %}
|
||||||
@@ -217,8 +284,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Source Report Table -->
|
<!-- Source Report Table -->
|
||||||
|
<section id="sending-sources">
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -405,6 +474,7 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Recent Reports -->
|
<!-- Recent Reports -->
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
@@ -511,6 +581,11 @@ function domainDetailsApp(domainId) {
|
|||||||
dkim: false,
|
dkim: false,
|
||||||
dkimSelectors: []
|
dkimSelectors: []
|
||||||
},
|
},
|
||||||
|
dnsHealth: {
|
||||||
|
status: '',
|
||||||
|
checks: [],
|
||||||
|
recommendations: []
|
||||||
|
},
|
||||||
selectors: [],
|
selectors: [],
|
||||||
reportSelectors: [],
|
reportSelectors: [],
|
||||||
newSelector: '',
|
newSelector: '',
|
||||||
@@ -528,6 +603,7 @@ function domainDetailsApp(domainId) {
|
|||||||
init() {
|
init() {
|
||||||
this.fetchDomainStats();
|
this.fetchDomainStats();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
|
this.fetchDNSHealth();
|
||||||
this.fetchSelectors();
|
this.fetchSelectors();
|
||||||
this.fetchReports();
|
this.fetchReports();
|
||||||
this.fetchSources();
|
this.fetchSources();
|
||||||
@@ -563,6 +639,13 @@ function domainDetailsApp(domainId) {
|
|||||||
return 'Verified';
|
return 'Verified';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get dnsHealthStatusClass() {
|
||||||
|
if (this.dnsHealth.status === 'healthy') return 'bg-green-100 text-green-700';
|
||||||
|
if (this.dnsHealth.status === 'degraded') return 'bg-yellow-100 text-yellow-800';
|
||||||
|
if (this.dnsHealth.status === 'critical') return 'bg-red-100 text-red-700';
|
||||||
|
return 'bg-base-200 text-base-content/70';
|
||||||
|
},
|
||||||
|
|
||||||
recommendationSeverityClass(severity) {
|
recommendationSeverityClass(severity) {
|
||||||
if (severity === 'error') return 'bg-red-500';
|
if (severity === 'error') return 'bg-red-500';
|
||||||
if (severity === 'warning') return 'bg-yellow-500';
|
if (severity === 'warning') return 'bg-yellow-500';
|
||||||
@@ -593,6 +676,17 @@ function domainDetailsApp(domainId) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchDNSHealth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/health`);
|
||||||
|
if (response.ok) {
|
||||||
|
this.dnsHealth = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching DNS health:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchSelectors() {
|
async fetchSelectors() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
|
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
|
||||||
@@ -621,6 +715,7 @@ function domainDetailsApp(domainId) {
|
|||||||
// Refresh selectors (both manual and report) and DNS check
|
// Refresh selectors (both manual and report) and DNS check
|
||||||
this.fetchSelectors();
|
this.fetchSelectors();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
|
this.fetchDNSHealth();
|
||||||
} else {
|
} else {
|
||||||
const err = await response.json();
|
const err = await response.json();
|
||||||
this.selectorError = err.detail || 'Failed to add selector';
|
this.selectorError = err.detail || 'Failed to add selector';
|
||||||
@@ -641,6 +736,7 @@ function domainDetailsApp(domainId) {
|
|||||||
// Refresh selectors (both manual and report) and DNS check
|
// Refresh selectors (both manual and report) and DNS check
|
||||||
this.fetchSelectors();
|
this.fetchSelectors();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
|
this.fetchDNSHealth();
|
||||||
} else {
|
} else {
|
||||||
console.error('Error deleting selector:', response.status);
|
console.error('Error deleting selector:', response.status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,6 +320,114 @@ def test_dns_endpoint_404_for_unknown_domain(client: TestClient):
|
|||||||
assert response.status_code == 404
|
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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with _mock_dns(result=missing_dkim):
|
||||||
|
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")
|
||||||
|
assert dkim_check["status"] == "fail"
|
||||||
|
assert dkim_check["evidence"][0]["href"] == "#dns-records"
|
||||||
|
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(recommendation_types)
|
||||||
|
assert any(item["type"] == "policy_needs_more_data" for item in data["recommendations"])
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
# GET /api/v1/domains/summary (DNS fields included)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user