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
+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()