mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +00:00
07942a82e8
Issue #10 — Silent timezone failure on ACME certificate save - Normalize tz-aware expiry_date to UTC tz-naive before INSERT/UPDATE in ssl_certificates (TIMESTAMP WITHOUT TIME ZONE) — restores ACME download path Issue #11 — Duplicate _acme_challenge_backend in generated config - Generator guard: skip auto-append when backend already rendered - Agent-sync filter: _should_sync_backend() drops system-managed backends and detaches their server rows to prevent orphans - Restore filter: IGNORED_BACKENDS skips reserved names during cluster restore - Parser warning: reserved_backend_names blocks accidental manual import - Cleanup migration: removes orphan rows + cascading server entries (idempotent) Issue #12 — Validated orders required manual completion - New 60s background task complete_pending_acme_orders, flag-independent, multi-replica safe via FOR UPDATE SKIP LOCKED + 30s updated_at watermark - Per-order pg_advisory_lock(0x41434D45, order_id) serializes UI-Complete and auto-task races; idempotency guard returns existing certificate cleanly - retry_order endpoint reports in_progress: true within 30s window so the UI surfaces an info toast instead of duplicating CA requests - ACMEAutomation surfaces stuck orders (status=valid && !ssl_certificate_id) with a one-click Complete action and Cancel fallback; conditional 30s polling Other hardening - ACME state machine error_detail persisted as structured JSON across challenge, finalize, download stages for actionable post-mortems - CertificateRequest Pydantic model: domain regex + min_length/max_length and cluster_ids defaulting to all ACME-enabled clusters when "global" is selected - Renewal cluster fallback now requires acme_enabled=TRUE in addition to active - _complete_certificate preserves manual cluster assignments on renewal, surfaces cluster_errors, raises explicit error on missing private key - Audit logging covers acme_certificate_requested/revoked, ca_chain_imported, account created/deactivated/purged, order retried/cancelled - Settings UI exposes acme.staging_url_override for private test CAs (Pebble) - Schema additions: acme_challenges.attempts (default 0) and last_attempt_at, index idx_letsencrypt_orders_status_updated; all migrations idempotent Tests (41/41 passing) - test_acme_expiry_normalize, test_acme_duplicate_backend, test_acme_state_machine, test_acme_pydantic_validation, test_acme_audit_logging, test_acme_concurrency CI / packaging - docker-build.yml reads version.json and pushes additional product-version tag (e.g. 1.4.0) alongside latest and timestamp build id Closes #10 Closes #11 Closes #12
76 lines
3.1 KiB
Python
76 lines
3.1 KiB
Python
"""
|
|
Audit Tur 6 / Commit 5c, 5h, 5i: ACME order state machine error_detail serialization.
|
|
|
|
Verifies that error_detail is persisted as structured JSON-as-TEXT for:
|
|
- check_order_status non-200 (Commit 5c)
|
|
- finalize_order non-200 (Commit 5h)
|
|
- download_certificate non-200 (Commit 5i)
|
|
"""
|
|
import pytest
|
|
import json
|
|
from datetime import datetime
|
|
|
|
|
|
class TestErrorDetailSerialization:
|
|
"""Validate the structured payload format used across all 3 stages."""
|
|
|
|
def _build_payload(self, stage, http_status, ca_response):
|
|
return json.dumps({
|
|
"stage": stage,
|
|
"http_status": http_status,
|
|
"ca_response": ca_response if isinstance(ca_response, dict) else str(ca_response)[:1000],
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
})
|
|
|
|
def test_check_order_status_payload_parses(self):
|
|
payload = self._build_payload("check_order_status", 503, {"detail": "Service unavailable"})
|
|
parsed = json.loads(payload)
|
|
assert parsed["stage"] == "check_order_status"
|
|
assert parsed["http_status"] == 503
|
|
assert parsed["ca_response"]["detail"] == "Service unavailable"
|
|
assert "timestamp" in parsed
|
|
|
|
def test_finalize_payload_parses(self):
|
|
payload = self._build_payload("finalize_order", 400, {"detail": "Invalid CSR"})
|
|
parsed = json.loads(payload)
|
|
assert parsed["stage"] == "finalize_order"
|
|
assert parsed["http_status"] == 400
|
|
|
|
def test_download_payload_parses(self):
|
|
payload = self._build_payload("download_certificate", 502, "raw text response")
|
|
parsed = json.loads(payload)
|
|
assert parsed["stage"] == "download_certificate"
|
|
assert parsed["http_status"] == 502
|
|
assert parsed["ca_response"] == "raw text response"
|
|
|
|
def test_string_response_truncated_to_1000(self):
|
|
long_str = "a" * 5000
|
|
payload = self._build_payload("check_order_status", 500, long_str)
|
|
parsed = json.loads(payload)
|
|
assert len(parsed["ca_response"]) == 1000
|
|
|
|
def test_dict_response_preserved_as_object(self):
|
|
payload = self._build_payload("finalize_order", 400, {"key1": "v1", "key2": [1, 2]})
|
|
parsed = json.loads(payload)
|
|
assert parsed["ca_response"]["key1"] == "v1"
|
|
assert parsed["ca_response"]["key2"] == [1, 2]
|
|
|
|
|
|
class TestStuckOrderDetection:
|
|
"""Verify the conditions that mark an order as 'stuck'."""
|
|
|
|
def test_valid_order_without_certificate_id_is_stuck(self):
|
|
order = {"status": "valid", "ssl_certificate_id": None}
|
|
is_stuck = order["status"] == "valid" and not order["ssl_certificate_id"]
|
|
assert is_stuck is True
|
|
|
|
def test_valid_order_with_certificate_id_is_not_stuck(self):
|
|
order = {"status": "valid", "ssl_certificate_id": 42}
|
|
is_stuck = order["status"] == "valid" and not order["ssl_certificate_id"]
|
|
assert is_stuck is False
|
|
|
|
def test_pending_order_is_not_stuck(self):
|
|
order = {"status": "pending", "ssl_certificate_id": None}
|
|
is_stuck = order["status"] == "valid" and not order["ssl_certificate_id"]
|
|
assert is_stuck is False
|