Refactor ACME module and add cert issuance conflict handling

- Move acme.sh utilities (_run_acme, _find_acme, etc.) from lib/state to lib/acme
- Rewrite _parse_list_output to support pipe, tab, and column-separated formats
- Add ConflictError (409) to block issuing when cert already exists
- Move _find_issuance helper to detect in-progress issuance per domain
- Update issue_cert to check existing certs and return issuance status
- Fix start_polling to accept event loop explicitly
- Add sudoers entry for chown on vacuum-wall.conf
- Extend systemd ReadWritePaths for /run/nginx.pid and /var/log/nginx
- Update frontend to handle 'existing' issuance status
This commit is contained in:
2026-06-27 00:38:49 +00:00
parent feaf253403
commit 398831b6e2
11 changed files with 268 additions and 176 deletions
+40 -7
View File
@@ -61,17 +61,14 @@ class TestRunAcme:
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"
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: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"
)
raw = "Main_Domain\tCA\na.com\tLE\nb.com\tLE\n"
result = acme._parse_list_output(raw)
assert len(result) == 2
@@ -79,10 +76,46 @@ class TestParseListOutput:
result = acme._parse_list_output("")
assert result == []
def test_skips_lines_without_colons(self):
raw = "some random line\nMain_Domain:a.com"
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 == []
class TestDaysUntil:
+64
View File
@@ -1,5 +1,6 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
import asyncio
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -24,8 +25,10 @@ from daemon.handlers.acme import (
deactivate_account,
generate_self_signed,
get_account,
issue_cert,
register_account,
)
from daemon.server import ConflictError
class TestGenerateSelfSigned:
@@ -1088,3 +1091,64 @@ class TestDeactivateAccount:
assert not (acme_dir / ".account.conf").is_file()
assert not (acme_dir / "account.conf").is_file()
class TestIssueCertExistingCerts:
"""Phase 3: issue_cert blocks when cert expires today (days == 0) or tomorrow (days == 1)."""
def test_days_zero_blocks(self):
"""days_until_expiry returns 0 (expires today) — should block."""
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": 0}],
),
pytest.raises(ConflictError, match="0 days remaining"),
):
asyncio.run(issue_cert(None, {"domain": "example.com"}))
def test_days_one_blocks(self):
"""days_until_expiry returns 1 (expires tomorrow) — should still block."""
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": 1}],
),
pytest.raises(ConflictError, match="1 day remaining"),
):
asyncio.run(issue_cert(None, {"domain": "example.com"}))
def test_days_negative_one_allows(self):
"""days_until_expiry returns -1 (already expired) — should not block."""
async def _fake_run_issue(req):
pass
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": -1}],
),
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch(
"daemon.handlers.acme._run_issue",
new=MagicMock(side_effect=_fake_run_issue),
) as mock_run_issue,
):
result = asyncio.run(issue_cert(None, {"domain": "example.com"}))
assert result["domain"] == "example.com"
assert "request_id" in result
mock_run_issue.assert_called_once()