Files
vacuum-wall/tests/test_acme.py
T
mteehan 78fcb01877 fix: install.sh loop abort, ACME poll sudo gate, /static/ sub-paths
- install.sh: the traversal-chmod loop assigned _d but looped over the
  never-set $d; under set -u every fresh install aborted with
  "d: unbound variable" at that line. Loop over $_d.
- acme collector: the self-heal normalize (sudo chmod g+rwX) now runs
  only when a no-sudo group-read-bit probe detects a lost bit — acme.sh
  re-hardens the tree 600 on every run, so the steady-state poll makes
  no sudo call. The group bit (not daemon readability) is what the
  two-user model keeps for the WebUI user.
- lib.acme: new get_acme_home() accessor (ACME_HOME env, default
  data/acme), reused by _run_acme; _summarize_acme_output preserves a
  "Permission denied" line even when it is not among the final two, so
  the collector's actionable-error matcher keeps firing.
- nginx template: emit location /static/ for any is_management path
  (not only '/'); the SPA references /static/... at the domain root
  regardless of the management backend path.
- tests: probe, summarizer, and nginx-subpath cases in
  test_state.py, test_acme.py, test_nginx.py.
2026-09-05 00:38:34 +00:00

312 lines
12 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 <file> must trail the subcommand args: acme.sh would
# otherwise consume the first subcommand arg as its file argument.
# The explicit file path (not a bare trailing --log) is required
# because a valueless trailing --log makes acme.sh's arg loop
# double-shift under dash and fail with "shift: can't shift that
# many".
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[-2] == "--log"
assert cmd[-1].endswith("acme.sh.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
class TestSummarizeAcmeOutput:
def test_last_two_lines(self):
out = (
"[2026-09-04] line one\n[2026-09-04] retry failed\n[2026-09-04] giving up\n"
)
assert acme._summarize_acme_output(out) == "retry failed; giving up"
def test_strips_timestamps_and_log_pointer(self):
out = "[ts] work\nPlease check log file /x/acme.sh.log\n[ts] done\n"
assert acme._summarize_acme_output(out) == "work; done"
def test_empty_returns_placeholder(self):
assert acme._summarize_acme_output("") == "(no output)"
def test_preserves_permission_denied_outside_tail(self):
out = (
"[ts] starting\n"
"[ts] /data/acme/account.conf: Permission denied\n"
"[ts] step three\n"
"[ts] step four\n"
)
summary = acme._summarize_acme_output(out)
# The permission line is not among the final two, but the
# actionable-error matcher (daemon/collectors/acme.py) keys off it.
assert "account.conf: Permission denied" in summary
assert summary.count("; ") == 2 # capped at three lines
def test_permission_denied_in_tail_not_duplicated(self):
out = "[ts] ok\n[ts] account.conf: Permission denied\n"
assert acme._summarize_acme_output(out) == "ok; account.conf: Permission denied"