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,258 @@
|
||||
"""Shared, non-fixture helpers for the test suite.
|
||||
|
||||
Pure DNS / TSIG / handler plumbing that does not touch the database. Database
|
||||
factories live in ``conftest.py`` as pytest fixtures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import dns.message
|
||||
import dns.name
|
||||
import dns.rdatatype
|
||||
import dns.tsig
|
||||
import dns.tsigkeyring
|
||||
|
||||
from netbox_dns_bridge.management.commands.dns_transfer_endpoint.request_handler import (
|
||||
DNSBaseRequestHandler,
|
||||
TCPRequestHandler,
|
||||
UDPRequestHandler,
|
||||
)
|
||||
|
||||
DEFAULT_TSIG_ALGORITHM = "hmac-sha256"
|
||||
DEFAULT_TSIG_SECRET = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
|
||||
|
||||
|
||||
def make_tsig_key(
|
||||
key_name: str = "testkey.",
|
||||
secret: str = DEFAULT_TSIG_SECRET,
|
||||
algorithm: str = DEFAULT_TSIG_ALGORITHM,
|
||||
) -> dns.tsig.Key:
|
||||
name = dns.name.from_text(key_name, origin=dns.name.root).canonicalize()
|
||||
return dns.tsig.Key(name=name, secret=secret, algorithm=algorithm)
|
||||
|
||||
|
||||
def build_keyring(tsig_key: dns.tsig.Key) -> dict:
|
||||
return {tsig_key.name: tsig_key}
|
||||
|
||||
|
||||
def build_tsig_view_map(key_name: str, view) -> dict:
|
||||
canon = dns.name.from_text(key_name, origin=dns.name.root).canonicalize().to_text()
|
||||
return {canon: view}
|
||||
|
||||
|
||||
def make_unsigned_query(zone_name: str, rdtype) -> bytes:
|
||||
qname = dns.name.from_text(zone_name, dns.name.root)
|
||||
msg = dns.message.make_query(qname, rdtype)
|
||||
return msg.to_wire()
|
||||
|
||||
|
||||
def make_signed_query(
|
||||
zone_name: str,
|
||||
rdtype,
|
||||
tsig_key: dns.tsig.Key,
|
||||
) -> bytes:
|
||||
qname = dns.name.from_text(zone_name, dns.name.root)
|
||||
msg = dns.message.make_query(qname, rdtype)
|
||||
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
|
||||
return msg.to_wire()
|
||||
|
||||
|
||||
class FakeServer:
|
||||
"""Stand-in for socketserver's ``self.server`` carrying TSIG state."""
|
||||
|
||||
def __init__(self, keyring: dict, tsig_view_map: dict):
|
||||
self.keyring = keyring
|
||||
self.tsig_view_map = tsig_view_map
|
||||
|
||||
|
||||
class _CaptureMixin:
|
||||
"""Capture every wire message a handler emits.
|
||||
|
||||
Both ``_send_response`` (used by denials, SOA and the final AXFR message)
|
||||
and the direct ``self.request.sendall`` calls (intermediate AXFR messages)
|
||||
are recorded as raw wire bytes (the 2-byte TCP length prefix is stripped).
|
||||
"""
|
||||
|
||||
transport = "tcp"
|
||||
|
||||
def _setup_capture(self, captured: list):
|
||||
self._captured = captured
|
||||
# For TCP: AXFR intermediate sends use self.request.sendall directly.
|
||||
self.request = MagicMock()
|
||||
self.request.sendall.side_effect = lambda data: self._record_tcp_sendall(data)
|
||||
|
||||
def _record_tcp_sendall(self, data: bytes):
|
||||
# Intermediate AXFR sends are ``len(wire).to_bytes(2,"big") + wire``.
|
||||
if len(data) >= 2:
|
||||
self._captured.append(data[2:])
|
||||
else:
|
||||
self._captured.append(data)
|
||||
|
||||
def _send_response(self, data: bytes):
|
||||
self._captured.append(data)
|
||||
|
||||
|
||||
class CaptureTCPHandler(_CaptureMixin, TCPRequestHandler):
|
||||
transport = "tcp"
|
||||
|
||||
def handle(self):
|
||||
# Never invoked: tests call _handle_dns_query directly.
|
||||
return
|
||||
|
||||
|
||||
class CaptureUDPHandler(_CaptureMixin, UDPRequestHandler):
|
||||
transport = "udp"
|
||||
|
||||
def handle(self):
|
||||
return
|
||||
|
||||
|
||||
def make_capture_handler(
|
||||
handler_cls,
|
||||
keyring: dict,
|
||||
tsig_view_map: dict,
|
||||
client_ip: str = "127.0.0.1",
|
||||
):
|
||||
"""Instantiate a capture handler without running ``handle()``.
|
||||
|
||||
Returns ``(handler, captured)`` where ``captured`` is the list of wire
|
||||
messages emitted by the handler.
|
||||
"""
|
||||
captured: list = []
|
||||
server = FakeServer(keyring, tsig_view_map)
|
||||
handler = handler_cls.__new__(handler_cls)
|
||||
# Replicate DNSBaseRequestHandler.__init__ without calling super().__init__
|
||||
# (which would invoke setup()/handle()).
|
||||
handler.MAX_WIRE = 65535
|
||||
handler.RESERVED_TSIG = 300
|
||||
handler._setup_capture(captured)
|
||||
handler.client_address = (client_ip, 12345)
|
||||
handler.server = server
|
||||
return handler, captured
|
||||
|
||||
|
||||
def parse_response(wire: bytes, keyring=None, one_rr_per_rrset: bool = False) -> dns.message.Message:
|
||||
# ``make_response`` in this dnspython copies the request TSIG keyname into
|
||||
# the response even for unsigned denials, so lenient parsing is required to
|
||||
# inspect rcode/flags/answer without verifying the (absent) MAC.
|
||||
# Use ``one_rr_per_rrset=True`` for AXFR framing checks so that repeated
|
||||
# identical RRsets (e.g. opening + closing SOA) are not merged on parse.
|
||||
return dns.message.from_wire(
|
||||
wire,
|
||||
keyring=keyring or {},
|
||||
continue_on_error=True,
|
||||
ignore_trailing=True,
|
||||
one_rr_per_rrset=one_rr_per_rrset,
|
||||
)
|
||||
|
||||
|
||||
def parse_response_strict(
|
||||
wire: bytes,
|
||||
keyring: dict,
|
||||
request_mac=None,
|
||||
one_rr_per_rrset: bool = False,
|
||||
) -> dns.message.Message:
|
||||
"""Parse and fully verify TSIG. Raises on bad signature / unknown key.
|
||||
|
||||
Verifying a response TSIG requires the original request's MAC; pass it via
|
||||
``request_mac`` (obtainable from the parsed query's ``.mac`` attribute).
|
||||
"""
|
||||
return dns.message.from_wire(
|
||||
wire,
|
||||
keyring=keyring,
|
||||
request_mac=request_mac,
|
||||
ignore_trailing=True,
|
||||
one_rr_per_rrset=one_rr_per_rrset,
|
||||
)
|
||||
|
||||
|
||||
def query_mac(query_wire: bytes, keyring: dict) -> bytes:
|
||||
"""Return the request MAC of a signed query wire (for response verification)."""
|
||||
q = dns.message.from_wire(query_wire, keyring=keyring, ignore_trailing=True)
|
||||
return q.mac
|
||||
|
||||
|
||||
def tcp_exchange(wire: bytes, host: str, port: int, timeout: float = 5.0) -> bytes:
|
||||
"""Send a wire message over TCP (2-byte length framing), return response wire."""
|
||||
import socket
|
||||
|
||||
with socket.create_connection((host, port), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(len(wire).to_bytes(2, "big") + wire)
|
||||
# Read response length then body (handle partial reads).
|
||||
length = _recv_exact(sock, 2)
|
||||
resp_len = int.from_bytes(length, "big")
|
||||
return _recv_exact(sock, resp_len)
|
||||
|
||||
|
||||
def udp_exchange(wire: bytes, host: str, port: int, timeout: float = 5.0) -> bytes:
|
||||
"""Send a wire message over UDP, return response wire."""
|
||||
import socket
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.sendto(wire, (host, port))
|
||||
return sock.recv(65535)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _recv_exact(sock, n: int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise OSError("connection closed")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def xfr_rrsets(xfr_messages):
|
||||
"""Flatten AXFR/IXFR response messages into a flat list of RRsets.
|
||||
|
||||
``dns.query.xfr`` yields ``Message`` objects (one per TCP response chunk).
|
||||
Each message's ``answer`` section contains RRsets. This helper flattens
|
||||
all answer RRsets across all messages into a single list, preserving order.
|
||||
"""
|
||||
rrsets = []
|
||||
for msg in xfr_messages:
|
||||
rrsets.extend(msg.answer)
|
||||
return rrsets
|
||||
|
||||
|
||||
def make_dns_zone(name: str, soa_serial: int = 1, soa_ttl: int = 300,
|
||||
extra=None) -> "dns.zone.Zone":
|
||||
"""Build a ``dns.zone.Zone`` directly (no database) for unit tests.
|
||||
|
||||
Always includes an SOA at the apex. ``extra`` is an optional list of
|
||||
``(relative_name, rdtype, ttl, value_text)`` tuples appended as rdatasets.
|
||||
"""
|
||||
import dns.rdata
|
||||
import dns.rdataclass
|
||||
import dns.rdataset
|
||||
import dns.rdatatype
|
||||
import dns.zone
|
||||
|
||||
origin = dns.name.from_text(name, dns.name.root)
|
||||
zone = dns.zone.Zone(origin)
|
||||
zone.rdclass = dns.rdataclass.IN
|
||||
|
||||
soa_rdata = dns.rdata.from_text(
|
||||
dns.rdataclass.IN,
|
||||
dns.rdatatype.SOA,
|
||||
f"ns1.{name}. hostmaster.{name}. {soa_serial} 300 100 1209600 3600",
|
||||
)
|
||||
soa_rds = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.SOA)
|
||||
soa_rds.add(soa_rdata, soa_ttl)
|
||||
zone.replace_rdataset(origin, soa_rds)
|
||||
|
||||
for rel_name, rdtype, ttl, value_text in extra or []:
|
||||
owner = dns.name.from_text(rel_name, origin)
|
||||
rdtype_int = dns.rdatatype.from_text(rdtype)
|
||||
rdata = dns.rdata.from_text(dns.rdataclass.IN, rdtype_int, value_text)
|
||||
rds = zone.find_rdataset(owner, rdtype_int, create=True)
|
||||
rds.add(rdata, ttl)
|
||||
|
||||
return zone
|
||||
Reference in new issue
Block a user