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.
This commit is contained in:
@@ -58,6 +58,20 @@ class TestRunAcme:
|
||||
assert cmd[0] == "/usr/local/bin/acme.sh"
|
||||
assert "sudo" not in cmd
|
||||
|
||||
@patch("lib.acme._find_acme")
|
||||
@patch("lib.acme.subprocess.run")
|
||||
def test_log_flag_is_last(self, mock_run, mock_find):
|
||||
# --log must trail the subcommand args: acme.sh would otherwise
|
||||
# consume the first subcommand arg as its (optional) file argument.
|
||||
mock_find.return_value = "/usr/local/bin/acme.sh"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
acme._run_acme(["--issue", "-d", "example.com"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd.count("--log") == 1
|
||||
assert cmd[-1] == "--log"
|
||||
assert cmd.index("--issue") < cmd.index("--log")
|
||||
assert "example.com" in cmd
|
||||
|
||||
|
||||
class TestParseListOutput:
|
||||
def test_parses_single_entry(self):
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the daemon-startup filesystem bootstrap (lib.bootstrap)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from lib import bootstrap, dnsmasq, firewall, network, nginx, wireguard
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sandbox(tmp_path, monkeypatch):
|
||||
"""Point every bootstrap-referenced path into a throwaway tree."""
|
||||
cfg = tmp_path / "config"
|
||||
data = tmp_path / "data"
|
||||
monkeypatch.setattr(dnsmasq, "CONFIG_DIR", cfg / "dnsmasq")
|
||||
monkeypatch.setattr(dnsmasq, "DATA_DIR", data / "dnsmasq")
|
||||
monkeypatch.setattr(dnsmasq, "FRAGMENTS_DIR", data / "dnsmasq" / "fragments")
|
||||
monkeypatch.setattr(firewall, "CONFIG_DIR", cfg / "firewall")
|
||||
monkeypatch.setattr(firewall, "DATA_DIR", data / "firewall")
|
||||
monkeypatch.setattr(network, "CONFIG_DIR", cfg / "network")
|
||||
monkeypatch.setattr(network, "DATA_DIR", data / "networkd")
|
||||
monkeypatch.setattr(nginx, "CONFIG_DIR", cfg / "nginx")
|
||||
monkeypatch.setattr(nginx, "DATA_DIR", data / "nginx")
|
||||
monkeypatch.setattr(nginx, "SITES_DIR", data / "nginx" / "sites-enabled")
|
||||
monkeypatch.setattr(nginx, "CONFIG_FILE", cfg / "nginx" / "config.json")
|
||||
monkeypatch.setattr(wireguard, "CONFIG_PATH", cfg / "wireguard" / "config.json")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_creates_runtime_dirs(sandbox):
|
||||
bootstrap.bootstrap()
|
||||
assert dnsmasq.FRAGMENTS_DIR.is_dir()
|
||||
assert firewall.DATA_DIR.is_dir()
|
||||
assert network.DATA_DIR.is_dir()
|
||||
assert nginx.SITES_DIR.is_dir()
|
||||
assert wireguard.CONFIG_PATH.parent.is_dir()
|
||||
|
||||
|
||||
def test_does_not_create_config_files(sandbox):
|
||||
# Config files are left for system-import (first start) or the first
|
||||
# save_config — bootstrap must not pre-empt either.
|
||||
bootstrap.bootstrap()
|
||||
assert not nginx.CONFIG_FILE.exists()
|
||||
assert not (dnsmasq.CONFIG_DIR / "config.json").exists()
|
||||
assert not (network.CONFIG_DIR / "config.json").exists()
|
||||
assert not (firewall.CONFIG_DIR / "config.json").exists()
|
||||
assert not wireguard.CONFIG_PATH.exists()
|
||||
|
||||
|
||||
def test_persists_nginx_migration(sandbox):
|
||||
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||
bootstrap.bootstrap()
|
||||
on_disk = nginx.get_config()
|
||||
assert on_disk["backends"]["webui"]["_migrated"] is True
|
||||
raw = nginx.CONFIG_FILE.read_text()
|
||||
assert '"_migrated": true' in raw or '"_migrated":True' in raw
|
||||
|
||||
|
||||
def test_idempotent(sandbox):
|
||||
nginx.save_config({"domains": {}})
|
||||
bootstrap.bootstrap()
|
||||
mtime = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||
bootstrap.bootstrap()
|
||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
compute_pending,
|
||||
config_hash,
|
||||
deep_diff,
|
||||
load_json,
|
||||
@@ -78,6 +79,56 @@ class TestDeepDiff:
|
||||
assert not any(p.startswith("z.ranges[0].n") for p in paths)
|
||||
|
||||
|
||||
class TestComputePending:
|
||||
def test_hash_match_no_pending(self):
|
||||
cfg = {"a": 1}
|
||||
stamp_applied(cfg)
|
||||
pending, diff = compute_pending(cfg)
|
||||
assert pending is False
|
||||
assert diff == []
|
||||
|
||||
def test_never_applied_pending_no_snapshot(self):
|
||||
pending, diff = compute_pending({"a": 1})
|
||||
assert pending is True
|
||||
assert diff == []
|
||||
|
||||
def test_never_applied_pending_with_foreign_snapshot(self):
|
||||
# A recorded snapshot that does not match the current hash is still
|
||||
# used for the diff.
|
||||
cfg = {"a": 2, _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
|
||||
pending, diff = compute_pending(cfg)
|
||||
assert pending is True
|
||||
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
|
||||
|
||||
def test_hash_mismatch_with_snapshot_diffs(self):
|
||||
applied = {"zones": {"lan": {"services": ["http"]}}}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
drifted = {"zones": {"lan": {"services": ["http", "ssh"]}}}
|
||||
drifted[_LAST_APPLIED_CONFIG_KEY] = applied
|
||||
drifted[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
|
||||
pending, diff = compute_pending(drifted)
|
||||
assert pending is True
|
||||
paths = {d["path"] for d in diff}
|
||||
assert "zones.lan.services" in paths
|
||||
|
||||
def test_hash_mismatch_snapshot_not_dict(self):
|
||||
cfg = {"a": 1, _LAST_APPLIED_CONFIG_KEY: "not-a-dict"}
|
||||
pending, diff = compute_pending(cfg)
|
||||
assert pending is True
|
||||
assert diff == []
|
||||
|
||||
def test_meta_keys_excluded_from_diff(self):
|
||||
cfg = {"a": 1}
|
||||
stamp_applied(cfg)
|
||||
cfg["a"] = 2 # drift
|
||||
pending, diff = compute_pending(cfg)
|
||||
assert pending is True
|
||||
assert not any(
|
||||
p.startswith(("_last_applied",)) for d in diff for p in [d["path"]]
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardFallback:
|
||||
def test_hash_subsystem_unchanged_generic(self):
|
||||
# Guards that a pending status without a snapshot still yields a
|
||||
|
||||
+227
-91
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.handlers import common as daemoncommon
|
||||
from daemon.handlers import firewall as daemonfirewall
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
from lib import firewall
|
||||
@@ -377,25 +378,26 @@ _PENDING_LIVE_PUBLIC = {
|
||||
|
||||
|
||||
class TestComputePendingChangesAbsentInterfaces:
|
||||
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
|
||||
so their interfaces diff must not be reported; other field drift is."""
|
||||
"""The config is the source of truth for zone interfaces: an absent
|
||||
'interfaces' key counts as an empty list, so every config zone is
|
||||
diffed on interfaces (no hands-off zones)."""
|
||||
|
||||
def test_services_drift_reported_without_interfaces_key(self):
|
||||
def test_services_and_interfaces_drift_reported_without_interfaces_key(self):
|
||||
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
|
||||
result = firewall._compute_pending_changes(
|
||||
cfg, {"public": _PENDING_LIVE_PUBLIC}
|
||||
)
|
||||
types = {c["type"] for c in result["pending"]}
|
||||
assert "services" in types
|
||||
assert "interfaces" not in types
|
||||
# Absent key counts as an empty list: live eth0 is a pending removal.
|
||||
assert "interfaces" in types
|
||||
|
||||
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
|
||||
# Config in sync on everything except a missing interfaces key: the
|
||||
# zone's live interfaces are intentionally left alone by apply.
|
||||
def test_absent_key_zone_in_sync_live_reports_nothing(self):
|
||||
# Config lacks the interfaces key and the live zone has no
|
||||
# interfaces either — absent key equals the empty live set.
|
||||
live = {**_PENDING_LIVE_PUBLIC, "interfaces": []}
|
||||
cfg = {"zones": {"public": {"services": ["http"]}}}
|
||||
result = firewall._compute_pending_changes(
|
||||
cfg, {"public": _PENDING_LIVE_PUBLIC}
|
||||
)
|
||||
result = firewall._compute_pending_changes(cfg, {"public": live})
|
||||
assert result["pending"] == []
|
||||
assert result["needs_apply"] is False
|
||||
|
||||
@@ -457,6 +459,51 @@ class TestTargetDriftSemantics:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateCoverage:
|
||||
def test_all_covered(self):
|
||||
fw = {"zones": {"public": {"interfaces": ["eth0"]}}}
|
||||
net = {"interfaces": {"eth0": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == []
|
||||
|
||||
def test_uncovered_reported_sorted(self):
|
||||
fw = {"zones": {"public": {"interfaces": ["eth1"]}}}
|
||||
net = {"interfaces": {"eth5": {}, "eth0": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == ["eth0", "eth5"]
|
||||
|
||||
def test_unmanaged_exempts(self):
|
||||
fw = {
|
||||
"zones": {"public": {"interfaces": ["eth1"]}},
|
||||
"unmanaged": ["eth0"],
|
||||
}
|
||||
net = {"interfaces": {"eth0": {}, "eth1": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == []
|
||||
|
||||
def test_lo_and_wg_exempt(self):
|
||||
fw = {"zones": {}}
|
||||
net = {"interfaces": {"lo": {}, "wg0": {}, "wg-full": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == []
|
||||
|
||||
def test_absent_key_counts_as_empty(self):
|
||||
# A zone without an 'interfaces' key covers nothing.
|
||||
fw = {"zones": {"public": {"services": ["http"]}}}
|
||||
net = {"interfaces": {"eth0": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == ["eth0"]
|
||||
|
||||
def test_empty_network_config(self):
|
||||
assert firewall.validate_coverage({"zones": {}}, {"interfaces": {}}) == []
|
||||
assert firewall.validate_coverage({"zones": {}}, {}) == []
|
||||
|
||||
def test_non_dict_zone_and_non_list_unmanaged_ignored(self):
|
||||
fw = {"zones": {"public": "oops"}, "unmanaged": "eth0"}
|
||||
net = {"interfaces": {"eth0": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == ["eth0"]
|
||||
|
||||
def test_non_string_entries_ignored(self):
|
||||
fw = {"zones": {"public": {"interfaces": [None, 7]}}, "unmanaged": [None]}
|
||||
net = {"interfaces": {"eth0": {}}}
|
||||
assert firewall.validate_coverage(fw, net) == ["eth0"]
|
||||
|
||||
|
||||
class TestGetZoneInfo:
|
||||
def test_parses_zone_info(self):
|
||||
result = firewall._parse_zone_output(
|
||||
@@ -656,7 +703,7 @@ class TestDaemonConfigApply:
|
||||
"daemon.handlers.firewall._get_state",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"public": {}}},
|
||||
@@ -702,8 +749,8 @@ class TestDaemonMgmtLockoutGuard:
|
||||
patch.object(daemonfirewall, "_reload"),
|
||||
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.set_zone_services(
|
||||
@@ -723,8 +770,8 @@ class TestDaemonMgmtLockoutGuard:
|
||||
patch.object(daemonfirewall, "_reload"),
|
||||
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
|
||||
patch.object(daemonfirewall, "_save_config"),
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.set_zone_services(
|
||||
@@ -791,7 +838,7 @@ class TestDaemonMgmtLockoutGuard:
|
||||
"daemon.handlers.firewall._get_state",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"public": {}}},
|
||||
@@ -803,8 +850,10 @@ class TestDaemonMgmtLockoutGuard:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interface-coverage guard: apply must not leave a network-managed interface
|
||||
# in no zone (clients lose connectivity/DHCP) unless forced.
|
||||
# Coverage invariant: the config must cover every network-managed
|
||||
# interface (or declare it unmanaged). Pure config check — the config is
|
||||
# the source of truth for zone interfaces (absent key = empty), so there
|
||||
# is no live-state comparison and no hands-off zones.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -849,7 +898,7 @@ def _apply_with(
|
||||
) as mock_run,
|
||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||
patch("daemon.handlers.firewall._save_backup", return_value=backup),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value=deepcopy(cfg),
|
||||
@@ -860,21 +909,88 @@ def _apply_with(
|
||||
return result, mock_run
|
||||
|
||||
|
||||
class TestDaemonInterfaceCoverageGuard:
|
||||
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
|
||||
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
|
||||
class TestDaemonCoverageInvariant:
|
||||
def test_conflict_when_network_iface_uncovered(self):
|
||||
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
||||
patch("daemon.handlers.firewall.run") as mock_run,
|
||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||
patch("daemon.handlers.firewall._save_backup") as mock_backup,
|
||||
pytest.raises(ConflictError) as exc,
|
||||
):
|
||||
daemonfirewall._config_apply()
|
||||
msg = str(exc.value)
|
||||
assert "eth0" in msg
|
||||
assert "unmanaged" in msg
|
||||
assert "force" in msg
|
||||
mock_backup.assert_not_called()
|
||||
# Pure config check: the guard performs no live-state reads at all.
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_unmanaged_exempts_iface(self):
|
||||
cfg = {
|
||||
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
|
||||
"unmanaged": ["eth0"],
|
||||
}
|
||||
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
|
||||
assert result["applied_zones"] == ["public"]
|
||||
# The guard reads live zones once, up front.
|
||||
assert mock_run.call_args_list[0].args[0] == [
|
||||
cmds = [c.args[0] for c in mock_run.call_args_list]
|
||||
# Live eth0 is removed, config eth1 added exactly once (permanent).
|
||||
assert [
|
||||
"firewall-cmd",
|
||||
"--get-active-zones",
|
||||
]
|
||||
# Hands off: no interface mutation commands for the absent-key zone.
|
||||
for c in mock_run.call_args_list:
|
||||
for arg in c.args[0]:
|
||||
assert not arg.startswith("--remove-interface=")
|
||||
assert not arg.startswith("--add-interface=")
|
||||
"--zone=public",
|
||||
"--remove-interface=eth0",
|
||||
"--permanent",
|
||||
] in cmds
|
||||
assert (
|
||||
cmds.count(
|
||||
["firewall-cmd", "--zone=public", "--add-interface=eth1", "--permanent"]
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_absent_key_zone_counts_as_empty(self):
|
||||
# No hands-off zones: a zone without an 'interfaces' key covers
|
||||
# nothing, so a managed interface left out of every zone blocks.
|
||||
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
||||
patch("daemon.handlers.firewall.run"),
|
||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||
pytest.raises(ConflictError) as exc,
|
||||
):
|
||||
daemonfirewall._config_apply()
|
||||
assert "eth0" in str(exc.value)
|
||||
|
||||
def test_live_only_zone_does_not_count_as_covered(self):
|
||||
# eth1 is held by 'guest' live but the config (the source of
|
||||
# truth) does not cover it — apply is blocked regardless of live.
|
||||
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth1": {}}}),
|
||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
||||
patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
side_effect=_make_run("public\n eth0\nguest\n eth1\n"),
|
||||
),
|
||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||
pytest.raises(ConflictError),
|
||||
):
|
||||
daemonfirewall._config_apply()
|
||||
|
||||
def test_force_bypasses_coverage_guard(self):
|
||||
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
def test_guard_ignores_lo_and_wg(self):
|
||||
# lo/wg* are never guarded even though the network config carries them.
|
||||
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
def test_explicit_empty_list_unassigns(self):
|
||||
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||
@@ -891,60 +1007,80 @@ class TestDaemonInterfaceCoverageGuard:
|
||||
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
|
||||
)
|
||||
|
||||
def test_conflict_when_network_iface_goes_uncovered(self):
|
||||
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||
|
||||
class TestDaemonSaveCoverageValidation:
|
||||
"""The coverage invariant is enforced at save time too (POST/PATCH
|
||||
/firewall/config), so bad configs are rejected before they are written."""
|
||||
|
||||
def test_save_blocks_uncovered(self):
|
||||
body = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
||||
patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
side_effect=_make_run("public\n eth0\n"),
|
||||
),
|
||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||
pytest.raises(ConflictError) as exc,
|
||||
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||
pytest.raises(ValueError) as exc,
|
||||
):
|
||||
daemonfirewall._config_apply()
|
||||
daemonfirewall.save_config_handler(None, body)
|
||||
assert "eth0" in str(exc.value)
|
||||
assert "force" in str(exc.value)
|
||||
assert "unmanaged" in str(exc.value)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_force_bypasses_coverage_guard(self):
|
||||
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
def test_guard_ignores_lo_and_wg(self):
|
||||
# lo/wg* are never guarded even though the network config carries them.
|
||||
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
def test_live_only_zone_interfaces_count_as_covered(self):
|
||||
# eth1 is held by 'guest', which is live but absent from the config —
|
||||
# apply never touches it, so eth1 counts as covered.
|
||||
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
|
||||
result, _ = _apply_with(cfg, {"eth1": {}}, "public\n eth0\nguest\n eth1\n")
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
def test_coverage_guard_conflict_writes_no_backup(self):
|
||||
# Guard conflict must be side-effect free, like the lockout conflict.
|
||||
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
|
||||
def test_save_allows_unmanaged(self):
|
||||
body = {
|
||||
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
|
||||
"unmanaged": ["eth0"],
|
||||
}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
patch("lib.firewall.get_config", return_value=cfg, create=True),
|
||||
patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
side_effect=_make_run("public\n eth0\n"),
|
||||
) as mock_run,
|
||||
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
|
||||
patch("daemon.handlers.firewall._save_backup") as mock_backup,
|
||||
pytest.raises(ConflictError),
|
||||
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
daemonfirewall._config_apply()
|
||||
mock_backup.assert_not_called()
|
||||
# Only the guard's live-zone read ran — no mutation commands at all.
|
||||
assert [c.args[0] for c in mock_run.call_args_list] == [
|
||||
["firewall-cmd", "--get-active-zones"]
|
||||
]
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.save_config_handler(None, body)
|
||||
assert result == {"config_saved": True}
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_save_rejects_non_list_unmanaged(self):
|
||||
body = {"zones": {"public": {"interfaces": ["eth0"]}}, "unmanaged": "eth0"}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
pytest.raises(ValueError) as exc,
|
||||
):
|
||||
daemonfirewall.save_config_handler(None, body)
|
||||
assert "unmanaged" in str(exc.value)
|
||||
|
||||
def test_patch_blocks_merge_that_uncovers(self):
|
||||
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
|
||||
body = {"zones": {"public": {"interfaces": ["eth1"]}}}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||
pytest.raises(ValueError) as exc,
|
||||
):
|
||||
daemonfirewall.patch_config(None, body)
|
||||
assert "eth0" in str(exc.value)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_patch_allows_merge_that_covers(self):
|
||||
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
|
||||
body = {"zones": {"internal": {"interfaces": ["eth1"], "services": []}}}
|
||||
with (
|
||||
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.patch_config(None, body)
|
||||
assert result == {"config_saved": True}
|
||||
saved = mock_save.call_args[0][0]
|
||||
assert set(saved["zones"]) == {"public", "internal"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -957,8 +1093,8 @@ class TestDaemonCreateZone:
|
||||
with (
|
||||
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
|
||||
patch.object(daemonfirewall, "_reload"),
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
result = daemonfirewall.create_zone(None, body)
|
||||
@@ -1023,7 +1159,7 @@ class TestDaemonConfigApplyBackup:
|
||||
"daemon.handlers.firewall._save_backup",
|
||||
return_value="/tmp/rules.json",
|
||||
) as mock_backup,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
|
||||
patch("daemon.handlers.firewall._save_config"),
|
||||
):
|
||||
@@ -1082,7 +1218,7 @@ class TestDaemonConfigApplyStamp:
|
||||
"daemon.handlers.firewall._get_state",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value=deepcopy(_STAMP_TEST_CFG),
|
||||
@@ -1114,8 +1250,8 @@ class TestDaemonMutatorBaselineStamp:
|
||||
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
|
||||
with (
|
||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_zone_interfaces(
|
||||
@@ -1138,8 +1274,8 @@ class TestDaemonMutatorBaselineStamp:
|
||||
daemonfirewall, "_parse_zone_output", return_value={"services": []}
|
||||
),
|
||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_zone_services(
|
||||
@@ -1162,8 +1298,8 @@ class TestDaemonMutatorBaselineStamp:
|
||||
):
|
||||
with (
|
||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
|
||||
@@ -1182,8 +1318,8 @@ class TestDaemonMutatorBaselineStamp:
|
||||
would manufacture spurious service diffs on the next poll."""
|
||||
with (
|
||||
patch.object(daemonfirewall, "_save_config") as mock_save,
|
||||
patch.object(daemonfirewall, "bus") as mock_bus,
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch.object(daemoncommon, "bus") as mock_bus,
|
||||
patch("daemon.handlers.common.refresh_state"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
|
||||
@@ -1208,10 +1344,10 @@ class TestDaemonGetConfigEndpoint:
|
||||
"daemon.handlers.firewall._config_apply",
|
||||
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
||||
)
|
||||
@patch("daemon.handlers.firewall.bus")
|
||||
@patch("daemon.handlers.common.bus")
|
||||
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
with patch("daemon.handlers.firewall.refresh_state"):
|
||||
with patch("daemon.handlers.common.refresh_state"):
|
||||
daemonfirewall.config_apply(None, None)
|
||||
mock_apply.assert_called_once_with(force=False)
|
||||
mock_apply.reset_mock()
|
||||
|
||||
@@ -29,10 +29,11 @@ class TestGetConfig:
|
||||
assert isinstance(cfg, dict)
|
||||
assert "interfaces" in cfg
|
||||
|
||||
def test_creates_config_file(self, tmp_network):
|
||||
def test_missing_file_returns_default_without_writing(self, tmp_network):
|
||||
# Pure read: get_config never materializes the file.
|
||||
cfg = _net.get_config()
|
||||
assert _net.CONFIG_FILE.exists()
|
||||
assert cfg["interfaces"] == {}
|
||||
assert not _net.CONFIG_FILE.exists()
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
|
||||
@@ -279,18 +279,18 @@ class TestStateParserDedup:
|
||||
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
|
||||
|
||||
def test_state_uses_network_parser(self):
|
||||
"""The networkd collector in state.py should import from lib.network."""
|
||||
import lib.state as _state
|
||||
"""The networkd collector should import from lib.network."""
|
||||
import daemon.collectors.networkd as _collector
|
||||
|
||||
source = Path(_state.__file__).read_text()
|
||||
assert "from lib.network import parse_networkctl_status" in source
|
||||
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 lib.state as _state
|
||||
import daemon.collectors.networkd as _collector
|
||||
|
||||
with patch("lib.state.run") as mock_run:
|
||||
with patch("daemon.collectors.networkd.run") as mock_run:
|
||||
mock_run.return_value = json.dumps(
|
||||
{
|
||||
"Interfaces": [
|
||||
@@ -320,7 +320,7 @@ class TestStateParserDedup:
|
||||
]
|
||||
}
|
||||
)
|
||||
result = _state._collect_networkd()
|
||||
result = _collector._collect_networkd()
|
||||
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
@@ -329,10 +329,12 @@ class TestStateParserDedup:
|
||||
|
||||
def test_networkd_collector_handles_failure(self):
|
||||
"""_collect_networkd returns empty interfaces on error."""
|
||||
import lib.state as _state
|
||||
import daemon.collectors.networkd as _collector
|
||||
|
||||
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
|
||||
result = _state._collect_networkd()
|
||||
with patch(
|
||||
"daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl")
|
||||
):
|
||||
result = _collector._collect_networkd()
|
||||
|
||||
assert result["interfaces"] == {}
|
||||
assert "timestamp" in result
|
||||
|
||||
+17
-3
@@ -70,14 +70,28 @@ class TestGetConfig:
|
||||
# No churn: reading a current-format config leaves the file alone.
|
||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
|
||||
|
||||
def test_read_saves_when_migration_applied(self, temp_data_dir):
|
||||
"""get_config() persists the file when migration actually changes it."""
|
||||
def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
|
||||
"""get_config() is pure: migration is applied in memory, file untouched."""
|
||||
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||
cfg = nginx.get_config()
|
||||
# Migration added the builtin webui backend.
|
||||
# Migration added the builtin webui backend (in memory only).
|
||||
assert cfg["backends"]["webui"]["_migrated"] is True
|
||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
|
||||
|
||||
def test_migrate_config_file_persists_legacy(self, temp_data_dir):
|
||||
"""migrate_config_file() rewrites the file when migration changes it."""
|
||||
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
|
||||
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||
assert nginx.migrate_config_file() is True
|
||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
|
||||
# Idempotent: a second run is a no-op.
|
||||
assert nginx.migrate_config_file() is False
|
||||
|
||||
def test_migrate_config_file_noop_when_missing(self, temp_data_dir):
|
||||
assert not nginx.CONFIG_FILE.exists()
|
||||
assert nginx.migrate_config_file() is False
|
||||
assert not nginx.CONFIG_FILE.exists()
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
|
||||
+25
-15
@@ -9,7 +9,13 @@ these tests catch drift between the schemas and the collectors.
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import lib.state
|
||||
import daemon.collectors.acme
|
||||
import daemon.collectors.dnsmasq
|
||||
import daemon.collectors.firewall
|
||||
import daemon.collectors.networkd
|
||||
import daemon.collectors.nginx
|
||||
import daemon.collectors.system
|
||||
import daemon.collectors.wireguard
|
||||
from lib import schema
|
||||
|
||||
|
||||
@@ -20,9 +26,9 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
|
||||
class TestCollectorShapesMatchSchema:
|
||||
def test_firewall_state(self):
|
||||
with (
|
||||
patch.object(lib.state, "run") as mock_run,
|
||||
patch.object(daemon.collectors.firewall, "run") as mock_run,
|
||||
patch.object(
|
||||
lib.state,
|
||||
daemon.collectors.firewall,
|
||||
"_network_get_config",
|
||||
return_value={
|
||||
"interfaces": {
|
||||
@@ -63,7 +69,7 @@ class TestCollectorShapesMatchSchema:
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
result = lib.state._collect_firewall()
|
||||
result = daemon.collectors.firewall._collect_firewall()
|
||||
|
||||
assert not _missing(schema.FirewallState.__required_keys__, result)
|
||||
for iface in result["interfaces"]:
|
||||
@@ -74,38 +80,40 @@ class TestCollectorShapesMatchSchema:
|
||||
assert result["uncovered_interfaces"] == ["eth1"]
|
||||
|
||||
def test_dnsmasq_state(self):
|
||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||
with patch.object(daemon.collectors.dnsmasq, "run_proc") as mock_proc:
|
||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||
result = lib.state._collect_dnsmasq()
|
||||
result = daemon.collectors.dnsmasq._collect_dnsmasq()
|
||||
|
||||
assert not _missing(schema.DnsmasqState.__required_keys__, result)
|
||||
for k in schema.DnsmasqStatus.__required_keys__:
|
||||
assert k in result["status"], f"DnsmasqStatus missing {k}"
|
||||
|
||||
def test_nginx_state(self):
|
||||
result = lib.state._collect_nginx()
|
||||
result = daemon.collectors.nginx._collect_nginx()
|
||||
assert not _missing(schema.NginxState.__required_keys__, result)
|
||||
assert "pending_changes" in result["status"]
|
||||
|
||||
def test_acme_state(self):
|
||||
with (
|
||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch("lib.acme.list_certs", return_value=[]),
|
||||
patch.object(
|
||||
lib.state,
|
||||
daemon.collectors.acme,
|
||||
"_parse_account_conf",
|
||||
return_value={"registered": False, "email": "", "ca": ""},
|
||||
),
|
||||
):
|
||||
result = lib.state._collect_acme()
|
||||
result = daemon.collectors.acme._collect_acme()
|
||||
|
||||
assert not _missing(schema.AcmeState.__required_keys__, result)
|
||||
assert result["status"]["error"] is None
|
||||
|
||||
def test_wireguard_state(self):
|
||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||
with patch.object(daemon.collectors.wireguard, "run_proc") as mock_proc:
|
||||
mock_proc.return_value = Mock(stdout="", returncode=1)
|
||||
result = lib.state._collect_wireguard()
|
||||
result = daemon.collectors.wireguard._collect_wireguard()
|
||||
|
||||
assert not _missing(schema.WgState.__required_keys__, result)
|
||||
for k in schema.WgStatus.__required_keys__:
|
||||
@@ -135,8 +143,10 @@ class TestCollectorShapesMatchSchema:
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
|
||||
result = lib.state._collect_networkd()
|
||||
with patch.object(
|
||||
daemon.collectors.networkd, "run", return_value=json.dumps(networkctl)
|
||||
):
|
||||
result = daemon.collectors.networkd._collect_networkd()
|
||||
|
||||
assert not _missing(schema.NetworkdState.__required_keys__, result)
|
||||
assert "eth0" in result["interfaces"]
|
||||
@@ -148,7 +158,7 @@ class TestCollectorShapesMatchSchema:
|
||||
|
||||
def test_system_state(self):
|
||||
"""Reads /proc and /sys directly — no mocking needed on Linux."""
|
||||
result = lib.state._collect_system()
|
||||
result = daemon.collectors.system._collect_system()
|
||||
assert not _missing(schema.SystemState.__required_keys__, result)
|
||||
for k in schema.CpuLoad.__required_keys__:
|
||||
assert k in result["load"], f"CpuLoad missing {k}"
|
||||
|
||||
+31
-19
@@ -3,7 +3,9 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import lib
|
||||
import daemon.collectors.acme
|
||||
import daemon.collectors.dnsmasq
|
||||
import daemon.collectors.firewall
|
||||
from lib.state import State, state
|
||||
|
||||
|
||||
@@ -51,9 +53,9 @@ class TestState:
|
||||
|
||||
|
||||
class TestCollectAll:
|
||||
@patch("lib.state.run")
|
||||
@patch("daemon.collectors.firewall.run")
|
||||
def test_collect_firewall_returns_dict(self, mock_run):
|
||||
from lib.state import _collect_firewall
|
||||
from daemon.collectors.firewall import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
@@ -88,10 +90,10 @@ class TestCollectAll:
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
|
||||
@patch("lib.state.run")
|
||||
@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 lib.state import _collect_firewall
|
||||
from daemon.collectors.firewall import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
@@ -144,10 +146,10 @@ class TestCollectAll:
|
||||
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
|
||||
assert "10.0.0.1/24" in vlan_iface["ips"]
|
||||
|
||||
@patch("lib.state.get_service_descriptions")
|
||||
@patch("lib.state.run")
|
||||
@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 lib.state import _collect_firewall
|
||||
from daemon.collectors.firewall import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
@@ -168,11 +170,11 @@ class TestCollectAll:
|
||||
mock_desc.assert_called_once_with()
|
||||
assert result["service_descriptions"] == descs
|
||||
|
||||
@patch("lib.state.run_proc")
|
||||
@patch("daemon.collectors.dnsmasq.run_proc")
|
||||
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from lib.state import _collect_dnsmasq
|
||||
from daemon.collectors.dnsmasq import _collect_dnsmasq
|
||||
|
||||
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||
result = _collect_dnsmasq()
|
||||
@@ -181,12 +183,12 @@ class TestCollectAll:
|
||||
assert "config" in result
|
||||
assert "leases" in result
|
||||
|
||||
@patch("lib.state.run_proc")
|
||||
@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
|
||||
from lib.state import _collect_dnsmasq
|
||||
|
||||
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
||||
applied = {
|
||||
@@ -226,7 +228,9 @@ class TestCollectAll:
|
||||
_APPLY_HASH_KEY: "stale-hash",
|
||||
}
|
||||
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
||||
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
|
||||
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)
|
||||
|
||||
@@ -257,15 +261,19 @@ 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 lib.state import _collect_acme
|
||||
from daemon.collectors.acme import _collect_acme
|
||||
|
||||
with (
|
||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
||||
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(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||
),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
@@ -275,12 +283,16 @@ class TestAcmeCollectNonFatal:
|
||||
assert "exit code 2" in result["status"]["error"]
|
||||
|
||||
def test_success_reports_no_error(self):
|
||||
from lib.state import _collect_acme
|
||||
from daemon.collectors.acme import _collect_acme
|
||||
|
||||
with (
|
||||
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch("lib.acme.list_certs", return_value=[]),
|
||||
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||
),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
|
||||
+38
-2
@@ -336,7 +336,7 @@ class TestGenerateWgShowParser:
|
||||
" listening port: 51820\n"
|
||||
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
||||
)
|
||||
result = wireguard._parse_wg_show_output(output)
|
||||
result = wireguard.parse_wg_show_output(output)
|
||||
assert result["up"] is True
|
||||
assert result["interface"]["public_key"] == "IFACE-PUB"
|
||||
assert result["interface"]["listen_port"] == 51820
|
||||
@@ -346,10 +346,46 @@ class TestGenerateWgShowParser:
|
||||
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
|
||||
|
||||
def test_empty_output(self):
|
||||
result = wireguard._parse_wg_show_output("")
|
||||
result = wireguard.parse_wg_show_output("")
|
||||
assert result["up"] is False
|
||||
assert result["peers"] == []
|
||||
|
||||
def test_parses_fwmark(self):
|
||||
output = (
|
||||
"interface: wg0\n"
|
||||
" public key: IFACE-PUB\n"
|
||||
" listening port: 51820\n"
|
||||
" fwmark: 0x0\n"
|
||||
)
|
||||
result = wireguard.parse_wg_show_output(output)
|
||||
assert result["up"] is True
|
||||
assert result["interface"]["fwmark"] == "0x0"
|
||||
|
||||
def test_peer_transfer_and_keepalive(self):
|
||||
output = (
|
||||
"interface: wg0\n"
|
||||
" public key: IFACE-PUB\n"
|
||||
" listening port: 51820\n"
|
||||
" peer: PUBKEY1\n"
|
||||
" endpoint: 203.0.113.1:51820\n"
|
||||
" allowed ips: 10.0.0.0/24, 10.0.1.0/24\n"
|
||||
" latest handshake: 2 minutes ago\n"
|
||||
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
|
||||
" persistent-keepalive: 25\n"
|
||||
)
|
||||
result = wireguard.parse_wg_show_output(output)
|
||||
peer = result["peers"][0]
|
||||
assert peer["allowed_ips"] == ["10.0.0.0/24", "10.0.1.0/24"]
|
||||
assert peer["latest_handshake"] == "2 minutes ago"
|
||||
assert peer["transfer_received"] == "1.23 GiB received"
|
||||
assert peer["transfer_sent"] == "4.56 GiB sent"
|
||||
assert peer["persistent_keepalive"] == 25
|
||||
|
||||
def test_bad_keepalive_value(self):
|
||||
output = "interface: wg0\n peer: PUBKEY1\n persistent-keepalive: bogus\n"
|
||||
result = wireguard.parse_wg_show_output(output)
|
||||
assert result["peers"][0]["persistent_keepalive"] is None
|
||||
|
||||
|
||||
class TestAccessClasses:
|
||||
def test_default_config_has_access_classes(self):
|
||||
|
||||
Reference in New Issue
Block a user