Files
vacuum-wall/tests/test_sync.py
T
mteehan 9088f34345 sync: add cross-subsystem event bus for config consistency
Add EventBus with loop guards to keep firewall, dnsmasq, wireguard,
and network configs consistent. Handlers emit SyncEvent after mutations;
subscribers compute diffs and write JSON without manual cascade loops.
2026-06-30 01:18:44 +00:00

1032 lines
35 KiB
Python

"""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.firewall.save_config")
@patch("lib.firewall.get_config")
@patch("lib.dnsmasq.get_config")
def test_adds_dhcp_dns_masquerade(self, mock_dm_get, mock_fw_get, mock_fw_save):
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"]
assert saved_cfg["zones"]["internal"]["masquerade"] is True
@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"]
assert saved_cfg["zones"]["internal"]["masquerade"] is False
@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_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_removes_stale_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save):
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 "dnsmasq" in result.affected_subsystems
assert any("Removed stale DHCP range" in c for c in result.changes)
assert any("eth2" in c for c in result.changes)
# Verify saved config only has eth1 range
mock_dm_save.assert_called_once()
saved = mock_dm_save.call_args[0][0]
saved_ranges = saved["dhcp"]["ranges"]
assert len(saved_ranges) == 1
assert saved_ranges[0]["interface"] == "eth1"
@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):
"""Ranges without an interface (global) are never removed."""
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 "dnsmasq" in result.affected_subsystems
saved = mock_dm_save.call_args[0][0]
saved_ranges = saved["dhcp"]["ranges"]
assert len(saved_ranges) == 1
assert saved_ranges[0]["start"] == "192.168.1.100"
assert (
saved_ranges[0].get("interface") is None
or saved_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