"""Tests for notify: _load_tsig_key, _get_soa_serial, _resolve_zone_nameserver_ips, schedule_client_notify, schedule_ns_notify, schedule_catalog_zone_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.jobs.notify 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_enabled(self, make_zone, monkeypatch): zone = make_zone(name="sched-client.test", soa_serial=77) monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True}) with patch.object(nh.SendClientNotify, "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, monkeypatch): from netbox_dns.models import Record zone = make_zone(name="sched-client-nosoa.test") Record.objects.filter(zone=zone, type="SOA").delete() monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True}) with patch.object(nh.SendClientNotify, "enqueue") as mock_enq: nh.schedule_client_notify(zone) mock_enq.assert_not_called() def test_does_not_enqueue_when_notify_clients_disabled(self, make_zone, monkeypatch): zone = make_zone(name="sched-client-disabled.test", soa_serial=77) monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": False}) with patch.object(nh.SendClientNotify, "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_enabled(self, make_zone, monkeypatch): zone = make_zone(name="sched-ns.test", soa_serial=88) monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_all_zones": True}) 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, monkeypatch): from netbox_dns.models import Record zone = make_zone(name="sched-ns-nosoa.test") Record.objects.filter(zone=zone, type="SOA").delete() monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_all_zones": True}) with patch.object(nh.SendNSNotify, "enqueue") as mock_enq: nh.schedule_ns_notify(zone) mock_enq.assert_not_called() def test_does_not_enqueue_when_ns_notify_disabled(self, make_zone, monkeypatch): zone = make_zone(name="sched-ns-disabled.test", soa_serial=88) monkeypatch.setattr(nh, "SETTINGS", {}) with patch.object(nh.SendNSNotify, "enqueue") as mock_enq: nh.schedule_ns_notify(zone) mock_enq.assert_not_called() @pytest.mark.django_db class TestScheduleCatalogZoneNotify: def test_enqueues_two_jobs_when_enabled(self, make_view, monkeypatch): from netbox_dns_bridge.models import CatalogZone view = make_view(name="catz-notify-view") CatalogZone.objects.create(view=view, soa_serial=42) monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True}) with patch.object(nh.SendClientNotify, "enqueue") as mock_enq: nh.schedule_catalog_zone_notify(view) assert mock_enq.call_count == 2 mock_enq.assert_any_call( zone_name="catz", soa_serial=42, view_name="catz-notify-view", soa_refresh=60, soa_retry=10, soa_expire=1209600, soa_minimum=0, ) mock_enq.assert_any_call( zone_name="catz-notify-view.catz", soa_serial=42, view_name="catz-notify-view", soa_refresh=60, soa_retry=10, soa_expire=1209600, soa_minimum=0, ) def test_does_not_enqueue_when_notify_clients_disabled(self, make_view, monkeypatch): from netbox_dns_bridge.models import CatalogZone view = make_view(name="catz-notify-disabled") CatalogZone.objects.create(view=view, soa_serial=42) monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": False}) with patch.object(nh.SendClientNotify, "enqueue") as mock_enq: nh.schedule_catalog_zone_notify(view) mock_enq.assert_not_called() def test_does_not_enqueue_when_no_catalog_zone(self, make_view, monkeypatch): view = make_view(name="catz-notify-no-cz") monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True}) with patch.object(nh.SendClientNotify, "enqueue") as mock_enq: nh.schedule_catalog_zone_notify(view) mock_enq.assert_not_called() @pytest.mark.django_db class TestSendClientNotifyRun: def test_sends_single_notify_to_seen_clients(self, make_zone, make_view, monkeypatch): import netbox_dns_bridge.jobs.notify as nh from netbox_dns_bridge.models import SeenTransferClient from django.utils import timezone view = make_view(name="notify-run-view") SeenTransferClient.objects.create( source_ip="192.0.2.99", view=view, last_seen=timezone.now() ) monkeypatch.setattr(nh, "SETTINGS", { "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() with patch("dns.query.udp") as mock_udp: nh.SendClientNotify.run(job, zone_name="z.test", soa_serial=10, view_name="notify-run-view") assert mock_udp.call_count == 1 def test_sends_notify_without_catalog_zone(self, monkeypatch, make_view): from netbox_dns_bridge.models import SeenTransferClient from django.utils import timezone view = make_view(name="no-catz-view") SeenTransferClient.objects.create( source_ip="192.0.2.1", view=view, last_seen=timezone.now() ) monkeypatch.setattr(nh, "SETTINGS", { "tsig_keys": { "no-catz-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.SendClientNotify.run(job, zone_name="z.test", soa_serial=1, view_name="no-catz-view") assert mock_udp.call_count == 1 @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.jobs.notify 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() @pytest.mark.django_db class TestNotifyNsEnabled: def test_enabled_by_all_zones_flag(self, make_zone, monkeypatch): monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_all_zones": True}) from netbox_dns_bridge.jobs.notify import _notify_ns_enabled zone = make_zone(name="nsflag.test") assert _notify_ns_enabled(zone) is True def test_disabled_by_default(self, make_zone, monkeypatch): monkeypatch.setattr(nh, "SETTINGS", {}) from netbox_dns_bridge.jobs.notify import _notify_ns_enabled zone = make_zone(name="nsdisabled.test") assert _notify_ns_enabled(zone) is False def test_enabled_by_custom_field(self, make_zone, monkeypatch): monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_custom_field_name": "enable_notify"}) from netbox_dns_bridge.jobs.notify import _notify_ns_enabled zone = make_zone(name="nscf.test") zone.custom_field_data = {"enable_notify": True} assert _notify_ns_enabled(zone) is True def test_disabled_when_custom_field_false(self, make_zone, monkeypatch): monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_custom_field_name": "enable_notify"}) from netbox_dns_bridge.jobs.notify import _notify_ns_enabled zone = make_zone(name="nscf-false.test") zone.custom_field_data = {"enable_notify": False} assert _notify_ns_enabled(zone) is False def test_disabled_when_custom_field_missing(self, make_zone, monkeypatch): monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_custom_field_name": "enable_notify"}) from netbox_dns_bridge.jobs.notify import _notify_ns_enabled zone = make_zone(name="nscf-missing.test") zone.custom_field_data = {} assert _notify_ns_enabled(zone) is False