"""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"]