65741644a3
- dashboard.html: Fix zones, leases, wg_status, cert key names, add services var
- server.py: Pass services to dashboard template via _get_service_status()
- lib/acme.py: Fix dead third date format (%Y%m%d%H%M%z) using astimezone(UTC)
- lib/wireguard.py: Add -- separator to cp command to match sudoers rule
- lib/nginx.py: Replace shallow dict.copy() with {**...} for DEFAULT_SSL
- AGENTS.md: Update test count 149 -> 154
- docs/api.md: Rename cert field expiry -> expires_at
173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
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"])
|
|
|
|
@patch("lib.acme._find_acme")
|
|
@patch("lib.acme.subprocess.run")
|
|
def test_no_sudo_prefix(self, mock_run, mock_find):
|
|
mock_find.return_value = "/usr/local/bin/acme.sh"
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
acme._run_acme(["--list"])
|
|
cmd = mock_run.call_args[0][0]
|
|
assert cmd[0] == "/usr/local/bin/acme.sh"
|
|
assert "sudo" not in cmd
|
|
|
|
|
|
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")
|
|
|
|
|
|
class TestDeployHook:
|
|
@patch("lib.acme._run_acme")
|
|
def test_deploy_registers_hook(self, mock_run):
|
|
acme.deploy("example.com")
|
|
args = mock_run.call_args[0][0]
|
|
assert "--deploy" in args
|
|
assert "-d" in args
|
|
assert "example.com" in args
|
|
assert "--deploy-hook" in args
|
|
assert any("acme-deploy.sh" in arg for arg in args)
|
|
|
|
|
|
class TestHasAutoRenew:
|
|
@patch("lib.acme.Path.home")
|
|
def test_true_when_conf_exists(self, mock_home):
|
|
tmpdir = tempfile.mkdtemp()
|
|
acme_dir = Path(tmpdir) / ".acme.sh"
|
|
acme_dir.mkdir()
|
|
conf = acme_dir / "example.com.conf"
|
|
conf.touch()
|
|
mock_home.return_value = Path(tmpdir)
|
|
result = acme._has_auto_renew("example.com")
|
|
assert result is True
|
|
conf.unlink()
|
|
|
|
@patch("lib.acme.Path.home")
|
|
def test_false_when_conf_missing(self, mock_home):
|
|
mock_home.return_value = Path(tempfile.mkdtemp())
|
|
result = acme._has_auto_renew("nonexistent.com")
|
|
assert result is False
|