"""Tests for daemon/handlers/network.py — handler endpoint logic.""" from pathlib import Path from unittest.mock import patch import pytest from daemon.handlers.network import ( apply_all, get_infer_dhcp_ranges, get_infer_zones, get_interface, get_interfaces, reload_interface, save_interface, set_sysctl, ) from lib import dnsmasq as _dm from lib import firewall as _fw from lib import network as _net @pytest.fixture def tmp_network(tmp_path): # Handler endpoints emit "networkd" sync events; the subscribers # (lib.sync.NetworkToAllSync) read/write the firewall and dnsmasq # configs, and apply_all re-stamps the dnsmasq config. Point all of # those paths at tmp so tests never touch the real config files. orig_net = (_net.CONFIG_FILE, _net.DATA_DIR) orig_dm = (_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR) orig_fw = (_fw.CONFIG_DIR, _fw.CONFIG_FILE) _net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json" _net.DATA_DIR = tmp_path / "data" / "networkd" _dm.CONFIG_DIR = tmp_path / "config" / "dnsmasq" _dm.DATA_DIR = tmp_path / "data" / "dnsmasq" _dm.CONFIG_PATH = _dm.CONFIG_DIR / "config.json" _dm.FRAGMENTS_DIR = _dm.DATA_DIR / "fragments" _dm.CONFIG_DIR.mkdir(parents=True, exist_ok=True) _dm.DATA_DIR.mkdir(parents=True, exist_ok=True) _dm.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True) _fw.CONFIG_DIR = tmp_path / "config" / "firewall" _fw.CONFIG_FILE = _fw.CONFIG_DIR / "config.json" _fw.CONFIG_DIR.mkdir(parents=True, exist_ok=True) yield tmp_path _net.CONFIG_FILE, _net.DATA_DIR = orig_net _dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR = orig_dm _fw.CONFIG_DIR, _fw.CONFIG_FILE = orig_fw # ================================================================= # TF-12: Handler tests # ================================================================= class TestSaveInterface: def test_save_interface_saves_config(self, tmp_network): with ( patch("daemon.handlers.network.run") as mock_run, patch( "daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd" ), ): mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n" save_interface( None, {"name": "eth0", "addresses": ["10.0.0.1/24"], "gateway": "10.0.0.254"}, ) cfg = _net.get_config() assert "eth0" in cfg["interfaces"] assert cfg["interfaces"]["eth0"]["addresses"] == ["10.0.0.1/24"] def test_save_interface_renders_file(self, tmp_network): with ( patch("daemon.handlers.network.run") as mock_run, patch( "daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd" ), ): mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n" save_interface( None, {"name": "eth0", "addresses": ["10.0.0.1/24"]}, ) data_dir = tmp_network / "data" / "networkd" assert (data_dir / "99-eth0.network").exists() content = (data_dir / "99-eth0.network").read_text() assert "Name=eth0" in content assert "Address=10.0.0.1/24" in content def test_save_interface_requires_name(self, tmp_network): with pytest.raises(ValueError, match="name"): save_interface(None, {"addresses": ["10.0.0.1/24"]}) def test_save_interface_requires_body(self): with pytest.raises(ValueError, match="body"): save_interface(None, None) def test_save_interface_rejects_invalid_name(self, tmp_network): invalid_names = [ "../../etc/passwd", "eth 0", "", "eth/0", "eth..0", ] for invalid in invalid_names: with ( patch("daemon.handlers.network.run") as mock_run, patch( "daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd", ), ): mock_run.return_value = ( "1: eth0 ethernet routable\n State: routable\n" ) with pytest.raises(ValueError, match="name"): save_interface(None, {"name": invalid}) class TestReloadInterfaceValidation: def test_reload_interface_rejects_invalid_name(self): invalid_names = [ "../../etc/passwd", "eth 0", "", "eth/0", ] for invalid in invalid_names: with pytest.raises(ValueError, match="name"): reload_interface(None, {"name": invalid}) class TestGetInterfaceValidation: def test_get_interface_rejects_invalid_name(self, tmp_network): invalid_names = [ "../../etc/passwd", "eth 0", "", "eth/0", ] for invalid in invalid_names: with pytest.raises(ValueError, match="name"): get_interface(None, {"name": invalid}) class TestReloadInterface: def test_reload_interface(self): with patch("daemon.handlers.network.run") as mock_run: mock_run.return_value = "reloaded" result = reload_interface(None, {"name": "eth0"}) assert result["name"] == "eth0" assert result["reloaded"] is True mock_run.assert_called_with( ["networkctl", "reconfigure", "eth0"], sudo=True ) def test_reload_interface_requires_name(self): with pytest.raises(ValueError, match="name"): reload_interface(None, None) def test_reload_interface_missing_name(self): with pytest.raises(ValueError, match="name"): reload_interface(None, {}) class TestApplyAll: def test_apply_all_generates_files(self, tmp_network): _net.save_config( { "interfaces": { "eth0": {"addresses": ["10.0.0.1/24"]}, "eth1": {"addresses": ["192.168.1.1/24"]}, } } ) runtime_dir = tmp_network / "run" / "vacuum-wall" runtime_dir.mkdir(parents=True) with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], "cleaned": [], } mock_run.return_value = "" result = apply_all(None, None) assert result["applied"] == 1 assert len(result["files"]) == 1 def test_apply_all_syncs_dns(self, tmp_network): _net.save_config( { "interfaces": { "eth0": { "dhcp": "ipv4", "dns": ["8.8.8.8", "192.168.1.1"], } } } ) runtime_dir = tmp_network / "run" / "vacuum-wall" runtime_dir.mkdir(parents=True) with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams, patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], "cleaned": [], } mock_run.return_value = "" mock_collect.return_value = ["8.8.8.8"] apply_all(None, None) mock_set_upstreams.assert_called_once_with(["8.8.8.8"]) def test_apply_all_handles_dns_sync_failure(self, tmp_network): _net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}}) runtime_dir = tmp_network / "run" / "vacuum-wall" runtime_dir.mkdir(parents=True) with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch( "daemon.handlers.network.set_upstreams", side_effect=RuntimeError("fail"), ), patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], "cleaned": [], } mock_run.return_value = "" mock_collect.return_value = ["8.8.8.8"] result = apply_all(None, None) assert "applied" in result def test_apply_all_removes_stale_system_files(self, tmp_network): _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) sys_dir = tmp_network / "etc" / "systemd" / "network" sys_dir.mkdir(parents=True) (sys_dir / "stale-file.network").write_text("[Match]\nName=old\n") runtime_dir = tmp_network / "run" / "vacuum-wall" runtime_dir.mkdir(parents=True) with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], "cleaned": [], } mock_run.return_value = "" class FakePath: def __init__(self, p="/etc/systemd/network") -> None: self._p = sys_dir if p == "/etc/systemd/network" else Path(p) def exists(self): return True def iterdir(self): return iter(self._p.iterdir()) def __truediv__(self, other): return self._p / other def mkdir(self, *args, **kwargs) -> None: self._p.mkdir(parents=True, exist_ok=True) with patch("daemon.handlers.network.Path", FakePath): apply_all(None, None) assert (sys_dir / "stale-file.network").exists() class TestGetInterfaces: def test_get_interfaces_returns_merged_data(self, tmp_network): _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) with patch("daemon.handlers.network.run") as mock_run: mock_run.return_value = ( "1: eth0 ethernet 10.0.0.0/24 routable\n" " State: routable\n" " Addresses: 10.0.0.1/24,\n" ) result = get_interfaces(None, None) assert "interfaces" in result assert "eth0" in result["interfaces"] assert "config" in result["interfaces"]["eth0"] assert "runtime" in result["interfaces"]["eth0"] def test_get_interfaces_handles_networkctl_failure(self, tmp_network): _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) with patch( "daemon.handlers.network.run", side_effect=RuntimeError("no networkctl") ): result = get_interfaces(None, None) assert "interfaces" in result assert "eth0" in result["interfaces"] class TestGetInterface: def test_get_single_interface(self, tmp_network): _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) with patch("daemon.handlers.network.run") as mock_run: mock_run.return_value = "1: eth0 ethernet\n State: routable\n" result = get_interface(None, {"name": "eth0"}) assert result["name"] == "eth0" assert "config" in result assert result["config"]["addresses"] == ["10.0.0.1/24"] def test_get_interface_not_found(self, tmp_network): _net.save_config({"interfaces": {}}) with pytest.raises(Exception, match="not found"): get_interface(None, {"name": "eth0"}) def test_get_interface_requires_name(self): with pytest.raises(ValueError, match="required"): get_interface(None, None) class TestInferEndpoints: def test_infer_dhcp_ranges_endpoint(self, tmp_network): _net.save_config( { "interfaces": { "eth0": {"addresses": [{"address": "192.168.1.1/24"}]}, } } ) result = get_infer_dhcp_ranges(None, None) assert "ranges" in result assert "eth0" in result["ranges"] def test_infer_zones_endpoint(self, tmp_network): _net.save_config( { "interfaces": { "wg0": {}, "eth0": {"addresses": [{"address": "192.168.1.1/24"}]}, } } ) result = get_infer_zones(None, None) assert "zones" in result assert result["zones"]["wg0"] == "wan" assert result["zones"]["eth0"] == "lan" class TestSetSysctl: def test_set_sysctl_success(self, tmp_network): with ( patch("daemon.handlers.network.run") as mock_run, patch.object(Path, "read_text", return_value="1"), ): mock_run.return_value = "" # sysctl -w call result = set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"}) assert result["name"] == "net.ipv4.ip_forward" assert result["value"] == "1" assert mock_run.call_count == 1 assert mock_run.call_args_list[0].args == ( ["sysctl", "-w", "net.ipv4.ip_forward=1"], ) assert mock_run.call_args_list[0].kwargs == {"sudo": True} def test_set_sysctl_rejects_slash_in_name(self): with pytest.raises(ValueError, match="valid sysctl key"): set_sysctl(None, {"name": "net.ipv4/ip_forward", "value": "1"}) def test_set_sysctl_rejects_double_dot(self): with pytest.raises(ValueError, match="valid sysctl key"): set_sysctl(None, {"name": "net..ipv4", "value": "1"}) def test_set_sysctl_requires_name(self): with pytest.raises(ValueError, match="name"): set_sysctl(None, {"value": "1"}) def test_set_sysctl_requires_value(self): with pytest.raises(ValueError, match="value"): set_sysctl(None, {"name": "net.ipv4.ip_forward"}) def test_set_sysctl_requires_body(self): with pytest.raises(ValueError, match="body"): set_sysctl(None, None) def test_set_sysctl_verify_failure(self): with ( patch("daemon.handlers.network.run") as mock_run, patch.object(Path, "read_text", return_value="0"), ): mock_run.return_value = "" # sysctl -w call succeeds with pytest.raises(RuntimeError, match="verify failed"): set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})