Initial commit: SSL proxy / firewall appliance
Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP, WireGuard, and ACME certificate management.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
@@ -0,0 +1,130 @@
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import acme
|
||||
|
||||
|
||||
class TestFindAcme:
|
||||
@patch("lib.acme.shutil.which")
|
||||
@patch("lib.acme.Path.home")
|
||||
def test_finds_in_home(self, mock_home, mock_which):
|
||||
mock_home.return_value = Path("/tmp/fakehome")
|
||||
acme_path = mock_home.return_value / ".acme.sh" / "acme.sh"
|
||||
acme_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
acme_path.write_text("#!/bin/sh\n")
|
||||
acme_path.chmod(0o755)
|
||||
try:
|
||||
result = acme._find_acme()
|
||||
assert "acme.sh" in result
|
||||
finally:
|
||||
acme_path.unlink()
|
||||
|
||||
@patch("lib.acme.shutil.which")
|
||||
@patch("lib.acme.Path.home")
|
||||
def test_raises_when_not_found(self, mock_home, mock_which):
|
||||
mock_home.return_value = Path("/tmp/nonexistent-acme-dir")
|
||||
mock_which.return_value = None
|
||||
with pytest.raises(FileNotFoundError):
|
||||
acme._find_acme()
|
||||
|
||||
|
||||
class TestRunAcme:
|
||||
@patch("lib.acme._find_acme")
|
||||
@patch("lib.acme.subprocess.run")
|
||||
def test_success(self, mock_run, mock_find):
|
||||
mock_find.return_value = "/usr/local/bin/acme.sh"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="success\n", stderr="")
|
||||
result = acme._run_acme(["--list"])
|
||||
assert result == "success\n"
|
||||
|
||||
@patch("lib.acme._find_acme")
|
||||
@patch("lib.acme.subprocess.run")
|
||||
def test_failure(self, mock_run, mock_find):
|
||||
mock_find.return_value = "/usr/local/bin/acme.sh"
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error\n")
|
||||
with pytest.raises(RuntimeError):
|
||||
acme._run_acme(["--list"])
|
||||
|
||||
|
||||
class TestParseListOutput:
|
||||
def test_parses_single_entry(self):
|
||||
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 1
|
||||
assert result[0]["main_domain"] == "example.com"
|
||||
assert result[0]["ca"] == "LetsEncrypt"
|
||||
|
||||
def test_parses_multiple_entries(self):
|
||||
raw = (
|
||||
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
|
||||
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
|
||||
)
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_empty_input(self):
|
||||
result = acme._parse_list_output("")
|
||||
assert result == []
|
||||
|
||||
def test_skips_lines_without_colons(self):
|
||||
raw = "some random line\nMain_Domain:a.com"
|
||||
result = acme._parse_list_output(raw)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestDaysUntil:
|
||||
def test_future_date(self):
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
future = datetime.now(UTC) + timedelta(days=365)
|
||||
result = acme._days_until(future.strftime("%Y-%m-%d"))
|
||||
assert result >= 364
|
||||
|
||||
def test_empty_string(self):
|
||||
assert acme._days_until("") is None
|
||||
assert acme._days_until(None) is None
|
||||
|
||||
def test_invalid_format(self):
|
||||
assert acme._days_until("not-a-date") is None
|
||||
|
||||
def test_expired_date(self):
|
||||
result = acme._days_until("2020-01-01")
|
||||
assert result is not None
|
||||
assert result < 0
|
||||
|
||||
|
||||
class TestGetEmail:
|
||||
def test_returns_empty_when_no_account_conf(self):
|
||||
with patch("lib.acme.Path.home") as mock_home:
|
||||
mock_home.return_value = Path("/tmp/no-acme-email")
|
||||
result = acme.get_email()
|
||||
assert result == ""
|
||||
|
||||
def test_parses_email_from_account_conf(self):
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
acme_dir = Path(tmpdir) / ".acme.sh"
|
||||
acme_dir.mkdir(exist_ok=True)
|
||||
conf = acme_dir / "account.conf"
|
||||
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
|
||||
|
||||
with patch("lib.acme.Path.home", return_value=Path(tmpdir)):
|
||||
result = acme.get_email()
|
||||
assert result == "test@example.com"
|
||||
|
||||
|
||||
class TestGetCertPaths:
|
||||
@patch("lib.acme.Path.home")
|
||||
def test_returns_paths(self, mock_home):
|
||||
mock_home.return_value = Path("/home/user")
|
||||
paths = acme.get_cert_paths("example.com")
|
||||
assert paths["cert"].endswith("example.com/example.com.cert")
|
||||
assert paths["key"].endswith("example.com/example.com.key")
|
||||
assert paths["ca"].endswith("example.com/ca.cer")
|
||||
assert paths["fullchain"].endswith("example.com/fullchain.cer")
|
||||
@@ -0,0 +1,369 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp
|
||||
from webui.api.proxy import bp as proxy_bp
|
||||
from webui.api.wireguard import bp as wg_bp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
app.register_blueprint(bp, url_prefix="/api/firewall")
|
||||
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()
|
||||
|
||||
|
||||
class TestFirewallListZones:
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_success(self, mock_available, mock_active, client):
|
||||
mock_active.return_value = {"public": ["eth0"]}
|
||||
mock_available.return_value = ["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"]
|
||||
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
def test_runtime_error(self, mock_active, client):
|
||||
mock_active.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:
|
||||
@patch("webui.api.firewall.get_zone_info")
|
||||
def test_success(self, mock_info, client):
|
||||
mock_info.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:
|
||||
@patch("webui.api.firewall.create_zone")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_success(self, mock_zones, mock_create, client):
|
||||
mock_zones.return_value = ["public", "internal"]
|
||||
mock_create.return_value = None
|
||||
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:
|
||||
@patch("webui.api.firewall.delete_zone")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_success(self, mock_zones, mock_delete, client):
|
||||
mock_zones.return_value = ["public", "dmz"]
|
||||
mock_delete.return_value = None
|
||||
resp = client.delete("/api/firewall/zones/dmz")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_not_found(self, mock_zones, client):
|
||||
mock_zones.return_value = ["public"]
|
||||
resp = client.delete("/api/firewall/zones/dmz")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestFirewallRichRules:
|
||||
@patch("webui.api.firewall.add_rich_rule")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
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
|
||||
|
||||
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"]
|
||||
resp = client.get("/api/firewall/rich-rules/public")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["data"] == ["rule1", "rule2"]
|
||||
|
||||
|
||||
class TestFirewallServices:
|
||||
@patch("webui.api.firewall.get_services")
|
||||
def test_list(self, mock_services, client):
|
||||
mock_services.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:
|
||||
@patch("webui.api.firewall.get_interfaces")
|
||||
def test_list(self, mock_ifaces, client):
|
||||
mock_ifaces.return_value = ["eth0", "eth1"]
|
||||
resp = client.get("/api/firewall/interfaces")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["data"] == ["eth0", "eth1"]
|
||||
|
||||
|
||||
class TestFirewallMasquerade:
|
||||
@patch("webui.api.firewall.set_masquerade")
|
||||
def test_enable(self, mock_set, client):
|
||||
mock_set.return_value = None
|
||||
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:
|
||||
@patch("webui.api.firewall.add_forward_port")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/firewall/forward-port",
|
||||
json={"zone": "public", "port": 443, "proto": "tcp"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_fields(self, client):
|
||||
resp = client.post(
|
||||
"/api/firewall/forward-port",
|
||||
json={"zone": "public"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestDhcpConfig:
|
||||
@patch("webui.api.dhcp.get_config")
|
||||
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
|
||||
|
||||
def test_post_invalid_body(self, client):
|
||||
resp = client.post(
|
||||
"/api/dhcp/config", data="not json", content_type="text/plain"
|
||||
)
|
||||
data = resp.get_json()
|
||||
assert data is not None
|
||||
|
||||
|
||||
class TestDhcpStaticLease:
|
||||
@patch("webui.api.dhcp.add_static_lease")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
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
|
||||
|
||||
@patch("webui.api.dhcp.remove_static_lease")
|
||||
def test_remove(self, mock_remove, client):
|
||||
mock_remove.return_value = None
|
||||
resp = client.delete("/api/dhcp/static-lease?mac=AA:BB:CC")
|
||||
assert resp.status_code == 200
|
||||
|
||||
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")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
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
|
||||
|
||||
|
||||
class TestProxyDomains:
|
||||
@patch("webui.api.proxy.get_domains")
|
||||
def test_list(self, mock_get, client):
|
||||
mock_get.return_value = []
|
||||
resp = client.get("/api/proxy/domains")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.proxy.add_domain")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = None
|
||||
resp = client.post(
|
||||
"/api/proxy/domains",
|
||||
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
|
||||
)
|
||||
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:
|
||||
@patch("webui.api.proxy.apply")
|
||||
def test_apply(self, mock_apply, client):
|
||||
mock_apply.return_value = None
|
||||
resp = client.post("/api/proxy/apply")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestCertsList:
|
||||
@patch("webui.api.certs.list_certs")
|
||||
def test_list(self, mock_list, client):
|
||||
mock_list.return_value = []
|
||||
resp = client.get("/api/certs/list")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.certs.get_cert_info")
|
||||
def test_details_not_found(self, mock_info, client):
|
||||
mock_info.side_effect = ValueError("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", 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
|
||||
|
||||
|
||||
class TestWireguardConfig:
|
||||
@patch("webui.api.wireguard.get_config")
|
||||
def test_get(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"interface": {"name": "wg0", "private_key": "secret"},
|
||||
"peers": {},
|
||||
}
|
||||
resp = client.get("/api/wireguard/config")
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "private_key" not in data["data"]["interface"]
|
||||
|
||||
@patch("webui.api.wireguard.save_config")
|
||||
def test_post(self, mock_save, client):
|
||||
mock_save.return_value = None
|
||||
resp = client.post("/api/wireguard/config", json={"peers": {}})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestWireguardPeers:
|
||||
@patch("webui.api.wireguard.get_peers")
|
||||
def test_list(self, mock_get, client):
|
||||
mock_get.return_value = []
|
||||
resp = client.get("/api/wireguard/peers")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@patch("webui.api.wireguard.add_peer")
|
||||
def test_add(self, mock_add, client):
|
||||
mock_add.return_value = {"public_key": "pub", "private_key": "priv"}
|
||||
resp = client.post(
|
||||
"/api/wireguard/add-peer",
|
||||
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/add-peer", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
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": {},
|
||||
}
|
||||
resp = client.post("/api/wireguard/initialize")
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert "private_key" not in data["data"]["interface"]
|
||||
|
||||
|
||||
class TestWireguardGenerateClient:
|
||||
def test_missing_name(self, client):
|
||||
resp = client.post("/api/wireguard/generate-client", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestWireguardStatus:
|
||||
@patch("webui.api.wireguard.status")
|
||||
def test_get(self, mock_status, client):
|
||||
mock_status.return_value = {"up": True, "interface": {}, "peers": []}
|
||||
resp = client.get("/api/wireguard/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestResponseHelpers:
|
||||
@patch("webui.api.firewall.get_active_zones")
|
||||
@patch("webui.api.firewall.get_available_zones")
|
||||
def test_error_response_format(self, mock_a, mock_b, client):
|
||||
mock_a.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
|
||||
@@ -0,0 +1,166 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import dnsmasq
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir(tmp_path):
|
||||
original = dnsmasq.DATA_DIR
|
||||
original_config = dnsmasq.CONFIG_PATH
|
||||
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
|
||||
dnsmasq.CONFIG_PATH = dnsmasq.DATA_DIR / "config.json"
|
||||
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
|
||||
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
yield tmp_path
|
||||
dnsmasq.DATA_DIR = original
|
||||
dnsmasq.CONFIG_PATH = original_config
|
||||
|
||||
|
||||
class TestDeepMerge:
|
||||
def test_merge_flat_dicts(self):
|
||||
base = {"a": 1, "b": 2}
|
||||
override = {"b": 3, "c": 4}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
assert result == {"a": 1, "b": 3, "c": 4}
|
||||
|
||||
def test_merge_nested_dicts(self):
|
||||
base = {"a": {"x": 1, "y": 2}}
|
||||
override = {"a": {"y": 3, "z": 4}}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
assert result == {"a": {"x": 1, "y": 3, "z": 4}}
|
||||
|
||||
def test_merge_non_dict_override(self):
|
||||
base = {"a": {"x": 1}}
|
||||
override = {"a": "flat"}
|
||||
result = dnsmasq._deep_merge(base, override)
|
||||
assert result == {"a": "flat"}
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
@patch("lib.dnsmasq._load_json")
|
||||
def test_returns_default_when_no_config(self, mock_load, temp_data_dir):
|
||||
mock_load.return_value = {}
|
||||
result = dnsmasq.get_config()
|
||||
assert "dhcp" in result
|
||||
assert "dns" in result
|
||||
assert result["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
|
||||
|
||||
@patch("lib.dnsmasq._load_json")
|
||||
def test_merges_with_existing_config(self, mock_load, temp_data_dir):
|
||||
mock_load.return_value = {"dns": {"upstreams": ["9.9.9.9"]}}
|
||||
result = dnsmasq.get_config()
|
||||
assert result["dns"]["upstreams"] == ["9.9.9.9"]
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_saves_and_reloads(self, temp_data_dir):
|
||||
cfg = {"dns": {"upstreams": ["1.2.3.4"], "domain": "test.lan"}}
|
||||
dnsmasq.save_config(cfg)
|
||||
loaded = dnsmasq.get_config()
|
||||
assert loaded["dns"]["upstreams"] == ["1.2.3.4"]
|
||||
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"])
|
||||
cfg = dnsmasq.get_config()
|
||||
assert cfg["dns"]["upstreams"] == ["1.1.1.1", "9.9.9.9"]
|
||||
|
||||
def test_set_domain(self, temp_data_dir):
|
||||
dnsmasq.set_domain("internal.lan")
|
||||
cfg = dnsmasq.get_config()
|
||||
assert cfg["dns"]["domain"] == "internal.lan"
|
||||
|
||||
def test_clear_domain(self, temp_data_dir):
|
||||
dnsmasq.set_domain("internal.lan")
|
||||
dnsmasq.set_domain(None)
|
||||
cfg = dnsmasq.get_config()
|
||||
assert cfg["dns"]["domain"] is None
|
||||
@@ -0,0 +1,156 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib import firewall
|
||||
|
||||
|
||||
class TestParseForwardPorts:
|
||||
def test_single_entry(self):
|
||||
result = firewall._parse_forward_ports("port=443/proto=tcp")
|
||||
assert result == ["port=443/proto=tcp"]
|
||||
|
||||
def test_multiple_entries(self):
|
||||
result = firewall._parse_forward_ports(
|
||||
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "port=443/proto=tcp"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert firewall._parse_forward_ports("") == []
|
||||
|
||||
|
||||
class TestGetActiveZones:
|
||||
@patch("lib.firewall._run")
|
||||
def test_parses_active_zones(self, mock_run):
|
||||
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {
|
||||
"public": ["eth0"],
|
||||
"internal": ["eth1", "eth2"],
|
||||
}
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_empty_output(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {}
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_zone_with_no_interfaces(self, mock_run):
|
||||
mock_run.return_value = "dmz"
|
||||
result = firewall.get_active_zones()
|
||||
assert result == {"dmz": []}
|
||||
|
||||
|
||||
class TestGetZoneInfo:
|
||||
@patch("lib.firewall._run")
|
||||
def test_parses_zone_info(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
"target: default\n"
|
||||
"interfaces: eth0\n"
|
||||
"sources: \n"
|
||||
"services: ssh dhcp\n"
|
||||
"ports: 8080/tcp\n"
|
||||
"protocols: \n"
|
||||
"forward-ports: \n"
|
||||
"masquerade: yes\n"
|
||||
"ics: no\n"
|
||||
"rich-rules: \n"
|
||||
"icmp-blocks: \n"
|
||||
"module: \n"
|
||||
)
|
||||
result = firewall.get_zone_info("public")
|
||||
assert result["name"] == "public"
|
||||
assert result["services"] == ["ssh", "dhcp"]
|
||||
assert result["ports"] == ["8080/tcp"]
|
||||
assert result["masquerade"] is True
|
||||
assert result["interfaces"] == ["eth0"]
|
||||
assert result["sources"] == []
|
||||
assert result["rich-rules"] == []
|
||||
|
||||
|
||||
class TestGetInterfaces:
|
||||
@patch("lib.firewall._run")
|
||||
def test_parses_interfaces(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
|
||||
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
|
||||
)
|
||||
result = firewall.get_interfaces()
|
||||
assert result == ["lo", "eth0", "eth1"]
|
||||
|
||||
|
||||
class TestGetRichRules:
|
||||
@patch("lib.firewall._run")
|
||||
def test_single_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4" port protocol="tcp" port="443" accept;'
|
||||
)
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_empty_rules(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert result == []
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_multiline_rule(self, mock_run):
|
||||
mock_run.return_value = (
|
||||
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
|
||||
)
|
||||
result = firewall.get_rich_rules("public")
|
||||
assert len(result) == 1
|
||||
assert "10.0.0.0/24" in result[0]
|
||||
|
||||
|
||||
class TestNowIso:
|
||||
def test_returns_iso_string(self):
|
||||
result = firewall._now_iso()
|
||||
datetime.fromisoformat(result)
|
||||
assert "+" in result
|
||||
|
||||
|
||||
class TestAddForwardPort:
|
||||
@patch("lib.firewall._run")
|
||||
def test_forward_port_basic(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
|
||||
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||
assert any("--add-forward-port=" in str(c) for c in calls)
|
||||
|
||||
@patch("lib.firewall._run")
|
||||
def test_forward_port_port_only(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
firewall.add_forward_port("public", 80, "tcp", toport=8080)
|
||||
|
||||
|
||||
class TestGetState:
|
||||
@patch("lib.firewall.get_available_zones")
|
||||
@patch("lib.firewall.get_zone_info")
|
||||
@patch("lib.firewall.get_active_zones")
|
||||
@patch("lib.firewall.get_interfaces")
|
||||
@patch("lib.firewall.get_services")
|
||||
@patch("lib.firewall.get_rich_rules")
|
||||
def test_returns_full_state(
|
||||
self,
|
||||
mock_rich,
|
||||
mock_services,
|
||||
mock_ifaces,
|
||||
mock_active,
|
||||
mock_zone_info,
|
||||
mock_available,
|
||||
):
|
||||
mock_available.return_value = ["public", "internal"]
|
||||
mock_active.return_value = {"public": ["eth0"]}
|
||||
mock_ifaces.return_value = ["eth0", "eth1"]
|
||||
mock_services.return_value = ["ssh", "http"]
|
||||
mock_zone_info.return_value = {"name": "public", "services": []}
|
||||
mock_rich.return_value = []
|
||||
result = firewall.get_state()
|
||||
assert "zones" in result
|
||||
assert "active_zones" in result
|
||||
assert "timestamp" in result
|
||||
@@ -0,0 +1,246 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import nginx
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_default_config():
|
||||
"""Reset the shared mutable DEFAULT_CONFIG before each test."""
|
||||
original = nginx.DEFAULT_CONFIG.copy()
|
||||
yield
|
||||
# Reset the shared "domains" dict that leaks due to shallow copy in _json_load
|
||||
nginx.DEFAULT_CONFIG = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_dir(tmp_path):
|
||||
original_config = nginx.CONFIG_FILE
|
||||
original_sites = nginx.SITES_DIR
|
||||
original_htpasswd = nginx.HTPASSWD_FILE
|
||||
original_ssl_snippet = nginx.SSL_SNIPPET
|
||||
original_include = nginx.INCLUDE_FILE
|
||||
|
||||
nginx.DATA_DIR = tmp_path / "nginx"
|
||||
nginx.SITES_DIR = tmp_path / "nginx" / "sites-enabled"
|
||||
nginx.CONFIG_FILE = tmp_path / "nginx" / "config.json"
|
||||
nginx.HTPASSWD_FILE = tmp_path / "nginx" / ".htpasswd"
|
||||
nginx.SSL_SNIPPET = tmp_path / "ssl_snippet.conf"
|
||||
nginx.INCLUDE_FILE = tmp_path / "include.conf"
|
||||
|
||||
nginx.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
nginx.SITES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yield tmp_path
|
||||
|
||||
nginx.CONFIG_FILE = original_config
|
||||
nginx.SITES_DIR = original_sites
|
||||
nginx.HTPASSWD_FILE = original_htpasswd
|
||||
nginx.SSL_SNIPPET = original_ssl_snippet
|
||||
nginx.INCLUDE_FILE = original_include
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
def test_returns_default_when_no_file(self, temp_data_dir):
|
||||
cfg = nginx.get_config()
|
||||
assert "domains" in cfg
|
||||
assert "ssl" in cfg
|
||||
assert cfg["domains"] == {}
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_saves_and_reloads(self, temp_data_dir):
|
||||
cfg = {
|
||||
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
loaded = nginx.get_config()
|
||||
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost"
|
||||
|
||||
|
||||
class TestGetDomains:
|
||||
def test_empty_domains(self, temp_data_dir):
|
||||
result = nginx.get_domains()
|
||||
assert result == []
|
||||
|
||||
def test_returns_domain_list(self, temp_data_dir):
|
||||
cfg = {
|
||||
"domains": {
|
||||
"example.com": {
|
||||
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
nginx.save_config(cfg)
|
||||
result = nginx.get_domains()
|
||||
assert len(result) == 1
|
||||
assert result[0]["domain"] == "example.com"
|
||||
|
||||
|
||||
class TestAddDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_add_domain(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||
cfg = nginx.get_config()
|
||||
assert "example.com" in cfg["domains"]
|
||||
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5"
|
||||
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080
|
||||
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
|
||||
},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||
|
||||
|
||||
class TestRemoveDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_remove_existing_domain(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
|
||||
},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.remove_domain("example.com")
|
||||
cfg = nginx.get_config()
|
||||
assert "example.com" not in cfg["domains"]
|
||||
|
||||
def test_remove_nonexistent_domain(self, temp_data_dir):
|
||||
nginx.remove_domain("nonexistent.com")
|
||||
cfg = nginx.get_config()
|
||||
assert "nonexistent.com" not in cfg["domains"]
|
||||
|
||||
|
||||
class TestUpdateDomain:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_update_existing_domain(self, mock_get, temp_data_dir):
|
||||
entry = {
|
||||
"backend": {"host": "10.0.0.5", "port": 8080, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
}
|
||||
mock_get.return_value = {
|
||||
"domains": {"example.com": entry},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.update_domain("example.com", force_ssl=False)
|
||||
cfg = nginx.get_config()
|
||||
assert cfg["domains"]["example.com"]["force_ssl"] is False
|
||||
|
||||
def test_update_nonexistent_raises(self, temp_data_dir):
|
||||
with pytest.raises(KeyError):
|
||||
nginx.update_domain("nonexistent.com", force_ssl=False)
|
||||
|
||||
|
||||
class TestWriteSite:
|
||||
def test_write_creates_file(self, temp_data_dir):
|
||||
nginx.write_site("example.com", "server { listen 443; }")
|
||||
path = nginx.SITES_DIR / "example.com.conf"
|
||||
assert path.exists()
|
||||
content = Path(path).read_text()
|
||||
assert "server { listen 443; }" in content
|
||||
|
||||
|
||||
class TestWriteAllSites:
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_writes_all_domains(self, mock_get, temp_data_dir):
|
||||
mock_get.return_value = {
|
||||
"domains": {
|
||||
"a.com": {
|
||||
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
},
|
||||
"b.com": {
|
||||
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"},
|
||||
"force_ssl": True,
|
||||
},
|
||||
},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.write_all_sites()
|
||||
assert (nginx.SITES_DIR / "a.com.conf").exists()
|
||||
assert (nginx.SITES_DIR / "b.com.conf").exists()
|
||||
|
||||
@patch("lib.nginx.get_config")
|
||||
def test_removes_old_sites(self, mock_get, temp_data_dir):
|
||||
# Pre-create an old site
|
||||
nginx.write_site("old.com", "server {}")
|
||||
assert (nginx.SITES_DIR / "old.com.conf").exists()
|
||||
|
||||
mock_get.return_value = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||
}
|
||||
nginx.write_all_sites()
|
||||
assert not (nginx.SITES_DIR / "old.com.conf").exists()
|
||||
|
||||
|
||||
class TestTestConfig:
|
||||
@patch("lib.nginx._run")
|
||||
def test_passes(self, mock_run, temp_data_dir):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout="", stderr="test passed\n"
|
||||
)
|
||||
ok, _msg = nginx.test_config()
|
||||
assert ok is True
|
||||
|
||||
@patch("lib.nginx._run")
|
||||
def test_fails(self, mock_run, temp_data_dir):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="nginx: configuration test failed\n"
|
||||
)
|
||||
ok, _msg = nginx.test_config()
|
||||
assert ok is False
|
||||
|
||||
|
||||
class TestWriteHtpasswd:
|
||||
@patch("lib.nginx._hash_password")
|
||||
def test_creates_file(self, mock_hash, temp_data_dir):
|
||||
mock_hash.return_value = "$apr1$hash"
|
||||
nginx.write_htpasswd("admin", "secret")
|
||||
assert nginx.HTPASSWD_FILE.exists()
|
||||
content = nginx.HTPASSWD_FILE.read_text()
|
||||
assert "admin:" in content
|
||||
|
||||
@patch("lib.nginx._hash_password")
|
||||
def test_replaces_existing_user(self, mock_hash, temp_data_dir):
|
||||
mock_hash.return_value = "$apr1$hash1"
|
||||
nginx.write_htpasswd("admin", "old")
|
||||
mock_hash.return_value = "$apr1$hash2"
|
||||
nginx.write_htpasswd("admin", "new")
|
||||
lines = [
|
||||
line
|
||||
for line in nginx.HTPASSWD_FILE.read_text().strip().splitlines()
|
||||
if line
|
||||
]
|
||||
assert len([line for line in lines if line.startswith("admin:")]) == 1
|
||||
|
||||
|
||||
class TestHashPasswordFallback:
|
||||
@patch("lib.nginx._hash_password")
|
||||
def test_hash_returns_string(self, mock_hash, temp_data_dir):
|
||||
mock_hash.return_value = "$apr1$hash"
|
||||
result = nginx._hash_password("test")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
@@ -0,0 +1,119 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from webui.server import app
|
||||
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestTemplateFilters:
|
||||
@pytest.fixture
|
||||
def env(self):
|
||||
from webui.server import app
|
||||
|
||||
return app.jinja_env
|
||||
|
||||
def test_timestamp_filter_valid(self, env):
|
||||
result = env.filters["timestamp"]("2026-04-01T12:00:00Z")
|
||||
assert "2026-04-01" in result
|
||||
|
||||
def test_timestamp_filter_empty(self, env):
|
||||
assert env.filters["timestamp"]("") == ""
|
||||
assert env.filters["timestamp"](None) == ""
|
||||
|
||||
def test_timestamp_filter_invalid(self, env):
|
||||
result = env.filters["timestamp"]("not-a-date")
|
||||
assert result == "not-a-date"
|
||||
|
||||
def test_bytes_filter_zero(self, env):
|
||||
assert env.filters["bytes"](0) == "0.0 B"
|
||||
|
||||
def test_bytes_filter_kb(self, env):
|
||||
result = env.filters["bytes"](1536)
|
||||
assert "KB" in result
|
||||
|
||||
def test_bytes_filter_mb(self, env):
|
||||
result = env.filters["bytes"](1500000)
|
||||
assert "MB" in result
|
||||
|
||||
def test_bytes_filter_negative(self, env):
|
||||
assert env.filters["bytes"](-1) == "0 B"
|
||||
|
||||
def test_bytes_filter_invalid(self, env):
|
||||
assert env.filters["bytes"]("not-a-number") == "not-a-number"
|
||||
|
||||
def test_duration_filter_zero(self, env):
|
||||
assert env.filters["duration"](0) == "0s"
|
||||
|
||||
def test_duration_filter_seconds(self, env):
|
||||
assert env.filters["duration"](65) == "1m 5s"
|
||||
|
||||
def test_duration_filter_hours(self, env):
|
||||
result = env.filters["duration"](3661)
|
||||
assert "1h" in result
|
||||
|
||||
def test_duration_filter_days(self, env):
|
||||
result = env.filters["duration"](90000)
|
||||
assert "1d" in result
|
||||
|
||||
def test_duration_filter_invalid(self, env):
|
||||
assert env.filters["duration"]("bad") == "bad"
|
||||
|
||||
def test_json_pretty_filter(self, env):
|
||||
result = env.filters["json_pretty"]({"key": "value"})
|
||||
assert '{"key": "value"}' in result or "key" in result
|
||||
|
||||
|
||||
class TestSafelyHelper:
|
||||
def test_returns_result(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 42)
|
||||
assert result == 42
|
||||
|
||||
def test_returns_default_on_exception(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 1 / 0, default=None)
|
||||
assert result is None
|
||||
|
||||
def test_returns_custom_default(self):
|
||||
from webui.server import _safely
|
||||
|
||||
result = _safely(lambda: 1 / 0, default="fallback")
|
||||
assert result == "fallback"
|
||||
|
||||
|
||||
class TestPageRoutes:
|
||||
@patch("webui.server.get_active_zones")
|
||||
@patch("webui.server.get_interfaces")
|
||||
@patch("webui.server.dnsmasq_status")
|
||||
@patch("webui.server.get_domains")
|
||||
@patch("webui.server.list_certs")
|
||||
@patch("webui.server.wg_status")
|
||||
def test_dashboard_no_crash(
|
||||
self,
|
||||
mock_wg,
|
||||
mock_certs,
|
||||
mock_domains,
|
||||
mock_dnsmasq,
|
||||
mock_ifaces,
|
||||
mock_zones,
|
||||
client,
|
||||
):
|
||||
mock_zones.return_value = {}
|
||||
mock_ifaces.return_value = []
|
||||
mock_dnsmasq.return_value = {}
|
||||
mock_domains.return_value = []
|
||||
mock_certs.return_value = []
|
||||
mock_wg.return_value = {}
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,248 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
||||
|
||||
from lib import wireguard
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config(tmp_path):
|
||||
original = wireguard.CONFIG_PATH
|
||||
wireguard.CONFIG_PATH = str(tmp_path / "config.json")
|
||||
yield tmp_path
|
||||
wireguard.CONFIG_PATH = original
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
def test_returns_skeleton(self):
|
||||
cfg = wireguard._default_config()
|
||||
assert cfg["interface"]["name"] == "wg0"
|
||||
assert cfg["interface"]["listen_port"] == 51820
|
||||
assert cfg["interface"]["private_key"] == ""
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
def test_returns_default_when_no_file(self, temp_config):
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["name"] == "wg0"
|
||||
assert cfg["peers"] == {}
|
||||
|
||||
def test_loads_existing_config(self, temp_config):
|
||||
path = Path(wireguard.CONFIG_PATH)
|
||||
expected = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "existing-key",
|
||||
"public_key": "existing-pub",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
path.write_text(json.dumps(expected))
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["private_key"] == "existing-key"
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_save_and_reload(self, temp_config):
|
||||
cfg = wireguard._default_config()
|
||||
cfg["interface"]["listen_port"] = 51821
|
||||
wireguard.save_config(cfg)
|
||||
loaded = wireguard.get_config()
|
||||
assert loaded["interface"]["listen_port"] == 51821
|
||||
|
||||
|
||||
class TestGenerateKeyPair:
|
||||
@patch("lib.wireguard._run")
|
||||
def test_returns_keypair(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="private-key\n"),
|
||||
MagicMock(returncode=0, stdout="public-key\n"),
|
||||
]
|
||||
private, public = wireguard.generate_keypair()
|
||||
assert private == "private-key"
|
||||
assert public == "public-key"
|
||||
|
||||
|
||||
class TestGetPeers:
|
||||
def test_empty_peers(self, temp_config):
|
||||
peers = wireguard.get_peers()
|
||||
assert peers == []
|
||||
|
||||
def test_lists_peers_without_private_keys(self, temp_config):
|
||||
cfg = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {
|
||||
"client1": {
|
||||
"public_key": "pub1",
|
||||
"private_key": "priv1",
|
||||
"endpoint": "203.0.113.1:51820",
|
||||
"allowed_ips": ["0.0.0.0/0"],
|
||||
"persistent_keepalive": None,
|
||||
"preshared_key": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg))
|
||||
peers = wireguard.get_peers()
|
||||
assert len(peers) == 1
|
||||
assert peers[0]["name"] == "client1"
|
||||
assert "private_key" not in peers[0]
|
||||
|
||||
|
||||
class TestAddPeer:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_adds_new_peer(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
result = wireguard.add_peer("client1", allowed_ips=["10.0.0.0/24"])
|
||||
assert result["public_key"] == "pub"
|
||||
assert result["private_key"] == "priv"
|
||||
assert result["allowed_ips"] == ["10.0.0.0/24"]
|
||||
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_updates_existing_peer(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
wireguard.add_peer("client1")
|
||||
wireguard.add_peer("client1", endpoint="203.0.113.1:51820")
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["peers"]["client1"]["endpoint"] == "203.0.113.1:51820"
|
||||
|
||||
|
||||
class TestRemovePeer:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_removes_peer(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
wireguard.add_peer("client1")
|
||||
wireguard.remove_peer("client1")
|
||||
cfg = wireguard.get_config()
|
||||
assert "client1" not in cfg["peers"]
|
||||
|
||||
|
||||
class TestSetListenPort:
|
||||
def test_set_valid_port(self, temp_config):
|
||||
wireguard.set_listen_port(12345)
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["listen_port"] == 12345
|
||||
|
||||
def test_set_invalid_port_raises(self, temp_config):
|
||||
with pytest.raises(ValueError):
|
||||
wireguard.set_listen_port(0)
|
||||
with pytest.raises(ValueError):
|
||||
wireguard.set_listen_port(70000)
|
||||
|
||||
|
||||
class TestSetPostHooks:
|
||||
def test_set_post_up(self, temp_config):
|
||||
wireguard.set_post_up("iptables -I FORWARD -i wg0 -j ACCEPT")
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
|
||||
|
||||
def test_clear_post_up(self, temp_config):
|
||||
wireguard.set_post_up("some-cmd")
|
||||
wireguard.set_post_up(None)
|
||||
cfg = wireguard.get_config()
|
||||
assert cfg["interface"]["post_up"] is None
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_initializes_once(self, mock_gen, temp_config):
|
||||
mock_gen.return_value = ("priv", "pub")
|
||||
cfg = wireguard.initialize()
|
||||
assert cfg["interface"]["private_key"] == "priv"
|
||||
assert cfg["interface"]["public_key"] == "pub"
|
||||
|
||||
@patch("lib.wireguard.generate_keypair")
|
||||
def test_does_not_overwrite_existing(self, mock_gen, temp_config):
|
||||
existing = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "original-private",
|
||||
"public_key": "original-pub",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing))
|
||||
cfg = wireguard.initialize()
|
||||
assert cfg["interface"]["private_key"] == "original-private"
|
||||
mock_gen.assert_not_called()
|
||||
|
||||
|
||||
class TestStatus:
|
||||
@patch("lib.wireguard._run")
|
||||
def test_returns_down_when_interface_down(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="interface not found"
|
||||
)
|
||||
result = wireguard.status()
|
||||
assert result["up"] is False
|
||||
|
||||
@patch("lib.wireguard._run")
|
||||
def test_parses_interface_info(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=("interface:\n public key: ABCDEF\n listening port: 51820\n"),
|
||||
)
|
||||
result = wireguard.status()
|
||||
assert result["up"] is True
|
||||
assert result["interface"]["public_key"] == "ABCDEF"
|
||||
assert result["interface"]["listen_port"] == 51820
|
||||
|
||||
@patch("lib.wireguard._run")
|
||||
def test_parses_peer_info(self, mock_run, temp_config):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"interface:\n"
|
||||
" public key: PUB\n"
|
||||
" listening port: 51820\n"
|
||||
"\n"
|
||||
"peer: PUBKEY1\n"
|
||||
" endpoint: 203.0.113.1:51820\n"
|
||||
" allowed ips: 10.137.0.2/32\n"
|
||||
" latest handshake: 2 minutes ago\n"
|
||||
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
|
||||
" persistent-keepalive: 25\n"
|
||||
),
|
||||
)
|
||||
result = wireguard.status()
|
||||
assert len(result["peers"]) == 1
|
||||
peer = result["peers"][0]
|
||||
assert peer["public_key"] == "PUBKEY1"
|
||||
assert peer["endpoint"] == "203.0.113.1:51820"
|
||||
assert peer["persistent_keepalive"] == 25
|
||||
|
||||
|
||||
class TestGenerateWgShowParser:
|
||||
def test_parses_peer_output(self):
|
||||
output = (
|
||||
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
|
||||
)
|
||||
result = wireguard._parse_wg_show(output)
|
||||
assert "PUBKEY1" in result
|
||||
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820"
|
||||
|
||||
def test_empty_output(self):
|
||||
result = wireguard._parse_wg_show("")
|
||||
assert result == {}
|
||||
Reference in New Issue
Block a user