fix htmx refactor route mismatches and remaining TODO items

- wireguard: POST /peers with JSON encoding (was /add-peer)
- rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render
- nat: port forward delete uses URL path params to match blueprint
- nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch)
- app.js renderers updated to use URL path deletes for rules and forwards
- remove TODO.md
This commit is contained in:
2026-05-17 01:15:52 +00:00
parent 0e7090a2cb
commit 37039351be
26 changed files with 1737 additions and 848 deletions
+219 -20
View File
@@ -1,3 +1,7 @@
"""
API integration tests — all blueprints tested via a single Flask app fixture.
"""
from unittest.mock import patch
import pytest
@@ -24,6 +28,11 @@ def client():
return app.test_client()
# ============================================================================
# Firewall
# ============================================================================
class TestFirewallListZones:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
@@ -100,7 +109,7 @@ class TestFirewallDeleteZone:
class TestFirewallRichRules:
@patch("webui.api.firewall.add_rich_rule")
def test_add(self, mock_add, client):
mock_add.return_value = None
mock_add.return_value = {"id": "abc123", "rule": "rule accept"}
resp = client.post(
"/api/firewall/rich-rules",
json={
@@ -111,18 +120,35 @@ class TestFirewallRichRules:
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["id"] == "abc123"
def test_missing_fields(self, client):
resp = client.post("/api/firewall/rich-rules", json={})
assert resp.status_code == 400
@patch("webui.api.firewall.get_rich_rules")
def test_list(self, mock_list, client):
mock_list.return_value = ["rule1", "rule2"]
@patch("webui.api.firewall.config_get")
def test_list(self, mock_cfg, mock_list, client):
mock_list.return_value = ["rule1"]
mock_cfg.return_value = {"zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}}}
resp = client.get("/api/firewall/rich-rules/public")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"] == ["rule1", "rule2"]
assert isinstance(data["data"], list)
@patch("webui.api.firewall.remove_rich_rule_by_id")
def test_remove_by_id(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/firewall/rich-rules/public/abc123")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@patch("webui.api.firewall.remove_rich_rule_by_id")
def test_remove_not_found(self, mock_remove, client):
mock_remove.side_effect = ValueError("not found")
resp = client.delete("/api/firewall/rich-rules/public/abc123")
assert resp.status_code == 404
class TestFirewallServices:
@@ -161,12 +187,14 @@ class TestFirewallMasquerade:
class TestFirewallForwardPort:
@patch("webui.api.firewall.add_forward_port")
def test_add(self, mock_add, client):
mock_add.return_value = None
mock_add.return_value = {"id": "fp1", "port": 443, "proto": "tcp"}
resp = client.post(
"/api/firewall/forward-port",
json={"zone": "public", "port": 443, "proto": "tcp"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["id"] == "fp1"
def test_missing_fields(self, client):
resp = client.post(
@@ -175,6 +203,25 @@ class TestFirewallForwardPort:
)
assert resp.status_code == 400
@patch("webui.api.firewall.remove_forward_port_by_id")
def test_remove_by_id(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete("/api/firewall/forward-port/public/443/tcp")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
@patch("webui.api.firewall.remove_forward_port_by_id")
def test_remove_not_found(self, mock_remove, client):
mock_remove.side_effect = ValueError("not found")
resp = client.delete("/api/firewall/forward-port/public/999/tcp")
assert resp.status_code == 404
# ============================================================================
# DHCP
# ============================================================================
class TestDhcpConfig:
@patch("webui.api.dhcp.get_config")
@@ -192,6 +239,67 @@ class TestDhcpConfig:
assert data is not None
class TestDhcpApply:
@patch("webui.api.dhcp.apply_config")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/dhcp/apply")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
class TestDhcpStatus:
@patch("lib.dnsmasq.get_status")
def test_success(self, mock_status, client):
mock_status.return_value = {"service_active": True}
resp = client.get("/api/dhcp/status")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["service_active"] is True
class TestDhcpRanges:
@patch("webui.api.dhcp.set_dhcp_range")
def test_add_range(self, mock_set, client):
mock_set.return_value = None
resp = client.post(
"/api/dhcp/ranges",
json={
"interface": "eth0",
"start": "192.168.1.100",
"end": "192.168.1.200",
"lease_time": "2h",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_add_range_missing_fields(self, client):
resp = client.post("/api/dhcp/ranges", json={"start": "192.168.1.100"})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_dhcp_range")
def test_remove_range(self, mock_remove, client):
mock_remove.return_value = None
resp = client.delete(
"/api/dhcp/ranges",
json={
"interface": "eth0",
"start": "192.168.1.100",
"end": "192.168.1.200",
},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
def test_remove_range_missing_fields(self, client):
resp = client.delete("/api/dhcp/ranges", json={})
assert resp.status_code == 400
class TestDhcpStaticLease:
@patch("webui.api.dhcp.add_static_lease")
def test_add(self, mock_add, client):
@@ -213,19 +321,15 @@ class TestDhcpStaticLease:
"dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]}
}
mock_remove.return_value = None
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
assert resp.status_code == 200
@patch("webui.api.dhcp.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"dhcp": {"static_leases": []}}
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
assert resp.status_code == 404
def test_remove_missing_mac(self, client):
resp = client.delete("/api/dhcp/static-lease")
assert resp.status_code == 400
class TestDhcpDnsRecord:
@patch("webui.api.dhcp.add_dns_record")
@@ -241,6 +345,27 @@ class TestDhcpDnsRecord:
resp = client.post("/api/dhcp/dns-record", json={})
assert resp.status_code == 400
@patch("webui.api.dhcp.remove_dns_record")
@patch("webui.api.dhcp.get_config")
def test_remove(self, mock_get, mock_remove, client):
mock_get.return_value = {
"dns": {"custom_records": [{"name": "host.local", "address": "10.0.0.10"}]}
}
mock_remove.return_value = None
resp = client.delete("/api/dhcp/dns-record/host.local")
assert resp.status_code == 200
@patch("webui.api.dhcp.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"dns": {"custom_records": []}}
resp = client.delete("/api/dhcp/dns-record/host.local")
assert resp.status_code == 404
# ============================================================================
# Proxy
# ============================================================================
class TestProxyDomains:
@patch("webui.api.proxy.get_domains")
@@ -271,6 +396,30 @@ class TestProxyApply:
assert resp.status_code == 200
class TestProxyTest:
@patch("webui.api.proxy.test_config")
def test_valid(self, mock_test, client):
mock_test.return_value = (True, "syntax ok")
resp = client.post("/api/proxy/test")
assert resp.status_code == 200
data = resp.get_json()
assert data["data"]["valid"] is True
@patch("webui.api.proxy.test_config")
def test_invalid(self, mock_test, client):
mock_test.return_value = (False, "error msg")
resp = client.post("/api/proxy/test")
assert resp.status_code == 400
data = resp.get_json()
assert data["ok"] is False
assert data["error"] == "error msg"
# ============================================================================
# Certs
# ============================================================================
class TestCertsList:
@patch("webui.api.certs.list_certs")
def test_list(self, mock_list, client):
@@ -297,6 +446,11 @@ class TestCertsEmail:
assert resp.status_code == 400
# ============================================================================
# WireGuard
# ============================================================================
class TestWireguardConfig:
@patch("webui.api.wireguard.get_config")
def test_get(self, mock_get, client):
@@ -325,9 +479,9 @@ class TestWireguardPeers:
@patch("webui.api.wireguard.add_peer")
def test_add(self, mock_add, client):
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
mock_add.return_value = {"name": "client1", "public_key": "pub", "private_key": "priv"}
resp = client.post(
"/api/wireguard/add-peer",
"/api/wireguard/peers",
json={"name": "client1"},
)
data = resp.get_json()
@@ -335,21 +489,31 @@ class TestWireguardPeers:
assert "private_key" not in data["data"]
def test_add_missing_name(self, client):
resp = client.post("/api/wireguard/add-peer", json={})
resp = client.post("/api/wireguard/peers", json={})
assert resp.status_code == 400
@patch("webui.api.wireguard.remove_peer")
@patch("webui.api.wireguard.get_config")
def test_remove_by_name(self, mock_get, mock_remove, client):
mock_get.return_value = {"peers": {"client1": {}}}
mock_remove.return_value = None
resp = client.delete("/api/wireguard/peers/client1")
assert resp.status_code == 200
@patch("webui.api.wireguard.get_config")
def test_remove_not_found(self, mock_get, client):
mock_get.return_value = {"peers": {}}
resp = client.delete("/api/wireguard/peers/unknown")
assert resp.status_code == 404
class TestWireguardInitialize:
@patch("webui.api.wireguard.initialize")
def test_initialize(self, mock_init, client):
mock_init.return_value = {
"interface": {"name": "wg0", "private_key": "priv"},
"peers": {},
}
mock_init.return_value = None
resp = client.post("/api/wireguard/initialize")
data = resp.get_json()
assert data["ok"] is True
assert data["data"] is None
class TestWireguardGenerateClient:
@@ -366,6 +530,41 @@ class TestWireguardStatus:
assert resp.status_code == 200
class TestWireguardUp:
@patch("webui.api.wireguard.apply")
def test_up_starts_tunnel(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/wireguard/up")
assert resp.status_code == 200
@patch("webui.api.wireguard.apply")
def test_up_error(self, mock_apply, client):
mock_apply.side_effect = RuntimeError("interface down")
resp = client.post("/api/wireguard/up")
assert resp.status_code == 500
class TestWireguardDown:
@patch("webui.api.wireguard.down")
def test_down_stops_tunnel(self, mock_down, client):
mock_down.return_value = None
resp = client.post("/api/wireguard/down")
assert resp.status_code == 200
class TestWireguardApply:
@patch("webui.api.wireguard.apply")
def test_apply(self, mock_apply, client):
mock_apply.return_value = None
resp = client.post("/api/wireguard/apply")
assert resp.status_code == 200
# ============================================================================
# Helpers
# ============================================================================
class TestResponseHelpers:
@patch("webui.api.firewall.get_active_zones")
@patch("webui.api.firewall.get_available_zones")
@@ -375,4 +574,4 @@ class TestResponseHelpers:
data = resp.get_json()
assert "error" in data
assert "ok" in data
assert data["ok"] is False
assert data["ok"] is False