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
This commit is contained in:
1 parent
e8762bf807
commit
4ad110ca53
17 files changed
+2680
No files matched your search
@@ -0,0 +1,358 @@
|
||||
"""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"]
|
||||
Reference in new issue
Block a user