Files
Gondulf/tests/unit/test_dns.py
Phil Skentelbery 1ef5cd9229 fix(dns): query _gondulf subdomain for domain verification
The DNS TXT verification was querying the base domain instead of
_gondulf.{domain}, causing verification to always fail even when
users had correctly configured their DNS records.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:46:38 -07:00

402 lines
15 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 TestGondulfDomainVerification:
"""Tests for Gondulf domain verification (queries _gondulf.{domain})."""
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_gondulf_verification_queries_prefixed_subdomain(self, mock_resolve):
"""
Test Gondulf domain verification queries _gondulf.{domain}.
This is the critical bug fix test - verifies we query the correct
subdomain (_gondulf.example.com) not the base domain (example.com).
"""
mock_rdata = MagicMock()
mock_rdata.strings = [b"gondulf-verify-domain"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify-domain")
assert result is True
# Critical: verify we queried _gondulf.example.com, not example.com
mock_resolve.assert_called_once_with("_gondulf.example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_gondulf_verification_with_missing_txt_record(self, mock_resolve):
"""Test Gondulf verification fails when no TXT records exist at _gondulf subdomain."""
mock_resolve.side_effect = dns.resolver.NoAnswer()
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify-domain")
assert result is False
mock_resolve.assert_called_once_with("_gondulf.example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_gondulf_verification_with_wrong_txt_value(self, mock_resolve):
"""Test Gondulf verification fails when TXT value doesn't match."""
mock_rdata = MagicMock()
mock_rdata.strings = [b"wrong-value"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify-domain")
assert result is False
mock_resolve.assert_called_once_with("_gondulf.example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_non_gondulf_verification_queries_base_domain(self, mock_resolve):
"""
Test non-Gondulf TXT verification still queries base domain.
Ensures backward compatibility - other TXT verification uses
should not be affected by the _gondulf prefix fix.
"""
mock_rdata = MagicMock()
mock_rdata.strings = [b"some-other-value"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("example.com", "some-other-value")
assert result is True
# Should query example.com directly, not _gondulf.example.com
mock_resolve.assert_called_once_with("example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_gondulf_verification_with_nxdomain(self, mock_resolve):
"""Test Gondulf verification handles NXDOMAIN for _gondulf subdomain."""
mock_resolve.side_effect = dns.resolver.NXDOMAIN()
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify-domain")
assert result is False
mock_resolve.assert_called_once_with("_gondulf.example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_gondulf_verification_among_multiple_txt_records(self, mock_resolve):
"""Test Gondulf verification finds value among multiple TXT records."""
mock_rdata1 = MagicMock()
mock_rdata1.strings = [b"v=spf1 include:example.com ~all"]
mock_rdata2 = MagicMock()
mock_rdata2.strings = [b"gondulf-verify-domain"]
mock_rdata3 = MagicMock()
mock_rdata3.strings = [b"other-record"]
mock_resolve.return_value = [mock_rdata1, mock_rdata2, mock_rdata3]
service = DNSService()
result = service.verify_txt_record("example.com", "gondulf-verify-domain")
assert result is True
mock_resolve.assert_called_once_with("_gondulf.example.com", "TXT")
@patch("gondulf.dns.dns.resolver.Resolver.resolve")
def test_gondulf_verification_with_subdomain(self, mock_resolve):
"""Test Gondulf verification works correctly with subdomains."""
mock_rdata = MagicMock()
mock_rdata.strings = [b"gondulf-verify-domain"]
mock_resolve.return_value = [mock_rdata]
service = DNSService()
result = service.verify_txt_record("blog.example.com", "gondulf-verify-domain")
assert result is True
# Should query _gondulf.blog.example.com
mock_resolve.assert_called_once_with("_gondulf.blog.example.com", "TXT")
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"]