"""Integration tests for networkd interactions with other subsystems. Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference). """ import json from pathlib import Path from unittest.mock import patch import pytest from lib import network as _net @pytest.fixture def tmp_network(tmp_path): orig_config = _net.CONFIG_FILE orig_data = _net.DATA_DIR _net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json" _net.DATA_DIR = tmp_path / "data" / "networkd" yield tmp_path _net.CONFIG_FILE = orig_config _net.DATA_DIR = orig_data # ================================================================= # TF-13: Integration tests — DNS upstream sync # ================================================================= class TestDnsUpstreamIntegration: """collect_upstream_dns + set_upstreams integration.""" def test_wan_dns_becomes_dnsmasq_upstream(self, tmp_network): """WAN interface with public DNS should produce upstream list.""" cfg = _net.get_config() cfg["interfaces"] = { "eth0": { "dhcp": "ipv4", "dns": ["8.8.8.8", "1.1.1.1"], }, "lan0": { "addresses": [{"address": "192.168.1.1/24"}], "dns": ["127.0.0.1"], }, } _net.save_config(cfg) upstreams = _net.collect_upstream_dns(cfg) assert "8.8.8.8" in upstreams assert "1.1.1.1" in upstreams assert "127.0.0.1" not in upstreams def test_all_dns_local_yields_empty(self, tmp_network): """When all DNS servers are local, no upstreams.""" cfg = { "interfaces": { "lan0": { "addresses": [{"address": "192.168.1.1/24"}], "dns": ["127.0.0.1", "192.168.1.1"], "ipv6_dns": ["fe80::1"], } } } _net.save_config(cfg) upstreams = _net.collect_upstream_dns(cfg) assert upstreams == [] def test_mixed_v4_v6_upstreams(self, tmp_network): """Collects both IPv4 and IPv6 public DNS.""" cfg = { "interfaces": { "eth0": { "dns": ["8.8.8.8"], "ipv6_dns": ["2001:4860:4860::8888"], } } } _net.save_config(cfg) upstreams = _net.collect_upstream_dns(cfg) assert "8.8.8.8" in upstreams assert "2001:4860:4860::8888" in upstreams def test_upstream_dns_after_generate_files(self, tmp_network): """Full flow: save config -> generate files -> collect upstreams.""" cfg = { "interfaces": { "eth0": { "dhcp": "ipv4", "dns": ["8.8.8.8", "192.168.1.1"], "gateway": "10.0.0.254", } } } _net.save_config(cfg) result = _net.generate_network_files(cfg) assert len(result["generated"]) == 1 upstreams = _net.collect_upstream_dns(cfg) assert "8.8.8.8" in upstreams assert "192.168.1.1" not in upstreams # ================================================================= # TF-13: Integration tests — DHCP range inference # ================================================================= class TestDhcpRangesIntegration: """infer_dhcp_ranges for multiple interface scenarios.""" def test_multi_interface_ranges(self, tmp_network): """Each interface with static IP gets its own range.""" cfg = { "interfaces": { "lan1": { "addresses": [{"address": "192.168.1.1/24"}], }, "lan2": { "addresses": [{"address": "10.10.0.1/16"}], }, "wan0": { "dhcp": "ipv4", }, } } _net.save_config(cfg) ranges = _net.infer_dhcp_ranges(cfg) assert "lan1" in ranges assert "lan2" in ranges assert "wan0" not in ranges assert ranges["lan1"]["start"] == "192.168.1.100" assert ranges["lan1"]["end"] == "192.168.1.200" assert ranges["lan2"]["start"] == "10.10.0.100" assert ranges["lan2"]["end"] == "10.10.0.200" def test_generate_then_infer(self, tmp_network): """End-to-end: save, generate, infer ranges.""" cfg = { "interfaces": { "eth0": { "addresses": [{"address": "172.16.0.1/24"}], "gateway": "172.16.0.254", "dns": ["8.8.8.8"], } } } _net.save_config(cfg) _net.generate_network_files(cfg) ranges = _net.infer_dhcp_ranges(cfg) assert "eth0" in ranges assert ranges["eth0"]["subnet"] == "172.16.0.0" assert ranges["eth0"]["prefix"] == 24 def test_ranges_cross_reference_with_zones(self, tmp_network): """DHCP ranges for LAN interfaces correlate with zone inference.""" cfg = { "interfaces": { "lan0": { "addresses": [{"address": "192.168.1.1/24"}], }, "wan0": { "dhcp": "ipv4", }, } } _net.save_config(cfg) ranges = _net.infer_dhcp_ranges(cfg) zones = _net.infer_zones(cfg) assert "lan0" in ranges assert zones["lan0"] == "lan" assert "wan0" not in ranges assert zones["wan0"] == "wan" # ================================================================= # TF-13: Integration tests — zone inference with networkd config # ================================================================= class TestZonesIntegration: """infer_zones with realistic networkd configurations.""" def test_typical_router_setup(self, tmp_network): """WAN (DHCP), LAN (static), WG (WireGuard) zones.""" cfg = { "interfaces": { "eth0": { "dhcp": "ipv4", }, "eth1": { "addresses": [{"address": "192.168.1.1/24"}], "gateway": "192.168.1.254", }, "wg0": { "addresses": [{"address": "10.137.0.1/24"}], }, "br-mgmt": { "addresses": [{"address": "10.0.0.1/24"}], "routes": [ {"destination": "0.0.0.0/0", "gateway": "10.0.0.254"}, ], }, } } _net.save_config(cfg) zones = _net.infer_zones(cfg) assert zones["eth0"] == "wan" assert zones["eth1"] == "lan" assert zones["wg0"] == "wan" assert zones["br-mgmt"] == "management" def test_zone_inference_after_generate(self, tmp_network): """Zone inference works after generate_network_files.""" cfg = { "interfaces": { "wan0": {"dhcp": "ipv4"}, "lan0": {"addresses": [{"address": "192.168.10.1/24"}]}, } } _net.save_config(cfg) _net.generate_network_files(cfg) zones = _net.infer_zones(cfg) assert zones["wan0"] == "wan" assert zones["lan0"] == "lan" def test_full_pipeline(self, tmp_network): """Full pipeline: config -> generate -> DNS -> ranges -> zones.""" cfg = { "interfaces": { "eth0": { "dhcp": "ipv4", "dns": ["8.8.8.8", "1.1.1.1", "192.168.1.1"], }, "eth1": { "addresses": [{"address": "192.168.1.1/24"}], "dns": ["127.0.0.1"], }, "wg0": { "addresses": [{"address": "10.137.0.1/24"}], }, } } _net.save_config(cfg) gen_result = _net.generate_network_files(cfg) assert len(gen_result["generated"]) == 3 upstreams = _net.collect_upstream_dns(cfg) assert "8.8.8.8" in upstreams assert "1.1.1.1" in upstreams assert "192.168.1.1" not in upstreams assert "127.0.0.1" not in upstreams ranges = _net.infer_dhcp_ranges(cfg) assert "eth1" in ranges assert "eth0" not in ranges # wg0 has a static address so it also gets a candidate range assert "wg0" in ranges zones = _net.infer_zones(cfg) assert zones["eth0"] == "wan" assert zones["eth1"] == "lan" assert zones["wg0"] == "wan" # ================================================================= # TF-11: state.py parser dedup verification # ================================================================= class TestStateParserDedup: """Verify lib/state.py uses lib.network.parse_networkctl_status().""" def test_state_uses_network_parser(self): """The networkd collector should import from lib.network.""" import daemon.collectors.networkd as _collector source = Path(_collector.__file__).read_text() assert "from lib.network import" in source assert "parse_networkctl_status" in source def test_networkd_collector_returns_correct_format(self): """_collect_networkd should return interfaces dict + timestamp.""" import daemon.collectors.networkd as _collector with patch("daemon.collectors.networkd.run") as mock_run: mock_run.return_value = json.dumps( { "Interfaces": [ { "Name": "eth0", "Type": "ether", "OperationalState": "routable", "Addresses": [ { "Family": 2, "Address": [10, 0, 0, 1], "PrefixLength": 24, } ], "DNS": [ {"Family": 2, "Address": [8, 8, 8, 8]}, ], "Routes": [ { "Family": 2, "Destination": [0, 0, 0, 0], "DestinationPrefixLength": 0, "Gateway": [10, 0, 0, 254], } ], } ] } ) result = _collector._collect_networkd() assert "interfaces" in result assert "timestamp" in result assert "eth0" in result["interfaces"] assert "10.0.0.1/24" in result["interfaces"]["eth0"]["addresses"] def test_networkd_collector_handles_failure(self): """_collect_networkd returns empty interfaces on error.""" import daemon.collectors.networkd as _collector with patch( "daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl") ): result = _collector._collect_networkd() assert result["interfaces"] == {} assert "timestamp" in result