certs: async renewal via background task + status polling
- POST /acme/renew returns a request_id and spawns a background task (renew/deploy/refresh steps); dedups per-domain like issue - completes as "skipped" when acme.sh reports the renewal window has not been reached (no --force) - new GET /acme/renew/status endpoint (iface + handler + blueprint) - run acme.sh subprocesses off the event loop (asyncio.to_thread) in both issuance and renewal - ActionCell: busy/busyLabel props; certs page disables the Renew button and polls renewal status with toasts for success/skip/fail
This commit is contained in:
@@ -530,6 +530,64 @@ class TestCertsIssue:
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestCertsRenew:
|
||||
@_ce("post")
|
||||
def test_start_renew(self, mock_post, client):
|
||||
mock_post.return_value = {"request_id": "abc123", "domain": "example.com"}
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["request_id"] == "abc123"
|
||||
args = mock_post.call_args.args
|
||||
assert args[0] == ("POST", "/acme/renew")
|
||||
assert args[1] == {"domain": "example.com"}
|
||||
|
||||
@_ce("post")
|
||||
def test_start_renew_rejected(self, mock_post, client):
|
||||
from daemon.client import BadRequest
|
||||
|
||||
mock_post.side_effect = BadRequest("'domain' is required")
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
@_ce("post")
|
||||
def test_start_renew_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("daemon unreachable")
|
||||
resp = client.post("/api/certs/example.com/renew")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestCertsRenewStatus:
|
||||
@_ce("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"request_id": "abc123",
|
||||
"domain": "example.com",
|
||||
"status": "completed",
|
||||
"steps": [],
|
||||
}
|
||||
resp = client.get("/api/certs/renew/abc123")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["status"] == "completed"
|
||||
args = mock_get.call_args.args
|
||||
assert args[0] == ("GET", "/acme/renew/status")
|
||||
assert args[1] == {"id": "abc123"}
|
||||
|
||||
@_ce("get")
|
||||
def test_not_found(self, mock_get, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_get.side_effect = NotFound("renewal request not found")
|
||||
resp = client.get("/api/certs/renew/unknown")
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestCertsEmail:
|
||||
def test_missing_email(self, client):
|
||||
resp = client.post("/api/certs/email", json={})
|
||||
|
||||
+139
-1
@@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import daemon.handlers.acme as acme_mod
|
||||
from daemon.handlers.acme import (
|
||||
IssueRequest,
|
||||
_check_account_registered,
|
||||
_check_acme_account,
|
||||
_check_acme_home_writable,
|
||||
@@ -25,10 +27,25 @@ from daemon.handlers.acme import (
|
||||
deactivate_account,
|
||||
generate_self_signed,
|
||||
get_account,
|
||||
get_renew_status,
|
||||
issue_cert,
|
||||
register_account,
|
||||
renew_cert,
|
||||
)
|
||||
from daemon.server import ConflictError
|
||||
from daemon.server import ConflictError, NotFoundError
|
||||
|
||||
|
||||
def _await_renew(body):
|
||||
"""Start a renewal via renew_cert and await its background task."""
|
||||
|
||||
async def _run():
|
||||
result = renew_cert(None, body)
|
||||
task = acme_mod._ISSUANCE_TASKS.get(result.get("request_id"))
|
||||
if task is not None:
|
||||
await task
|
||||
return result
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
class TestGenerateSelfSigned:
|
||||
@@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts:
|
||||
assert result["domain"] == "example.com"
|
||||
assert "request_id" in result
|
||||
mock_run_issue.assert_called_once()
|
||||
|
||||
|
||||
class TestRenewCert:
|
||||
def test_requires_body(self):
|
||||
with pytest.raises(ValueError, match="Request body required"):
|
||||
renew_cert(None, None)
|
||||
|
||||
def test_requires_domain(self):
|
||||
with pytest.raises(ValueError, match="'domain' is required"):
|
||||
renew_cert(None, {"force": True})
|
||||
|
||||
def test_starts_background_task(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch(
|
||||
"daemon.handlers.acme._run_acme", return_value="Renewed 'example.com'"
|
||||
),
|
||||
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert result["domain"] == "example.com"
|
||||
assert "request_id" in result
|
||||
assert req.status == "completed"
|
||||
assert [s.status for s in req.steps] == ["done", "done", "done"]
|
||||
mock_refresh.assert_called_once_with(["acme"])
|
||||
|
||||
def test_skip_when_not_due(self):
|
||||
output = (
|
||||
"[Thu Aug 20 01:27:41 AM UTC 2026] Skipping. "
|
||||
"Next renewal time is: 1785068261 (2026-07-26T12:17:41Z)"
|
||||
)
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value=output),
|
||||
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert req.status == "skipped"
|
||||
assert req.steps[0].status == "done"
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_failure_marks_failed(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch(
|
||||
"daemon.handlers.acme._run_acme",
|
||||
side_effect=RuntimeError("acme.sh failed with exit code 1: boom"),
|
||||
),
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
req = acme_mod._ISSUANCES[result["request_id"]]
|
||||
|
||||
assert req.status == "failed"
|
||||
assert req.steps[0].status == "error"
|
||||
assert "boom" in req.steps[0].message
|
||||
|
||||
def test_force_appends_flag(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value="ok") as mock_acme,
|
||||
patch("daemon.handlers.acme.refresh_state"),
|
||||
):
|
||||
_await_renew({"domain": "example.com", "force": True})
|
||||
|
||||
renew_args = mock_acme.call_args_list[0].args[0]
|
||||
assert "--force" in renew_args
|
||||
|
||||
def test_dedup_running_returns_existing(self):
|
||||
existing = IssueRequest(request_id="existing123", domain="example.com")
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme") as mock_acme,
|
||||
):
|
||||
acme_mod._ISSUANCES["existing123"] = existing
|
||||
result = renew_cert(None, {"domain": "example.com"})
|
||||
|
||||
assert result == {
|
||||
"request_id": "existing123",
|
||||
"domain": "example.com",
|
||||
"status": "existing",
|
||||
}
|
||||
mock_acme.assert_not_called()
|
||||
|
||||
|
||||
class TestGetRenewStatus:
|
||||
def test_missing_id(self):
|
||||
with pytest.raises(ValueError, match="'id' is required"):
|
||||
get_renew_status(None, None)
|
||||
with pytest.raises(ValueError, match="'id' is required"):
|
||||
get_renew_status(None, {})
|
||||
|
||||
def test_unknown_id(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
pytest.raises(NotFoundError, match="not found"),
|
||||
):
|
||||
get_renew_status(None, {"id": "unknown"})
|
||||
|
||||
def test_returns_request_dict(self):
|
||||
with (
|
||||
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
|
||||
patch("daemon.handlers.acme._run_acme", return_value="Renewed"),
|
||||
patch("daemon.handlers.acme.refresh_state"),
|
||||
):
|
||||
result = _await_renew({"domain": "example.com"})
|
||||
status = get_renew_status(None, {"id": result["request_id"]})
|
||||
|
||||
assert status["request_id"] == result["request_id"]
|
||||
assert status["domain"] == "example.com"
|
||||
assert status["status"] == "completed"
|
||||
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
|
||||
|
||||
Reference in New Issue
Block a user