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:
2026-08-20 13:10:16 +00:00
parent 332d14e37d
commit 94705490b9
9 changed files with 462 additions and 44 deletions
+58
View File
@@ -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={})