Files
vacuum-wall/tests/test_network_integration.py
T
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00

341 lines
11 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).
"""
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