78fcb01877
- install.sh: the traversal-chmod loop assigned _d but looped over the never-set $d; under set -u every fresh install aborted with "d: unbound variable" at that line. Loop over $_d. - acme collector: the self-heal normalize (sudo chmod g+rwX) now runs only when a no-sudo group-read-bit probe detects a lost bit — acme.sh re-hardens the tree 600 on every run, so the steady-state poll makes no sudo call. The group bit (not daemon readability) is what the two-user model keeps for the WebUI user. - lib.acme: new get_acme_home() accessor (ACME_HOME env, default data/acme), reused by _run_acme; _summarize_acme_output preserves a "Permission denied" line even when it is not among the final two, so the collector's actionable-error matcher keeps firing. - nginx template: emit location /static/ for any is_management path (not only '/'); the SPA references /static/... at the domain root regardless of the management backend path. - tests: probe, summarizer, and nginx-subpath cases in test_state.py, test_acme.py, test_nginx.py.
753 lines
26 KiB
Python
753 lines
26 KiB
Python
"""Tests for lib/state.py — state store and collect functions."""
|
|
|
|
import json
|
|
import os
|
|
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(
|
|
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
|
|
),
|
|
patch("daemon.handlers.acme.normalize_acme_home"),
|
|
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(
|
|
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
|
|
),
|
|
patch("daemon.handlers.acme.normalize_acme_home"),
|
|
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}
|
|
|
|
def test_self_heal_normalizes_before_list(self):
|
|
from daemon.collectors.acme import _collect_acme
|
|
|
|
order: list[str] = []
|
|
|
|
def _norm():
|
|
order.append("normalize")
|
|
|
|
def _list():
|
|
order.append("list")
|
|
return [{"domain": "example.com"}]
|
|
|
|
with (
|
|
patch.object(
|
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
|
),
|
|
patch(
|
|
"daemon.collectors.acme._acme_home_needs_normalize", return_value=True
|
|
),
|
|
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
|
|
patch("lib.acme.list_certs", side_effect=_list),
|
|
patch.object(
|
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
|
),
|
|
):
|
|
result = _collect_acme()
|
|
|
|
# The poll must normalize ACME_HOME perms before listing when the
|
|
# probe detects a lost group-read bit, so a mid-lifetime ownership
|
|
# flip self-heals without a restart.
|
|
assert order == ["normalize", "list"]
|
|
assert result["certs"] == [{"domain": "example.com"}]
|
|
assert result["status"] == {"error": None}
|
|
|
|
def test_no_normalize_when_probe_clean(self):
|
|
from daemon.collectors.acme import _collect_acme
|
|
|
|
order: list[str] = []
|
|
|
|
def _norm():
|
|
order.append("normalize")
|
|
|
|
def _list():
|
|
order.append("list")
|
|
return []
|
|
|
|
with (
|
|
patch.object(
|
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
|
),
|
|
patch(
|
|
"daemon.collectors.acme._acme_home_needs_normalize", return_value=False
|
|
),
|
|
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
|
|
patch("lib.acme.list_certs", side_effect=_list),
|
|
patch.object(
|
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
|
),
|
|
):
|
|
result = _collect_acme()
|
|
|
|
# Steady state: the probe sees group-read bits intact, so the poll
|
|
# must not pay for a sudo normalize.
|
|
assert order == ["list"]
|
|
assert result["certs"] == []
|
|
assert result["status"] == {"error": None}
|
|
|
|
|
|
class TestAcmeHomeProbe:
|
|
"""_acme_home_needs_normalize probes the group-read bit without sudo."""
|
|
|
|
def test_flags_file_without_group_read(self, tmp_path, monkeypatch):
|
|
from daemon.collectors.acme import _acme_home_needs_normalize
|
|
|
|
monkeypatch.setenv("ACME_HOME", str(tmp_path))
|
|
(tmp_path / "account.conf").write_text("x")
|
|
os.chmod(tmp_path / "account.conf", 0o600)
|
|
assert _acme_home_needs_normalize() is True
|
|
|
|
def test_clean_when_group_read_set(self, tmp_path, monkeypatch):
|
|
from daemon.collectors.acme import _acme_home_needs_normalize
|
|
|
|
monkeypatch.setenv("ACME_HOME", str(tmp_path))
|
|
(tmp_path / "account.conf").write_text("x")
|
|
os.chmod(tmp_path / "account.conf", 0o640)
|
|
assert _acme_home_needs_normalize() is False
|
|
|
|
def test_clean_on_empty_home(self, tmp_path, monkeypatch):
|
|
from daemon.collectors.acme import _acme_home_needs_normalize
|
|
|
|
monkeypatch.setenv("ACME_HOME", str(tmp_path))
|
|
assert _acme_home_needs_normalize() is False
|
|
|
|
def test_clean_on_missing_home(self, tmp_path, monkeypatch):
|
|
from daemon.collectors.acme import _acme_home_needs_normalize
|
|
|
|
monkeypatch.setenv("ACME_HOME", str(tmp_path / "does-not-exist"))
|
|
assert _acme_home_needs_normalize() is False
|
|
|
|
def test_permission_error_is_actionable(self):
|
|
from daemon.collectors.acme import _collect_acme
|
|
|
|
msg = "acme.sh failed with exit code 2: .../account.conf: Permission denied"
|
|
with (
|
|
patch.object(
|
|
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
|
),
|
|
patch("daemon.handlers.acme.normalize_acme_home"),
|
|
patch("lib.acme.list_certs", side_effect=RuntimeError(msg)),
|
|
patch.object(
|
|
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
|
),
|
|
):
|
|
result = _collect_acme()
|
|
|
|
assert result["status"]["error"] is not None
|
|
assert "sudo chown" in result["status"]["error"]
|
|
|
|
|
|
class TestParseAccountConf:
|
|
"""_parse_account_conf reads acme.sh v3's account.conf (no leading dot)."""
|
|
|
|
def test_reads_no_dot_account_conf(self, tmp_path):
|
|
from daemon.collectors.acme import _parse_account_conf
|
|
|
|
(tmp_path / "account.conf").write_text(
|
|
"ACME_LEEMAIL='me@example.com'\nACME_MCA='zerossl'\nACME_CERTKEYSIZE=256\n"
|
|
)
|
|
acct = _parse_account_conf(acme_home=tmp_path)
|
|
assert acct["registered"] is True
|
|
assert acct["email"] == "me@example.com"
|
|
assert acct["ca"] == "ZeroSSL"
|
|
assert acct["key_length"] == 256
|
|
|
|
def test_prefers_no_dot_over_legacy_dot(self, tmp_path):
|
|
from daemon.collectors.acme import _parse_account_conf
|
|
|
|
(tmp_path / "account.conf").write_text(
|
|
"ACME_LEEMAIL='new@example.com'\nACME_MCA='letsencrypt'\n"
|
|
)
|
|
(tmp_path / ".account.conf").write_text(
|
|
"ACME_LEEMAIL='old@example.com'\nACME_MCA='zerossl'\n"
|
|
)
|
|
acct = _parse_account_conf(acme_home=tmp_path)
|
|
assert acct["email"] == "new@example.com"
|
|
|
|
def test_falls_back_to_legacy_dot(self, tmp_path):
|
|
from daemon.collectors.acme import _parse_account_conf
|
|
|
|
(tmp_path / ".account.conf").write_text(
|
|
"ACME_LEEMAIL='legacy@example.com'\nACME_MCA='zerossl'\n"
|
|
)
|
|
acct = _parse_account_conf(acme_home=tmp_path)
|
|
assert acct["registered"] is True
|
|
assert acct["email"] == "legacy@example.com"
|
|
assert acct["ca"] == "ZeroSSL"
|
|
|
|
|
|
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]
|