05524f3756
Phase 1 (critical bugs): - Fix firewall import string-to-list bug (system_import.py) - Add rich rules removal in firewall config apply (handlers/firewall.py) Phase 2 (security hardening): - Restrict sudo wildcards to specific paths (sudoers.d/vacuum-walld) - Fix TOCTOU: use /run/vacuum-wall/ for temp files (nginx, dnsmasq, network handlers) - Remove unnecessary sudo from wg genkey/pubkey (handlers/wireguard.py) Phase 3 (validation): - Validate poll intervals > 0 (daemon/server.py) - Restrict sysctl to whitelisted parameters (handlers/network.py) Phase 4 (defensive programming): - Enforce shell=False in run() and run_proc() (lib/common.py) - Track issuance tasks for graceful shutdown (handlers/acme.py) - Add nginx template marker consistency tests (tests/test_system_import.py)
886 lines
28 KiB
Python
886 lines
28 KiB
Python
"""
|
|
API integration tests — all blueprints tested via a single Flask app fixture.
|
|
|
|
Mocks daemon.client in each blueprint's module namespace to avoid needing
|
|
a running daemon.
|
|
"""
|
|
|
|
from unittest.mock import patch as _patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _fw(func, **kw):
|
|
"""Patch daemon.client.{func} in the firewall blueprint namespace."""
|
|
return _patch(f"webui.api.firewall.{func}", **kw)
|
|
|
|
|
|
def _dh(func, **kw):
|
|
"""Patch daemon.client.{func} in the dhcp blueprint namespace."""
|
|
return _patch(f"webui.api.dhcp.{func}", **kw)
|
|
|
|
|
|
def _px(func, **kw):
|
|
"""Patch daemon.client.{func} in the proxy blueprint namespace."""
|
|
return _patch(f"webui.api.proxy.{func}", **kw)
|
|
|
|
|
|
def _ce(func, **kw):
|
|
"""Patch daemon.client.{func} in the certs blueprint namespace."""
|
|
return _patch(f"webui.api.certs.{func}", **kw)
|
|
|
|
|
|
def _wg(func, **kw):
|
|
"""Patch daemon.client.{func} in the wireguard blueprint namespace."""
|
|
return _patch(f"webui.api.wireguard.{func}", **kw)
|
|
|
|
|
|
def _ne(func, **kw):
|
|
"""Patch daemon.client.{func} in the network blueprint namespace."""
|
|
return _patch(f"webui.api.network.{func}", **kw)
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from flask import Flask
|
|
|
|
from webui.api.certs import bp as certs_bp
|
|
from webui.api.dhcp import bp as dhcp_bp
|
|
from webui.api.firewall import bp as firewall_bp
|
|
from webui.api.network import bp as network_bp
|
|
from webui.api.proxy import bp as proxy_bp
|
|
from webui.api.wireguard import bp as wg_bp
|
|
|
|
app = Flask(__name__)
|
|
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
|
app.register_blueprint(network_bp, url_prefix="/api/network")
|
|
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
|
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
|
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
|
app.register_blueprint(wg_bp, url_prefix="/api/wireguard")
|
|
return app.test_client()
|
|
|
|
|
|
# ============================================================================
|
|
# Firewall
|
|
# ============================================================================
|
|
|
|
|
|
class TestFirewallListZones:
|
|
@_fw("get")
|
|
def test_success(self, mock_get, client):
|
|
mock_get.return_value = {
|
|
"active": {"public": ["eth0"]},
|
|
"available": ["public", "internal"],
|
|
}
|
|
resp = client.get("/api/firewall/zones")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert "public" in data["data"]["active"]
|
|
|
|
@_fw("get")
|
|
def test_runtime_error(self, mock_get, client):
|
|
mock_get.side_effect = RuntimeError("no sudo")
|
|
resp = client.get("/api/firewall/zones")
|
|
assert resp.status_code == 500
|
|
data = resp.get_json()
|
|
assert data["ok"] is False
|
|
|
|
|
|
class TestFirewallZoneDetails:
|
|
@_fw("get")
|
|
def test_success(self, mock_get, client):
|
|
mock_get.return_value = {"name": "public", "services": ["ssh"]}
|
|
resp = client.get("/api/firewall/zones/public")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["data"]["name"] == "public"
|
|
|
|
|
|
class TestFirewallCreateZone:
|
|
@_fw("post")
|
|
@_fw("get")
|
|
def test_success(self, mock_get, mock_post, client):
|
|
mock_get.return_value = {"active": {}, "available": ["public", "internal"]}
|
|
mock_post.return_value = {"zone": "dmz"}
|
|
resp = client.post(
|
|
"/api/firewall/zones",
|
|
json={"name": "dmz", "target": "default"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
def test_missing_name(self, client):
|
|
resp = client.post(
|
|
"/api/firewall/zones",
|
|
json={"target": "default"},
|
|
)
|
|
assert resp.status_code == 400
|
|
data = resp.get_json()
|
|
assert data["ok"] is False
|
|
|
|
|
|
class TestFirewallDeleteZone:
|
|
@_fw("delete")
|
|
def test_success(self, mock_delete, client):
|
|
mock_delete.return_value = {"zone": "dmz"}
|
|
resp = client.delete("/api/firewall/zones/dmz")
|
|
assert resp.status_code == 200
|
|
|
|
@_fw("delete")
|
|
def test_not_found(self, mock_delete, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_delete.side_effect = NotFound("Zone does not exist")
|
|
resp = client.delete("/api/firewall/zones/dmz")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestFirewallRichRules:
|
|
@_fw("post")
|
|
def test_add(self, mock_post, client):
|
|
mock_post.return_value = {
|
|
"id": "abc123",
|
|
"rule": "rule accept",
|
|
"zone": "public",
|
|
}
|
|
resp = client.post(
|
|
"/api/firewall/rich-rules",
|
|
json={
|
|
"zone": "public",
|
|
"rule": 'rule family="ipv4" port protocol="tcp" port="443" accept',
|
|
},
|
|
)
|
|
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
|
|
|
|
@_fw("get")
|
|
def test_list(self, mock_get, client):
|
|
mock_get.return_value = [{"id": "a1", "rule": "rule1"}]
|
|
resp = client.get("/api/firewall/rich-rules/public")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert isinstance(data["data"], list)
|
|
|
|
@_fw("delete")
|
|
def test_remove_by_id(self, mock_delete, client):
|
|
mock_delete.return_value = {"zone": "public", "id": "abc123"}
|
|
resp = client.delete("/api/firewall/rich-rules/public/abc123")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
@_fw("delete")
|
|
def test_remove_not_found(self, mock_delete, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_delete.side_effect = NotFound("not found")
|
|
resp = client.delete("/api/firewall/rich-rules/public/abc123")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestFirewallServices:
|
|
@_fw("get")
|
|
def test_list(self, mock_get, client):
|
|
mock_get.return_value = ["ssh", "http", "dns"]
|
|
resp = client.get("/api/firewall/services")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["data"] == ["ssh", "http", "dns"]
|
|
|
|
|
|
class TestFirewallInterfaces:
|
|
@_fw("get")
|
|
def test_list(self, mock_get, client):
|
|
mock_get.return_value = [
|
|
{
|
|
"name": "eth0",
|
|
"mac": "aa:bb:cc:dd:ee:00",
|
|
"state": "UP",
|
|
"mtu": 1500,
|
|
"ips": ["192.168.1.1/24"],
|
|
"ipv6": [],
|
|
"zone": "internal",
|
|
},
|
|
{
|
|
"name": "eth1",
|
|
"mac": "aa:bb:cc:dd:ee:01",
|
|
"state": "UP",
|
|
"mtu": 1500,
|
|
"ips": ["10.0.0.1/24"],
|
|
"ipv6": [],
|
|
"zone": "public",
|
|
},
|
|
]
|
|
resp = client.get("/api/firewall/interfaces")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()["data"]
|
|
assert len(data) == 2
|
|
assert data[0]["name"] == "eth0"
|
|
|
|
|
|
class TestFirewallMasquerade:
|
|
@_fw("post")
|
|
def test_enable(self, mock_post, client):
|
|
mock_post.return_value = {"zone": "internal", "masquerade": True}
|
|
resp = client.post(
|
|
"/api/firewall/masquerade",
|
|
json={"zone": "internal", "enable": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_missing_fields(self, client):
|
|
resp = client.post("/api/firewall/masquerade", json={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestFirewallForwardPort:
|
|
@_fw("post")
|
|
def test_add(self, mock_post, client):
|
|
mock_post.return_value = {
|
|
"id": "fp1",
|
|
"port": 443,
|
|
"proto": "tcp",
|
|
"zone": "public",
|
|
}
|
|
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(
|
|
"/api/firewall/forward-port",
|
|
json={"zone": "public"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
@_fw("delete")
|
|
def test_remove_by_id(self, mock_delete, client):
|
|
mock_delete.return_value = {"zone": "public", "port": 443, "proto": "tcp"}
|
|
resp = client.delete("/api/firewall/forward-port/public/443/tcp")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
@_fw("delete")
|
|
def test_remove_not_found(self, mock_delete, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_delete.side_effect = NotFound("not found")
|
|
resp = client.delete("/api/firewall/forward-port/public/999/tcp")
|
|
assert resp.status_code == 404
|
|
|
|
def test_add_value_error(self, client):
|
|
resp = client.post(
|
|
"/api/firewall/forward-port",
|
|
json={"zone": "public", "port": "not_a_number", "proto": "tcp"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestFirewallConfigApply:
|
|
@_fw("post")
|
|
def test_config_apply_success(self, mock_post, client):
|
|
mock_post.return_value = {
|
|
"applied_zones": ["public"],
|
|
"backup": "/tmp/rules.json",
|
|
}
|
|
resp = client.post("/api/firewall/config/apply")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert "public" in data["data"]["applied_zones"]
|
|
|
|
@_fw("post")
|
|
def test_config_apply_error(self, mock_post, client):
|
|
mock_post.side_effect = RuntimeError("apply failed")
|
|
resp = client.post("/api/firewall/config/apply")
|
|
assert resp.status_code == 500
|
|
data = resp.get_json()
|
|
assert data["ok"] is False
|
|
assert data["error"] == "apply failed"
|
|
|
|
|
|
# ============================================================================
|
|
# DHCP
|
|
# ============================================================================
|
|
|
|
|
|
class TestDhcpConfig:
|
|
@_dh("get")
|
|
def test_get(self, mock_get, client):
|
|
mock_get.return_value = {"dhcp": {}, "dns": {}}
|
|
resp = client.get("/api/dhcp/config")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["ok"] is True
|
|
|
|
@_dh("post")
|
|
def test_post_invalid_body(self, mock_post, client):
|
|
resp = client.post(
|
|
"/api/dhcp/config", data="not json", content_type="text/plain"
|
|
)
|
|
data = resp.get_json()
|
|
assert data is not None
|
|
|
|
|
|
class TestDhcpApply:
|
|
@_dh("post")
|
|
def test_apply(self, mock_post, client):
|
|
mock_post.return_value = {"applied": True}
|
|
resp = client.post("/api/dhcp/apply")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
|
|
class TestDhcpStatus:
|
|
@_dh("get")
|
|
def test_success(self, mock_get, client):
|
|
mock_get.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:
|
|
@_dh("post")
|
|
def test_add_range(self, mock_post, client):
|
|
mock_post.return_value = {
|
|
"interface": "eth0",
|
|
"start": "192.168.1.100",
|
|
"end": "192.168.1.200",
|
|
}
|
|
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
|
|
|
|
@_dh("delete")
|
|
def test_remove_range(self, mock_delete, client):
|
|
mock_delete.return_value = {
|
|
"interface": "eth0",
|
|
"start": "192.168.1.100",
|
|
"end": "192.168.1.200",
|
|
}
|
|
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:
|
|
@_dh("post")
|
|
def test_add(self, mock_post, client):
|
|
mock_post.return_value = {"mac": "AA:BB:CC", "ip": "10.0.0.5"}
|
|
resp = client.post(
|
|
"/api/dhcp/static-lease",
|
|
json={"mac": "AA:BB:CC", "ip": "10.0.0.5"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_missing_mac(self, client):
|
|
resp = client.post("/api/dhcp/static-lease", json={"ip": "10.0.0.5"})
|
|
assert resp.status_code == 400
|
|
|
|
@_dh("delete")
|
|
def test_remove(self, mock_delete, client):
|
|
mock_delete.return_value = {"mac": "AA:BB:CC"}
|
|
resp = client.delete("/api/dhcp/static-lease/AA:BB:CC")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestDhcpDnsRecord:
|
|
@_dh("post")
|
|
def test_add(self, mock_post, client):
|
|
mock_post.return_value = {"name": "host.local", "address": "10.0.0.10"}
|
|
resp = client.post(
|
|
"/api/dhcp/dns-record",
|
|
json={"name": "host.local", "address": "10.0.0.10"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_missing_fields(self, client):
|
|
resp = client.post("/api/dhcp/dns-record", json={})
|
|
assert resp.status_code == 400
|
|
|
|
@_dh("delete")
|
|
def test_remove(self, mock_delete, client):
|
|
mock_delete.return_value = {"name": "host.local"}
|
|
resp = client.delete("/api/dhcp/dns-record/host.local")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# Proxy
|
|
# ============================================================================
|
|
|
|
|
|
class TestProxyDomains:
|
|
@_px("get")
|
|
def test_list(self, mock_get, client):
|
|
mock_get.return_value = []
|
|
resp = client.get("/api/proxy/domains")
|
|
assert resp.status_code == 200
|
|
|
|
@_px("post")
|
|
def test_add(self, mock_post, client):
|
|
mock_post.return_value = {"domain": "ex.com"}
|
|
resp = client.post(
|
|
"/api/proxy/domains",
|
|
json={"domain": "ex.com", "backend": "webui"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_add_missing_domain(self, client):
|
|
resp = client.post("/api/proxy/domains", json={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestProxyApply:
|
|
@_px("post")
|
|
def test_apply(self, mock_post, client):
|
|
mock_post.return_value = {"applied": True}
|
|
resp = client.post("/api/proxy/apply")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestProxyTest:
|
|
@_px("post")
|
|
def test_valid(self, mock_post, client):
|
|
mock_post.return_value = {"valid": True, "output": "syntax ok"}
|
|
resp = client.post("/api/proxy/test")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["data"]["valid"] is True
|
|
|
|
@_px("post")
|
|
def test_invalid(self, mock_post, client):
|
|
mock_post.return_value = {"valid": False, "output": "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:
|
|
@_ce("get")
|
|
def test_list(self, mock_get, client):
|
|
mock_get.return_value = []
|
|
resp = client.get("/api/certs/list")
|
|
assert resp.status_code == 200
|
|
|
|
@_ce("get")
|
|
def test_details_not_found(self, mock_get, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_get.side_effect = NotFound("not found")
|
|
resp = client.get("/api/certs/example.com")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestCertsIssue:
|
|
def test_missing_domain(self, client):
|
|
resp = client.post("/api/certs/issue/start", json={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestCertsEmail:
|
|
def test_missing_email(self, client):
|
|
resp = client.post("/api/certs/email", json={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# ============================================================================
|
|
# WireGuard
|
|
# ============================================================================
|
|
|
|
|
|
class TestWireguardConfig:
|
|
@_wg("get")
|
|
def test_get(self, mock_get, client):
|
|
mock_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {},
|
|
}
|
|
resp = client.get("/api/wireguard/config")
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert "private_key" not in data["data"]["interface"]
|
|
|
|
@_wg("post")
|
|
def test_post(self, mock_post, client):
|
|
mock_post.return_value = {"config_saved": True}
|
|
resp = client.post("/api/wireguard/config", json={"peers": {}})
|
|
assert resp.status_code == 200
|
|
|
|
@_wg("patch")
|
|
def test_patch(self, mock_patch, client):
|
|
mock_patch.return_value = {"config_saved": True}
|
|
resp = client.patch(
|
|
"/api/wireguard/config",
|
|
json={"interface": {"name": "wg0"}},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestWireguardPeers:
|
|
@_wg("get")
|
|
def test_list(self, mock_get, client):
|
|
mock_get.return_value = []
|
|
resp = client.get("/api/wireguard/peers")
|
|
assert resp.status_code == 200
|
|
|
|
@_wg("post")
|
|
def test_add(self, mock_post, client):
|
|
mock_post.return_value = {
|
|
"name": "client1",
|
|
"public_key": "pub",
|
|
}
|
|
resp = client.post(
|
|
"/api/wireguard/peers",
|
|
json={"name": "client1"},
|
|
)
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert "private_key" not in data["data"]
|
|
|
|
def test_add_missing_name(self, client):
|
|
resp = client.post("/api/wireguard/peers", json={})
|
|
assert resp.status_code == 400
|
|
|
|
@_wg("delete")
|
|
def test_remove_by_name(self, mock_delete, client):
|
|
mock_delete.return_value = {"name": "client1"}
|
|
resp = client.delete("/api/wireguard/peers/client1")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestWireguardInitialize:
|
|
@_wg("post")
|
|
def test_initialize(self, mock_post, client):
|
|
mock_post.return_value = {"initialized": True}
|
|
resp = client.post("/api/wireguard/initialize")
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
|
|
class TestWireguardGenerateClient:
|
|
def test_missing_name(self, client):
|
|
resp = client.post("/api/wireguard/generate-client", json={})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
class TestWireguardStatus:
|
|
@_wg("get")
|
|
def test_get(self, mock_get, client):
|
|
mock_get.return_value = {"up": True, "interface": {}, "peers": []}
|
|
resp = client.get("/api/wireguard/status")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestWireguardUp:
|
|
@_wg("post")
|
|
def test_up_starts_tunnel(self, mock_post, client):
|
|
mock_post.return_value = {"applied": True}
|
|
resp = client.post("/api/wireguard/up")
|
|
assert resp.status_code == 200
|
|
|
|
@_wg("post")
|
|
def test_up_error(self, mock_post, client):
|
|
mock_post.side_effect = RuntimeError("interface down")
|
|
resp = client.post("/api/wireguard/up")
|
|
assert resp.status_code == 500
|
|
|
|
|
|
class TestWireguardDown:
|
|
@_wg("post")
|
|
def test_down_stops_tunnel(self, mock_post, client):
|
|
mock_post.return_value = {"down": True}
|
|
resp = client.post("/api/wireguard/down")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestWireguardApply:
|
|
@_wg("post")
|
|
def test_apply(self, mock_post, client):
|
|
mock_post.return_value = {"applied": True}
|
|
resp = client.post("/api/wireguard/apply")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
|
|
class TestResponseHelpers:
|
|
@_fw("get")
|
|
def test_error_response_format(self, mock_get, client):
|
|
mock_get.side_effect = RuntimeError("fail")
|
|
resp = client.get("/api/firewall/zones")
|
|
data = resp.get_json()
|
|
assert "error" in data
|
|
assert "ok" in data
|
|
assert data["ok"] is False
|
|
|
|
|
|
# ============================================================================
|
|
# Firewall Config CRUD (daemon-backed)
|
|
# ============================================================================
|
|
|
|
|
|
class TestFirewallConfig:
|
|
@_fw("get")
|
|
def test_config_get(self, mock_get, client):
|
|
mock_get.return_value = {"zones": {"public": {"interfaces": ["eth0"]}}}
|
|
resp = client.get("/api/firewall/config")
|
|
assert resp.status_code == 200
|
|
|
|
@_fw("post")
|
|
@_fw("get")
|
|
def test_config_save(self, mock_get, mock_post, client):
|
|
mock_post.return_value = {"config_saved": True}
|
|
mock_get.return_value = {
|
|
"pending": [],
|
|
"needs_apply": False,
|
|
"unmanaged_zones": {},
|
|
}
|
|
resp = client.post(
|
|
"/api/firewall/config", json={"zones": {"public": {"interfaces": ["eth0"]}}}
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_config_save_missing_zones(self, client):
|
|
resp = client.post("/api/firewall/config", json={})
|
|
assert resp.status_code == 400
|
|
|
|
@_fw("patch")
|
|
@_fw("get")
|
|
def test_config_patch(self, mock_get, mock_patch, client):
|
|
mock_patch.return_value = {"config_saved": True}
|
|
mock_get.return_value = {
|
|
"pending": [],
|
|
"needs_apply": False,
|
|
"unmanaged_zones": {},
|
|
}
|
|
resp = client.patch("/api/firewall/config", json={"zones": {}})
|
|
assert resp.status_code == 200
|
|
|
|
@_fw("post")
|
|
def test_config_apply(self, mock_post, client):
|
|
mock_post.return_value = {
|
|
"applied_zones": ["public"],
|
|
"backup": "/tmp/rules.json",
|
|
}
|
|
resp = client.post("/api/firewall/config/apply")
|
|
assert resp.status_code == 200
|
|
|
|
@_fw("get")
|
|
def test_config_pending(self, mock_get, client):
|
|
mock_get.return_value = {"pending": [], "needs_apply": False}
|
|
resp = client.get("/api/firewall/config/pending")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# Proxy config
|
|
# ============================================================================
|
|
|
|
|
|
class TestProxyConfig:
|
|
@_px("get")
|
|
def test_get(self, mock_get, client):
|
|
mock_get.return_value = {"domains": {}, "ssl": {}}
|
|
resp = client.get("/api/proxy/config")
|
|
assert resp.status_code == 200
|
|
|
|
@_px("post")
|
|
def test_post(self, mock_post, client):
|
|
mock_post.return_value = {"config_saved": True}
|
|
resp = client.post("/api/proxy/config", json={"domains": {}})
|
|
assert resp.status_code == 200
|
|
|
|
@_px("patch")
|
|
def test_patch(self, mock_patch, client):
|
|
mock_patch.return_value = {"config_saved": True}
|
|
resp = client.patch("/api/proxy/config", json={})
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# DHCP config
|
|
# ============================================================================
|
|
|
|
|
|
class TestDhcpConfigCrud:
|
|
@_dh("patch")
|
|
def test_patch(self, mock_patch, client):
|
|
mock_patch.return_value = {"config_saved": True}
|
|
resp = client.patch("/api/dhcp/config", json={"dhcp": {}})
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# Proxy management
|
|
# ============================================================================
|
|
|
|
|
|
# ============================================================================
|
|
# Proxy domain update
|
|
# ============================================================================
|
|
|
|
|
|
class TestProxyDomainUpdate:
|
|
@_px("post")
|
|
def test_update(self, mock_post, client):
|
|
mock_post.return_value = {"domain": "ex.com"}
|
|
resp = client.put(
|
|
"/api/proxy/domains/ex.com",
|
|
json={"backend_host": "10.0.0.2"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ============================================================================
|
|
# Network
|
|
# ============================================================================
|
|
|
|
|
|
class TestNetworkListInterfaces:
|
|
@_ne("get")
|
|
def test_success(self, mock_get, client):
|
|
mock_get.return_value = [
|
|
{"name": "eth0", "config": {"addresses": ["10.0.0.1/24"]}}
|
|
]
|
|
resp = client.get("/api/network/interfaces")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
@_ne("get")
|
|
def test_runtime_error(self, mock_get, client):
|
|
mock_get.side_effect = RuntimeError("networkctl not found")
|
|
resp = client.get("/api/network/interfaces")
|
|
assert resp.status_code == 500
|
|
assert resp.get_json()["ok"] is False
|
|
|
|
|
|
class TestNetworkGetInterface:
|
|
@_ne("get")
|
|
def test_success(self, mock_get, client):
|
|
mock_get.return_value = {
|
|
"name": "eth0",
|
|
"config": {"addresses": ["10.0.0.1/24"]},
|
|
}
|
|
resp = client.get("/api/network/interfaces/eth0")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
@_ne("get")
|
|
def test_not_found(self, mock_get, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_get.side_effect = NotFound("interface not found")
|
|
resp = client.get("/api/network/interfaces/nonexist")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestNetworkSaveInterface:
|
|
@_ne("post")
|
|
def test_success(self, mock_post, client):
|
|
mock_post.return_value = {"name": "eth0", "applied": True}
|
|
resp = client.post(
|
|
"/api/network/interfaces/eth0",
|
|
json={
|
|
"addresses": ["10.0.0.1/24"],
|
|
"gateway": "10.0.0.254",
|
|
"dns": ["8.8.8.8"],
|
|
"routes": [],
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
|
|
@_ne("post")
|
|
def test_not_found(self, mock_post, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_post.side_effect = NotFound("interface not found")
|
|
resp = client.post("/api/network/interfaces/missing", json={})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestNetworkReloadInterface:
|
|
@_ne("post")
|
|
def test_success(self, mock_post, client):
|
|
mock_post.return_value = {"name": "eth0", "reloaded": True}
|
|
resp = client.post("/api/network/interfaces/eth0/reload")
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["ok"] is True
|
|
|
|
@_ne("post")
|
|
def test_runtime_error(self, mock_post, client):
|
|
mock_post.side_effect = RuntimeError("reload failed")
|
|
resp = client.post("/api/network/interfaces/eth0/reload")
|
|
assert resp.status_code == 500
|
|
|
|
|
|
class TestNetworkApplyAll:
|
|
@_ne("post")
|
|
def test_success(self, mock_post, client):
|
|
mock_post.return_value = {"applied": 2, "interfaces": ["eth0", "eth1"]}
|
|
resp = client.post("/api/network/apply")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert data["data"]["applied"] == 2
|
|
|
|
@_ne("post")
|
|
def test_runtime_error(self, mock_post, client):
|
|
mock_post.side_effect = RuntimeError("apply failed")
|
|
resp = client.post("/api/network/apply")
|
|
assert resp.status_code == 500
|