Files
netbox-plugin-dns-bridge/netbox_dns_bridge/tests/test_request_handler_unit.py
T
sven 4ad110ca53 new file: conftest.py
new file:   netbox_dns_bridge/tests/__init__.py
new file:   netbox_dns_bridge/tests/_helpers.py
new file:   netbox_dns_bridge/tests/conftest.py
new file:   netbox_dns_bridge/tests/test_bootstrap.py
new file:   netbox_dns_bridge/tests/test_catalog_zone_manager.py
new file:   netbox_dns_bridge/tests/test_dns_server.py
new file:   netbox_dns_bridge/tests/test_management_commands.py
new file:   netbox_dns_bridge/tests/test_models.py
new file:   netbox_dns_bridge/tests/test_notify_handler.py
new file:   netbox_dns_bridge/tests/test_request_handler_integration.py
new file:   netbox_dns_bridge/tests/test_request_handler_unit.py
new file:   netbox_dns_bridge/tests/test_signals.py
new file:   netbox_dns_bridge/tests/test_smoke.py
new file:   netbox_dns_bridge/tests/test_utils.py
new file:   netbox_dns_bridge/tests/test_views.py
modified:   pyproject.toml
Add some tests. #AI generated
2026-08-12 12:25:46 +02:00

359 lines
15 KiB
Python

"""Unit tests for the DNS transfer request handler.
These exercise routing, denial gates, SOA/AXFR response construction and TSIG
handling by feeding crafted wire bytes to ``_handle_dns_query`` and capturing
emitted responses. Zone lookups are mocked so no database is needed for the
routing/response logic; the integration suite covers the real-socket path.
"""
from types import SimpleNamespace
from unittest.mock import patch
import dns.flags
import dns.message
import dns.name
import dns.rcode
import dns.rdatatype
import netbox_dns_bridge.management.commands.dns_transfer_endpoint.request_handler as rh
from netbox_dns_bridge.tests._helpers import (
CaptureTCPHandler,
CaptureUDPHandler,
make_dns_zone,
make_signed_query,
make_tsig_key,
make_unsigned_query,
parse_response,
parse_response_strict,
query_mac,
build_keyring,
build_tsig_view_map,
)
# ---------------------------------------------------------------------------
# Fixtures local to this module
# ---------------------------------------------------------------------------
import pytest
@pytest.fixture
def view():
return SimpleNamespace(name="default")
@pytest.fixture
def keyring(tsig_key):
return build_keyring(tsig_key)
@pytest.fixture
def view_map(tsig_key, view):
return build_tsig_view_map("testkey.", view)
@pytest.fixture
def tcp_handler(capture_handler, keyring, view_map):
"""Return a factory building a fresh TCP capture handler + captured list."""
def _make(client_ip="127.0.0.1"):
return capture_handler(CaptureTCPHandler, keyring, view_map, client_ip)
return _make
def _patch_regular_zone(monkeypatch, handler, zone):
handler._get_zone_from_nb = lambda name, view_name: zone
def _patch_catz(monkeypatch, zone):
monkeypatch.setattr(rh.catzm, "create_zone", lambda name, view_name: zone)
# ---------------------------------------------------------------------------
# Denial / routing gates
# ---------------------------------------------------------------------------
class TestRoutingGates:
def test_no_tsig_refused(self, tcp_handler, tsig_key):
handler, captured = tcp_handler()
wire = make_unsigned_query("example.test", dns.rdatatype.SOA)
handler._handle_dns_query(wire)
assert len(captured) == 1
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_question_count_zero_refused(self, tcp_handler, tsig_key):
handler, captured = tcp_handler()
msg = dns.message.make_query("example.test", dns.rdatatype.SOA)
msg.question = []
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
handler._handle_dns_query(msg.to_wire())
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_unsupported_qtype_refused(self, tcp_handler, tsig_key):
handler, captured = tcp_handler()
wire = make_signed_query("example.test", dns.rdatatype.A, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_ixfr_not_supported_refused(self, tcp_handler, tsig_key):
handler, captured = tcp_handler()
wire = make_signed_query("example.test", dns.rdatatype.IXFR, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_axfr_over_udp_refused(self, capture_handler, keyring, view_map, tsig_key):
handler, captured = capture_handler(
CaptureUDPHandler, keyring, view_map
)
wire = make_signed_query("example.test", dns.rdatatype.AXFR, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_key_not_mapped_to_view_refused(self, capture_handler, view):
other_key = make_tsig_key("unknownkey.")
keyring = build_keyring(other_key)
view_map = {} # no mapping
handler, captured = capture_handler(CaptureTCPHandler, keyring, view_map)
wire = make_signed_query("example.test", dns.rdatatype.SOA, other_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_zone_not_found_refused_not_nxdomain(self, tcp_handler, tsig_key, monkeypatch):
handler, captured = tcp_handler()
handler._get_zone_from_nb = lambda name, view_name: None
wire = make_signed_query("missing.test", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
assert resp.rcode() != dns.rcode.NXDOMAIN
def test_malformed_query_silent_drop(self, tcp_handler):
handler, captured = tcp_handler()
handler._handle_dns_query(b"\x00\x00not a dns message")
assert captured == []
def test_zone_without_soa_silent_drop(self, tcp_handler, tsig_key, monkeypatch):
import dns.zone
import dns.name
zone = dns.zone.Zone(dns.name.from_text("empty.test", dns.name.root))
handler, captured = tcp_handler()
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("empty.test", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
assert captured == []
# ---------------------------------------------------------------------------
# TSIG error paths
# ---------------------------------------------------------------------------
class TestTSIGErrors:
def test_bad_signature_refused_with_badsig(self, tcp_handler, tsig_key):
handler, captured = tcp_handler()
wire = make_signed_query("example.test", dns.rdatatype.SOA, tsig_key)
# Flip a byte in the TSIG MAC region to break the signature.
tampered = bytearray(wire)
tampered[-5] ^= 0xFF
handler._handle_dns_query(bytes(tampered))
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_unknown_key_refused_with_badkey(self, tcp_handler):
handler, captured = tcp_handler()
unknown = make_tsig_key("nopekey.")
wire = make_signed_query("example.test", dns.rdatatype.SOA, unknown)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
# ---------------------------------------------------------------------------
# SOA responses
# ---------------------------------------------------------------------------
class TestSOAResponse:
def test_soa_regular_zone(self, tcp_handler, tsig_key, keyring, monkeypatch):
zone = make_dns_zone("example.test", soa_serial=42)
handler, captured = tcp_handler()
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("example.test", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response_strict(captured[0], keyring, query_mac(wire, keyring))
assert resp.rcode() == dns.rcode.NOERROR
assert resp.flags & dns.flags.AA
assert resp.flags & dns.flags.QR
assert len(resp.answer) == 1
rrset = resp.answer[0]
assert rrset.rdtype == dns.rdatatype.SOA
assert rrset[0].serial == 42
assert resp.had_tsig
def test_soa_for_catz(self, tcp_handler, tsig_key, monkeypatch):
zone = make_dns_zone("catz", soa_serial=99)
_patch_catz(monkeypatch, zone)
handler, captured = tcp_handler()
wire = make_signed_query("catz", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.NOERROR
assert resp.answer[0].rdtype == dns.rdatatype.SOA
assert resp.answer[0][0].serial == 99
def test_soa_for_view_catz_case_insensitive(self, tcp_handler, tsig_key, monkeypatch):
zone = make_dns_zone("default.catz", soa_serial=99)
_patch_catz(monkeypatch, zone)
handler, captured = tcp_handler()
wire = make_signed_query("DEFAULT.CATZ", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.NOERROR
def test_soa_catz_case_insensitive(self, tcp_handler, tsig_key, monkeypatch):
zone = make_dns_zone("catz", soa_serial=99)
_patch_catz(monkeypatch, zone)
handler, captured = tcp_handler()
wire = make_signed_query("CATZ", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.NOERROR
def test_soa_tsig_signed(self, tcp_handler, tsig_key, keyring, monkeypatch):
zone = make_dns_zone("example.test", soa_serial=1)
handler, captured = tcp_handler()
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("example.test", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response_strict(captured[0], keyring, query_mac(wire, keyring))
assert resp.had_tsig
assert resp.keyname == tsig_key.name
# ---------------------------------------------------------------------------
# AXFR responses
# ---------------------------------------------------------------------------
class TestAXFRResponse:
def test_axfr_single_message_framing(self, tcp_handler, tsig_key, keyring, monkeypatch):
zone = make_dns_zone(
"example.test",
soa_serial=7,
extra=[("www", "A", 300, "192.0.2.1"), ("mail", "A", 300, "192.0.2.2")],
)
handler, captured = tcp_handler()
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("example.test", dns.rdatatype.AXFR, tsig_key)
handler._handle_dns_query(wire)
# Single message expected (small zone).
assert len(captured) == 1
resp = parse_response_strict(
captured[0], keyring, query_mac(wire, keyring), one_rr_per_rrset=True
)
assert resp.rcode() == dns.rcode.NOERROR
assert resp.flags & dns.flags.AA
assert resp.flags & dns.flags.QR
assert resp.had_tsig
# First and last answer rrsets are SOA (opening + closing).
assert resp.answer[0].rdtype == dns.rdatatype.SOA
assert resp.answer[-1].rdtype == dns.rdatatype.SOA
assert resp.answer[0][0].serial == 7
# Middle records present.
types = {rr.rdtype for rr in resp.answer}
assert dns.rdatatype.A in types
def test_axfr_multi_message_when_large(self, tcp_handler, tsig_key, monkeypatch):
# Build a zone with many records, then shrink MAX_WIRE to force splits.
extras = [(f"host{i}", "A", 300, f"192.0.2.{i % 255}") for i in range(200)]
zone = make_dns_zone("big.test", soa_serial=1, extra=extras)
handler, captured = tcp_handler()
handler.MAX_WIRE = 800
handler.RESERVED_TSIG = 300
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("big.test", dns.rdatatype.AXFR, tsig_key)
handler._handle_dns_query(wire)
assert len(captured) >= 2
# First message opens with SOA, last message closes with SOA.
first = parse_response(captured[0], one_rr_per_rrset=True)
last = parse_response(captured[-1], one_rr_per_rrset=True)
assert first.answer[0].rdtype == dns.rdatatype.SOA
assert last.answer[-1].rdtype == dns.rdatatype.SOA
def test_axfr_over_udp_refused(self, capture_handler, keyring, view_map, tsig_key):
handler, captured = capture_handler(CaptureUDPHandler, keyring, view_map)
wire = make_signed_query("example.test", dns.rdatatype.AXFR, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
def test_axfr_zone_without_soa_silent_drop(self, tcp_handler, tsig_key, monkeypatch):
import dns.zone
import dns.name
zone = dns.zone.Zone(dns.name.from_text("nosoa.test", dns.name.root))
handler, captured = tcp_handler()
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("nosoa.test", dns.rdatatype.AXFR, tsig_key)
handler._handle_dns_query(wire)
assert captured == []
def test_axfr_tsig_chain_uses_query_mac_first(self, tcp_handler, tsig_key, keyring, monkeypatch):
extras = [(f"host{i}", "A", 300, f"192.0.2.{i % 255}") for i in range(120)]
zone = make_dns_zone("chain.test", soa_serial=1, extra=extras)
handler, captured = tcp_handler()
handler.MAX_WIRE = 800
handler.RESERVED_TSIG = 300
handler._get_zone_from_nb = lambda name, view_name: zone
wire = make_signed_query("chain.test", dns.rdatatype.AXFR, tsig_key)
handler._handle_dns_query(query_mac_wire := wire)
# At least two messages produced.
assert len(captured) >= 2
# First message verifies against the request MAC and opens with SOA.
first = parse_response_strict(
captured[0], keyring, query_mac(wire, keyring), one_rr_per_rrset=True
)
assert first.had_tsig
assert first.answer[0].rdtype == dns.rdatatype.SOA
# ---------------------------------------------------------------------------
# Routing: case sensitivity, trailing dot
# ---------------------------------------------------------------------------
class TestRoutingDetails:
def test_regular_zone_case_sensitive(self, tcp_handler, tsig_key):
zone = make_dns_zone("example.test", soa_serial=1)
handler, captured = tcp_handler()
calls = []
def _lookup(name, view_name):
calls.append(name)
return zone if name == "example.test" else None
handler._get_zone_from_nb = _lookup
wire = make_signed_query("Example.TEST", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.REFUSED
assert calls == ["Example.TEST"]
def test_trailing_dot_normalized(self, tcp_handler, tsig_key):
zone = make_dns_zone("example.test", soa_serial=1)
handler, captured = tcp_handler()
seen = []
handler._get_zone_from_nb = lambda name, view_name: (
seen.append(name) or zone
)
wire = make_signed_query("example.test.", dns.rdatatype.SOA, tsig_key)
handler._handle_dns_query(wire)
resp = parse_response(captured[0])
assert resp.rcode() == dns.rcode.NOERROR
assert seen == ["example.test"]