Files
Gondulf/tests/unit/test_dns.py
Phil Skentelbery bebd47955f feat(core): implement Phase 1 foundation infrastructure
Implements Phase 1 Foundation with all core services:

Core Components:
- Configuration management with GONDULF_ environment variables
- Database layer with SQLAlchemy and migration system
- In-memory code storage with TTL support
- Email service with SMTP and TLS support (STARTTLS + implicit TLS)
- DNS service with TXT record verification
- Structured logging with Python standard logging
- FastAPI application with health check endpoint

Database Schema:
- authorization_codes table for OAuth 2.0 authorization codes
- domains table for domain verification
- migrations table for tracking schema versions
- Simple sequential migration system (001_initial_schema.sql)

Configuration:
- Environment-based configuration with validation
- .env.example template with all GONDULF_ variables
- Fail-fast validation on startup
- Sensible defaults for optional settings

Testing:
- 96 comprehensive tests (77 unit, 5 integration)
- 94.16% code coverage (exceeds 80% requirement)
- All tests passing
- Test coverage includes:
  - Configuration loading and validation
  - Database migrations and health checks
  - In-memory storage with expiration
  - Email service (STARTTLS, implicit TLS, authentication)
  - DNS service (TXT records, domain verification)
  - Health check endpoint integration

Documentation:
- Implementation report with test results
- Phase 1 clarifications document
- ADRs for key decisions (config, database, email, logging)

Technical Details:
- Python 3.10+ with type hints
- SQLite with configurable database URL
- System DNS with public DNS fallback
- Port-based TLS detection (465=SSL, 587=STARTTLS)
- Lazy configuration loading for testability

Exit Criteria Met:
✓ All foundation services implemented
✓ Application starts without errors
✓ Health check endpoint operational
✓ Database migrations working
✓ Test coverage exceeds 80%
✓ All tests passing

Ready for Architect review and Phase 2 development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:21:42 -07:00

294 lines
10 KiB
Python

"""
Unit tests for DNS service.
Tests TXT record querying, domain verification, and error handling.
Uses mocking to avoid actual DNS queries.
"""
from unittest.mock import MagicMock, patch
import pytest
import dns.resolver
from dns.exception import DNSException
from gondulf.dns import DNSError, DNSService
class TestDNSServiceInit:
"""Tests for DNSService initialization."""
def test_init_creates_resolver(self):
"""Test DNSService initializes with resolver."""
service = DNSService()
assert service.resolver is not None
assert isinstance(service.resolver, dns.resolver.Resolver)
class TestGetTxtRecords:
"""Tests for get_txt_records method."""
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_success(self, mock_resolve):
"""Test getting TXT records successfully."""
# Mock TXT record response
mock_rdata = MagicMock()
mock_rdata.strings = [b"v=spf1 include:example.com ~all"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
records = service.get_txt_records("example.com")
assert len(records) == 1
assert records[0] == "v=spf1 include:example.com ~all"
mock_resolve.assert_called_once_with("example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_multiple(self, mock_resolve):
"""Test getting multiple TXT records."""
# Mock multiple TXT records
mock_rdata1 = MagicMock()
mock_rdata1.strings = [b"record1"]
mock_rdata2 = MagicMock()
mock_rdata2.strings = [b"record2"]
mock_resolve.return_value = [mock_rdata1, mock_rdata2]
service = DNSService()
records = service.get_txt_records("example.com")
assert len(records) == 2
assert "record1" in records
assert "record2" in records
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_multipart(self, mock_resolve):
"""Test getting TXT record with multiple strings (joined)."""
# Mock TXT record with multiple strings
mock_rdata = MagicMock()
mock_rdata.strings = [b"part1", b"part2", b"part3"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
records = service.get_txt_records("example.com")
assert len(records) == 1
assert records[0] == "part1part2part3"
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_no_answer(self, mock_resolve):
"""Test getting TXT records when none exist returns empty list."""
mock_resolve.side_effect = dns.resolver.NoAnswer()
service = DNSService()
records = service.get_txt_records("example.com")
assert records == []
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_nxdomain(self, mock_resolve):
"""Test DNSError raised when domain doesn't exist."""
mock_resolve.side_effect = dns.resolver.NXDOMAIN()
service = DNSService()
with pytest.raises(DNSError, match="Domain does not exist"):
service.get_txt_records("nonexistent.example")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_timeout(self, mock_resolve):
"""Test DNSError raised on timeout."""
mock_resolve.side_effect = dns.resolver.Timeout()
service = DNSService()
with pytest.raises(DNSError, match="timeout"):
service.get_txt_records("example.com")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_get_txt_records_dns_exception(self, mock_resolve):
"""Test DNSError raised on other DNS exceptions."""
mock_resolve.side_effect = DNSException("DNS query failed")
service = DNSService()
with pytest.raises(DNSError, match="DNS query failed"):
service.get_txt_records("example.com")
class TestVerifyTxtRecord:
"""Tests for verify_txt_record method."""
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_success(self, mock_resolve):
"""Test TXT record verification succeeds when value found."""
mock_rdata = MagicMock()
mock_rdata.strings = [b"gondulf-verify=ABC123"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify=ABC123")
assert result is True
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_partial_match(self, mock_resolve):
"""Test TXT record verification succeeds with partial match."""
mock_rdata = MagicMock()
mock_rdata.strings = [b"some prefix gondulf-verify=ABC123 some suffix"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify=ABC123")
assert result is True
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_not_found(self, mock_resolve):
"""Test TXT record verification fails when value not found."""
mock_rdata = MagicMock()
mock_rdata.strings = [b"different-value"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify=ABC123")
assert result is False
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_no_txt_records(self, mock_resolve):
"""Test TXT record verification fails when no TXT records exist."""
mock_resolve.side_effect = dns.resolver.NoAnswer()
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify=ABC123")
assert result is False
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_nxdomain(self, mock_resolve):
"""Test TXT record verification fails when domain doesn't exist."""
mock_resolve.side_effect = dns.resolver.NXDOMAIN()
service = DNSService()
result = service.verify_txt_record("nonexistent.example", "value")
assert result is False
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_timeout(self, mock_resolve):
"""Test TXT record verification fails on timeout."""
mock_resolve.side_effect = dns.resolver.Timeout()
service = DNSService()
result = service.verify_txt_record("example.com", "value")
assert result is False
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_verify_txt_record_among_multiple(self, mock_resolve):
"""Test TXT record verification finds value among multiple records."""
mock_rdata1 = MagicMock()
mock_rdata1.strings = [b"unrelated-record"]
mock_rdata2 = MagicMock()
mock_rdata2.strings = [b"gondulf-verify=ABC123"]
mock_rdata3 = MagicMock()
mock_rdata3.strings = [b"another-record"]
mock_resolve.return_value = [mock_rdata1, mock_rdata2, mock_rdata3]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify=ABC123")
assert result is True
class TestCheckDomainExists:
"""Tests for check_domain_exists method."""
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_check_domain_exists_a_record(self, mock_resolve):
"""Test domain exists check succeeds with A record."""
mock_resolve.return_value = [MagicMock()]
service = DNSService()
result = service.check_domain_exists("example.com")
assert result is True
mock_resolve.assert_called_with("example.com", "A")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_check_domain_exists_aaaa_record(self, mock_resolve):
"""Test domain exists check succeeds with AAAA record."""
# First call (A record) fails, second call (AAAA) succeeds
mock_resolve.side_effect = [
dns.resolver.NoAnswer(),
[MagicMock()], # AAAA record exists
]
service = DNSService()
result = service.check_domain_exists("example.com")
assert result is True
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_check_domain_exists_no_records(self, mock_resolve):
"""Test domain exists check succeeds even with no A/AAAA records."""
# Both A and AAAA fail with NoAnswer (but not NXDOMAIN)
mock_resolve.side_effect = [
dns.resolver.NoAnswer(),
dns.resolver.NoAnswer(),
]
service = DNSService()
result = service.check_domain_exists("example.com")
# Domain exists even if no A/AAAA records (might have MX, TXT, etc.)
assert result is True
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_check_domain_not_exists_nxdomain(self, mock_resolve):
"""Test domain exists check fails with NXDOMAIN."""
mock_resolve.side_effect = dns.resolver.NXDOMAIN()
service = DNSService()
result = service.check_domain_exists("nonexistent.example")
assert result is False
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_check_domain_exists_dns_error(self, mock_resolve):
"""Test domain exists check returns False on DNS error."""
mock_resolve.side_effect = DNSException("DNS failure")
service = DNSService()
result = service.check_domain_exists("example.com")
assert result is False
class TestResolverFallback:
"""Tests for DNS resolver fallback configuration."""
@patch("gondulf.dns.dns.resolver.Resolver")
def test_resolver_uses_system_dns(self, mock_resolver_class):
"""Test resolver uses system DNS when available."""
mock_resolver = MagicMock()
mock_resolver.nameservers = ["192.168.1.1"] # System DNS
mock_resolver_class.return_value = mock_resolver
service = DNSService()
# System DNS should be used
assert service.resolver.nameservers == ["192.168.1.1"]
@patch("gondulf.dns.dns.resolver.Resolver")
def test_resolver_fallback_to_public_dns(self, mock_resolver_class):
"""Test resolver falls back to public DNS when system DNS unavailable."""
mock_resolver = MagicMock()
mock_resolver.nameservers = [] # No system DNS
mock_resolver_class.return_value = mock_resolver
service = DNSService()
# Should fall back to public DNS
assert service.resolver.nameservers == ["8.8.8.8", "1.1.1.1"]