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.
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Tests for notify_handler: _load_tsig_key, _get_soa_serial, _resolve_zone_nameserver_ips,
|
|
schedule_client_notify, schedule_ns_notify, and job classes."""
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import dns.name
|
|
import dns.rdatatype
|
|
import dns.tsig
|
|
import pytest
|
|
|
|
import netbox_dns_bridge.signals.notify_handler as nh
|
|
|
|
|
|
class TestLoadTsigKey:
|
|
def _patch_settings(self, monkeypatch, tsig_keys):
|
|
monkeypatch.setattr(nh, "SETTINGS", {"tsig_keys": tsig_keys})
|
|
|
|
def test_valid_config(self, monkeypatch):
|
|
self._patch_settings(monkeypatch, {
|
|
"myview": {
|
|
"keyname": "testkey.",
|
|
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
|
|
"algorithm": "hmac-sha256",
|
|
}
|
|
})
|
|
key = nh._load_tsig_key("myview")
|
|
assert isinstance(key, dns.tsig.Key)
|
|
assert key.name == dns.name.from_text("testkey.", dns.name.root).canonicalize()
|
|
|
|
def test_missing_view_raises(self, monkeypatch):
|
|
self._patch_settings(monkeypatch, {})
|
|
with pytest.raises(RuntimeError, match="No TSIG key configured"):
|
|
nh._load_tsig_key("nope")
|
|
|
|
def test_missing_algorithm_raises(self, monkeypatch):
|
|
self._patch_settings(monkeypatch, {
|
|
"v": {"keyname": "k.", "secret": "s"}
|
|
})
|
|
with pytest.raises(RuntimeError, match="Missing 'algorithm'"):
|
|
nh._load_tsig_key("v")
|
|
|
|
def test_missing_keyname_raises(self, monkeypatch):
|
|
self._patch_settings(monkeypatch, {
|
|
"v": {"secret": "s", "algorithm": "hmac-sha256"}
|
|
})
|
|
with pytest.raises(RuntimeError, match="Incomplete TSIG key"):
|
|
nh._load_tsig_key("v")
|
|
|
|
def test_missing_secret_raises(self, monkeypatch):
|
|
self._patch_settings(monkeypatch, {
|
|
"v": {"keyname": "k.", "algorithm": "hmac-sha256"}
|
|
})
|
|
with pytest.raises(RuntimeError, match="Incomplete TSIG key"):
|
|
nh._load_tsig_key("v")
|
|
|
|
def test_relative_key_name_gets_root(self, monkeypatch):
|
|
self._patch_settings(monkeypatch, {
|
|
"v": {
|
|
"keyname": "relkey",
|
|
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
|
|
"algorithm": "hmac-sha256",
|
|
}
|
|
})
|
|
key = nh._load_tsig_key("v")
|
|
assert key.name.is_absolute()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestGetSoaSerial:
|
|
def test_valid_soa(self, make_zone):
|
|
zone = make_zone(name="soa-valid.test", soa_serial=12345)
|
|
result = nh._get_soa_serial(zone)
|
|
assert result == 12345
|
|
|
|
def test_no_soa_record_returns_none(self, make_zone):
|
|
from netbox_dns.models import Record
|
|
|
|
zone = make_zone(name="soa-none.test")
|
|
Record.objects.filter(zone=zone, type="SOA").delete()
|
|
result = nh._get_soa_serial(zone)
|
|
assert result is None
|
|
|
|
def test_inactive_soa_returns_none(self, make_zone):
|
|
from netbox_dns.models import Record
|
|
from netbox_dns.choices import RecordStatusChoices
|
|
|
|
zone = make_zone(name="soa-inactive.test")
|
|
Record.objects.filter(zone=zone, type="SOA").update(
|
|
status=RecordStatusChoices.STATUS_INACTIVE
|
|
)
|
|
result = nh._get_soa_serial(zone)
|
|
assert result is None
|
|
|
|
def test_malformed_serial_returns_none(self, make_zone):
|
|
from netbox_dns.models import Record
|
|
|
|
zone = make_zone(name="soa-bad.test")
|
|
Record.objects.filter(zone=zone, type="SOA").update(
|
|
value="ns1.test. hostmaster.test. NOTANUMBER 300 100 1209600 3600"
|
|
)
|
|
result = nh._get_soa_serial(zone)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestResolveZoneNameserverIps:
|
|
def test_resolves_a_records(self, make_zone, make_record, make_nameserver):
|
|
zone = make_zone(name="nsresolv.test")
|
|
ns = make_nameserver("ns1.nsresolv.test")
|
|
zone.nameservers.add(ns)
|
|
make_record(zone, name="ns1", type_="A", value="192.0.2.1")
|
|
ips = nh._resolve_zone_nameserver_ips(zone)
|
|
assert "192.0.2.1" in ips
|
|
|
|
def test_resolves_aaaa_records(self, make_zone, make_record, make_nameserver):
|
|
zone = make_zone(name="nsresolv6.test")
|
|
ns = make_nameserver("ns1.nsresolv6.test")
|
|
zone.nameservers.add(ns)
|
|
make_record(zone, name="ns1", type_="AAAA", value="2001:db8::1")
|
|
ips = nh._resolve_zone_nameserver_ips(zone)
|
|
assert "2001:db8::1" in ips
|
|
|
|
def test_deduplicates_ips(self, make_zone, make_record, make_nameserver):
|
|
zone = make_zone(name="nsdedup.test")
|
|
ns = make_nameserver("ns1.nsdedup.test")
|
|
zone.nameservers.add(ns)
|
|
make_record(zone, name="ns1", type_="A", value="192.0.2.1")
|
|
make_record(zone, name="ns1", type_="A", value="192.0.2.2")
|
|
ips = nh._resolve_zone_nameserver_ips(zone)
|
|
assert "192.0.2.1" in ips
|
|
assert "192.0.2.2" in ips
|
|
assert len(ips) == 2
|
|
|
|
def test_no_nameservers_returns_empty(self, make_zone):
|
|
zone = make_zone(name="nsnone.test")
|
|
ips = nh._resolve_zone_nameserver_ips(zone)
|
|
assert ips == []
|
|
|
|
def test_multiple_nameservers(self, make_zone, make_record, make_nameserver):
|
|
zone = make_zone(name="nsmulti.test")
|
|
ns1 = make_nameserver("ns1.nsmulti.test")
|
|
ns2 = make_nameserver("ns2.nsmulti.test")
|
|
zone.nameservers.add(ns1, ns2)
|
|
make_record(zone, name="ns1", type_="A", value="192.0.2.1")
|
|
make_record(zone, name="ns2", type_="A", value="192.0.2.2")
|
|
ips = nh._resolve_zone_nameserver_ips(zone)
|
|
assert set(ips) == {"192.0.2.1", "192.0.2.2"}
|
|
|
|
def test_trailing_dot_resilience(self, make_zone, make_record, make_nameserver):
|
|
zone = make_zone(name="nstrail.test")
|
|
ns = make_nameserver("ns1.nstrail.test")
|
|
zone.nameservers.add(ns)
|
|
make_record(zone, name="ns1", type_="A", value="192.0.2.5")
|
|
ips = nh._resolve_zone_nameserver_ips(zone)
|
|
assert "192.0.2.5" in ips
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestScheduleClientNotify:
|
|
def test_enqueues_job_when_soa_exists(self, make_zone):
|
|
zone = make_zone(name="sched-client.test", soa_serial=77)
|
|
with patch.object(nh.SendClientDNSNotify, "enqueue") as mock_enq:
|
|
nh.schedule_client_notify(zone)
|
|
mock_enq.assert_called_once_with(
|
|
zone_name="sched-client.test",
|
|
view_name=zone.view.name,
|
|
soa_serial=77,
|
|
)
|
|
|
|
def test_does_not_enqueue_when_no_soa(self, make_zone):
|
|
from netbox_dns.models import Record
|
|
|
|
zone = make_zone(name="sched-client-nosoa.test")
|
|
Record.objects.filter(zone=zone, type="SOA").delete()
|
|
with patch.object(nh.SendClientDNSNotify, "enqueue") as mock_enq:
|
|
nh.schedule_client_notify(zone)
|
|
mock_enq.assert_not_called()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestScheduleNsNotify:
|
|
def test_enqueues_job_when_soa_exists(self, make_zone):
|
|
zone = make_zone(name="sched-ns.test", soa_serial=88)
|
|
with patch.object(nh.SendNSNotify, "enqueue") as mock_enq:
|
|
nh.schedule_ns_notify(zone)
|
|
mock_enq.assert_called_once_with(
|
|
zone_id=zone.pk,
|
|
zone_name="sched-ns.test",
|
|
view_name=zone.view.name,
|
|
soa_serial=88,
|
|
)
|
|
|
|
def test_does_not_enqueue_when_no_soa(self, make_zone):
|
|
from netbox_dns.models import Record
|
|
|
|
zone = make_zone(name="sched-ns-nosoa.test")
|
|
Record.objects.filter(zone=zone, type="SOA").delete()
|
|
with patch.object(nh.SendNSNotify, "enqueue") as mock_enq:
|
|
nh.schedule_ns_notify(zone)
|
|
mock_enq.assert_not_called()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestSendClientDNSNotifyRun:
|
|
def test_sends_notify_to_seen_clients(self, make_zone, make_view, monkeypatch):
|
|
import netbox_dns_bridge.signals.notify_handler as nh
|
|
from netbox_dns_bridge.models import SeenTransferClients, CatalogZone
|
|
from django.utils import timezone
|
|
|
|
view = make_view(name="notify-run-view")
|
|
CatalogZone.objects.update_or_create(
|
|
view=view, defaults={"soa_serial": 42}
|
|
)
|
|
SeenTransferClients.objects.create(
|
|
source_ip="192.0.2.99", view=view, last_seen=timezone.now()
|
|
)
|
|
|
|
monkeypatch.setattr(nh, "SETTINGS", {
|
|
"notify_clients": True,
|
|
"tsig_keys": {
|
|
"notify-run-view": {
|
|
"keyname": "nk.",
|
|
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
|
|
"algorithm": "hmac-sha256",
|
|
}
|
|
},
|
|
"notify_over_tcp": False,
|
|
"notify_client_port": 53,
|
|
"notify_client_alive_threshold_hours": 24,
|
|
})
|
|
|
|
job = MagicMock()
|
|
job.run.__wrapped__ = nh.SendClientDNSNotify.run
|
|
with patch("dns.query.udp") as mock_udp:
|
|
nh.SendClientDNSNotify.run(job,
|
|
zone_name="z.test", soa_serial=10, view_name="notify-run-view")
|
|
assert mock_udp.call_count >= 3 # zone + catz + view.catz
|
|
|
|
def test_handles_missing_serial_setting(self, monkeypatch, make_view):
|
|
# When the CatalogZone for the view doesn't exist,
|
|
# the job should log an error and not send any notifies
|
|
view = make_view(name="missing-serial-view")
|
|
|
|
monkeypatch.setattr(nh, "SETTINGS", {
|
|
"notify_clients": True,
|
|
"tsig_keys": {
|
|
"missing-serial-view": {
|
|
"keyname": "nk.",
|
|
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
|
|
"algorithm": "hmac-sha256",
|
|
}
|
|
},
|
|
"notify_over_tcp": False,
|
|
"notify_client_port": 53,
|
|
"notify_client_alive_threshold_hours": 24,
|
|
})
|
|
|
|
job = MagicMock()
|
|
with patch("dns.query.udp") as mock_udp:
|
|
nh.SendClientDNSNotify.run(job,
|
|
zone_name="z.test", soa_serial=1, view_name="missing-serial-view")
|
|
mock_udp.assert_not_called()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestSendNSNotifyRun:
|
|
def test_sends_notify_to_nameserver_ips(self, make_zone, make_record, make_nameserver, monkeypatch):
|
|
import netbox_dns_bridge.signals.notify_handler as nh
|
|
|
|
zone = make_zone(name="ns-notify-run.test", soa_serial=55)
|
|
ns = make_nameserver("ns1.ns-notify-run.test")
|
|
zone.nameservers.add(ns)
|
|
make_record(zone, name="ns1", type_="A", value="192.0.2.200")
|
|
|
|
monkeypatch.setattr(nh, "SETTINGS", {
|
|
"tsig_keys": {
|
|
zone.view.name: {
|
|
"keyname": "nk.",
|
|
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
|
|
"algorithm": "hmac-sha256",
|
|
}
|
|
},
|
|
"notify_over_tcp": False,
|
|
"notify_ns_port": 53,
|
|
})
|
|
|
|
job = MagicMock()
|
|
with patch("dns.query.udp") as mock_udp:
|
|
nh.SendNSNotify.run(job,
|
|
zone_id=zone.pk, zone_name="ns-notify-run.test",
|
|
view_name=zone.view.name, soa_serial=55)
|
|
mock_udp.assert_called_once()
|
|
|
|
def test_skips_when_zone_deleted(self, monkeypatch):
|
|
job = MagicMock()
|
|
with patch("dns.query.udp") as mock_udp:
|
|
nh.SendNSNotify.run(job,
|
|
zone_id=999999, zone_name="gone.test",
|
|
view_name="v", soa_serial=1)
|
|
mock_udp.assert_not_called()
|
|
|
|
def test_skips_when_no_nameservers(self, make_zone, monkeypatch):
|
|
zone = make_zone(name="ns-notify-empty.test")
|
|
|
|
monkeypatch.setattr(nh, "SETTINGS", {
|
|
"tsig_keys": {
|
|
zone.view.name: {
|
|
"keyname": "nk.",
|
|
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
|
|
"algorithm": "hmac-sha256",
|
|
}
|
|
},
|
|
"notify_over_tcp": False,
|
|
"notify_ns_port": 53,
|
|
})
|
|
|
|
job = MagicMock()
|
|
with patch("dns.query.udp") as mock_udp:
|
|
nh.SendNSNotify.run(job,
|
|
zone_id=zone.pk, zone_name="ns-notify-empty.test",
|
|
view_name=zone.view.name, soa_serial=1)
|
|
mock_udp.assert_not_called()
|