94705490b9
- POST /acme/renew returns a request_id and spawns a background task (renew/deploy/refresh steps); dedups per-domain like issue - completes as "skipped" when acme.sh reports the renewal window has not been reached (no --force) - new GET /acme/renew/status endpoint (iface + handler + blueprint) - run acme.sh subprocesses off the event loop (asyncio.to_thread) in both issuance and renewal - ActionCell: busy/busyLabel props; certs page disables the Renew button and polls renewal status with toasts for success/skip/fail
1001 lines
32 KiB
Python
1001 lines
32 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)
|
|
|
|
|
|
def _st(func, **kw):
|
|
"""Patch daemon.client.{func} in the status blueprint namespace."""
|
|
return _patch(f"webui.api.status.{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 TestCertsRenew:
|
|
@_ce("post")
|
|
def test_start_renew(self, mock_post, client):
|
|
mock_post.return_value = {"request_id": "abc123", "domain": "example.com"}
|
|
resp = client.post("/api/certs/example.com/renew")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert data["data"]["request_id"] == "abc123"
|
|
args = mock_post.call_args.args
|
|
assert args[0] == ("POST", "/acme/renew")
|
|
assert args[1] == {"domain": "example.com"}
|
|
|
|
@_ce("post")
|
|
def test_start_renew_rejected(self, mock_post, client):
|
|
from daemon.client import BadRequest
|
|
|
|
mock_post.side_effect = BadRequest("'domain' is required")
|
|
resp = client.post("/api/certs/example.com/renew")
|
|
assert resp.status_code == 400
|
|
assert resp.get_json()["ok"] is False
|
|
|
|
@_ce("post")
|
|
def test_start_renew_runtime_error(self, mock_post, client):
|
|
mock_post.side_effect = RuntimeError("daemon unreachable")
|
|
resp = client.post("/api/certs/example.com/renew")
|
|
assert resp.status_code == 500
|
|
assert resp.get_json()["ok"] is False
|
|
|
|
|
|
class TestCertsRenewStatus:
|
|
@_ce("get")
|
|
def test_success(self, mock_get, client):
|
|
mock_get.return_value = {
|
|
"request_id": "abc123",
|
|
"domain": "example.com",
|
|
"status": "completed",
|
|
"steps": [],
|
|
}
|
|
resp = client.get("/api/certs/renew/abc123")
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert data["data"]["status"] == "completed"
|
|
args = mock_get.call_args.args
|
|
assert args[0] == ("GET", "/acme/renew/status")
|
|
assert args[1] == {"id": "abc123"}
|
|
|
|
@_ce("get")
|
|
def test_not_found(self, mock_get, client):
|
|
from daemon.client import NotFound
|
|
|
|
mock_get.side_effect = NotFound("renewal request not found")
|
|
resp = client.get("/api/certs/renew/unknown")
|
|
assert resp.status_code == 404
|
|
assert resp.get_json()["ok"] is False
|
|
|
|
|
|
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
|
|
|
|
|
|
# ============================================================================
|
|
# Status
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def status_client():
|
|
from flask import Flask
|
|
|
|
from webui.api.status import bp as status_bp
|
|
|
|
app = Flask(__name__)
|
|
app.register_blueprint(status_bp, url_prefix="/api/status")
|
|
return app.test_client()
|
|
|
|
|
|
class TestStatusRefresh:
|
|
def test_filtered_subsystems_passed_through(self, status_client):
|
|
"""The subsystem body is forwarded to the daemon POST endpoint."""
|
|
from daemon.iface import POST_STATUS_REFRESH
|
|
|
|
with _st("post") as mock_post:
|
|
mock_post.return_value = {"firewall": {"zones": {}}}
|
|
resp = status_client.post(
|
|
"/api/status/refresh", json={"subsystems": ["firewall"]}
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["ok"] is True
|
|
assert data["data"] == {"firewall": {"zones": {}}}
|
|
mock_post.assert_called_once_with(
|
|
POST_STATUS_REFRESH, {"subsystems": ["firewall"]}
|
|
)
|
|
|
|
def test_empty_body_forwards_empty_dict(self, status_client):
|
|
"""An empty body becomes {} (daemon-side 'all subsystems' default)."""
|
|
from daemon.iface import POST_STATUS_REFRESH
|
|
|
|
with _st("post") as mock_post:
|
|
mock_post.return_value = {}
|
|
resp = status_client.post("/api/status/refresh")
|
|
assert resp.status_code == 200
|
|
mock_post.assert_called_once_with(POST_STATUS_REFRESH, {})
|
|
|
|
@_st("post")
|
|
def test_runtime_error(self, mock_post, status_client):
|
|
mock_post.side_effect = RuntimeError("no daemon")
|
|
resp = status_client.post("/api/status/refresh", json={})
|
|
assert resp.status_code == 500
|
|
assert resp.get_json()["ok"] is False
|