Add update-vendor.sh symlink support, unify install.sh vendor flow
- update-vendor.sh now creates webui/vendor symlinks (htm.js) - install.sh calls update-vendor.sh after package install - Add vendor/.empty and webui/vendor/.empty as directory placeholders in git
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Tests for hoover/components/applyconfirm.js
|
||||
*
|
||||
* Component-level tests: VNode structure, buildRows logic,
|
||||
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
|
||||
*/
|
||||
|
||||
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
|
||||
|
||||
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
|
||||
|
||||
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 ApplyConfirm component\n');
|
||||
|
||||
// === isPending ===
|
||||
test('isPending returns true for needs_apply', () => {
|
||||
assertEq(isPending({ needs_apply: true }), true);
|
||||
});
|
||||
|
||||
test('isPending returns true for pending_changes', () => {
|
||||
assertEq(isPending({ pending_changes: true }), true);
|
||||
});
|
||||
|
||||
test('isPending returns false when neither flag set', () => {
|
||||
assertEq(isPending({}), false);
|
||||
});
|
||||
|
||||
test('isPending returns false for explicit false', () => {
|
||||
assertEq(isPending({ needs_apply: false, pending_changes: false }), false);
|
||||
});
|
||||
|
||||
// === SUBSYSTEM_LIST ===
|
||||
test('SUBSYSTEM_LIST contains 5 subsystems', () => {
|
||||
assertEq(SUBSYSTEM_LIST.length, 5);
|
||||
});
|
||||
|
||||
test('SUBSYSTEM_LIST uses networkd key (not network)', () => {
|
||||
assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd');
|
||||
assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network');
|
||||
});
|
||||
|
||||
test('SUBSYSTEM_LIST keys match daemon response keys', () => {
|
||||
const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'];
|
||||
for (const key of expectedKeys) {
|
||||
assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// === buildRows ===
|
||||
test('buildRows returns 5 rows for empty subsystems', () => {
|
||||
const rows = buildRows({}, {});
|
||||
assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state');
|
||||
});
|
||||
|
||||
test('buildRows marks pending firewall subsystem correctly', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const fwRow = rows[0];
|
||||
assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class');
|
||||
});
|
||||
|
||||
test('buildRows changes are VNodes with proper structure', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const fwRow = rows[0];
|
||||
// Status span should contain "1 pending changes"
|
||||
const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
||||
assert(statusSpan, 'should have status span');
|
||||
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
||||
assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count');
|
||||
});
|
||||
|
||||
test('buildRows marks pending dnsmasq subsystem correctly', () => {
|
||||
const data = {
|
||||
dnsmasq: {
|
||||
pending_changes: true,
|
||||
changes: [
|
||||
{ summary: 'DHCP/DNS configuration has unapplied changes', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const dnsmasqRow = rows[1];
|
||||
assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class');
|
||||
});
|
||||
|
||||
test('buildRows shows correct change count text', () => {
|
||||
const data = {
|
||||
dnsmasq: {
|
||||
pending_changes: true,
|
||||
changes: [
|
||||
{ summary: 'Range 1', detail: '' },
|
||||
{ summary: 'Range 2', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const dnsmasqRow = rows[1];
|
||||
const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
||||
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
||||
assertEq(textChild.text, '2 pending changes');
|
||||
});
|
||||
|
||||
test('buildRows shows up-to-date for non-pending', () => {
|
||||
const data = {
|
||||
nginx: { pending_changes: false, changes: [] },
|
||||
wireguard: { pending_changes: false, changes: [] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const nginxRow = rows[2];
|
||||
assert(
|
||||
!nginxRow.props.class.includes('pending'),
|
||||
'nginx row should not have pending class',
|
||||
);
|
||||
const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
|
||||
const textChild = statusSpan.ch.find(c => c.tag === '#text');
|
||||
assertEq(textChild.text, 'Up to date');
|
||||
});
|
||||
|
||||
test('buildRows shows expand icon and details when expanded', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
{ summary: 'Zone dmz: services changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, { firewall: true });
|
||||
assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)');
|
||||
});
|
||||
|
||||
test('buildRows hides expand icon when not expanded', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
assertEq(rows.length, 5, 'should only have 5 rows, no detail section');
|
||||
});
|
||||
|
||||
test('buildRows pending flag but no changes treated as up-to-date', () => {
|
||||
const data = {
|
||||
firewall: { needs_apply: true, changes: [] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const fwRow = rows[0];
|
||||
assert(
|
||||
!fwRow.props.class.includes('pending'),
|
||||
'no changes = up to date',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildRows detail section contains item VNodes', () => {
|
||||
const data = {
|
||||
firewall: {
|
||||
needs_apply: true,
|
||||
changes: [
|
||||
{ summary: 'Zone internal: interfaces changed', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, { firewall: true });
|
||||
const detailSection = rows[1];
|
||||
assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section');
|
||||
assert(detailSection.ch.length > 0, 'detail section should have children');
|
||||
});
|
||||
|
||||
test('buildRows handles networkd key correctly', () => {
|
||||
const data = {
|
||||
networkd: {
|
||||
pending_changes: true,
|
||||
changes: [
|
||||
{ summary: 'Network configuration has unapplied changes', detail: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
const networkdRow = rows[4]; // networkd is 5th in list
|
||||
assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class');
|
||||
});
|
||||
|
||||
test('buildRows row VNodes have correct tag', () => {
|
||||
const data = {
|
||||
firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] },
|
||||
};
|
||||
const rows = buildRows(data, {});
|
||||
for (const row of rows.slice(0, 5)) {
|
||||
assertEq(row.tag, 'div', 'row should be a div');
|
||||
assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class');
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -70,87 +70,6 @@ class TestSaveConfig:
|
||||
assert loaded["dns"]["domain"] == "test.lan"
|
||||
|
||||
|
||||
class TestSetDhcpRange:
|
||||
def test_add_new_range(self, temp_data_dir):
|
||||
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
|
||||
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
|
||||
|
||||
def test_replace_existing_range(self, temp_data_dir):
|
||||
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
|
||||
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["ranges"]) == 1
|
||||
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
|
||||
|
||||
|
||||
class TestStaticLeases:
|
||||
def test_add_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
|
||||
|
||||
def test_update_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
|
||||
|
||||
def test_remove_static_lease(self, temp_data_dir):
|
||||
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
|
||||
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
|
||||
dnsmasq.remove_static_lease("aa:bb:cc")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dhcp"]["static_leases"]) == 1
|
||||
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
|
||||
|
||||
|
||||
class TestDnsRecords:
|
||||
def test_add_dns_record(self, temp_data_dir):
|
||||
dnsmasq.add_dns_record("host", "10.0.0.100")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
|
||||
def test_remove_dns_record(self, temp_data_dir):
|
||||
dnsmasq.add_dns_record("host", "10.0.0.100")
|
||||
dnsmasq.add_dns_record("other", "10.0.0.101")
|
||||
dnsmasq.remove_dns_record("host")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert len(cfg["dns"]["custom_records"]) == 1
|
||||
assert cfg["dns"]["custom_records"][0]["name"] == "other"
|
||||
|
||||
|
||||
class TestParseLeaseLine:
|
||||
def test_valid_line(self):
|
||||
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
|
||||
result = dnsmasq._parse_lease_line(line)
|
||||
assert result is not None
|
||||
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||
assert result["ip"] == "10.0.0.50"
|
||||
assert result["hostname"] == "printer"
|
||||
|
||||
def test_empty_line(self):
|
||||
assert dnsmasq._parse_lease_line("") is None
|
||||
|
||||
def test_comment_line(self):
|
||||
assert dnsmasq._parse_lease_line("# comment") is None
|
||||
|
||||
def test_short_line(self):
|
||||
assert dnsmasq._parse_lease_line("incomplete") is None
|
||||
|
||||
def test_minimal_fields(self):
|
||||
line = "1700000000 AA:BB:CC 10.0.0.50"
|
||||
result = dnsmasq._parse_lease_line(line)
|
||||
assert result is not None
|
||||
assert result["hostname"] == ""
|
||||
assert result["interface"] == ""
|
||||
|
||||
|
||||
class TestUpstreamsAndDomain:
|
||||
def test_set_upstreams(self, temp_data_dir):
|
||||
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
|
||||
|
||||
@@ -477,6 +477,172 @@ class TestDaemonConfigPending:
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert result["needs_apply"] is True
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_no_state_mutation(self, mock_st):
|
||||
pending = {
|
||||
"needs_apply": True,
|
||||
"pending": [{"zone": "public", "type": "services"}],
|
||||
}
|
||||
mock_st.get.return_value = {**_mock_state(), "pending": pending}
|
||||
original_keys = set(pending.keys())
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert "pending_summary" in result
|
||||
assert set(pending.keys()) == original_keys, (
|
||||
"config_pending_handler must not mutate state store pending dict"
|
||||
)
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_interfaces(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "internal",
|
||||
"type": "interfaces",
|
||||
"config": ["eth1", "eth2"],
|
||||
"live": ["eth1"],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
|
||||
assert "eth2" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_services(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "dmz",
|
||||
"type": "services",
|
||||
"config": ["ssh", "dns"],
|
||||
"live": ["ssh"],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone dmz: services changed" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_rich_rules(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "public",
|
||||
"type": "rich_rules",
|
||||
"config_count": 3,
|
||||
"live_count": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone public: rich rules differ" in result["pending_summary"][0]
|
||||
assert "config: 3" in result["pending_summary"][0]
|
||||
assert "live: 1" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_masquerade(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "wan",
|
||||
"type": "masquerade",
|
||||
"config": True,
|
||||
"live": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone wan: masquerade changed" in result["pending_summary"][0]
|
||||
assert "config: True" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_target(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "trusted",
|
||||
"type": "target",
|
||||
"config": "ACCEPT",
|
||||
"live": "default",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone trusted: target changed" in result["pending_summary"][0]
|
||||
assert "config: ACCEPT" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_unknown_type(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [{"zone": "public", "type": "foobarLayout"}],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 1
|
||||
assert "Zone public: foobarLayout changed" in result["pending_summary"][0]
|
||||
|
||||
@patch("lib.state.state")
|
||||
def test_detail_text_mixed_types(self, mock_st):
|
||||
mock_st.get.return_value = {
|
||||
**_mock_state(),
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "internal",
|
||||
"type": "interfaces",
|
||||
"config": ["eth1"],
|
||||
"live": [],
|
||||
},
|
||||
{
|
||||
"zone": "dmz",
|
||||
"type": "services",
|
||||
"config": ["ssh", "dns"],
|
||||
"live": ["ssh"],
|
||||
},
|
||||
{
|
||||
"zone": "public",
|
||||
"type": "rich_rules",
|
||||
"config_count": 2,
|
||||
"live_count": 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
result = daemonfirewall.config_pending_handler(None, None)
|
||||
assert len(result["pending_summary"]) == 3
|
||||
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
|
||||
assert "Zone dmz: services changed" in result["pending_summary"][1]
|
||||
assert "Zone public: rich rules differ" in result["pending_summary"][2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
|
||||
|
||||
@@ -66,7 +66,7 @@ class TestBlueprintsRegistered:
|
||||
def test_all_blueprints_registered(self, client):
|
||||
from webui.server import BLUEPRINTS
|
||||
|
||||
assert len(BLUEPRINTS) == 7
|
||||
assert len(BLUEPRINTS) == 8
|
||||
names = [name for name, _ in BLUEPRINTS]
|
||||
assert "firewall" in names
|
||||
assert "network" in names
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Tests for daemon/handlers/status.py — aggregate pending + apply-all."""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from daemon.handlers import status
|
||||
from lib.state import State
|
||||
|
||||
|
||||
def _make_state(**kwargs):
|
||||
"""Create a minimal in-memory state for pending checks."""
|
||||
st = State()
|
||||
for name, data in kwargs.items():
|
||||
st.set(name, data)
|
||||
return st
|
||||
|
||||
|
||||
def _mock_state_store(state_dict):
|
||||
"""Return a mock that looks like state_store.get()."""
|
||||
mock = MagicMock()
|
||||
mock.get.side_effect = lambda name: state_dict.get(name)
|
||||
return mock
|
||||
|
||||
|
||||
class TestFwChangeSummary:
|
||||
"""Test fw_change_summary helper from status module."""
|
||||
|
||||
def test_interfaces_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"internal", "interfaces", {"config": ["eth1"], "live": []}
|
||||
)
|
||||
assert "Zone internal: interfaces changed" in s
|
||||
assert "eth1" in s
|
||||
|
||||
def test_services_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"dmz", "services", {"config": ["ssh", "dns"], "live": ["ssh"]}
|
||||
)
|
||||
assert "Zone dmz: services changed" in s
|
||||
|
||||
def test_rich_rules_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"public", "rich_rules", {"config_count": 2, "live_count": 1}
|
||||
)
|
||||
assert "config: 2" in s
|
||||
assert "live: 1" in s
|
||||
|
||||
def test_forward_ports_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"wan", "forward_ports", {"config_count": 3, "live_count": 0}
|
||||
)
|
||||
assert "Zone wan: port forwards differ" in s
|
||||
|
||||
def test_masquerade_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"lan", "masquerade", {"config": True, "live": False}
|
||||
)
|
||||
assert "Zone lan: masquerade changed" in s
|
||||
|
||||
def test_target_summary(self):
|
||||
s = status.fw_change_summary(
|
||||
"vpn", "target", {"config": "ACCEPT", "live": "default"}
|
||||
)
|
||||
assert "Zone vpn: target changed" in s
|
||||
|
||||
def test_unknown_type_summary(self):
|
||||
s = status.fw_change_summary("public", "weird", {})
|
||||
assert "Zone public: weird changed" in s
|
||||
|
||||
|
||||
class TestHashSubsystem:
|
||||
"""Test _hash_subsystem helper from status module."""
|
||||
|
||||
def test_no_state(self):
|
||||
result = status._hash_subsystem("nginx", None)
|
||||
assert result["pending_changes"] is False
|
||||
assert result["summary"] == "Up to date"
|
||||
|
||||
def test_pending_true(self):
|
||||
st = {"status": {"pending_changes": True}}
|
||||
result = status._hash_subsystem("wireguard", st)
|
||||
assert result["pending_changes"] is True
|
||||
assert "unapplied changes" in result["summary"]
|
||||
assert len(result["changes"]) == 1
|
||||
|
||||
def test_pending_false(self):
|
||||
st = {"status": {"pending_changes": False}}
|
||||
result = status._hash_subsystem("networkd", st)
|
||||
assert result["pending_changes"] is False
|
||||
|
||||
def test_empty_status(self):
|
||||
st = {}
|
||||
result = status._hash_subsystem("dnsmasq", st)
|
||||
assert result["pending_changes"] is False
|
||||
|
||||
|
||||
class TestStatusPending:
|
||||
"""Test the aggregate pending endpoint."""
|
||||
|
||||
@patch("daemon.handlers.status.state_store")
|
||||
def test_all_synced(self, mock_store):
|
||||
mock_store.get.return_value = {
|
||||
"firewall": {"pending": {"needs_apply": False, "pending": []}},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": False}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
assert not result["dnsmasq"]["pending_changes"]
|
||||
|
||||
def _patch_store(self, data):
|
||||
mock = MagicMock()
|
||||
mock.get.side_effect = lambda name: data.get(name)
|
||||
return patch("daemon.handlers.status.state_store", mock)
|
||||
|
||||
def test_firewall_pending_only(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "internal",
|
||||
"type": "interfaces",
|
||||
"config": ["eth1"],
|
||||
"live": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": False}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 1
|
||||
assert result["firewall"]["change_count"] == 1
|
||||
|
||||
def test_multiple_subsystems_pending(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {
|
||||
"needs_apply": True,
|
||||
"pending": [
|
||||
{
|
||||
"zone": "lan",
|
||||
"type": "services",
|
||||
"config": ["ssh"],
|
||||
"live": [],
|
||||
},
|
||||
{
|
||||
"zone": "wan",
|
||||
"type": "masquerade",
|
||||
"config": True,
|
||||
"live": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
"dnsmasq": {"status": {"pending_changes": True}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": True}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 4 # 2 FW + 1 DHCP + 1 WG
|
||||
assert result["firewall"]["change_count"] == 2
|
||||
assert result["firewall"]["needs_apply"] is True
|
||||
assert result["dnsmasq"]["pending_changes"] is True
|
||||
assert result["wireguard"]["pending_changes"] is True
|
||||
|
||||
def test_empty_state(self):
|
||||
with self._patch_store({}):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
|
||||
def test_firewall_no_pending_key(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": None,
|
||||
"wireguard": None,
|
||||
"networkd": None,
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
|
||||
|
||||
class TestStatusApplyAll:
|
||||
"""Test the apply-all endpoint.
|
||||
|
||||
Patches SYS_APPLY dict entries directly since they hold function
|
||||
references at import time.
|
||||
"""
|
||||
|
||||
_fake_pending_all: ClassVar[dict[str, Any]] = {
|
||||
"firewall": {"needs_apply": False, "change_count": 0, "changes": []},
|
||||
"dnsmasq": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"nginx": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"wireguard": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"networkd": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
}
|
||||
|
||||
@patch("daemon.handlers.status.status_pending")
|
||||
@patch("daemon.handlers.status.refresh_state")
|
||||
def test_nothing_to_apply(self, mock_refresh, mock_pending):
|
||||
mock_pending.return_value = self._fake_pending_all
|
||||
result = status.status_apply_all(None, None)
|
||||
assert result["applied"] == []
|
||||
assert result["errors"] == {}
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
def test_applies_pending_subsystems(self):
|
||||
mock_net = MagicMock()
|
||||
mock_fw = MagicMock()
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
pending_data["networkd"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"networkd": mock_net,
|
||||
"firewall": mock_fw,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = status.status_apply_all(None, None)
|
||||
assert "networkd" in result["applied"]
|
||||
assert "firewall" in result["applied"]
|
||||
mock_net.assert_called_once()
|
||||
mock_fw.assert_called_once()
|
||||
|
||||
def test_error_in_subsystem(self):
|
||||
mock_fw = MagicMock(side_effect=RuntimeError("firewalld not running"))
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
|
||||
):
|
||||
result = status.status_apply_all(None, None)
|
||||
assert "firewall" not in result["applied"]
|
||||
assert "Firewall" in result["errors"]
|
||||
assert "firewalld not running" in result["errors"]["Firewall"]
|
||||
|
||||
def test_order_is_respected(self):
|
||||
call_order = []
|
||||
|
||||
def track(name):
|
||||
def wrapper(*args):
|
||||
call_order.append(name)
|
||||
|
||||
return wrapper
|
||||
|
||||
mock_net = MagicMock(side_effect=track("networkd"))
|
||||
mock_wg = MagicMock(side_effect=track("wireguard"))
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["wireguard"]["pending_changes"] = True
|
||||
pending_data["networkd"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"networkd": mock_net,
|
||||
"wireguard": mock_wg,
|
||||
},
|
||||
),
|
||||
):
|
||||
status.status_apply_all(None, None)
|
||||
assert call_order == ["networkd", "wireguard"]
|
||||
|
||||
def test_partial_failure_still_applies_others(self):
|
||||
mock_fw = MagicMock(side_effect=RuntimeError("fail"))
|
||||
mock_nginx = MagicMock()
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
pending_data["nginx"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"firewall": mock_fw,
|
||||
"nginx": mock_nginx,
|
||||
},
|
||||
),
|
||||
):
|
||||
result = status.status_apply_all(None, None)
|
||||
assert "firewall" not in result["applied"]
|
||||
assert "nginx" in result["applied"]
|
||||
assert "Firewall" in result["errors"]
|
||||
mock_nginx.assert_called_once()
|
||||
|
||||
|
||||
class TestSysOrder:
|
||||
"""Verify SYS_ORDER and SYS_LABELS constants."""
|
||||
|
||||
def test_order_network_first(self):
|
||||
assert status.SYS_ORDER[0] == "networkd"
|
||||
|
||||
def test_all_subsystems_present(self):
|
||||
expected = {"networkd", "firewall", "wireguard", "dnsmasq", "nginx"}
|
||||
assert set(status.SYS_ORDER) == expected
|
||||
|
||||
def test_labels_match(self):
|
||||
for name in status.SYS_ORDER:
|
||||
assert name in status.SYS_LABELS
|
||||
assert name in status.SYS_APPLY
|
||||
|
||||
def test_apply_functions_callable(self):
|
||||
for name in status.SYS_ORDER:
|
||||
assert callable(status.SYS_APPLY[name])
|
||||
Reference in New Issue
Block a user