faa076370d
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
275 lines
10 KiB
Python
275 lines
10 KiB
Python
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from lib import acme
|
|
|
|
|
|
class TestFindAcme:
|
|
@patch("lib.acme.shutil.which")
|
|
@patch("lib.acme._ACME_HOME")
|
|
def test_finds_in_acme_home(self, mock_acme_home, mock_which):
|
|
mock_acme_home = Path("/tmp/fake-acme-home")
|
|
mock_acme_home.mkdir(parents=True, exist_ok=True)
|
|
acme_bin = mock_acme_home / "acme.sh"
|
|
acme_bin.write_text("#!/bin/sh\n")
|
|
acme_bin.chmod(0o755)
|
|
|
|
with patch.object(acme, "_ACME_HOME", mock_acme_home):
|
|
result = acme._find_acme()
|
|
assert "acme.sh" in result
|
|
|
|
acme_bin.unlink()
|
|
|
|
@patch("lib.acme._find_acme")
|
|
def test_raises_when_not_found(self, mock_find):
|
|
mock_find.side_effect = FileNotFoundError()
|
|
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
|
|
|
|
@patch("lib.acme._find_acme")
|
|
@patch("lib.acme.subprocess.run")
|
|
def test_log_flag_is_last(self, mock_run, mock_find):
|
|
# --log must trail the subcommand args: acme.sh would otherwise
|
|
# consume the first subcommand arg as its (optional) file argument.
|
|
mock_find.return_value = "/usr/local/bin/acme.sh"
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
acme._run_acme(["--issue", "-d", "example.com"])
|
|
cmd = mock_run.call_args[0][0]
|
|
assert cmd.count("--log") == 1
|
|
assert cmd[-1] == "--log"
|
|
assert cmd.index("--issue") < cmd.index("--log")
|
|
assert "example.com" in cmd
|
|
|
|
|
|
class TestParseListOutput:
|
|
def test_parses_single_entry(self):
|
|
raw = "Main_Domain\tCA\nexample.com\tLetsEncrypt\n"
|
|
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\tCA\na.com\tLE\nb.com\tLE\n"
|
|
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_empty_lines(self):
|
|
raw = "Main_Domain\tCA\nexample.com\tLE\n\n \nother.com\tLE\n"
|
|
result = acme._parse_list_output(raw)
|
|
assert len(result) == 2
|
|
|
|
def test_header_skipped(self):
|
|
"""Header row is not included in results."""
|
|
raw = "Main_Domain\tKeyLength\tCA\nexample.com\tec-256\tZeroSSL.com\n"
|
|
result = acme._parse_list_output(raw)
|
|
assert len(result) == 1
|
|
assert result[0]["main_domain"] == "example.com"
|
|
|
|
def test_field_mapping(self):
|
|
"""Tab columns are mapped to lowercased header names as dict keys."""
|
|
raw = (
|
|
"Main_Domain\tKeyLength\tSAN_Domains\tProfile\tCA\tCreated\tRenew\n"
|
|
'example.com\t"ec-256"\twww.example.com\t\tZeroSSL.com\t2026-01-01\t2026-07-01\n'
|
|
)
|
|
result = acme._parse_list_output(raw)
|
|
assert len(result) == 1
|
|
entry = result[0]
|
|
assert entry["main_domain"] == "example.com"
|
|
assert entry["keylength"] == "ec-256"
|
|
assert entry["san_domains"] == "www.example.com"
|
|
assert entry["profile"] == ""
|
|
assert entry["ca"] == "ZeroSSL.com"
|
|
assert entry["created"] == "2026-01-01"
|
|
assert entry["renew"] == "2026-07-01"
|
|
|
|
def test_quoted_values_stripped(self):
|
|
"""Quoted values have quotes removed."""
|
|
raw = 'Main_Domain\tKeyLength\nexample.com\t"ec-256"\n'
|
|
result = acme._parse_list_output(raw)
|
|
assert result[0]["keylength"] == "ec-256"
|
|
|
|
def test_header_only(self):
|
|
"""Header with no data rows returns empty list."""
|
|
raw = "Main_Domain\tKeyLength\tCA\n"
|
|
result = acme._parse_list_output(raw)
|
|
assert result == []
|
|
|
|
def test_pipe_separated_raw_format(self):
|
|
"""Pipe-separated output with empty fields (what --listraw produces)."""
|
|
raw = (
|
|
"Main_Domain|KeyLength|SAN_Domains|Profile|CA|Created|Renew\n"
|
|
'example.com|"ec-256"|no||ZeroSSL.com|2026-01-01|2026-07-01\n'
|
|
)
|
|
result = acme._parse_list_output(raw)
|
|
assert len(result) == 1
|
|
assert result[0]["main_domain"] == "example.com"
|
|
assert result[0]["profile"] == ""
|
|
assert result[0]["ca"] == "ZeroSSL.com"
|
|
assert result[0]["created"] == "2026-01-01"
|
|
assert result[0]["renew"] == "2026-07-01"
|
|
|
|
|
|
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.object(acme, "_ACME_HOME", 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) / "data" / "acme"
|
|
acme_dir.mkdir(parents=True, exist_ok=True)
|
|
conf = acme_dir / "account.conf"
|
|
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
|
|
|
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
|
result = acme.get_email()
|
|
assert result == "test@example.com"
|
|
|
|
|
|
class TestFindCertDir:
|
|
def test_rsa_dir(self, tmp_path):
|
|
rsa_dir = tmp_path / "example.com"
|
|
rsa_dir.mkdir()
|
|
result = acme.find_cert_dir("example.com", tmp_path)
|
|
assert result == rsa_dir
|
|
|
|
def test_ecc_dir(self, tmp_path):
|
|
ecc_dir = tmp_path / "example.com_ecc"
|
|
ecc_dir.mkdir()
|
|
result = acme.find_cert_dir("example.com", tmp_path)
|
|
assert result == ecc_dir
|
|
|
|
def test_eccPreferred(self, tmp_path):
|
|
rsa_dir = tmp_path / "example.com"
|
|
rsa_dir.mkdir()
|
|
ecc_dir = tmp_path / "example.com_ecc"
|
|
ecc_dir.mkdir()
|
|
result = acme.find_cert_dir("example.com", tmp_path)
|
|
assert result == ecc_dir
|
|
|
|
def test_fallback_when_neither(self, tmp_path):
|
|
result = acme.find_cert_dir("example.com", tmp_path)
|
|
assert result == tmp_path / "example.com"
|
|
|
|
def test_resolves_ecc_only(self, tmp_path):
|
|
"""Only _ecc dir exists, no RSA dir — should resolve to _ecc."""
|
|
ecc_dir = tmp_path / "example.com_ecc"
|
|
ecc_dir.mkdir()
|
|
result = acme.find_cert_dir("example.com", tmp_path)
|
|
assert result == ecc_dir
|
|
|
|
|
|
class TestGetCertPaths:
|
|
def test_returns_paths(self, tmp_path):
|
|
with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"):
|
|
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")
|
|
|
|
def test_resolves_ecc_dir(self, tmp_path):
|
|
acme_dir = tmp_path / "data" / "acme"
|
|
ecc_dir = acme_dir / "example.com_ecc"
|
|
ecc_dir.mkdir(parents=True)
|
|
|
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
|
paths = acme.get_cert_paths("example.com")
|
|
assert paths["cert"].endswith("example.com_ecc/example.com.cert")
|
|
assert paths["key"].endswith("example.com_ecc/example.com.key")
|
|
assert paths["ca"].endswith("example.com_ecc/ca.cer")
|
|
assert paths["fullchain"].endswith("example.com_ecc/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:
|
|
def test_true_when_conf_exists(self, tmp_path):
|
|
acme_dir = tmp_path / "data" / "acme"
|
|
acme_dir.mkdir(parents=True)
|
|
conf = acme_dir / "example.com.conf"
|
|
conf.touch()
|
|
|
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
|
result = acme._has_auto_renew("example.com")
|
|
assert result is True
|
|
|
|
conf.unlink()
|
|
|
|
def test_false_when_conf_missing(self, tmp_path):
|
|
acme_dir = tmp_path / "data" / "acme"
|
|
acme_dir.mkdir(parents=True)
|
|
|
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
|
result = acme._has_auto_renew("nonexistent.com")
|
|
assert result is False
|