Files
taylanbakircioglu 70ebc02e09 fix(acme): Cloudflare token, ZeroSSL EAB, Apply Management (v1.8.1)
Follow-up fixes for the DNS-01 feature reported on #35:

- Cloudflare: sanitize the API token (strip surrounding quotes + any non
  token68 chars) so a pasted token with quotes/spaces no longer fails with
  "Invalid request headers"; verify-on-save shows a precise hint when it
  cleaned the input. Covers the automated orchestrator path too.
- ZeroSSL/Google EAB: enter the EAB Key ID and HMAC Key per-account in the
  Register Account dialog (falls back to the global Settings value when blank);
  base64-validate the HMAC key; humanize the externalAccountRequired failure;
  and preserve the deliberate 409/422 instead of downgrading them to 400.
- Apply Management: cluster ACME enable/disable changes now show under a
  dedicated "ACME Challenge Routing" section, are counted in the Apply/Reject
  dialogs, and Apply/Reject All process them (previously "Rejected 0 HA/VIP
  change(s)") - consistent with every other entity. Reject rolls acme_enabled
  back to the original via ORDER BY created_at ASC over the snapshot chain.
- getErrorMsg surfaces field-level validation messages.

Backward compatible (additive / strict superset; HTTP-01 unchanged).

Addresses #35.
2026-06-24 20:37:05 +03:00

173 lines
6.9 KiB
Python

"""
v1.5.0 Feature A — humanize_error_detail() unit tests.
Table-driven coverage of the RFC8555 problem types we humanize, plus
backwards-compatible behaviour for legacy plain-string error_detail values
that were written by v1.3.x and earlier.
"""
import json
import pytest
from services.acme_diagnostics import humanize_error_detail, _PROBLEM_HUMANIZED
# ----------------------------------------------------------------------------
# 1. Static coverage — we promise >= 11 RFC8555 problem types are humanized.
# ----------------------------------------------------------------------------
def test_humanizes_at_least_11_rfc8555_problem_types():
assert len(_PROBLEM_HUMANIZED) >= 11
def test_every_humanized_entry_has_title_and_hint():
for ptype, body in _PROBLEM_HUMANIZED.items():
assert "title" in body and body["title"], ptype
assert "hint" in body, ptype
# ----------------------------------------------------------------------------
# 2. Empty / None handling.
# ----------------------------------------------------------------------------
def test_none_returns_no_error_marker():
out = humanize_error_detail(None)
assert out["title"] == "No error"
assert out["message"] == ""
def test_empty_string_returns_no_error_marker():
out = humanize_error_detail("")
assert out["title"] == "No error"
# ----------------------------------------------------------------------------
# 3. Legacy plain-string fallback (pre-v1.5.0 stored unstructured strings).
# ----------------------------------------------------------------------------
def test_legacy_plain_string_falls_back_cleanly():
out = humanize_error_detail("connection refused: agent offline")
assert out["title"] == "ACME error"
assert "connection refused" in out["message"]
# The hint is empty because we don't have a structured problem type.
assert out["hint"] == ""
assert out["raw"] == "connection refused: agent offline"
def test_legacy_plain_string_with_brace_but_invalid_json_falls_back():
# Defensive: '{' prefix is the trigger for JSON parse, but garbled JSON
# must NOT raise. It should fall through to the legacy string path.
out = humanize_error_detail("{not valid json{")
assert out["title"] == "ACME error"
assert out["raw"] == "{not valid json{"
# ----------------------------------------------------------------------------
# 4. Structured RFC8555 problem types — table-driven across 11+ entries.
# ----------------------------------------------------------------------------
@pytest.mark.parametrize("problem_type,detail_text,expected_title_contains", [
("urn:ietf:params:acme:error:rateLimited", "too many orders", "rate limit"),
("urn:ietf:params:acme:error:dns", "no A record", "DNS"),
("urn:ietf:params:acme:error:caa", "issuance forbidden", "CAA"),
("urn:ietf:params:acme:error:connection", "timeout to :80", "could not connect"),
("urn:ietf:params:acme:error:incorrectResponse", "wrong key auth", "challenge response"),
("urn:ietf:params:acme:error:unauthorized", "verification failed", "Unauthorized"),
("urn:ietf:params:acme:error:malformed", "missing field 'csr'", "Malformed"),
("urn:ietf:params:acme:error:badNonce", "stale nonce", "nonce"),
("urn:ietf:params:acme:error:rejectedIdentifier", "blacklisted", "rejected"),
("urn:ietf:params:acme:error:serverInternal", "internal err", "ACME server"),
("urn:ietf:params:acme:error:userActionRequired", "agree to ToS", "User action"),
("urn:ietf:params:acme:error:externalAccountRequired", "EAB required", "External Account Binding"),
])
def test_known_problem_types_are_humanized(problem_type, detail_text, expected_title_contains):
payload = json.dumps({"type": problem_type, "detail": detail_text, "status": 400})
out = humanize_error_detail(payload)
assert expected_title_contains.lower() in out["title"].lower(), (
f"Title '{out['title']}' missing expected substring '{expected_title_contains}'"
)
assert out["message"] == detail_text
assert out["status"] == 400
assert out["type"] == problem_type
# Hint should be a non-empty operator-targeted string
assert isinstance(out["hint"], str) and len(out["hint"]) > 10
def test_unknown_problem_type_falls_back_to_generic_title():
payload = json.dumps({
"type": "urn:ietf:params:acme:error:notARealType",
"detail": "future error",
"status": 500,
})
out = humanize_error_detail(payload)
assert out["title"] == "ACME error"
assert out["message"] == "future error"
assert out["hint"] == ""
assert out["status"] == 500
assert out["type"] == "urn:ietf:params:acme:error:notARealType"
# ----------------------------------------------------------------------------
# 5. Subproblems (per-domain failures) flatten cleanly.
# ----------------------------------------------------------------------------
def test_subproblems_are_flattened():
payload = json.dumps({
"type": "urn:ietf:params:acme:error:malformed",
"detail": "multiple validation failures",
"status": 400,
"subproblems": [
{
"type": "urn:ietf:params:acme:error:dns",
"detail": "no record for foo",
"identifier": {"type": "dns", "value": "foo.example.com"},
},
{
"type": "urn:ietf:params:acme:error:caa",
"detail": "caa forbids",
"identifier": {"type": "dns", "value": "bar.example.com"},
},
],
})
out = humanize_error_detail(payload)
assert "subproblems" in out
assert len(out["subproblems"]) == 2
sp = {item["identifier"]: item for item in out["subproblems"]}
assert sp["foo.example.com"]["type"] == "urn:ietf:params:acme:error:dns"
assert sp["bar.example.com"]["detail"] == "caa forbids"
def test_subproblems_with_garbage_entries_are_filtered():
payload = json.dumps({
"type": "urn:ietf:params:acme:error:malformed",
"detail": "x",
"subproblems": [
"not a dict", # filtered out
None, # filtered out
{"type": "urn:ietf:params:acme:error:dns", "detail": "ok"},
],
})
out = humanize_error_detail(payload)
assert len(out["subproblems"]) == 1
assert out["subproblems"][0]["detail"] == "ok"
# ----------------------------------------------------------------------------
# 6. Dict input is accepted directly (avoids double-encode in some callers).
# ----------------------------------------------------------------------------
def test_dict_input_handled_directly():
out = humanize_error_detail({
"type": "urn:ietf:params:acme:error:rateLimited",
"detail": "limit reached",
"status": 429,
})
assert "rate limit" in out["title"].lower()
assert out["message"] == "limit reached"
assert out["status"] == 429