Files
netbox-plugin-dns-bridge/netbox_dns_bridge/tests/conftest.py
T
sven 9f5e40ae48 Replace global IntegerKeyValueSetting with per-view CatalogZone model
Track the catalog zone SOA serial per view instead of a single global value.
The new CatalogZone model has a 1:1 FK to netbox_dns.models.View and its own
soa_serial field; each view's catalog zone now increments independently. The
existing global serial is preserved during migration by copying it into every
view's new CatalogZone row.

Change CatalogZoneMemberIdentifier.name from globally unique to unique per
catalog zone (UniqueConstraint(name, catalog_zone)) and add a catalog_zone FK
to enforce it.

Remove the dns-settings management command, which was a generic interface over
the now-removed IntegerKeyValueSetting model.

Increment both catalog zone serials when a zone moves between views: the old
view's catalog zone loses a member and the new view's gains one, so both must
be bumped. Also refresh the member identifier's catalog_zone FK on a view move
so it does not go stale. The pre_save handler now captures _old_view_id and the
post_save handler detects view changes, increments the old view's serial inside
on_commit (before the new view's), and re-creates the identifier.

AI generated, code manually verified.
2026-08-17 10:02:59 +02:00

260 lines
7.8 KiB
Python

"""Pytest configuration and shared fixtures.
Tests run inside the NetBox container (``DJANGO_SETTINGS_MODULE=netbox.configuration``)
where ``netbox`` and ``netbox_dns`` are importable. The plugin's module-level
``SETTINGS`` bindings are patched per-test via the ``patch_settings`` fixture.
Heavy imports are deferred into fixtures so this conftest imports cleanly before
the Django app registry is guaranteed ready (the root conftest calls
``django.setup()``).
"""
from __future__ import annotations
import pytest
@pytest.fixture
def default_view(db):
"""Ensure NetBox DNS has a default view (required before creating zones)."""
from netbox_dns.models import View
view, _ = View.objects.get_or_create(
name="default", defaults={"default_view": True}
)
return view
def _settings_modules():
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import (
request_handler,
catalog_zone_manager as catzm, # noqa: F401
)
from netbox_dns_bridge.signals import netbox_dns_zones, notify_handler
return [netbox_dns_zones, notify_handler, request_handler]
@pytest.fixture
def patch_settings(monkeypatch):
"""Patch the module-level ``SETTINGS`` dict on every consumer module."""
mods = _settings_modules()
originals = {mod: mod.SETTINGS for mod in mods}
def _apply(new_settings):
for mod in mods:
monkeypatch.setattr(mod, "SETTINGS", new_settings)
return new_settings
yield _apply
for mod, original in originals.items():
mod.SETTINGS = original
@pytest.fixture
def tsig_key():
from netbox_dns_bridge.tests._helpers import make_tsig_key
return make_tsig_key("testkey.")
@pytest.fixture
def capture_handler():
"""Factory: build a capture handler for unit tests."""
from netbox_dns_bridge.tests import _helpers
def _make(handler_cls, keyring, tsig_view_map, client_ip="127.0.0.1"):
return _helpers.make_capture_handler(
handler_cls, keyring, tsig_view_map, client_ip
)
return _make
# ---------------------------------------------------------------------------
# Database factories
# ---------------------------------------------------------------------------
@pytest.fixture
def make_view(db, default_view):
from netbox_dns.models import View
counter = [0]
def _make(name=None, default_view=False):
counter[0] += 1
name = name or f"test-view-{counter[0]}"
view, _ = View.objects.get_or_create(
name=name, defaults={"default_view": default_view}
)
return view
return _make
@pytest.fixture
def make_nameserver(db):
from netbox_dns.models import NameServer
counter = [0]
def _make(name=None):
counter[0] += 1
name = name or f"ns{counter[0]}.example.test"
ns, _ = NameServer.objects.get_or_create(name=name)
return ns
return _make
@pytest.fixture
def make_zone(db, make_view, make_nameserver):
from netbox_dns.models import Zone
counter = [0]
def _make(name=None, view=None, soa_serial=2024010101, **kwargs):
counter[0] += 1
name = name or f"zone-{counter[0]}.example.test"
view = view or make_view()
ns = make_nameserver(f"ns1.{name}")
defaults = dict(
view=view,
name=name,
soa_mname=ns,
soa_rname=f"hostmaster.{name}",
soa_ttl=300,
soa_serial=soa_serial,
soa_serial_auto=False,
soa_refresh=300,
soa_retry=100,
soa_expire=1209600,
soa_minimum=3600,
default_ttl=300,
status="active",
)
defaults.update(kwargs)
zone = Zone.objects.create(**defaults)
# Ensure an active SOA record exists (netbox_dns usually creates one,
# but be deterministic so build_dns_zone / _get_soa_serial always find it).
from netbox_dns.models import Record
from netbox_dns.choices import RecordStatusChoices
if not Record.objects.filter(zone=zone, type="SOA", active=True).exists():
fqdn = zone.name
Record.objects.create(
zone=zone,
name="",
fqdn=fqdn,
type="SOA",
value=(
f"ns1.{zone.name}. hostmaster.{zone.name}. {soa_serial} "
f"300 100 1209600 3600"
),
ttl=zone.soa_ttl,
status=RecordStatusChoices.STATUS_ACTIVE,
managed=False,
)
return zone
return _make
@pytest.fixture
def make_record(db):
from netbox_dns.models import Record
from netbox_dns.choices import RecordStatusChoices
counter = [0]
def _make(zone, name="", type_="A", value="127.0.0.1", ttl=None,
status=RecordStatusChoices.STATUS_ACTIVE, managed=False):
counter[0] += 1
fqdn = f"{name}.{zone.name}".lstrip(".") if name else zone.name
return Record.objects.create(
zone=zone,
name=name,
fqdn=fqdn,
type=type_,
value=value,
ttl=ttl if ttl is not None else zone.default_ttl,
status=status,
managed=managed,
)
return _make
@pytest.fixture
def catz_serial(db):
"""Initial SOA serial value (1) for freshly created catalog zones.
A ``CatalogZone`` is auto-created with ``soa_serial=1`` by the zone
post_save signal (via ``update_member_identifier``) whenever a zone is
created. Tests compare against this initial value.
"""
return 1
# ---------------------------------------------------------------------------
# Real-socket integration server
# ---------------------------------------------------------------------------
@pytest.fixture
def dns_server(tsig_key):
"""Start real TCP+UDP DNS servers on 127.0.0.1:0 in background threads.
Usage::
srv = dns_server.start(views={"testkey.": view_obj})
resp = dns.query.tcp(msg, srv["host"], port=srv["port"])
"""
import threading
import dns.name
from netbox_dns_bridge.management.commands.dns_transfer_endpoint.dns_server import (
TCPDNSServer,
UDPDNSServer,
)
from netbox_dns_bridge.management.commands.dns_transfer_endpoint.request_handler import (
TCPRequestHandler,
UDPRequestHandler,
)
class _Server:
def __init__(self, key):
self.tsig_key = key
self._tcp = None
self._udp = None
def start(self, views):
keyring = {self.tsig_key.name: self.tsig_key}
tsig_view_map = {}
for key_name, view in views.items():
canon = (
dns.name.from_text(key_name, origin=dns.name.root)
.canonicalize()
.to_text()
)
tsig_view_map[canon] = view
self._tcp = TCPDNSServer(
("127.0.0.1", 0), TCPRequestHandler, keyring, tsig_view_map
)
port = self._tcp.server_address[1]
# Bind UDP to the same port number (TCP/UDP sockets may share it).
self._udp = UDPDNSServer(
("127.0.0.1", port), UDPRequestHandler, keyring, tsig_view_map
)
threading.Thread(target=self._tcp.serve_forever, daemon=True).start()
threading.Thread(target=self._udp.serve_forever, daemon=True).start()
return {
"host": "127.0.0.1",
"port": port,
"tsig_key": self.tsig_key,
}
def shutdown(self):
for srv in (self._tcp, self._udp):
if srv is not None:
srv.shutdown()
srv.server_close()
server = _Server(tsig_key)
try:
yield server
finally:
server.shutdown()