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:
@@ -1,11 +1,23 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||||
from pydantic import BaseModel
|
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
|
from app.services.report_store import ReportStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -98,15 +110,70 @@ class DomainSummaryResponse(BaseModel):
|
|||||||
domains: List[Dict[str, Any]]
|
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)
|
@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.
|
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()
|
store = ReportStore.get_instance()
|
||||||
domains = store.get_domains()
|
domains = store.get_domains()
|
||||||
summaries = store.get_all_domain_summaries()
|
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
|
# Calculate overall statistics
|
||||||
total_domains = len(domains)
|
total_domains = len(domains)
|
||||||
total_emails = 0
|
total_emails = 0
|
||||||
@@ -115,22 +182,34 @@ async def get_domains_summary():
|
|||||||
|
|
||||||
domains_list = []
|
domains_list = []
|
||||||
|
|
||||||
for domain_name in domains:
|
for domain_name, dns in zip(domains, dns_results):
|
||||||
summary = summaries.get(domain_name, {})
|
summary = summaries.get(domain_name, {})
|
||||||
total_emails += summary.get("total_count", 0)
|
total_emails += summary.get("total_count", 0)
|
||||||
total_passed += summary.get("passed_count", 0)
|
total_passed += summary.get("passed_count", 0)
|
||||||
total_reports += summary.get("reports_processed", 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
|
# Format domain data for frontend
|
||||||
domains_list.append(
|
domains_list.append(
|
||||||
{
|
{
|
||||||
"id": domain_name, # Using the domain name as ID for now
|
"id": domain_name,
|
||||||
"domain_name": domain_name,
|
"domain_name": domain_name,
|
||||||
"total_emails": summary.get("total_count", 0),
|
"total_emails": summary.get("total_count", 0),
|
||||||
"passed_count": summary.get("passed_count", 0),
|
"passed_count": summary.get("passed_count", 0),
|
||||||
"failed_count": summary.get("failed_count", 0),
|
"failed_count": summary.get("failed_count", 0),
|
||||||
"pass_rate": summary.get("compliance_rate", 0),
|
"pass_rate": summary.get("compliance_rate", 0),
|
||||||
"report_count": summary.get("reports_processed", 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)
|
@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,
|
Get DNS records for a specific domain using live DNS lookups.
|
||||||
this returns mock data since DNS integration is part of a future milestone.
|
|
||||||
|
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()
|
store = ReportStore.get_instance()
|
||||||
domains = store.get_domains()
|
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",
|
detail="Domain not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
# For Milestone 1, return mock DNS record data
|
manual_selectors = _get_domain_selectors_from_db(db, domain_id)
|
||||||
# In a future milestone, this will be replaced with actual DNS lookups
|
report_selectors = _get_selectors_from_reports(store, domain_id)
|
||||||
mock_dmarc_record = (
|
combined_selectors = list(dict.fromkeys(manual_selectors + report_selectors))
|
||||||
"v=DMARC1; p=none; rua=mailto:dmarc@example.com;"
|
|
||||||
" ruf=mailto:forensic@example.com; pct=100"
|
provider = get_default_provider()
|
||||||
)
|
result = await provider.check_domain(domain_id, selectors=combined_selectors)
|
||||||
|
|
||||||
return DNSRecordResponse(
|
return DNSRecordResponse(
|
||||||
dmarc=True,
|
dmarc=result.dmarc,
|
||||||
dmarcRecord=mock_dmarc_record,
|
dmarcRecord=result.dmarc_record,
|
||||||
spf=True,
|
spf=result.spf,
|
||||||
spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all",
|
spfRecord=result.spf_record,
|
||||||
dkim=True,
|
dkim=result.dkim,
|
||||||
dkimSelectors="selector1, selector2",
|
dkimSelectors=result.dkim_selector,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -385,6 +471,90 @@ async def get_domain_sources(
|
|||||||
return DomainSourcesResponse(sources=source_entries)
|
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)
|
@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")):
|
async def delete_domain(domain_id: str = Path(..., title="The domain ID or name")):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -152,13 +152,55 @@
|
|||||||
</h3>
|
</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 class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.spfRecord || 'No SPF record found'">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- DKIM Selectors — live check result -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold mb-1 flex items-center">
|
<h3 class="font-semibold mb-1 flex items-center">
|
||||||
<span class="mr-2">DKIM Selectors</span>
|
<span class="mr-2">DKIM (live check)</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" 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 x-show="!dns.dkim" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
@@ -347,6 +389,9 @@ function domainDetailsApp(domainId) {
|
|||||||
dkim: false,
|
dkim: false,
|
||||||
dkimSelectors: ''
|
dkimSelectors: ''
|
||||||
},
|
},
|
||||||
|
selectors: [],
|
||||||
|
newSelector: '',
|
||||||
|
selectorError: '',
|
||||||
reports: [],
|
reports: [],
|
||||||
sources: [],
|
sources: [],
|
||||||
complianceChart: null,
|
complianceChart: null,
|
||||||
@@ -358,6 +403,7 @@ function domainDetailsApp(domainId) {
|
|||||||
init() {
|
init() {
|
||||||
this.fetchDomainStats();
|
this.fetchDomainStats();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
|
this.fetchSelectors();
|
||||||
this.fetchReports();
|
this.fetchReports();
|
||||||
this.fetchSources();
|
this.fetchSources();
|
||||||
|
|
||||||
@@ -375,6 +421,12 @@ function domainDetailsApp(domainId) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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() {
|
async fetchDomainStats() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/domains/${this.domainId}/stats`);
|
const response = await fetch(`/api/v1/domains/${this.domainId}/stats`);
|
||||||
@@ -399,6 +451,63 @@ function domainDetailsApp(domainId) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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() {
|
async fetchReports() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/domains/${this.domainId}/reports?limit=10`);
|
const response = await fetch(`/api/v1/domains/${this.domainId}/reports?limit=10`);
|
||||||
|
|||||||
@@ -111,13 +111,13 @@ function domainsApp() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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 => ({
|
this.domains = data.domains.map(domain => ({
|
||||||
name: domain.domain_name,
|
name: domain.domain_name,
|
||||||
dmarc_status: true, // In Milestone 1, assume DMARC is configured if we have reports
|
dmarc_status: domain.dmarc_status ?? false,
|
||||||
dmarc_policy: domain.policy || 'p=none',
|
dmarc_policy: domain.dmarc_policy || 'Not configured',
|
||||||
spf_status: true, // In future milestones, this will come from DNS checks
|
spf_status: domain.spf_status ?? false,
|
||||||
dkim_status: true, // In future milestones, this will come from DNS checks
|
dkim_status: domain.dkim_status ?? false,
|
||||||
reports_count: domain.report_count,
|
reports_count: domain.report_count,
|
||||||
emails_count: domain.total_emails,
|
emails_count: domain.total_emails,
|
||||||
compliance_rate: domain.pass_rate
|
compliance_rate: domain.pass_rate
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user