faa076370d
- 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.
587 lines
20 KiB
Python
587 lines
20 KiB
Python
"""Tests for lib/state.py — state store and collect functions."""
|
|
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import daemon.collectors.acme
|
|
import daemon.collectors.dnsmasq
|
|
import daemon.collectors.firewall
|
|
from lib.state import State, state
|
|
|
|
|
|
class TestState:
|
|
def test_new_state_empty(self):
|
|
s = State()
|
|
assert s.get("firewall") is None
|
|
assert s.is_populated() is False
|
|
|
|
def test_set_and_get(self):
|
|
s = State()
|
|
s.set("firewall", {"zones": {"public": {}}})
|
|
assert s.get("firewall") == {"zones": {"public": {}}}
|
|
|
|
def test_populate_all(self):
|
|
s = State()
|
|
with patch.object(s, "_data", {}):
|
|
pass
|
|
# Just verify populate doesn't crash on empty collectors
|
|
# (our collect functions need subprocess, so test mocks only)
|
|
pass
|
|
|
|
def test_singleton_exists(self):
|
|
assert state is not None
|
|
assert isinstance(state, State)
|
|
|
|
def test_get_snapshot_empty(self):
|
|
"""Fresh store: snapshot lists every subsystem, all None."""
|
|
s = State()
|
|
snap = s.get_snapshot()
|
|
assert set(snap) == set(s.SUBSYSTEMS)
|
|
assert all(v is None for v in snap.values())
|
|
|
|
def test_get_snapshot_reflects_set_and_none(self):
|
|
"""Snapshot carries set data; failed collections stay None."""
|
|
s = State()
|
|
s.set("firewall", {"zones": {}})
|
|
s.set("system", {"load": {"load1": 0.0}})
|
|
s.set("dnsmasq", None)
|
|
snap = s.get_snapshot()
|
|
assert snap["firewall"] == {"zones": {}}
|
|
assert snap["system"] == {"load": {"load1": 0.0}}
|
|
assert snap["dnsmasq"] is None
|
|
assert snap["acme"] is None
|
|
|
|
|
|
class TestCollectAll:
|
|
@patch("daemon.collectors.firewall.run")
|
|
def test_collect_firewall_returns_dict(self, mock_run):
|
|
from daemon.collectors.firewall import _collect_firewall
|
|
|
|
def run_side(args, **kwargs):
|
|
if "--get-active-zones" in args:
|
|
return "public\n eth0"
|
|
if "--get-default-zone" in args:
|
|
return "public\n"
|
|
if "--get-services" in args:
|
|
return "ssh http"
|
|
if "ip" in args[0]:
|
|
if "link" in args:
|
|
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
|
return ""
|
|
if "--list-all-zones" in args:
|
|
return (
|
|
"public\n"
|
|
" target: default\n"
|
|
" interfaces: eth0\n"
|
|
" services: \n"
|
|
" ports: \n"
|
|
" protocols: \n"
|
|
" forward-ports: \n"
|
|
" masquerade: no\n"
|
|
" rich rules: \n"
|
|
)
|
|
|
|
mock_run.side_effect = run_side
|
|
result = _collect_firewall()
|
|
assert isinstance(result, dict)
|
|
assert "active_zones" in result
|
|
assert "default_zone" in result
|
|
assert result["default_zone"] == "public"
|
|
assert "interfaces" in result
|
|
assert "timestamp" in result
|
|
|
|
@patch("daemon.collectors.firewall.run")
|
|
def test_collect_firewall_vlan_ips_populated(self, mock_run):
|
|
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
|
|
from daemon.collectors.firewall import _collect_firewall
|
|
|
|
def run_side(args, **kwargs):
|
|
if "--get-active-zones" in args:
|
|
return "public\n eth0\ninternal\n eth0.100"
|
|
if "--get-default-zone" in args:
|
|
return "public\n"
|
|
if "--get-services" in args:
|
|
return "ssh http"
|
|
if "ip" in args[0]:
|
|
if "link" in args:
|
|
return (
|
|
"1: lo: <LOOPBACK> mtu 65536\n"
|
|
"2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
|
"3: eth0.100@eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
|
)
|
|
if "addr" in args:
|
|
return (
|
|
"2: eth0 inet 192.168.1.1/24\n"
|
|
"3: eth0.100@if100 inet 10.0.0.1/24\n"
|
|
)
|
|
return ""
|
|
if "--list-all-zones" in args:
|
|
return (
|
|
"public\n"
|
|
" target: default\n"
|
|
" interfaces: eth0\n"
|
|
" services: \n"
|
|
" ports: \n"
|
|
" protocols: \n"
|
|
" forward-ports: \n"
|
|
" masquerade: no\n"
|
|
" rich rules: \n"
|
|
"internal\n"
|
|
" target: ACCEPT\n"
|
|
" interfaces: eth0.100\n"
|
|
" services: \n"
|
|
" ports: \n"
|
|
" protocols: \n"
|
|
" forward-ports: \n"
|
|
" masquerade: no\n"
|
|
" rich rules: \n"
|
|
)
|
|
|
|
mock_run.side_effect = run_side
|
|
result = _collect_firewall()
|
|
vlan_iface = next(
|
|
(i for i in result["interfaces"] if i["name"] == "eth0.100"), None
|
|
)
|
|
assert vlan_iface is not None, "VLAN interface should be present"
|
|
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
|
|
assert "10.0.0.1/24" in vlan_iface["ips"]
|
|
|
|
@patch("daemon.collectors.firewall.get_service_descriptions")
|
|
@patch("daemon.collectors.firewall.run")
|
|
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
|
|
from daemon.collectors.firewall import _collect_firewall
|
|
|
|
def run_side(args, **kwargs):
|
|
if "--get-active-zones" in args:
|
|
return ""
|
|
if "--get-default-zone" in args:
|
|
return "public\n"
|
|
if "--get-services" in args:
|
|
return "ssh http"
|
|
if "ip" in args[0]:
|
|
return ""
|
|
if "--list-all-zones" in args:
|
|
return ""
|
|
|
|
mock_run.side_effect = run_side
|
|
descs = {"ssh": "OpenSSH", "http": "WWW"}
|
|
mock_desc.return_value = descs
|
|
result = _collect_firewall()
|
|
mock_desc.assert_called_once_with()
|
|
assert result["service_descriptions"] == descs
|
|
|
|
@patch("daemon.collectors.dnsmasq.run_proc")
|
|
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
|
from unittest.mock import Mock
|
|
|
|
from daemon.collectors.dnsmasq import _collect_dnsmasq
|
|
|
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
|
result = _collect_dnsmasq()
|
|
assert isinstance(result, dict)
|
|
assert "status" in result
|
|
assert "config" in result
|
|
assert "leases" in result
|
|
|
|
@patch("daemon.collectors.dnsmasq.run_proc")
|
|
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
|
|
from unittest.mock import Mock
|
|
|
|
from daemon.collectors.dnsmasq import _collect_dnsmasq
|
|
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
|
|
|
|
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
|
applied = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "10.4.20.101",
|
|
"end": "10.4.20.200",
|
|
"lease_time": "1h",
|
|
"gateway": "10.4.20.1",
|
|
}
|
|
],
|
|
"static_leases": [],
|
|
},
|
|
"dns": {"upstreams": ["8.8.8.8"], "domain": None, "custom_records": []},
|
|
}
|
|
cfg = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "10.4.20.100",
|
|
"end": "10.4.20.200",
|
|
"lease_time": "12h",
|
|
"gateway": "10.4.20.1",
|
|
}
|
|
],
|
|
"static_leases": [],
|
|
},
|
|
"dns": {
|
|
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
|
"domain": None,
|
|
"custom_records": [],
|
|
},
|
|
_LAST_APPLIED_CONFIG_KEY: applied,
|
|
_APPLY_HASH_KEY: "stale-hash",
|
|
}
|
|
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
|
monkeypatch.setattr(
|
|
"lib.dnsmasq.CONFIG_PATH", tmp_path / "config" / "dnsmasq" / "config.json"
|
|
)
|
|
# service check -> active; lease file read -> no lines
|
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
|
|
|
result = _collect_dnsmasq()
|
|
assert result["status"]["pending_changes"] is True
|
|
paths = {d["path"] for d in result["status"]["pending_diff"]}
|
|
assert "dhcp.ranges[0].start" in paths
|
|
assert "dhcp.ranges[0].lease_time" in paths
|
|
# Apply metadata must not leak into the returned config.
|
|
assert _LAST_APPLIED_CONFIG_KEY not in result["config"]
|
|
assert _APPLY_HASH_KEY not in result["config"]
|
|
|
|
|
|
class TestCollectFailure:
|
|
def test_state_clears_on_failure(self):
|
|
"""State collection failure sets the subsystem to None."""
|
|
s = State()
|
|
s.set("firewall", {"zones": {"public": {}}})
|
|
s.set("firewall", None) # simulates failure
|
|
assert s.get("firewall") is None
|
|
assert s.is_populated() is False
|
|
|
|
|
|
_ACCOUNT = {"registered": False, "email": "", "ca": ""}
|
|
|
|
|
|
class TestAcmeCollectNonFatal:
|
|
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
|
|
|
|
def test_list_failure_yields_empty_certs_and_error(self):
|
|
from daemon.collectors.acme import _collect_acme
|
|
|
|
with (
|
|
patch.object(
|
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
|
),
|
|
patch(
|
|
"lib.acme.list_certs",
|
|
side_effect=RuntimeError("acme.sh failed with exit code 2"),
|
|
),
|
|
patch.object(
|
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
|
),
|
|
):
|
|
result = _collect_acme()
|
|
|
|
assert result["certs"] == []
|
|
assert result["email"] == "a@b.c"
|
|
assert result["status"]["error"] is not None
|
|
assert "exit code 2" in result["status"]["error"]
|
|
|
|
def test_success_reports_no_error(self):
|
|
from daemon.collectors.acme import _collect_acme
|
|
|
|
with (
|
|
patch.object(
|
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
|
),
|
|
patch("lib.acme.list_certs", return_value=[]),
|
|
patch.object(
|
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
|
),
|
|
):
|
|
result = _collect_acme()
|
|
|
|
assert result["status"] == {"error": None}
|
|
|
|
|
|
class TestStateVersions:
|
|
def test_version_starts_at_zero(self):
|
|
s = State()
|
|
versions = s.get_versions()
|
|
assert versions["firewall"] == 0
|
|
assert versions["dnsmasq"] == 0
|
|
|
|
def test_bump_increments_version(self):
|
|
s = State()
|
|
assert s.get_versions()["firewall"] == 0
|
|
s.bump("firewall")
|
|
assert s.get_versions()["firewall"] == 1
|
|
|
|
def test_bump_unknown_subsystem_noop(self):
|
|
s = State()
|
|
versions = s.get_versions()
|
|
s.bump("nonexistent")
|
|
assert versions == s.get_versions()
|
|
|
|
def test_get_updated_versions_first_call_empty(self):
|
|
s = State()
|
|
s.bump("firewall")
|
|
updated = s.get_updated_versions()
|
|
assert updated == {}
|
|
assert s.get_updated_versions() == {}
|
|
|
|
def test_get_updated_versions_detects_change(self):
|
|
s = State()
|
|
_ = s.get_updated_versions() # snapshot
|
|
s.bump("firewall")
|
|
updated = s.get_updated_versions()
|
|
assert updated["firewall"] == 1
|
|
|
|
def test_broadcast_maintains_snapshot(self):
|
|
s = State()
|
|
s.bump("firewall")
|
|
s.bump("dnsmasq")
|
|
_ = s.get_updated_versions() # snapshot at fw=1, dm=1
|
|
s.bump("wireguard")
|
|
updated = s.get_updated_versions()
|
|
assert updated["wireguard"] == 1
|
|
assert s.get_updated_versions() == {}
|
|
|
|
def test_multiple_bumps_aggregate(self):
|
|
s = State()
|
|
_ = s.get_updated_versions()
|
|
s.bump("firewall")
|
|
s.bump("firewall")
|
|
s.bump("dnsmasq")
|
|
updated = s.get_updated_versions()
|
|
assert updated["firewall"] == 2
|
|
assert updated["dnsmasq"] == 1
|
|
|
|
|
|
class TestStripVolatile:
|
|
def test_list_of_dicts_strips_nested_keys(self):
|
|
"""Wireguard-style: status.peers[].transfer_received gets stripped."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {
|
|
"status": {
|
|
"peers": [
|
|
{
|
|
"transfer_received": "100B",
|
|
"transfer_sent": "200B",
|
|
"latest_handshake": "ago",
|
|
"public_key": "abc",
|
|
},
|
|
{
|
|
"transfer_received": "300B",
|
|
"transfer_sent": "400B",
|
|
"latest_handshake": "now",
|
|
"public_key": "def",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
vol = frozenset(
|
|
{"status.peers[].transfer_received", "status.peers[].latest_handshake"}
|
|
)
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["status"]["peers"][0]["transfer_received"] is None
|
|
assert stripped["status"]["peers"][0]["latest_handshake"] is None
|
|
assert stripped["status"]["peers"][0]["transfer_sent"] == "200B"
|
|
assert stripped["status"]["peers"][0]["public_key"] == "abc"
|
|
assert stripped["status"]["peers"][1]["transfer_received"] is None
|
|
|
|
def test_scalar_path_strips(self):
|
|
"""Scalar paths get zeroed to None."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {"a": {"b": 1, "c": 2}}
|
|
vol = frozenset({"a.b"})
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["a"]["b"] is None
|
|
assert stripped["a"]["c"] == 2
|
|
|
|
def test_empty_volatile_returns_copy(self):
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {"x": 1}
|
|
stripped = _strip_volatile(data, frozenset())
|
|
assert stripped == data
|
|
assert stripped is not data
|
|
|
|
def test_firewall_volatile_strips_ips(self):
|
|
"""Firewall-style: interfaces[].ips gets stripped."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {
|
|
"interfaces": [
|
|
{
|
|
"name": "eth0",
|
|
"ips": ["10.0.0.1/24"],
|
|
"ipv6": ["fe80::1"],
|
|
"zone": "internal",
|
|
},
|
|
]
|
|
}
|
|
vol = frozenset({"interfaces[].ips", "interfaces[].ipv6"})
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["interfaces"][0]["ips"] is None
|
|
assert stripped["interfaces"][0]["ipv6"] is None
|
|
assert stripped["interfaces"][0]["name"] == "eth0"
|
|
assert stripped["interfaces"][0]["zone"] == "internal"
|
|
|
|
def test_networkd_volatile_strips_addresses(self):
|
|
"""Networkd-style: interfaces dict keyed by name, addresses stripped via fallback."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {
|
|
"interfaces": {
|
|
"eth0": {
|
|
"addresses": ["10.0.0.1/24", "fe80::1"],
|
|
"state": "routable",
|
|
"type": "ether",
|
|
},
|
|
"lo": {
|
|
"addresses": ["127.0.0.1/8"],
|
|
"state": "degraded",
|
|
"type": "loopback",
|
|
},
|
|
}
|
|
}
|
|
vol = frozenset({"interfaces[].addresses"})
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["interfaces"]["eth0"]["addresses"] is None
|
|
assert stripped["interfaces"]["eth0"]["state"] == "routable"
|
|
assert stripped["interfaces"]["eth0"]["type"] == "ether"
|
|
assert stripped["interfaces"]["lo"]["addresses"] is None
|
|
assert stripped["interfaces"]["lo"]["state"] == "degraded"
|
|
|
|
|
|
class TestDiffLayers:
|
|
def test_no_change(self):
|
|
"""Identical data (minus timestamp) returns (False, False)."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {"zones": {"public": {}}, "timestamp": "t1"}
|
|
new = {"zones": {"public": {}}, "timestamp": "t2"}
|
|
structural, volatile = _diff_layers(old, new, frozenset())
|
|
assert structural is False
|
|
assert volatile is False
|
|
|
|
def test_structural_only(self):
|
|
"""Non-volatile change detected as structural."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {
|
|
"zones": {"public": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"zones": {"internal": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t2",
|
|
}
|
|
vol = frozenset({"interfaces[].ips"})
|
|
structural, volatile = _diff_layers(old, new, vol)
|
|
assert structural is True
|
|
assert volatile is False
|
|
|
|
def test_volatile_only(self):
|
|
"""Only volatile fields changed returns (False, True)."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {
|
|
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"status": {"peers": [{"transfer_received": "200B", "public_key": "abc"}]},
|
|
"timestamp": "t2",
|
|
}
|
|
vol = frozenset({"status.peers[].transfer_received"})
|
|
structural, volatile = _diff_layers(old, new, vol)
|
|
assert structural is False
|
|
assert volatile is True
|
|
|
|
def test_neither(self):
|
|
"""No change at all returns (False, False)."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {
|
|
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
|
|
"timestamp": "t2",
|
|
}
|
|
vol = frozenset({"status.peers[].transfer_received"})
|
|
structural, volatile = _diff_layers(old, new, vol)
|
|
assert structural is False
|
|
assert volatile is False
|
|
|
|
def test_old_none_returns_both_true(self):
|
|
"""When old is None (first poll), both layers report True."""
|
|
from lib.state import _diff_layers
|
|
|
|
new = {"zones": {}}
|
|
structural, volatile = _diff_layers(None, new, frozenset())
|
|
assert structural is True
|
|
assert volatile is True
|
|
|
|
|
|
class TestPoll:
|
|
def test_poll_no_change(self):
|
|
"""poll() returns (False, False) when collector returns same data."""
|
|
import uuid
|
|
|
|
from lib.state import _COLLECTORS, State
|
|
|
|
name = f"test_{uuid.uuid4().hex}"
|
|
s = State()
|
|
data = {"value": 1, "timestamp": "t1"}
|
|
_COLLECTORS[name] = lambda: data
|
|
s.set(name, data)
|
|
structural, volatile = s.poll(name)
|
|
assert structural is False
|
|
assert volatile is False
|
|
del _COLLECTORS[name]
|
|
|
|
def test_poll_detects_structural(self):
|
|
"""poll() detects structural changes via collector."""
|
|
import uuid
|
|
|
|
from lib.state import _COLLECTORS, _VOLATILE, State
|
|
|
|
name = f"test_{uuid.uuid4().hex}"
|
|
s = State()
|
|
old = {
|
|
"zones": {"public": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"zones": {"internal": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t2",
|
|
}
|
|
_COLLECTORS[name] = lambda: new
|
|
_VOLATILE[name] = frozenset({"interfaces[].ips"})
|
|
s.set(name, old)
|
|
structural, _volatile = s.poll(name)
|
|
assert structural is True
|
|
del _COLLECTORS[name]
|
|
del _VOLATILE[name]
|
|
|
|
def test_poll_failure_returns_no_broadcast(self):
|
|
"""poll() returns (False, False) and preserves existing data on collector failure."""
|
|
import uuid
|
|
|
|
from lib.state import _COLLECTORS, State
|
|
|
|
name = f"test_{uuid.uuid4().hex}"
|
|
s = State()
|
|
s.set(name, {"value": 1})
|
|
_COLLECTORS[name] = lambda: (_ for _ in ()).throw(RuntimeError("fail"))
|
|
structural, volatile = s.poll(name)
|
|
assert structural is False
|
|
assert volatile is False
|
|
assert s.get(name) == {"value": 1}
|
|
del _COLLECTORS[name]
|