"""Tests for daemon/handlers/status.py — cancel-all (revert to applied).""" from __future__ import annotations import json from pathlib import Path from typing import Any, ClassVar from unittest.mock import patch from daemon.handlers import status from lib.common import ( _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY, config_hash, stamp_applied, ) def _pending(firewall: bool = False, **flags: bool) -> dict[str, Any]: """Build status_pending() output with the given subsystems pending. Mirrors the real handler shape: every subsystem key is always present. """ data: dict[str, Any] = { "firewall": { "needs_apply": firewall, "change_count": 1 if firewall else 0, "changes": [], }, } total = 1 if firewall else 0 for name in ("dnsmasq", "nginx", "wireguard", "networkd"): flagged = bool(flags.get(name, False)) data[name] = { "pending_changes": flagged, "summary": "x" if flagged else "Up to date", "changes": [], } total += 1 if flagged else 0 data["total_changes"] = total return data class TestConfigPathResolution: """SYS_CONFIG_PATHS points at the constants the handlers actually use.""" def test_resolves_to_module_constants(self): import daemon.handlers.dnsmasq as dm_h import daemon.handlers.firewall as fw_h import daemon.handlers.nginx as ngx_h import lib.network as net_lib import lib.wireguard as wg_lib assert status._config_path("firewall") == fw_h.CONFIG_FILE assert status._config_path("dnsmasq") == dm_h.CONFIG_PATH assert status._config_path("nginx") == ngx_h.CONFIG_FILE assert status._config_path("wireguard") == wg_lib.CONFIG_PATH assert status._config_path("networkd") == net_lib.CONFIG_FILE def test_all_subsystems_mapped(self): assert set(status.SYS_CONFIG_PATHS) == set(status.SYS_ORDER) class TestStatusCancelAll: """Test the cancel-all endpoint. Patches the ``_config_path`` seam directly since it resolves the real module constants via getattr at call time. """ _fake_pending_all: ClassVar[dict[str, Any]] = _pending() def _write_config( self, path: Path, cfg: dict[str, Any], baseline: dict | None ) -> None: """Write *cfg* (with an attached applied baseline when *baseline* given).""" path.parent.mkdir(parents=True, exist_ok=True) stamped = dict(cfg) if baseline is not None: b = dict(baseline) stamp_applied(b) stamped[_LAST_APPLIED_CONFIG_KEY] = dict(baseline) stamped[_APPLY_HASH_KEY] = b[_APPLY_HASH_KEY] path.write_text(json.dumps(stamped, indent=2) + "\n") def _read_config(self, path: Path) -> dict[str, Any]: return json.loads(path.read_text()) @patch("daemon.handlers.status.status_pending") @patch("daemon.handlers.status.refresh_state") def test_nothing_pending(self, mock_refresh, mock_pending): mock_pending.return_value = self._fake_pending_all with patch("daemon.handlers.status._config_path", side_effect=AssertionError): result = status.status_cancel_all(None, None) assert result == {"cancelled": [], "skipped": {}, "errors": {}} mock_refresh.assert_not_called() def test_reverts_pending_subsystem(self, tmp_path): paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} applied = {"dns": {"upstreams": ["8.8.8.8"]}} # Baseline applied, then the user edited the config (drift). self._write_config( paths["dnsmasq"], {"dns": {"upstreams": ["1.1.1.1"]}}, applied ) dirty = self._read_config(paths["dnsmasq"]) assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) # really pending with ( patch( "daemon.handlers.status.status_pending", return_value=_pending(dnsmasq=True), ), patch("daemon.handlers.status.refresh_state") as mock_refresh, patch( "daemon.handlers.status._config_path", side_effect=lambda name: paths[name], ), ): result = status.status_cancel_all(None, None) assert result == { "cancelled": ["dnsmasq"], "skipped": {}, "errors": {}, } mock_refresh.assert_called_once_with(status.SYS_ORDER) restored = self._read_config(paths["dnsmasq"]) assert restored.get(_LAST_APPLIED_CONFIG_KEY) == applied # Cancel restored the baseline: the pending check now passes. assert restored[_APPLY_HASH_KEY] == config_hash(restored) def test_skipped_when_no_baseline(self, tmp_path): paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} cfg_no_baseline = {"peers": {"a": {}}} self._write_config(paths["wireguard"], cfg_no_baseline, None) with ( patch( "daemon.handlers.status.status_pending", return_value=_pending(wireguard=True), ), patch("daemon.handlers.status.refresh_state") as mock_refresh, patch( "daemon.handlers.status._config_path", side_effect=lambda name: paths[name], ), ): result = status.status_cancel_all(None, None) assert result["cancelled"] == [] assert "WireGuard" in result["skipped"] assert result["errors"] == {} mock_refresh.assert_not_called() # File left untouched. assert self._read_config(paths["wireguard"]) == cfg_no_baseline def test_error_in_one_subsystem_others_still_cancel(self, tmp_path): paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} for name in ("firewall", "nginx"): self._write_config( paths[name], {"zones": {}} if name == "firewall" else {"domains": {}}, {"x": 1}, ) def fake_revert(path): if "nginx" in str(path): raise RuntimeError("disk full") return True, "" with ( patch( "daemon.handlers.status.status_pending", return_value=_pending(firewall=True, nginx=True), ), patch("daemon.handlers.status.refresh_state"), patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert), patch( "daemon.handlers.status._config_path", side_effect=lambda name: paths[name], ), ): result = status.status_cancel_all(None, None) # SYS_ORDER runs firewall before nginx. assert result["cancelled"] == ["firewall"] assert result["errors"] == {"Nginx": "disk full"} assert result["skipped"] == {} def test_order_matches_sys_order(self, tmp_path): paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER} call_order = [] def fake_revert(path): call_order.append(next(n for n, p in paths.items() if p == path)) return True, "" with ( patch( "daemon.handlers.status.status_pending", return_value=_pending( firewall=True, dnsmasq=True, nginx=True, wireguard=True, networkd=True, ), ), patch("daemon.handlers.status.refresh_state"), patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert), patch( "daemon.handlers.status._config_path", side_effect=lambda name: paths[name], ), ): result = status.status_cancel_all(None, None) assert result["cancelled"] == status.SYS_ORDER assert call_order == status.SYS_ORDER