Merge pull request #62 from christianlouis/copilot/check-dns-data-in-dashboard

fix: repair corrupted dns_resolver.py to pass black --check (py310 target)
This commit is contained in:
Christian Krakau-Louis
2026-03-29 21:17:25 +02:00
committed by GitHub
6 changed files with 1111 additions and 39 deletions
+190 -20
View File
@@ -1,11 +1,23 @@
import asyncio
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Path, Query, status
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.domain import Domain
from app.services.dns_resolver import (
DomainDNSResult,
extract_dmarc_policy,
get_default_provider,
)
from app.services.report_store import ReportStore
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -98,15 +110,70 @@ class DomainSummaryResponse(BaseModel):
domains: List[Dict[str, Any]]
class SelectorRequest(BaseModel):
"""Request body for adding a DKIM selector"""
selector: str = Field(..., min_length=1, description="DKIM selector name")
def _get_selectors_from_reports(store: "ReportStore", domain: str) -> List[str]:
"""Extract DKIM selectors seen in stored DMARC reports for *domain*.
DMARC aggregate report records include DKIM auth results that carry the
selector used by the sending server. Collecting these gives us a set of
real-world selectors to verify against live DNS, in addition to any
manually configured selectors.
"""
selectors: List[str] = []
for report in store.get_domain_reports(domain):
for record in report.get("records", []):
for dkim_entry in record.get("dkim", []):
sel = dkim_entry.get("selector", "").strip()
if sel and sel not in selectors:
selectors.append(sel)
return selectors
def _get_domain_selectors_from_db(db: Session, domain_name: str) -> List[str]:
"""Return the manually configured DKIM selectors for *domain_name* from the DB."""
domain_db = db.query(Domain).filter(Domain.name == domain_name).first()
if domain_db and domain_db.dkim_selectors:
return [s.strip() for s in domain_db.dkim_selectors.split(",") if s.strip()]
return []
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary():
async def get_domains_summary(db: Session = Depends(get_db)):
"""
Get summary statistics for all domains, formatted for the dashboard.
Performs live DNS lookups for each domain concurrently and includes the
results (DMARC/SPF/DKIM status and live DMARC policy) in the per-domain
entries. A per-domain timeout of 10 s prevents slow DNS responses from
blocking the page load.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
summaries = store.get_all_domain_summaries()
# Perform DNS checks concurrently for all domains
provider = get_default_provider()
async def _dns_for_domain(domain_name: str) -> DomainDNSResult:
manual_selectors = _get_domain_selectors_from_db(db, domain_name)
report_selectors = _get_selectors_from_reports(store, domain_name)
combined = list(dict.fromkeys(manual_selectors + report_selectors))
try:
return await asyncio.wait_for(
provider.check_domain(domain_name, selectors=combined),
timeout=10.0,
)
except (asyncio.TimeoutError, LookupError, OSError) as exc:
logger.warning("DNS check failed for %s: %s", domain_name, exc)
return DomainDNSResult()
dns_results = await asyncio.gather(*[_dns_for_domain(d) for d in domains])
# Calculate overall statistics
total_domains = len(domains)
total_emails = 0
@@ -115,22 +182,34 @@ async def get_domains_summary():
domains_list = []
for domain_name in domains:
for domain_name, dns in zip(domains, dns_results):
summary = summaries.get(domain_name, {})
total_emails += summary.get("total_count", 0)
total_passed += summary.get("passed_count", 0)
total_reports += summary.get("reports_processed", 0)
# Prefer live DNS policy; fall back to policy seen in reports
live_policy = extract_dmarc_policy(dns.dmarc_record)
reported_policy = summary.get("policy", {})
if isinstance(reported_policy, dict):
reported_policy = reported_policy.get("p")
dmarc_policy = live_policy or reported_policy or "none"
# Format domain data for frontend
domains_list.append(
{
"id": domain_name, # Using the domain name as ID for now
"id": domain_name,
"domain_name": domain_name,
"total_emails": summary.get("total_count", 0),
"passed_count": summary.get("passed_count", 0),
"failed_count": summary.get("failed_count", 0),
"pass_rate": summary.get("compliance_rate", 0),
"report_count": summary.get("reports_processed", 0),
# Real DNS status
"dmarc_status": dns.dmarc,
"dmarc_policy": dmarc_policy,
"spf_status": dns.spf,
"dkim_status": dns.dkim,
}
)
@@ -232,10 +311,16 @@ async def get_domain_stats(domain_id: str = Path(..., title="The domain ID or na
@router.get("/{domain_id}/dns", response_model=DNSRecordResponse)
async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID or name")):
async def get_domain_dns_records(
domain_id: str = Path(..., title="The domain ID or name"),
db: Session = Depends(get_db),
):
"""
Get DNS records for a specific domain. For Milestone 1,
this returns mock data since DNS integration is part of a future milestone.
Get DNS records for a specific domain using live DNS lookups.
Manual selectors (stored in the database) are checked first, followed by
selectors observed in stored DMARC reports, with common well-known
selectors used as a final fallback.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
@@ -246,19 +331,20 @@ async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID
detail="Domain not found",
)
# For Milestone 1, return mock DNS record data
# In a future milestone, this will be replaced with actual DNS lookups
mock_dmarc_record = (
"v=DMARC1; p=none; rua=mailto:dmarc@example.com;"
" ruf=mailto:forensic@example.com; pct=100"
)
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()
result = await provider.check_domain(domain_id, selectors=combined_selectors)
return DNSRecordResponse(
dmarc=True,
dmarcRecord=mock_dmarc_record,
spf=True,
spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all",
dkim=True,
dkimSelectors="selector1, selector2",
dmarc=result.dmarc,
dmarcRecord=result.dmarc_record,
spf=result.spf,
spfRecord=result.spf_record,
dkim=result.dkim,
dkimSelectors=result.dkim_selector,
)
@@ -385,6 +471,90 @@ async def get_domain_sources(
return DomainSourcesResponse(sources=source_entries)
@router.get("/{domain_id}/selectors")
async def get_domain_selectors(
domain_id: str = Path(..., title="The domain ID or name"),
db: Session = Depends(get_db),
):
"""Return the manually configured DKIM selectors for a domain."""
store = ReportStore.get_instance()
if domain_id not in store.get_domains():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
selectors = _get_domain_selectors_from_db(db, domain_id)
return {"selectors": selectors}
@router.post("/{domain_id}/selectors", status_code=status.HTTP_201_CREATED)
async def add_domain_selector(
selector_data: SelectorRequest,
domain_id: str = Path(..., title="The domain ID or name"),
db: Session = Depends(get_db),
):
"""Add a DKIM selector to the manual list for a domain.
The selector is persisted in the ``Domain`` database row so that it will
be used in all subsequent DNS checks, even if it has not yet appeared in
any received DMARC report.
"""
store = ReportStore.get_instance()
if domain_id not in store.get_domains():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
selector = selector_data.selector.strip()
if not selector:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Selector must not be empty",
)
domain_db = db.query(Domain).filter(Domain.name == domain_id).first()
if not domain_db:
domain_db = Domain(name=domain_id)
db.add(domain_db)
existing = [s.strip() for s in (domain_db.dkim_selectors or "").split(",") if s.strip()]
if selector not in existing:
existing.append(selector)
domain_db.dkim_selectors = ",".join(existing)
db.commit()
return {"selectors": existing}
@router.delete("/{domain_id}/selectors/{selector}", status_code=status.HTTP_200_OK)
async def delete_domain_selector(
domain_id: str = Path(..., title="The domain ID or name"),
selector: str = Path(..., title="The DKIM selector to remove"),
db: Session = Depends(get_db),
):
"""Remove a manually configured DKIM selector from a domain."""
domain_db = db.query(Domain).filter(Domain.name == domain_id).first()
if not domain_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
existing = [s.strip() for s in (domain_db.dkim_selectors or "").split(",") if s.strip()]
if selector not in existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Selector '{selector}' not found",
)
existing.remove(selector)
domain_db.dkim_selectors = ",".join(existing)
db.commit()
return {"selectors": existing}
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_domain(domain_id: str = Path(..., title="The domain ID or name")):
"""
+272
View File
@@ -0,0 +1,272 @@
"""
DNS resolver service for DMARC, SPF, and DKIM record lookups.
Provides an extensible provider architecture so that DNS data can be fetched
either via the system resolver (dnspython) or via the Cloudflare DNS API for
future Cloudflare integration.
"""
import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
def _sanitize_for_log(value: str) -> str:
"""Remove newline and carriage-return characters to prevent log injection."""
return value.replace("\r", "").replace("\n", "")
# Well-known DKIM selectors tried when no selectors are configured
COMMON_DKIM_SELECTORS: List[str] = [
"default",
"google",
"mail",
"selector1",
"selector2",
"dkim",
"k1",
"key1",
"mta",
"email",
"smtp",
"s1",
"s2",
"pm",
"mandrill",
"sendgrid",
]
# Seconds to wait for a single DNS query before giving up
DNS_TIMEOUT: float = 5.0
@dataclass
class DomainDNSResult:
"""Aggregated DNS authentication record results for one domain."""
dmarc: bool = False
dmarc_record: Optional[str] = None
spf: bool = False
spf_record: Optional[str] = None
dkim: bool = False
dkim_selector: Optional[str] = None
dkim_record: Optional[str] = None
# Track which selectors were tried so callers can surface this information
selectors_checked: List[str] = field(default_factory=list)
class BaseDNSProvider(ABC):
"""
Abstract base class for DNS providers.
Subclasses implement ``lookup_txt`` and inherit the higher-level helper
methods for DMARC, SPF, and DKIM checks so that provider-specific
differences stay confined to a single method.
"""
@abstractmethod
async def lookup_txt(self, name: str) -> List[str]:
"""Return TXT record strings for *name*.
Raises ``LookupError`` on failure (NXDOMAIN, timeout, network error
etc.). Returns an empty list when the name exists but has no TXT
records.
"""
# ------------------------------------------------------------------
# High-level record checks built on top of lookup_txt
# ------------------------------------------------------------------
async def check_dmarc(self, domain: str) -> Tuple[bool, Optional[str]]:
"""Return *(found, record_string)* for the domain's DMARC TXT record."""
try:
records = await self.lookup_txt(f"_dmarc.{domain}")
for record in records:
if record.lower().startswith("v=dmarc1"):
return True, record
except LookupError as exc:
logger.debug("DMARC lookup failed for %s: %s", _sanitize_for_log(domain), exc)
return False, None
async def check_spf(self, domain: str) -> Tuple[bool, Optional[str]]:
"""Return *(found, record_string)* for the domain's SPF TXT record."""
try:
records = await self.lookup_txt(domain)
for record in records:
if record.lower().startswith("v=spf1"):
return True, record
except LookupError as exc:
logger.debug("SPF lookup failed for %s: %s", _sanitize_for_log(domain), exc)
return False, None
async def check_dkim(
self, domain: str, selectors: List[str]
) -> Tuple[bool, Optional[str], Optional[str]]:
"""Return *(found, selector, record_string)* for the first working DKIM selector."""
for selector in selectors:
try:
records = await self.lookup_txt(f"{selector}._domainkey.{domain}")
for record in records:
if "v=dkim1" in record.lower() or "p=" in record.lower():
return True, selector, record
except LookupError as exc:
logger.debug(
"DKIM lookup failed for selector=%s domain=%s: %s",
selector,
_sanitize_for_log(domain),
exc,
)
return False, None, None
async def check_domain(
self, domain: str, selectors: Optional[List[str]] = None
) -> DomainDNSResult:
"""Run DMARC, SPF, and DKIM checks concurrently for *domain*.
*selectors* are tried first; common well-known selectors are appended
as a fallback so that a domain with no explicitly configured selectors
can still be verified.
"""
# Deduplicate while preserving priority order (manual selectors first)
all_selectors: List[str] = list(selectors or [])
for s in COMMON_DKIM_SELECTORS:
if s not in all_selectors:
all_selectors.append(s)
dmarc_coro = self.check_dmarc(domain)
spf_coro = self.check_spf(domain)
dkim_coro = self.check_dkim(domain, all_selectors)
(dmarc_ok, dmarc_record), (spf_ok, spf_record), (dkim_ok, dkim_sel, dkim_record) = (
await asyncio.gather(dmarc_coro, spf_coro, dkim_coro)
)
return DomainDNSResult(
dmarc=dmarc_ok,
dmarc_record=dmarc_record,
spf=spf_ok,
spf_record=spf_record,
dkim=dkim_ok,
dkim_selector=dkim_sel,
dkim_record=dkim_record,
selectors_checked=all_selectors,
)
class SystemDNSProvider(BaseDNSProvider):
"""DNS provider that resolves records via the system resolver using dnspython."""
async def lookup_txt(self, name: str) -> List[str]:
"""Resolve TXT records using dnspython's async resolver."""
# Import here so the module can be imported even if dnspython is absent
# (tests can mock this method directly without needing the library).
import dns.asyncresolver # type: ignore[import]
import dns.exception # type: ignore[import]
try:
answers = await dns.asyncresolver.resolve(
name, "TXT", lifetime=DNS_TIMEOUT, raise_on_no_answer=False
)
result: List[str] = []
if answers:
for rdata in answers:
for string in rdata.strings:
result.append(string.decode("utf-8", errors="replace"))
return result
except dns.exception.DNSException as exc:
raise LookupError(f"TXT lookup failed for {name}: {exc}") from exc
class CloudflareDNSProvider(BaseDNSProvider):
"""DNS provider using Cloudflare's DNS-over-HTTPS (DoH) endpoint.
This provider resolves DNS queries via Cloudflare's public DoH API
(``1.1.1.1`` / ``cloudflare-dns.com``). When *api_token* and *zone_id*
are supplied, future versions will also support reading and writing DNS
records directly through the Cloudflare REST API, enabling automated DNS
synchronisation.
Current status
--------------
* DoH-based lookups are fully functional.
* Direct Cloudflare API integration (zone management, record sync) is
reserved for a future release.
"""
#: Cloudflare DNS-over-HTTPS endpoint (JSON wire format)
CLOUDFLARE_DOH_URL: str = "https://cloudflare-dns.com/dns-query"
#: Cloudflare REST API base URL (for future zone-management support)
CLOUDFLARE_API_BASE: str = "https://api.cloudflare.com/client/v4"
def __init__(
self,
api_token: Optional[str] = None,
zone_id: Optional[str] = None,
) -> None:
"""
Parameters
----------
api_token:
Cloudflare API token. Required for future DNS record management;
not needed for read-only DoH lookups.
zone_id:
Cloudflare zone identifier. Required for future DNS record
management.
"""
self.api_token = api_token
self.zone_id = zone_id
async def lookup_txt(self, name: str) -> List[str]:
"""Resolve TXT records via Cloudflare's DoH endpoint (JSON format)."""
import httpx # type: ignore[import]
params = {"name": name, "type": "TXT"}
headers = {"Accept": "application/dns-json"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
self.CLOUDFLARE_DOH_URL,
params=params,
headers=headers,
timeout=DNS_TIMEOUT,
)
response.raise_for_status()
data = response.json()
records: List[str] = []
for answer in data.get("Answer", []):
if answer.get("type") == 16: # TXT record type
# Cloudflare wraps TXT values in double-quotes
txt = answer.get("data", "").strip('"')
records.append(txt)
return records
except (httpx.RequestError, httpx.HTTPStatusError, httpx.TimeoutException) as exc:
raise LookupError(f"Cloudflare DoH lookup failed for {name}: {exc}") from exc
def get_default_provider() -> BaseDNSProvider:
"""Return the default DNS provider (system resolver).
In a future release this function will inspect application settings and
return a ``CloudflareDNSProvider`` when Cloudflare credentials are
configured.
"""
return SystemDNSProvider()
def extract_dmarc_policy(dmarc_record: Optional[str]) -> Optional[str]:
"""Parse the *p=* tag from a DMARC TXT record string.
Returns the policy value (e.g. ``"none"``, ``"quarantine"``,
``"reject"``) or ``None`` if the record is absent or unparsable.
"""
if not dmarc_record:
return None
for part in dmarc_record.split(";"):
part = part.strip()
if part.lower().startswith("p="):
return part[2:].strip().lower()
return None
+120 -11
View File
@@ -152,13 +152,55 @@
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.spfRecord || 'No SPF record found'">-</div>
</div>
<!-- DKIM Selectors — live check result -->
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">DKIM Selectors</span>
<span x-show="dns.dkim && dns.dkim.length > 0" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.dkim || dns.dkim.length === 0" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
<span class="mr-2">DKIM (live check)</span>
<span x-show="dns.dkim" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.dkim" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.dkimSelectors || 'No DKIM selectors configured'">-</div>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono"
x-text="dkimLiveText">-</div>
</div>
<!-- DKIM Selector Management -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-3">DKIM Selectors</h3>
<p class="text-sm text-muted-foreground mb-3">
Manually configure selectors to check. Selectors seen in received DMARC
reports and common well-known selectors are checked automatically.
</p>
<!-- Existing selectors list -->
<div class="mb-3">
<template x-if="selectors.length === 0">
<p class="text-sm text-muted-foreground italic">No manually configured selectors yet.</p>
</template>
<template x-for="sel in selectors" :key="sel">
<div class="flex items-center justify-between py-1 px-2 rounded bg-muted mb-1">
<span class="font-mono text-sm" x-text="sel"></span>
<button
@click="deleteSelector(sel)"
class="text-red-500 hover:text-red-700 text-xs ml-4"
title="Remove selector"
></button>
</div>
</template>
</div>
<!-- Add selector form -->
<div class="flex items-center gap-2">
<input
x-model="newSelector"
@keydown.enter.prevent="addSelector()"
type="text"
placeholder="e.g. google, selector1, mail"
class="input input-sm input-bordered flex-1 font-mono"
/>
<button
@click="addSelector()"
:disabled="!newSelector.trim()"
class="btn btn-sm btn-primary"
>Add</button>
</div>
<p x-show="selectorError" x-text="selectorError" class="text-red-500 text-xs mt-1"></p>
</div>
</div>
{% endcall %}
@@ -347,6 +389,9 @@ function domainDetailsApp(domainId) {
dkim: false,
dkimSelectors: ''
},
selectors: [],
newSelector: '',
selectorError: '',
reports: [],
sources: [],
complianceChart: null,
@@ -354,27 +399,34 @@ function domainDetailsApp(domainId) {
dateRange: '30',
sourceFilter: ''
},
init() {
this.fetchDomainStats();
this.fetchDNSRecords();
this.fetchSelectors();
this.fetchReports();
this.fetchSources();
this.$watch('filters.dateRange', () => {
this.fetchSources();
});
},
get filteredSources() {
if (!this.sources) return [];
return this.sources.filter(source => {
if (!this.filters.sourceFilter) return true;
return source.ip.toLowerCase().includes(this.filters.sourceFilter.toLowerCase());
});
},
get dkimLiveText() {
if (!this.dns.dkim) return 'No DKIM record found for configured selectors';
if (this.dns.dkimSelectors) return 'selector: ' + this.dns.dkimSelectors;
return 'Verified';
},
async fetchDomainStats() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/stats`);
@@ -386,7 +438,7 @@ function domainDetailsApp(domainId) {
console.error('Error fetching domain stats:', error);
}
},
async fetchDNSRecords() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns`);
@@ -398,7 +450,64 @@ function domainDetailsApp(domainId) {
console.error('Error fetching DNS records:', error);
}
},
async fetchSelectors() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
if (response.ok) {
const data = await response.json();
this.selectors = data.selectors || [];
}
} catch (error) {
console.error('Error fetching selectors:', error);
}
},
async addSelector() {
this.selectorError = '';
const sel = this.newSelector.trim();
if (!sel) return;
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selector: sel })
});
if (response.ok) {
const data = await response.json();
this.selectors = data.selectors || [];
this.newSelector = '';
// Refresh DNS check to reflect the new selector
this.fetchDNSRecords();
} else {
const err = await response.json();
this.selectorError = err.detail || 'Failed to add selector';
}
} catch (error) {
this.selectorError = 'Network error — could not add selector';
console.error('Error adding selector:', error);
}
},
async deleteSelector(selector) {
try {
const response = await fetch(
`/api/v1/domains/${this.domainId}/selectors/${encodeURIComponent(selector)}`,
{ method: 'DELETE' }
);
if (response.ok) {
const data = await response.json();
this.selectors = data.selectors || [];
// Refresh DNS check after removing a selector
this.fetchDNSRecords();
} else {
console.error('Error deleting selector:', response.status);
}
} catch (error) {
console.error('Error deleting selector:', error);
}
},
async fetchReports() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/reports?limit=10`);
+8 -8
View File
@@ -99,25 +99,25 @@
function domainsApp() {
return {
domains: [],
init() {
// Fetch domains from server
this.fetchDomains();
},
async fetchDomains() {
try {
const response = await fetch('/api/v1/domains/summary');
if (response.ok) {
const data = await response.json();
// Format domains for display
// Map API fields — DNS status comes directly from live lookups
this.domains = data.domains.map(domain => ({
name: domain.domain_name,
dmarc_status: true, // In Milestone 1, assume DMARC is configured if we have reports
dmarc_policy: domain.policy || 'p=none',
spf_status: true, // In future milestones, this will come from DNS checks
dkim_status: true, // In future milestones, this will come from DNS checks
dmarc_status: domain.dmarc_status ?? false,
dmarc_policy: domain.dmarc_policy || 'Not configured',
spf_status: domain.spf_status ?? false,
dkim_status: domain.dkim_status ?? false,
reports_count: domain.report_count,
emails_count: domain.total_emails,
compliance_rate: domain.pass_rate
+252
View File
@@ -0,0 +1,252 @@
"""
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 unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.services.dns_resolver import DomainDNSResult
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_selector="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."""
return patch(
"app.api.api_v1.endpoints.domains.get_default_provider",
return_value=AsyncMock(check_domain=AsyncMock(return_value=result)),
)
# ---------------------------------------------------------------------------
# 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
assert response.json() == {"selectors": []}
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_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"]
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
# ---------------------------------------------------------------------------
# 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
+269
View File
@@ -0,0 +1,269 @@
"""
Unit tests for app.services.dns_resolver.
DNS network I/O is mocked at the ``lookup_txt`` level so no real DNS queries
are made during testing.
"""
from unittest.mock import AsyncMock, patch
import pytest
from app.services.dns_resolver import (
BaseDNSProvider,
CloudflareDNSProvider,
DomainDNSResult,
SystemDNSProvider,
extract_dmarc_policy,
get_default_provider,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class FakeDNSProvider(BaseDNSProvider):
"""Concrete provider backed by a simple dict for deterministic tests."""
def __init__(self, records: dict):
self._records = records
async def lookup_txt(self, name: str):
if name in self._records:
return self._records[name]
raise LookupError(f"NXDOMAIN: {name}")
# ---------------------------------------------------------------------------
# extract_dmarc_policy
# ---------------------------------------------------------------------------
def test_extract_dmarc_policy_none():
record = "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
assert extract_dmarc_policy(record) == "none"
def test_extract_dmarc_policy_quarantine():
record = "v=DMARC1; p=quarantine; pct=100"
assert extract_dmarc_policy(record) == "quarantine"
def test_extract_dmarc_policy_reject():
assert extract_dmarc_policy("v=DMARC1; p=reject") == "reject"
def test_extract_dmarc_policy_missing_tag():
assert extract_dmarc_policy("v=DMARC1; rua=mailto:dmarc@example.com") is None
def test_extract_dmarc_policy_none_input():
assert extract_dmarc_policy(None) is None
# ---------------------------------------------------------------------------
# BaseDNSProvider helpers via FakeDNSProvider
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_check_dmarc_found():
provider = FakeDNSProvider(
{"_dmarc.example.com": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"]}
)
found, record = await provider.check_dmarc("example.com")
assert found is True
assert record is not None
assert "p=quarantine" in record
@pytest.mark.asyncio
async def test_check_dmarc_not_found():
provider = FakeDNSProvider({})
found, record = await provider.check_dmarc("example.com")
assert found is False
assert record is None
@pytest.mark.asyncio
async def test_check_spf_found():
provider = FakeDNSProvider(
{"example.com": ["v=spf1 include:_spf.google.com ~all", "some-other-record"]}
)
found, record = await provider.check_spf("example.com")
assert found is True
assert record is not None
assert record.startswith("v=spf1")
@pytest.mark.asyncio
async def test_check_spf_not_found():
provider = FakeDNSProvider({"example.com": ["some-other-record"]})
found, record = await provider.check_spf("example.com")
assert found is False
assert record is None
@pytest.mark.asyncio
async def test_check_dkim_found_first_selector():
provider = FakeDNSProvider(
{"google._domainkey.example.com": ["v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3"]}
)
found, selector, record = await provider.check_dkim("example.com", ["google", "mail"])
assert found is True
assert selector == "google"
assert record is not None
@pytest.mark.asyncio
async def test_check_dkim_found_second_selector():
provider = FakeDNSProvider(
{"mail._domainkey.example.com": ["v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3"]}
)
found, selector, record = await provider.check_dkim("example.com", ["google", "mail"])
assert found is True
assert selector == "mail"
@pytest.mark.asyncio
async def test_check_dkim_not_found():
provider = FakeDNSProvider({})
found, selector, record = await provider.check_dkim("example.com", ["google", "mail"])
assert found is False
assert selector is None
assert record is None
@pytest.mark.asyncio
async def test_check_domain_all_present():
provider = FakeDNSProvider(
{
"_dmarc.example.com": ["v=DMARC1; p=none; rua=mailto:dmarc@example.com"],
"example.com": ["v=spf1 include:_spf.google.com ~all"],
"google._domainkey.example.com": ["v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3"],
}
)
result = await provider.check_domain("example.com", selectors=["google"])
assert isinstance(result, DomainDNSResult)
assert result.dmarc is True
assert result.spf is True
assert result.dkim is True
assert result.dkim_selector == "google"
@pytest.mark.asyncio
async def test_check_domain_none_present():
provider = FakeDNSProvider({})
result = await provider.check_domain("missing.example.com")
assert result.dmarc is False
assert result.spf is False
assert result.dkim is False
@pytest.mark.asyncio
async def test_check_domain_uses_common_selectors_as_fallback():
"""When no selectors are passed, common selectors should be tried."""
# Use 'default' which is in COMMON_DKIM_SELECTORS
provider = FakeDNSProvider({"default._domainkey.example.com": ["v=DKIM1; k=rsa; p=ABC"]})
result = await provider.check_domain("example.com", selectors=[])
assert result.dkim is True
assert result.dkim_selector == "default"
@pytest.mark.asyncio
async def test_check_domain_manual_selectors_take_priority():
"""Manually supplied selectors must be checked before common ones."""
# Only the manual selector 'custom' has a record
provider = FakeDNSProvider({"custom._domainkey.example.com": ["v=DKIM1; k=rsa; p=XYZ"]})
result = await provider.check_domain("example.com", selectors=["custom"])
assert result.dkim is True
assert result.dkim_selector == "custom"
# ---------------------------------------------------------------------------
# SystemDNSProvider
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_system_provider_returns_txt_records():
"""SystemDNSProvider.lookup_txt should decode dnspython rdata correctly."""
mock_string = b"v=DMARC1; p=none"
class FakeRdata:
strings = [mock_string]
class FakeAnswers:
def __iter__(self):
return iter([FakeRdata()])
with patch("dns.asyncresolver.resolve", new=AsyncMock(return_value=FakeAnswers())):
provider = SystemDNSProvider()
records = await provider.lookup_txt("_dmarc.example.com")
assert records == ["v=DMARC1; p=none"]
@pytest.mark.asyncio
async def test_system_provider_raises_lookup_error_on_dns_exception():
import dns.exception # type: ignore[import]
with patch(
"dns.asyncresolver.resolve",
new=AsyncMock(side_effect=dns.exception.DNSException("NXDOMAIN")),
):
provider = SystemDNSProvider()
with pytest.raises(LookupError):
await provider.lookup_txt("nonexistent.example.com")
# ---------------------------------------------------------------------------
# CloudflareDNSProvider
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cloudflare_provider_parses_doh_response():
"""CloudflareDNSProvider should parse the Cloudflare DoH JSON response."""
from unittest.mock import MagicMock
fake_response_data = {
"Answer": [
{"type": 16, "data": '"v=DMARC1; p=reject"'},
{"type": 1, "data": "93.184.216.34"}, # A record — should be ignored
]
}
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock() # raise_for_status is synchronous in httpx
mock_response.json = lambda: fake_response_data
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=mock_response)):
provider = CloudflareDNSProvider()
records = await provider.lookup_txt("_dmarc.example.com")
assert records == ["v=DMARC1; p=reject"]
@pytest.mark.asyncio
async def test_cloudflare_provider_raises_on_http_error():
import httpx
with patch(
"httpx.AsyncClient.get",
new=AsyncMock(side_effect=httpx.RequestError("connection refused")),
):
provider = CloudflareDNSProvider()
with pytest.raises(LookupError):
await provider.lookup_txt("_dmarc.example.com")
# ---------------------------------------------------------------------------
# get_default_provider
# ---------------------------------------------------------------------------
def test_get_default_provider_returns_system():
provider = get_default_provider()
assert isinstance(provider, SystemDNSProvider)