Files
vacuum-wall/tests/test_state.py
T
mteehan 78fcb01877 fix: install.sh loop abort, ACME poll sudo gate, /static/ sub-paths
- 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.
2026-09-05 00:38:34 +00:00

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]