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:
+40
-7
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user