316 lines
10 KiB
Python
316 lines
10 KiB
Python
"""Integration tests for networkd interactions with other subsystems.
|
|
|
|
Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference).
|
|
"""
|
|
|
|
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 in state.py should import from lib.network."""
|
|
import lib.state as _state
|
|
|
|
source = Path(_state.__file__).read_text()
|
|
assert "from lib.network import parse_networkctl_status" in source
|
|
assert "parse_networkctl_status" in source
|
|
|
|
def test_networkd_collector_returns_correct_format(self):
|
|
"""_collect_networkd should return interfaces dict + timestamp."""
|
|
import lib.state as _state
|
|
|
|
with patch("lib.state.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"
|
|
" Gateway: 10.0.0.254\n"
|
|
" DNS: 8.8.8.8\n"
|
|
)
|
|
result = _state._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 lib.state as _state
|
|
|
|
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
|
|
result = _state._collect_networkd()
|
|
|
|
assert result["interfaces"] == {}
|
|
assert "timestamp" in result
|