status: cancel-all reverts pending changes to last applied config
- lib.common.revert_to_applied(): restore a config file from its
_last_applied_config snapshot (stamped hash); no baseline -> skip with
reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
subsystems; GET /firewall/config and the state collector strip the
internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
(CancelConfirm, reuses the pending-changes modal rows); the pending
changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
stripping, /api/status/cancel-all route, node tests for CancelConfirm;
firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tests for hoover/components/applyconfirm.js — CancelConfirm
|
||||
*
|
||||
* Component-level tests: VNode structure of the cancel button.
|
||||
* Run with `node tests/test-cancelconfirm.js`.
|
||||
*/
|
||||
|
||||
import { CancelConfirm, buildRows, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.error(` \u2717 ${name}: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
function assertEq(a, b, msg) {
|
||||
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
|
||||
}
|
||||
|
||||
function assertIncludes(str, substr, msg) {
|
||||
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
|
||||
}
|
||||
|
||||
console.log('Testing CancelConfirm component\n');
|
||||
|
||||
test('CancelConfirm renders a button vnode', () => {
|
||||
const vnode = CancelConfirm();
|
||||
assertEq(vnode.tag, 'button');
|
||||
});
|
||||
|
||||
test('CancelConfirm default label', () => {
|
||||
const vnode = CancelConfirm();
|
||||
const text = vnode.ch.find(c => c.tag === '#text');
|
||||
assert(text, 'should have text child');
|
||||
assertEq(text.text, 'Cancel All Changes');
|
||||
});
|
||||
|
||||
test('CancelConfirm default class is danger', () => {
|
||||
const vnode = CancelConfirm();
|
||||
assertIncludes(vnode.props.class, 'btn-danger');
|
||||
assertIncludes(vnode.props.class, 'btn');
|
||||
});
|
||||
|
||||
test('CancelConfirm accepts a custom class', () => {
|
||||
const vnode = CancelConfirm({ cls: 'btn btn-sm btn-danger' });
|
||||
assertEq(vnode.props.class, 'btn btn-sm btn-danger');
|
||||
});
|
||||
|
||||
test('CancelConfirm accepts a custom label', () => {
|
||||
const vnode = CancelConfirm({ label: 'Discard Changes' });
|
||||
const text = vnode.ch.find(c => c.tag === '#text');
|
||||
assertEq(text.text, 'Discard Changes');
|
||||
});
|
||||
|
||||
test('CancelConfirm has a click handler', () => {
|
||||
const vnode = CancelConfirm();
|
||||
assert(typeof vnode.props['on:click'] === 'function', 'on:click should be a function');
|
||||
});
|
||||
|
||||
// === shared modal row builder (used by the cancel modal) ===
|
||||
test('buildRows still drives the cancel modal rows', () => {
|
||||
const data = {
|
||||
firewall: { needs_apply: true, changes: [{ summary: 'Zone internal: interfaces changed', detail: '' }] },
|
||||
dnsmasq: { pending_changes: true, changes: [{ summary: 'x', detail: '' }] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
assertEq(rows.length, SUBSYSTEM_LIST.length);
|
||||
const fwRow = rows[0];
|
||||
assertIncludes(fwRow.props.class, 'pending');
|
||||
const dmRow = rows[1];
|
||||
assertIncludes(dmRow.props.class, 'pending');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -997,4 +997,24 @@ class TestStatusRefresh:
|
||||
mock_post.side_effect = RuntimeError("no daemon")
|
||||
resp = status_client.post("/api/status/refresh", json={})
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestStatusCancelAll:
|
||||
@_st("post")
|
||||
def test_success(self, mock_post, status_client):
|
||||
from daemon.iface import POST_STATUS_CANCEL_ALL
|
||||
|
||||
mock_post.return_value = {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
|
||||
resp = status_client.post("/api/status/cancel-all")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"] == {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
|
||||
mock_post.assert_called_once_with(POST_STATUS_CANCEL_ALL)
|
||||
|
||||
@_st("post")
|
||||
def test_runtime_error(self, mock_post, status_client):
|
||||
mock_post.side_effect = RuntimeError("no daemon")
|
||||
resp = status_client.post("/api/status/cancel-all")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
@@ -7,6 +7,9 @@ from lib.common import (
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
deep_diff,
|
||||
load_json,
|
||||
revert_to_applied,
|
||||
save_json,
|
||||
stamp_applied,
|
||||
strip_apply_meta,
|
||||
)
|
||||
@@ -82,3 +85,66 @@ class TestDashboardFallback:
|
||||
status = {"pending_changes": True, "pending_diff": []}
|
||||
assert status["pending_changes"] is True
|
||||
assert status["pending_diff"] == []
|
||||
|
||||
|
||||
class TestRevertToApplied:
|
||||
def test_restores_snapshot_and_clears_pending(self, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
applied = {"zones": {"lan": {"services": ["http"]}}}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
# Drift the file after apply (the "pending" state).
|
||||
dirty = {"zones": {"lan": {"services": ["http", "ssh"]}}}
|
||||
dirty[_LAST_APPLIED_CONFIG_KEY] = dict(applied)
|
||||
dirty[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
|
||||
save_json(path, dirty, indent=2)
|
||||
# Sanity: pending check would report drift.
|
||||
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty)
|
||||
|
||||
ok, reason = revert_to_applied(path)
|
||||
assert ok and reason == ""
|
||||
|
||||
restored = load_json(path)
|
||||
assert _APPLY_HASH_KEY in restored and restored[_APPLY_HASH_KEY] == config_hash(
|
||||
restored
|
||||
)
|
||||
assert (
|
||||
_LAST_APPLIED_CONFIG_KEY in restored
|
||||
and restored[_LAST_APPLIED_CONFIG_KEY] == applied
|
||||
)
|
||||
assert (
|
||||
deep_diff(
|
||||
restored.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(restored)
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_no_baseline(self, tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
save_json(path, {"zones": {}}, indent=2)
|
||||
ok, reason = revert_to_applied(path)
|
||||
assert not ok
|
||||
assert reason
|
||||
# File untouched.
|
||||
assert _LAST_APPLIED_CONFIG_KEY not in load_json(path)
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
ok, reason = revert_to_applied(tmp_path / "nope.json")
|
||||
assert not ok
|
||||
assert reason
|
||||
|
||||
def test_stale_hash_but_snapshot_present(self, tmp_path):
|
||||
# Baseline recorded, hash stale (drifted) → still revertable.
|
||||
path = tmp_path / "config.json"
|
||||
applied = {"a": 1}
|
||||
stamped = dict(applied)
|
||||
stamp_applied(stamped)
|
||||
stamp = dict(stamped)
|
||||
stamp["a"] = 99 # edited without re-stamping
|
||||
save_json(path, stamp, indent=2)
|
||||
assert stamp[_APPLY_HASH_KEY] != config_hash(stamp)
|
||||
|
||||
ok, _ = revert_to_applied(path)
|
||||
assert ok
|
||||
restored = load_json(path)
|
||||
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
||||
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
@@ -7,6 +8,12 @@ import pytest
|
||||
from daemon.handlers import firewall as daemonfirewall
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
from lib import firewall
|
||||
from lib.common import (
|
||||
_APPLY_HASH_KEY,
|
||||
_LAST_APPLIED_CONFIG_KEY,
|
||||
config_hash,
|
||||
strip_apply_meta,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lib/firewall.py — pure parsing (no sudo)
|
||||
@@ -531,6 +538,11 @@ class TestDaemonConfigApply:
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config"),
|
||||
):
|
||||
result = daemonfirewall._config_apply()
|
||||
assert result["applied_zones"] == ["public"]
|
||||
@@ -661,10 +673,87 @@ class TestDaemonMgmtLockoutGuard:
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config"),
|
||||
):
|
||||
result = daemonfirewall._config_apply(force=True)
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
|
||||
_STAMP_TEST_CFG = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"target": "DEFAULT",
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDaemonConfigApplyStamp:
|
||||
"""Verify _config_apply records the applied baseline in the config file."""
|
||||
|
||||
ZONE_LIST_ALL_OUT = (
|
||||
"target: default\ninterfaces: \nsources: \nservices: \nports: \n"
|
||||
"protocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \n"
|
||||
"icmp-blocks: \nmodule: \n"
|
||||
)
|
||||
|
||||
@patch(
|
||||
"lib.firewall.get_config",
|
||||
return_value=_STAMP_TEST_CFG,
|
||||
create=True,
|
||||
)
|
||||
@patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
return_value=ZONE_LIST_ALL_OUT,
|
||||
)
|
||||
def test_stamps_applied_baseline(self, mock_run, mock_cfg):
|
||||
with (
|
||||
patch(
|
||||
"daemon.handlers.firewall._save_backup",
|
||||
return_value="/tmp/rules.json",
|
||||
),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_state",
|
||||
return_value={"zones": {"public": {}}},
|
||||
),
|
||||
patch("daemon.handlers.firewall.refresh_state"),
|
||||
patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value=deepcopy(_STAMP_TEST_CFG),
|
||||
),
|
||||
patch("daemon.handlers.firewall._save_config") as mock_save,
|
||||
):
|
||||
result = daemonfirewall._config_apply()
|
||||
|
||||
assert result["applied_zones"] == ["public"]
|
||||
saved = mock_save.call_args[0][0]
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
|
||||
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
|
||||
# The snapshot is the applied (meta-stripped) config.
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
|
||||
|
||||
|
||||
class TestDaemonGetConfigEndpoint:
|
||||
def test_strips_apply_meta(self):
|
||||
with patch.object(
|
||||
daemonfirewall,
|
||||
"_get_config",
|
||||
return_value={
|
||||
"zones": {},
|
||||
_APPLY_HASH_KEY: "h",
|
||||
_LAST_APPLIED_CONFIG_KEY: {"zones": {}},
|
||||
},
|
||||
):
|
||||
result = daemonfirewall.get_config(None, None)
|
||||
assert result == {"zones": {}}
|
||||
|
||||
@patch(
|
||||
"daemon.handlers.firewall._config_apply",
|
||||
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user