200e078bc5
- Add daemon/ module with aiohttp server, sync client, and handler registry - Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard) - Add system/acme-deploy.py, vacuum-walld sudoers and systemd service - Update API routes to use daemon client instead of lib/ directly - Update lib/, tests/, and webui/ for new architecture - Update docs and deployment scripts
270 lines
9.7 KiB
Python
270 lines
9.7 KiB
Python
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from lib import common, dnsmasq
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_data_dir(tmp_path):
|
|
original_config_dir = dnsmasq.CONFIG_DIR
|
|
original = dnsmasq.DATA_DIR
|
|
original_config = dnsmasq.CONFIG_PATH
|
|
original_fragments = dnsmasq.FRAGMENTS_DIR
|
|
dnsmasq.CONFIG_DIR = tmp_path / "dnsmasq"
|
|
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
|
|
dnsmasq.CONFIG_PATH = dnsmasq.CONFIG_DIR / "config.json"
|
|
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
|
|
dnsmasq.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
yield tmp_path
|
|
dnsmasq.CONFIG_DIR = original_config_dir
|
|
dnsmasq.DATA_DIR = original
|
|
dnsmasq.CONFIG_PATH = original_config
|
|
dnsmasq.FRAGMENTS_DIR = original_fragments
|
|
|
|
|
|
class TestDeepMerge:
|
|
def test_merge_flat_dicts(self):
|
|
base = {"a": 1, "b": 2}
|
|
override = {"b": 3, "c": 4}
|
|
result = common.deep_merge(base, override)
|
|
assert result == {"a": 1, "b": 3, "c": 4}
|
|
|
|
def test_merge_nested_dicts(self):
|
|
base = {"a": {"x": 1, "y": 2}}
|
|
override = {"a": {"y": 3, "z": 4}}
|
|
result = common.deep_merge(base, override)
|
|
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
|
|
|
def test_merge_non_dict_override(self):
|
|
base = {"a": {"x": 1}}
|
|
override = {"a": "flat"}
|
|
result = common.deep_merge(base, override)
|
|
assert result == {"a": "flat"}
|
|
|
|
|
|
class TestGetConfig:
|
|
@patch("lib.dnsmasq.load_json")
|
|
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
|
|
mock_load.return_value = {}
|
|
result = dnsmasq.get_config()
|
|
assert "dhcp" in result
|
|
assert "dns" in result
|
|
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
|
|
|
@patch("lib.dnsmasq.load_json")
|
|
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
|
|
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
|
|
result = dnsmasq.get_config()
|
|
assert result["dns"]["upstreams"] == ["9.9.9.9"]
|
|
|
|
|
|
class TestSaveConfig:
|
|
def test_saves_and_reloads(self, temp_data_dir):
|
|
cfg = {"dns": {"upstreams": ["1.2.3.4"], "domain": "test.lan"}}
|
|
dnsmasq.save_config(cfg)
|
|
loaded = dnsmasq.get_config()
|
|
assert loaded["dns"]["upstreams"] == ["1.2.3.4"]
|
|
assert loaded["dns"]["domain"] == "test.lan"
|
|
|
|
|
|
class TestSetDhcpRange:
|
|
def test_add_new_range(self, temp_data_dir):
|
|
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dhcp"]["ranges"]) == 1
|
|
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
|
|
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
|
|
|
|
def test_replace_existing_range(self, temp_data_dir):
|
|
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
|
|
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dhcp"]["ranges"]) == 1
|
|
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
|
|
|
|
|
|
class TestStaticLeases:
|
|
def test_add_static_lease(self, temp_data_dir):
|
|
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dhcp"]["static_leases"]) == 1
|
|
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
|
|
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
|
|
|
|
def test_update_static_lease(self, temp_data_dir):
|
|
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
|
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dhcp"]["static_leases"]) == 1
|
|
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
|
|
|
|
def test_remove_static_lease(self, temp_data_dir):
|
|
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
|
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
|
|
dnsmasq.remove_static_lease("aa:bb:cc")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dhcp"]["static_leases"]) == 1
|
|
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
|
|
|
|
|
|
class TestDnsRecords:
|
|
def test_add_dns_record(self, temp_data_dir):
|
|
dnsmasq.add_dns_record("host", "10.0.0.100")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dns"]["custom_records"]) == 1
|
|
|
|
def test_remove_dns_record(self, temp_data_dir):
|
|
dnsmasq.add_dns_record("host", "10.0.0.100")
|
|
dnsmasq.add_dns_record("other", "10.0.0.101")
|
|
dnsmasq.remove_dns_record("host")
|
|
cfg = dnsmasq.get_config()
|
|
assert len(cfg["dns"]["custom_records"]) == 1
|
|
assert cfg["dns"]["custom_records"][0]["name"] == "other"
|
|
|
|
|
|
class TestParseLeaseLine:
|
|
def test_valid_line(self):
|
|
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
|
|
result = dnsmasq._parse_lease_line(line)
|
|
assert result is not None
|
|
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
|
|
assert result["ip"] == "10.0.0.50"
|
|
assert result["hostname"] == "printer"
|
|
|
|
def test_empty_line(self):
|
|
assert dnsmasq._parse_lease_line("") is None
|
|
|
|
def test_comment_line(self):
|
|
assert dnsmasq._parse_lease_line("# comment") is None
|
|
|
|
def test_short_line(self):
|
|
assert dnsmasq._parse_lease_line("incomplete") is None
|
|
|
|
def test_minimal_fields(self):
|
|
line = "1700000000 AA:BB:CC 10.0.0.50"
|
|
result = dnsmasq._parse_lease_line(line)
|
|
assert result is not None
|
|
assert result["hostname"] == ""
|
|
assert result["interface"] == ""
|
|
|
|
|
|
class TestUpstreamsAndDomain:
|
|
def test_set_upstreams(self, temp_data_dir):
|
|
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
|
|
cfg = dnsmasq.get_config()
|
|
assert cfg["dns"]["upstreams"] == ["1.1.1.1", "9.9.9.9"]
|
|
|
|
def test_set_domain(self, temp_data_dir):
|
|
dnsmasq.set_domain("internal.lan")
|
|
cfg = dnsmasq.get_config()
|
|
assert cfg["dns"]["domain"] == "internal.lan"
|
|
|
|
def test_clear_domain(self, temp_data_dir):
|
|
dnsmasq.set_domain("internal.lan")
|
|
dnsmasq.set_domain(None)
|
|
cfg = dnsmasq.get_config()
|
|
assert cfg["dns"]["domain"] is None
|
|
|
|
|
|
# ── Daemon handler tests (NotFoundError on missing resources) ──
|
|
|
|
|
|
class TestDaemonRemoveStaticLease:
|
|
@patch("daemon.handlers.dnsmasq._get_config")
|
|
@patch("daemon.handlers.dnsmasq._save_config")
|
|
def test_raises_not_found_when_missing(self, mock_save, mock_get):
|
|
from daemon.handlers import dnsmasq as daemon_dnsmasq
|
|
|
|
mock_get.return_value = {"dhcp": {"static_leases": []}, "dns": {}}
|
|
with pytest.raises(Exception, match="not found"):
|
|
daemon_dnsmasq.remove_static_lease(None, {"mac": "FF:FF:FF"})
|
|
|
|
@patch("daemon.handlers.dnsmasq._get_config")
|
|
@patch("daemon.handlers.dnsmasq._save_config")
|
|
def test_succeeds_when_exists(self, mock_save, mock_get):
|
|
from daemon.handlers import dnsmasq as daemon_dnsmasq
|
|
|
|
mock_get.return_value = {
|
|
"dhcp": {"static_leases": [{"mac": "aa:bb:cc", "ip": "10.0.0.1"}]},
|
|
"dns": {},
|
|
}
|
|
result = daemon_dnsmasq.remove_static_lease(None, {"mac": "AA:BB:CC"})
|
|
assert result["mac"] == "AA:BB:CC"
|
|
|
|
|
|
class TestDaemonRemoveDnsRecord:
|
|
@patch("daemon.handlers.dnsmasq._get_config")
|
|
@patch("daemon.handlers.dnsmasq._save_config")
|
|
def test_raises_not_found_when_missing(self, mock_save, mock_get):
|
|
from daemon.handlers import dnsmasq as daemon_dnsmasq
|
|
|
|
mock_get.return_value = {
|
|
"dhcp": {},
|
|
"dns": {"custom_records": []},
|
|
}
|
|
with pytest.raises(Exception, match="not found"):
|
|
daemon_dnsmasq.remove_dns_record(None, {"name": "nonexistent"})
|
|
|
|
@patch("daemon.handlers.dnsmasq._get_config")
|
|
@patch("daemon.handlers.dnsmasq._save_config")
|
|
def test_succeeds_when_exists(self, mock_save, mock_get):
|
|
from daemon.handlers import dnsmasq as daemon_dnsmasq
|
|
|
|
mock_get.return_value = {
|
|
"dhcp": {},
|
|
"dns": {"custom_records": [{"name": "host", "address": "10.0.0.1"}]},
|
|
}
|
|
result = daemon_dnsmasq.remove_dns_record(None, {"name": "host"})
|
|
assert result["name"] == "host"
|
|
|
|
|
|
class TestDaemonRemoveDhcpRange:
|
|
@patch("daemon.handlers.dnsmasq._get_config")
|
|
@patch("daemon.handlers.dnsmasq._save_config")
|
|
def test_raises_not_found_when_missing(self, mock_save, mock_get):
|
|
from daemon.handlers import dnsmasq as daemon_dnsmasq
|
|
|
|
mock_get.return_value = {
|
|
"dhcp": {"ranges": []},
|
|
"dns": {},
|
|
}
|
|
with pytest.raises(Exception, match="not found"):
|
|
daemon_dnsmasq.remove_dhcp_range(
|
|
None,
|
|
{
|
|
"interface": "eth0",
|
|
"start": "10.0.0.100",
|
|
"end": "10.0.0.200",
|
|
},
|
|
)
|
|
|
|
@patch("daemon.handlers.dnsmasq._get_config")
|
|
@patch("daemon.handlers.dnsmasq._save_config")
|
|
def test_succeeds_when_exists(self, mock_save, mock_get):
|
|
from daemon.handlers import dnsmasq as daemon_dnsmasq
|
|
|
|
mock_get.return_value = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth0",
|
|
"start": "10.0.0.100",
|
|
"end": "10.0.0.200",
|
|
}
|
|
]
|
|
},
|
|
"dns": {},
|
|
}
|
|
result = daemon_dnsmasq.remove_dhcp_range(
|
|
None,
|
|
{
|
|
"interface": "eth0",
|
|
"start": "10.0.0.100",
|
|
"end": "10.0.0.200",
|
|
},
|
|
)
|
|
assert result["interface"] == "eth0"
|