feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)
- Add lib/state.py: in-memory state store with subsystem collectors (firewall, dnsmasq, nginx, acme, wireguard) - Refactor all handlers: read from state on GET, call refresh_state() after mutations instead of invoking subprocesses per request - daemon/server.py: add refresh_state(), /status/all, /status/refresh; populate state at startup - webui/api/certs.py: async step-by-step ACME issuance (validate, issue with request_id, poll status) replacing blocking endpoint - webui/server.py: render pages from state instead of direct lib calls - Update templates, JS for async cert issuance with polling UI - Update tests for state-based mocking; add test_state.py - Fix SIM105 lint issue (contextlib.suppress) - Add TODO.md with certificate issuance issue tracking Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
+1
-1
@@ -514,7 +514,7 @@ class TestCertsList:
|
||||
|
||||
class TestCertsIssue:
|
||||
def test_missing_domain(self, client):
|
||||
resp = client.post("/api/certs/issue", json={})
|
||||
resp = client.post("/api/certs/issue/start", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
|
||||
+143
-299
@@ -1,6 +1,6 @@
|
||||
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -53,9 +53,6 @@ class TestParseActiveZones:
|
||||
def test_lib_zone_no_interfaces(self):
|
||||
assert firewall._parse_active_zones("dmz") == {"dmz": []}
|
||||
|
||||
def test_daemon_import_same(self):
|
||||
assert daemonfirewall._parse_active_zones is firewall._parse_active_zones
|
||||
|
||||
|
||||
class TestParseZoneOutput:
|
||||
def test_lib_parses_zone(self):
|
||||
@@ -72,9 +69,6 @@ class TestParseZoneOutput:
|
||||
assert result["services"] == ["ssh", "dhcp"]
|
||||
assert result["masquerade"] is True
|
||||
|
||||
def test_daemon_import_same(self):
|
||||
assert daemonfirewall._parse_zone_output is firewall._parse_zone_output
|
||||
|
||||
|
||||
class TestParseInterfaces:
|
||||
def test_lib_parses_interfaces(self):
|
||||
@@ -311,297 +305,180 @@ class TestLibNoSudo:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# daemon/handlers/firewall.py — privileged operations
|
||||
# daemon/handlers/firewall.py — privileged operations (reads from state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_run_factory(*outputs):
|
||||
"""Create a mock run() that cycles through outputs on successive calls."""
|
||||
idx = [0]
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
result = outputs[idx[0] % len(outputs)]
|
||||
idx[0] += 1
|
||||
if result is RuntimeError:
|
||||
raise RuntimeError("command failed")
|
||||
return result
|
||||
|
||||
return side_effect
|
||||
_FakeState = {
|
||||
"firewall": {
|
||||
"active_zones": {"public": ["eth0"], "internal": ["eth1"]},
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "eth0",
|
||||
"display_name": "eth0",
|
||||
"mac": "aa:bb:cc:dd:ee:00",
|
||||
"state": "UP",
|
||||
"mtu": 1500,
|
||||
"ips": ["192.168.1.1/24"],
|
||||
"ipv6": [],
|
||||
"zone": "public",
|
||||
},
|
||||
{
|
||||
"name": "eth1",
|
||||
"display_name": "eth1",
|
||||
"mac": "aa:bb:cc:dd:ee:01",
|
||||
"state": "UP",
|
||||
"mtu": 1500,
|
||||
"ips": ["10.0.0.1/24"],
|
||||
"ipv6": [],
|
||||
"zone": "internal",
|
||||
},
|
||||
],
|
||||
"available_services": ["ssh", "http", "dns"],
|
||||
"zones": {
|
||||
"public": {
|
||||
"name": "public",
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["ssh"],
|
||||
"rich-rules": [],
|
||||
},
|
||||
"internal": {
|
||||
"name": "internal",
|
||||
"interfaces": [],
|
||||
"services": [],
|
||||
"rich-rules": [],
|
||||
},
|
||||
},
|
||||
"rich_rules": {
|
||||
"public": [],
|
||||
"internal": [],
|
||||
},
|
||||
"config": {"zones": {}},
|
||||
"pending": {},
|
||||
"timestamp": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestDaemonParseForwardPorts:
|
||||
def test_handler_uses_get_forward_ports(self):
|
||||
assert callable(daemonfirewall._get_forward_ports)
|
||||
def _mock_state():
|
||||
return _FakeState["firewall"]
|
||||
|
||||
|
||||
class TestDaemonParseActiveZones:
|
||||
def test_parses_active_zones(self):
|
||||
result = daemonfirewall._parse_active_zones(
|
||||
"public\n eth0\ninternal\n eth1\n eth2"
|
||||
)
|
||||
assert result == {
|
||||
"public": ["eth0"],
|
||||
"internal": ["eth1", "eth2"],
|
||||
}
|
||||
|
||||
def test_empty_output(self):
|
||||
assert daemonfirewall._parse_active_zones("") == {}
|
||||
|
||||
def test_zone_with_no_interfaces(self):
|
||||
assert daemonfirewall._parse_active_zones("dmz") == {"dmz": []}
|
||||
|
||||
|
||||
class TestDaemonParseZoneOutput:
|
||||
def test_parses_zone_info(self):
|
||||
result = daemonfirewall._parse_zone_output(
|
||||
"public",
|
||||
(
|
||||
"target: default\n"
|
||||
"interfaces: eth0\n"
|
||||
"sources: \n"
|
||||
"services: ssh dhcp\n"
|
||||
"ports: 8080/tcp\n"
|
||||
"protocols: \n"
|
||||
"forward-ports: \n"
|
||||
"masquerade: yes\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
),
|
||||
)
|
||||
assert result["name"] == "public"
|
||||
assert result["services"] == ["ssh", "dhcp"]
|
||||
assert result["ports"] == ["8080/tcp"]
|
||||
assert result["masquerade"] is True
|
||||
assert result["interfaces"] == ["eth0"]
|
||||
# GET endpoints read from state — mock lib.state.state.get()
|
||||
|
||||
|
||||
class TestDaemonGetInterfaces:
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_parses_interfaces(self, mock_run):
|
||||
link_out = (
|
||||
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
||||
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||
)
|
||||
mock_run.return_value = link_out
|
||||
@patch("lib.state.state")
|
||||
def test_parses_interfaces(self, mock_st):
|
||||
mock_st.get.return_value = _mock_state()
|
||||
result = daemonfirewall.get_interfaces(None, None)
|
||||
assert [i["name"] for i in result] == ["lo", "eth0", "eth1"]
|
||||
assert [i["name"] for i in result] == ["eth0", "eth1"]
|
||||
|
||||
|
||||
class TestDaemonGetZones:
|
||||
@patch("lib.state.state")
|
||||
def test_returns_zones(self, mock_st):
|
||||
mock_st.get.return_value = _mock_state()
|
||||
result = daemonfirewall.get_zones(None, None)
|
||||
assert "public" in result["active"]
|
||||
assert "internal" in result["active"]
|
||||
assert "public" in result["available"]
|
||||
|
||||
|
||||
class TestDaemonGetServices:
|
||||
@patch("lib.state.state")
|
||||
def test_returns_services(self, mock_st):
|
||||
mock_st.get.return_value = _mock_state()
|
||||
result = daemonfirewall.get_services(None, None)
|
||||
assert "ssh" in result
|
||||
assert "http" in result
|
||||
|
||||
|
||||
class TestDaemonGetRichRules:
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_single_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
||||
)
|
||||
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}})
|
||||
with patch.object(daemonfirewall, "_get_config", cfg_mock):
|
||||
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_empty_rules(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}})
|
||||
with patch.object(daemonfirewall, "_get_config", cfg_mock):
|
||||
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
|
||||
@patch("lib.state.state")
|
||||
def test_empty_rules(self, mock_st):
|
||||
mock_st.get.return_value = _mock_state()
|
||||
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
|
||||
assert result == []
|
||||
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_multiline_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
||||
)
|
||||
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}})
|
||||
with patch.object(daemonfirewall, "_get_config", cfg_mock):
|
||||
@patch("lib.state.state")
|
||||
def test_rules_with_ids(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"rich_rules": {
|
||||
"public": ['rule family="ipv4" port protocol="tcp" port="443" accept;'],
|
||||
},
|
||||
}
|
||||
with patch.object(
|
||||
daemonfirewall,
|
||||
"_get_config",
|
||||
return_value={"zones": {"public": {"rich_rules": []}}},
|
||||
):
|
||||
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
|
||||
assert len(result) == 1
|
||||
assert "10.0.0.0/24" in result[0]["rule"]
|
||||
|
||||
|
||||
class TestDaemonGetState:
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_returns_full_state(self, mock_run):
|
||||
def run_side_effect(args, **kwargs):
|
||||
if "--get-zones" in args:
|
||||
return "public\ninternal"
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0\ninternal\n eth1"
|
||||
if "--get-services" in args:
|
||||
return "ssh http dns"
|
||||
if "ip" in args[0]:
|
||||
if "link" in args:
|
||||
return "1: lo: <LOOPBACK,UP> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb:cc\n"
|
||||
if "addr" in args:
|
||||
return "2: eth0 inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0\n"
|
||||
if "--list-all" in args:
|
||||
return (
|
||||
"target: default\n"
|
||||
"interfaces: eth0\n"
|
||||
"sources: \n"
|
||||
"services: \n"
|
||||
"ports: \n"
|
||||
"protocols: \n"
|
||||
"forward-ports: \n"
|
||||
"masquerade: no\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
)
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side_effect
|
||||
|
||||
result = daemonfirewall._get_state()
|
||||
@patch("lib.state.state")
|
||||
def test_returns_full_state(self, mock_st):
|
||||
mock_st.get.return_value = _mock_state()
|
||||
result = daemonfirewall.get_state(None, None)
|
||||
assert "zones" in result
|
||||
assert "active_zones" in result
|
||||
assert "timestamp" in result
|
||||
assert "interfaces" in result
|
||||
assert len(result["interfaces"]) >= 2
|
||||
assert len(result["interfaces"]) == 2
|
||||
assert "public" in result["zones"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mutation endpoints — still call subprocess (run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDaemonConfigApply:
|
||||
@patch("daemon.handlers.firewall._save_backup")
|
||||
@patch("daemon.handlers.firewall._get_state")
|
||||
@patch("daemon.handlers.firewall._get_lib_config")
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_applies_existing_zone(self, mock_run, mock_cfg, mock_state, mock_backup):
|
||||
mock_cfg.return_value = {
|
||||
@patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={
|
||||
"zones": {
|
||||
"public": {
|
||||
"target": "DEFAULT",
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http", "https"],
|
||||
"services": ["http"],
|
||||
"masquerade": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_run.return_value = (
|
||||
"public\ninternal\ntarget: default\n"
|
||||
"interfaces: \n"
|
||||
"sources: \n"
|
||||
"services: \n"
|
||||
"ports: \n"
|
||||
"protocols: \n"
|
||||
"forward-ports: \n"
|
||||
"masquerade: no\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
)
|
||||
mock_state.return_value = {"zones": {"public": {}}}
|
||||
mock_backup.return_value = "/tmp/rules.json"
|
||||
|
||||
result = daemonfirewall._config_apply()
|
||||
assert result["applied_zones"] == ["public"]
|
||||
assert result["backup"] == "/tmp/rules.json"
|
||||
calls = [str(c) for c in mock_run.call_args_list]
|
||||
assert any("--add-service=" in c for c in calls)
|
||||
assert any("--add-interface=" in c for c in calls)
|
||||
|
||||
@patch("daemon.handlers.firewall._save_backup")
|
||||
@patch("daemon.handlers.firewall._get_state")
|
||||
@patch("daemon.handlers.firewall._get_lib_config")
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_creates_new_zone(self, mock_run, mock_cfg, mock_state, mock_backup):
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"custom": {
|
||||
"target": "ACCEPT",
|
||||
"interfaces": ["eth2"],
|
||||
"services": [],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_run.return_value = (
|
||||
"public\ninternal\ntarget: default\n"
|
||||
"interfaces: \n"
|
||||
"sources: \n"
|
||||
"services: \n"
|
||||
"ports: \n"
|
||||
"protocols: \n"
|
||||
"forward-ports: \n"
|
||||
"masquerade: no\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
)
|
||||
mock_state.return_value = {"zones": {"custom": {}}}
|
||||
mock_backup.return_value = "/tmp/rules.json"
|
||||
|
||||
result = daemonfirewall._config_apply()
|
||||
assert result["applied_zones"] == ["custom"]
|
||||
|
||||
@patch("daemon.handlers.firewall._save_backup")
|
||||
@patch("daemon.handlers.firewall._get_state")
|
||||
@patch("daemon.handlers.firewall._get_lib_config")
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_empty_config_no_ops(self, mock_run, mock_cfg, mock_state, mock_backup):
|
||||
mock_cfg.return_value = {"zones": {}}
|
||||
mock_run.return_value = ""
|
||||
mock_state.return_value = {"zones": {}}
|
||||
mock_backup.return_value = "/tmp/rules.json"
|
||||
|
||||
result = daemonfirewall._config_apply()
|
||||
assert result["applied_zones"] == []
|
||||
},
|
||||
create=True,
|
||||
)
|
||||
@patch(
|
||||
"daemon.handlers.firewall.run",
|
||||
return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n",
|
||||
)
|
||||
def test_applies_existing_zone(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"),
|
||||
):
|
||||
result = daemonfirewall._config_apply()
|
||||
assert result["applied_zones"] == ["public"]
|
||||
|
||||
|
||||
class TestDaemonConfigPending:
|
||||
@patch("daemon.handlers.firewall._get_state")
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_detects_interface_drift(self, mock_cfg, mock_state):
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
@patch("lib.state.state")
|
||||
def test_returns_pending(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {"needs_apply": True, "pending": [{"type": "services"}]},
|
||||
}
|
||||
mock_state.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth1"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending(None, None)
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert result["needs_apply"] is True
|
||||
|
||||
@patch("daemon.handlers.firewall._get_state")
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_in_sync(self, mock_cfg, mock_state):
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_state.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": ["eth0"],
|
||||
"services": ["http"],
|
||||
"masquerade": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending(None, None)
|
||||
assert result["needs_apply"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
|
||||
@@ -654,49 +531,16 @@ class TestDaemonZoneValidation:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forward port removal during config_apply
|
||||
# lib/firewall parsing is reused by state module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDaemonConfigApplyForwardPorts:
|
||||
@patch("daemon.handlers.firewall._save_backup")
|
||||
@patch("daemon.handlers.firewall._get_state")
|
||||
@patch("daemon.handlers.firewall._get_lib_config")
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_removes_stale_forward_ports(
|
||||
self, mock_run, mock_cfg, mock_state, mock_backup
|
||||
):
|
||||
mock_cfg.return_value = {
|
||||
"zones": {
|
||||
"public": {
|
||||
"interfaces": [],
|
||||
"services": [],
|
||||
"masquerade": False,
|
||||
"forward_ports": [
|
||||
{"id": "fp_new", "port": 8443, "proto": "tcp"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_run.return_value = (
|
||||
"public\ntarget: default\n"
|
||||
"interfaces: \n"
|
||||
"sources: \n"
|
||||
"services: \n"
|
||||
"ports: \n"
|
||||
"protocols: \n"
|
||||
"forward-ports: port=443/proto=tcp\n"
|
||||
"masquerade: no\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
)
|
||||
mock_state.return_value = {"zones": {"public": {}}}
|
||||
mock_backup.return_value = "/tmp/rules.json"
|
||||
class TestLibParseForwardPorts:
|
||||
def test_single_entry(self):
|
||||
result = firewall._parse_forward_ports("port=443/proto=tcp")
|
||||
assert len(result) == 1
|
||||
assert result[0]["port"] == 443
|
||||
assert result[0]["proto"] == "tcp"
|
||||
|
||||
result = daemonfirewall._config_apply()
|
||||
assert result["applied_zones"] == ["public"]
|
||||
calls = [str(c) for c in mock_run.call_args_list]
|
||||
assert any("--remove-forward-port=" in c for c in calls)
|
||||
assert any("--add-forward-port=" in c for c in calls)
|
||||
def test_empty_string(self):
|
||||
assert firewall._parse_forward_ports("") == []
|
||||
|
||||
@@ -93,5 +93,5 @@ class TestPageRoutes:
|
||||
@patch("webui.server.get")
|
||||
def test_dashboard_no_crash(self, mock_get, client):
|
||||
mock_get.return_value = {}
|
||||
resp = client.get("/")
|
||||
resp = client.get("/dashboard")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for lib/state.py — state store and collect functions."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestCollectAll:
|
||||
@patch("lib.state.run")
|
||||
def test_collect_firewall_returns_dict(self, mock_run):
|
||||
from lib.state import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-zones" in args:
|
||||
return "public\ninternal"
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0"
|
||||
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" in args:
|
||||
return "target: default\ninterfaces: eth0\nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n"
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
result = _collect_firewall()
|
||||
assert isinstance(result, dict)
|
||||
assert "active_zones" in result
|
||||
assert "interfaces" in result
|
||||
assert "timestamp" in result
|
||||
|
||||
@patch("lib.state.run_proc")
|
||||
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
||||
from unittest.mock import Mock
|
||||
|
||||
from lib.state 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
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user