"""Tests for lib/sync.py — event bus, sync primitives, and transitive deps.""" import logging from unittest.mock import MagicMock, patch from lib.sync import ( DnsToFirewallSync, EventBus, FirewallToDhcpSync, NetworkToAllSync, SyncEvent, SyncResult, WgToFirewallSync, get_affected, ) class TestSyncEvent: def test_defaults(self): e = SyncEvent(subsystem="dnsmasq", action="config_saved") assert e.subsystem == "dnsmasq" assert e.action == "config_saved" assert e.payload == {} def test_with_payload(self): e = SyncEvent("dnsmasq", "config_saved", {"interface": "eth0"}) assert e.payload == {"interface": "eth0"} class TestSyncResult: def test_defaults(self): r = SyncResult() assert r.affected_subsystems == [] assert r.changes == [] assert r.applied is False class TestEventBus: def test_subscribe_then_emit(self): bus = EventBus() handler = MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])) bus.subscribe("dnsmasq", "config_saved", handler) result = bus.emit(SyncEvent("dnsmasq", "config_saved", {"iface": "eth0"})) assert result.affected_subsystems == ["firewall"] handler.assert_called_once() def test_no_subscribers(self): bus = EventBus() result = bus.emit(SyncEvent("unknown", "config_saved")) assert result.affected_subsystems == [] def test_subscriber_returns_none(self): bus = EventBus() bus.subscribe("dnsmasq", "config_saved", MagicMock(return_value=None)) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert result.affected_subsystems == [] def test_subscriber_exception_contained(self): bus = EventBus() failing = MagicMock(side_effect=RuntimeError("boom")) good = MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])) bus.subscribe("dnsmasq", "config_saved", failing) bus.subscribe("dnsmasq", "config_saved", good) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert result.affected_subsystems == ["firewall"] assert good.called def test_multiple_handlers_aggregated(self): bus = EventBus() bus.subscribe( "dnsmasq", "config_saved", MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])), ) bus.subscribe( "dnsmasq", "config_saved", MagicMock(return_value=SyncResult(affected_subsystems=["networkd"])), ) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert result.affected_subsystems == ["firewall", "networkd"] def test_changes_aggregated(self): bus = EventBus() bus.subscribe( "dnsmasq", "config_saved", MagicMock(return_value=SyncResult(changes=["Added dhcp to zone"])), ) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert result.changes == ["Added dhcp to zone"] def test_applied_flag(self): bus = EventBus() bus.subscribe( "dnsmasq", "config_saved", MagicMock(return_value=SyncResult(applied=True)), ) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert result.applied is True class TestLoopGuard: def test_recursive_emit_dropped(self): """If a subscriber emits the same event key, it is silently dropped.""" bus = EventBus() events_seen = [] def handler(event): events_seen.append(event.subsystem) if event.subsystem == "dnsmasq" and not event.payload.get("_cascade"): bus.emit(SyncEvent("dnsmasq", "config_saved", {"loop": True})) return SyncResult(affected_subsystems=[event.subsystem]) bus.subscribe("dnsmasq", "config_saved", handler) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert events_seen == ["dnsmasq"] assert result.affected_subsystems == ["dnsmasq"] def test_cascade_different_key_applies(self): """When subscriber affects a different subsystem, cascade fires.""" bus = EventBus() calls = [] def dns_handler(event): calls.append(("dns", event.subsystem)) return SyncResult(affected_subsystems=["firewall"]) def fw_handler(event): calls.append(("fw", event.subsystem)) return SyncResult(affected_subsystems=["firewall"]) bus.subscribe("dnsmasq", "config_saved", dns_handler) bus.subscribe("firewall", "config_saved", fw_handler) result = bus.emit(SyncEvent("dnsmasq", "config_saved")) assert calls == [("dns", "dnsmasq"), ("fw", "firewall")] assert "firewall" in result.affected_subsystems class TestGetAffected: def test_single(self): result = get_affected(["dnsmasq"]) assert "dnsmasq" in result def test_single_target(self): bus = EventBus() bus.subscribe( "dnsmasq", "config_saved", MagicMock(), targets={"firewall"}, ) result = get_affected(["dnsmasq"], event_bus=bus) # Note: get_affected uses module singleton _bus, not local bus assert "dnsmasq" in result def test_transitive(self): bus = EventBus() bus.subscribe( "dnsmasq", "config_saved", MagicMock(), targets={"firewall"}, ) bus.subscribe( "firewall", "config_saved", MagicMock(), targets={"dnsmasq"}, ) result = get_affected(["dnsmasq"], event_bus=bus) assert "dnsmasq" in result assert "firewall" in result def test_chain(self): bus = EventBus() bus.subscribe( "network", "config_saved", MagicMock(), targets={"firewall"}, ) bus.subscribe( "firewall", "config_saved", MagicMock(), targets={"dnsmasq"}, ) result = get_affected(["network"], event_bus=bus) assert "network" in result assert "firewall" in result assert "dnsmasq" in result def test_multi_base(self): bus = EventBus() bus.subscribe( "dnsmasq", "config_saved", MagicMock(), targets={"firewall"}, ) bus.subscribe( "wireguard", "config_saved", MagicMock(), targets={"firewall"}, ) result = get_affected(["dnsmasq", "wireguard"], event_bus=bus) assert "dnsmasq" in result assert "wireguard" in result assert "firewall" in result def test_no_targets(self): bus = EventBus() bus.subscribe("dnsmasq", "config_saved", MagicMock()) result = get_affected(["dnsmasq"], event_bus=bus) assert "dnsmasq" in result # --------------------------------------------------------------------------- # Subscriber tests # --------------------------------------------------------------------------- class TestDnsToFirewallSync: @patch("lib.sync.get_interface_ip", return_value="10.0.0.1") @patch("lib.dnsmasq.save_config") @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") def test_adds_dhcp_dns( self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip ): mock_dm_get.return_value = { "dhcp": { "ranges": [ { "interface": "eth1", "start": "192.168.2.100", "end": "192.168.2.200", } ] }, "dns": {}, } mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh"], "masquerade": False, } } } result = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved") ) assert result is not None assert "firewall" in result.affected_subsystems mock_fw_save.assert_called_once() saved_cfg = mock_fw_save.call_args[0][0] assert "dhcp" in saved_cfg["zones"]["internal"]["services"] assert "dns" in saved_cfg["zones"]["internal"]["services"] @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") def test_removes_dhcp_dns_no_ranges(self, mock_dm_get, mock_fw_get, mock_fw_save): mock_dm_get.return_value = {"dhcp": {"ranges": []}, "dns": {}} mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh", "dhcp", "dns"], "masquerade": True, } } } result = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved") ) assert result is not None assert "firewall" in result.affected_subsystems saved_cfg = mock_fw_save.call_args[0][0] assert "dhcp" not in saved_cfg["zones"]["internal"]["services"] assert "dns" not in saved_cfg["zones"]["internal"]["services"] @patch("lib.sync.get_interface_ip", return_value="10.0.0.1") @patch("lib.dnsmasq.save_config") @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") def test_idempotent( self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip ): mock_dm_get.return_value = { "dhcp": { "ranges": [ { "interface": "eth1", "start": "192.168.2.100", "end": "192.168.2.200", } ] }, "dns": {}, } mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh", "dhcp", "dns"], "masquerade": True, } } } r1 = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved") ) r2 = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved") ) assert r1 is not None assert r2 is not None mock_fw_save.assert_not_called() @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") def test_cascade_skip(self, mock_dm_get, mock_fw_get, mock_fw_save): mock_dm_get.return_value = {"dhcp": {"ranges": []}} mock_fw_get.return_value = {"zones": {}} result = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved", {"_cascade": "firewall"}) ) assert result is None @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") def test_empty_dnsmasq_config(self, mock_dm_get, mock_fw_get, mock_fw_save): mock_dm_get.return_value = {} mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh"], "masquerade": False, } } } result = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved") ) assert result is not None assert "firewall" not in result.affected_subsystems mock_fw_save.assert_not_called() @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") def test_exception_contained(self, mock_dm_get, mock_fw_get, mock_fw_save): mock_dm_get.side_effect = RuntimeError("db error") result = DnsToFirewallSync.on_dnsmasq_config_saved( SyncEvent("dnsmasq", "config_saved") ) assert result is not None assert result.affected_subsystems == [] assert result.changes == [] mock_fw_save.assert_not_called() class TestWgToFirewallSync: @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_creates_vpn_zone(self, mock_wg_get, mock_fw_get, mock_fw_save): mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": {"alice": {"public_key": "abc123"}}, } mock_fw_get.return_value = {"zones": {}} result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None assert "firewall" in result.affected_subsystems mock_fw_save.assert_called_once() saved_cfg = mock_fw_save.call_args[0][0] assert "vpn" in saved_cfg["zones"] assert "wg0" in saved_cfg["zones"]["vpn"]["interfaces"] assert saved_cfg["zones"]["vpn"]["masquerade"] is True assert len(saved_cfg["zones"]["vpn"]["rich_rules"]) == 1 @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_existing_vpn_zone_unchanged(self, mock_wg_get, mock_fw_get, mock_fw_save): mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": {"alice": {"public_key": "abc123"}}, } mock_fw_get.return_value = { "zones": { "vpn": { "interfaces": ["wg0"], "masquerade": True, "rich_rules": [ { "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', "_source": "wg", } ], } } } result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_not_called() assert result.affected_subsystems == [] assert not result.changes @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_no_peers_no_create(self, mock_wg_get, mock_fw_get, mock_fw_save): mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": {}, } mock_fw_get.return_value = {"zones": {}} result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_not_called() @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_disabled_cleanup_vpn_zone(self, mock_wg_get, mock_fw_get, mock_fw_save): """When WireGuard is disabled, vpn zone entries are cleaned up.""" mock_wg_get.return_value = { "interface": {"name": ""}, "peers": {}, } mock_fw_get.return_value = { "zones": { "vpn": { "interfaces": ["wg0"], "masquerade": True, "rich_rules": [ { "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', "_source": "wg", }, { "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', "_source": "wg", }, ], } } } result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_called_once() saved_cfg = mock_fw_save.call_args[0][0] vpn_zone = saved_cfg["zones"]["vpn"] assert "wg0" not in vpn_zone.get("interfaces", []) assert vpn_zone.get("masquerade") is False assert len(vpn_zone.get("rich_rules", [])) == 0 assert any("interface" in c.lower() for c in result.changes) assert any("masquerade" in c.lower() for c in result.changes) assert any("rich rule" in c.lower() for c in result.changes) @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_idempotent(self, mock_wg_get, mock_fw_get, mock_fw_save): mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": {"alice": {"public_key": "abc123"}}, } mock_fw_get.return_value = { "zones": { "vpn": { "interfaces": ["wg0"], "masquerade": True, "rich_rules": [ { "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', "_source": "wg", } ], } } } r1 = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) r2 = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert r1 is not None assert r2 is not None mock_fw_save.assert_not_called() @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_cascade_skip(self, mock_wg_get, mock_fw_get, mock_fw_save): mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": {"alice": {"public_key": "abc123"}}, } mock_fw_get.return_value = {"zones": {}} result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved", {"_cascade": "firewall"}) ) assert result is None @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_allowed_ips_adds_rules(self, mock_wg_get, mock_fw_get, mock_fw_save): """Inter-zone rules are created for peer allowed_ips.""" mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": { "alice": { "public_key": "abc123", "allowed_ips": ["192.168.10.0/24"], }, "bob": { "public_key": "def456", "allowed_ips": ["10.20.0.0/16"], }, }, } mock_fw_get.return_value = {"zones": {}} result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_called_once() saved_cfg = mock_fw_save.call_args[0][0] vpn_zone = saved_cfg["zones"]["vpn"] rules = {r.get("rule", "") for r in vpn_zone.get("rich_rules", [])} rule_sources = { r.get("rule", ""): r.get("_source") for r in vpn_zone.get("rich_rules", []) } # UDP 51820 rule assert 'rule family="ipv4" port protocol="udp" port="51820" accept' in rules assert ( rule_sources['rule family="ipv4" port protocol="udp" port="51820" accept'] == "wg" ) # Inter-zone rules for each allowed_ips subnet assert ( 'rule family="ipv4" destination address="192.168.10.0/24" accept' in rules ) assert 'rule family="ipv4" destination address="10.20.0.0/16" accept' in rules assert ( rule_sources[ 'rule family="ipv4" destination address="192.168.10.0/24" accept' ] == "wg" ) assert ( rule_sources['rule family="ipv4" destination address="10.20.0.0/16" accept'] == "wg" ) # Check change descriptions assert any("inter-zone rule" in c for c in result.changes) @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_allowed_ips_no_duplicate(self, mock_wg_get, mock_fw_get, mock_fw_save): """Existing allowed_ips rules are not duplicated.""" mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": { "alice": { "public_key": "abc123", "allowed_ips": ["192.168.10.0/24"], }, }, } mock_fw_get.return_value = { "zones": { "vpn": { "interfaces": ["wg0"], "masquerade": True, "rich_rules": [ { "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', "_source": "wg", }, { "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', "_source": "wg", }, ], } } } result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_not_called() assert result.affected_subsystems == [] assert result.changes == [] @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_cleanup_preserves_manual_rules( self, mock_wg_get, mock_fw_get, mock_fw_save ): """Cleanup only removes rules with _source='wg', not manual rules.""" mock_wg_get.return_value = { "interface": {"name": ""}, "peers": {}, } mock_fw_get.return_value = { "zones": { "vpn": { "interfaces": ["wg0"], "masquerade": True, "rich_rules": [ { "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', "_source": "wg", }, { "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', "_source": "wg", }, { "rule": 'rule family="ipv4" destination address="10.0.0.0/24" accept', }, ], } } } result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_called_once() saved_cfg = mock_fw_save.call_args[0][0] vpn_zone = saved_cfg["zones"]["vpn"] # Only the manual rule (without _source="wg") should remain assert len(vpn_zone.get("rich_rules", [])) == 1 assert ( vpn_zone["rich_rules"][0]["rule"] == 'rule family="ipv4" destination address="10.0.0.0/24" accept' ) @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_allowed_ips_empty(self, mock_wg_get, mock_fw_get, mock_fw_save): """Peers with empty allowed_ips don't generate rules.""" mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": { "alice": { "public_key": "abc123", "allowed_ips": [], } }, } mock_fw_get.return_value = {"zones": {}} result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None mock_fw_save.assert_called_once() assert not any("inter-zone rule" in c for c in result.changes) @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.wireguard.get_config") def test_allowed_ips_idempotent(self, mock_wg_get, mock_fw_get, mock_fw_save): """Existing rules for allowed_ips subnets are not duplicated.""" mock_wg_get.return_value = { "interface": {"name": "wg0"}, "peers": { "alice": { "public_key": "abc123", "allowed_ips": ["192.168.10.0/24"], } }, } mock_fw_get.return_value = { "zones": { "vpn": { "interfaces": ["wg0"], "masquerade": True, "rich_rules": [ { "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', "_source": "wg", }, { "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', "_source": "wg", }, ], } } } result = WgToFirewallSync.on_wireguard_config_saved( SyncEvent("wireguard", "config_saved") ) assert result is not None assert not any("inter-zone rule" in c for c in result.changes) mock_fw_save.assert_not_called() assert result.affected_subsystems == [] assert result.changes == [] class TestFirewallToDhcpSync: @patch("lib.dnsmasq.save_config") @patch("lib.dnsmasq.get_config") @patch("lib.firewall.get_config") def test_flags_uncovered_range_without_deleting( self, mock_fw_get, mock_dm_get, mock_dm_save, caplog ): caplog.set_level(logging.WARNING) mock_fw_get.return_value = { "zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}} } mock_dm_get.return_value = { "dhcp": { "ranges": [ { "interface": "eth1", "start": "192.168.2.100", "end": "192.168.2.200", }, { "interface": "eth2", "start": "10.0.0.100", "end": "10.0.0.200", }, ] } } result = FirewallToDhcpSync.on_firewall_config_saved( SyncEvent("firewall", "config_saved") ) assert result is not None assert result.affected_subsystems == [] assert result.changes == [ "DHCP range on 'eth2' has no firewall zone coverage — " "inactive until a zone covers it" ] assert "DHCP range on 'eth2' has no firewall zone coverage" in caplog.text # Config untouched: no save, both ranges kept mock_dm_save.assert_not_called() assert len(mock_dm_get.return_value["dhcp"]["ranges"]) == 2 @patch("lib.dnsmasq.get_config") @patch("lib.firewall.get_config") def test_no_changes_when_all_valid(self, mock_fw_get, mock_dm_get): mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh", "dhcp", "dns"], } } } mock_dm_get.return_value = { "dhcp": { "ranges": [ { "interface": "eth1", "start": "192.168.2.100", "end": "192.168.2.200", } ] } } result = FirewallToDhcpSync.on_firewall_config_saved( SyncEvent("firewall", "config_saved") ) assert result is not None assert result.affected_subsystems == [] assert result.changes == [] @patch("lib.dnsmasq.get_config") @patch("lib.firewall.get_config") def test_warns_no_range_for_dhcp_service(self, mock_fw_get, mock_dm_get, caplog): caplog.set_level(logging.INFO) mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh", "dhcp"], } } } mock_dm_get.return_value = {"dhcp": {"ranges": []}} result = FirewallToDhcpSync.on_firewall_config_saved( SyncEvent("firewall", "config_saved") ) assert result is not None assert any("no DHCP range" in c for c in result.changes) assert result.affected_subsystems == [] @patch("lib.dnsmasq.get_config") @patch("lib.firewall.get_config") def test_cascade_skip(self, mock_fw_get, mock_dm_get): mock_fw_get.return_value = {"zones": {}} mock_dm_get.return_value = {"dhcp": {"ranges": []}} result = FirewallToDhcpSync.on_firewall_config_saved( SyncEvent("firewall", "config_saved", {"_cascade": "dnsmasq"}) ) assert result is None @patch("lib.dnsmasq.save_config") @patch("lib.dnsmasq.get_config") @patch("lib.firewall.get_config") def test_keeps_global_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save, caplog): """Global and uncovered ranges are both kept, never removed.""" caplog.set_level(logging.WARNING) mock_fw_get.return_value = { "zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}} } mock_dm_get.return_value = { "dhcp": { "ranges": [ {"start": "192.168.1.100", "end": "192.168.1.200"}, { "interface": "eth2", "start": "10.0.0.100", "end": "10.0.0.200", }, ] } } result = FirewallToDhcpSync.on_firewall_config_saved( SyncEvent("firewall", "config_saved") ) assert result is not None assert result.affected_subsystems == [] assert any( "DHCP range on 'eth2' has no firewall zone coverage" in c for c in result.changes ) assert "DHCP range on 'eth2' has no firewall zone coverage" in caplog.text # No save; both ranges (global + eth2) kept in the untouched config mock_dm_save.assert_not_called() ranges = mock_dm_get.return_value["dhcp"]["ranges"] assert len(ranges) == 2 assert ranges[0]["start"] == "192.168.1.100" assert ranges[0].get("interface") is None or ranges[0]["interface"] == "" @patch("lib.dnsmasq.save_config") @patch("lib.dnsmasq.get_config") @patch("lib.firewall.get_config") def test_exception_contained(self, mock_fw_get, mock_dm_get, mock_dm_save): mock_fw_get.side_effect = RuntimeError("db error") result = FirewallToDhcpSync.on_firewall_config_saved( SyncEvent("firewall", "config_saved") ) assert result is not None assert result.affected_subsystems == [] mock_dm_save.assert_not_called() class TestNetworkToAllSync: @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") @patch("lib.network.get_config") def test_suggests_dhcp_range( self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save, caplog ): mock_net_get.return_value = { "interfaces": { "eth1": { "addresses": ["192.168.2.1/24"], "dhcp": "no", } } } mock_dm_get.return_value = {"dhcp": {"ranges": []}} mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh"], } } } result = NetworkToAllSync.on_network_config_saved( SyncEvent("network", "config_saved") ) assert result is not None assert any("no DHCP range" in c for c in result.changes) @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") @patch("lib.network.get_config") def test_zone_sync_firewall( self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save ): mock_net_get.return_value = { "interfaces": {"eth1": {"addresses": ["192.168.2.1/24"]}} } mock_dm_get.return_value = {"dhcp": {"ranges": []}} mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth2"], "services": ["ssh"], } } } result = NetworkToAllSync.on_network_config_saved( SyncEvent("network", "config_saved") ) assert result is not None mock_fw_save.assert_called_once() assert any("not in any zone" in c for c in result.changes) @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") @patch("lib.network.get_config") def test_no_auto_create_ranges( self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save ): mock_net_get.return_value = { "interfaces": { "eth1": { "addresses": ["192.168.2.1/24"], "dhcp": "no", } } } mock_dm_get.return_value = {"dhcp": {"ranges": []}} mock_fw_get.return_value = { "zones": { "internal": { "interfaces": ["eth1"], "services": ["ssh"], } } } with patch("lib.dnsmasq.save_config") as mock_dm_save: NetworkToAllSync.on_network_config_saved( SyncEvent("network", "config_saved") ) mock_dm_save.assert_not_called() @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") @patch("lib.network.get_config") def test_cascade_no_skip( self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save ): """NetworkToAllSync does not check _cascade and processes normally.""" mock_net_get.return_value = {"interfaces": {}} mock_dm_get.return_value = {"dhcp": {"ranges": []}} mock_fw_get.return_value = {"zones": {}} result = NetworkToAllSync.on_network_config_saved( SyncEvent("network", "config_saved", {"_cascade": "firewall"}) ) assert result is not None