Skip to main content

acme_proxy_core/
error.rs

1//! [`Problem`]: every error an ACME handler answers with, as the RFC 8555 §6.7
2//! `application/problem+json` document a client parses.
3//!
4//! One constructor per problem type, generated by `problems!` from a table of
5//! status and URN, so a type cannot be paired with the wrong status at one call
6//! site. The web admin does **not** use this: its errors are plain
7//! `{error, message}` JSON (`webadmin::error`), because a browser and a script
8//! reading `/api` are not ACME clients.
9
10use std::borrow::Cow;
11
12use axum::{
13    Json,
14    http::{StatusCode, header},
15    response::{IntoResponse, Response},
16};
17use serde_json::{Value, json};
18
19/// An ACME error, rendered as an RFC 8555 §6.7 `application/problem+json`
20/// document:
21///
22/// ```json
23/// { "type": "urn:ietf:params:acme:error:malformed", "detail": "…", "status": 400 }
24/// ```
25///
26/// This struct represents ACME protocol errors that are returned to clients
27/// in the standardized RFC 8555 problem+json format. It implements the `IntoResponse`
28/// trait to convert errors into proper HTTP responses.
29///
30/// ## ACME Protocol Compliance
31///
32/// Following RFC 8555, each error has:
33/// - A `type` field with URN format identifying the error type
34/// - A `detail` field with human-readable description
35/// - A `status` field with HTTP status code
36///
37/// ## Error Types
38///
39/// - `malformed`: Request format issues (HTTP 400)
40/// - `bad_nonce`: Invalid or expired nonce (HTTP 400)
41/// - `unauthorized`: Signature verification failures (HTTP 401)
42/// - `server_internal`: Internal server errors (HTTP 500)
43/// - `account_does_not_exist`: Referenced account not found (HTTP 400)
44/// - `order_not_ready`: Finalize attempted on a non-`ready` order (HTTP 403)
45/// - `bad_csr`: Unacceptable finalize CSR (HTTP 400)
46/// - `unsupported_identifier`: Unsupported newOrder identifier type (HTTP 400)
47/// - `rejected_identifier`: Identifier refused by server policy (HTTP 403)
48/// - `access_denied`: Request blocked by a filter (HTTP 403)
49/// - `external_account_required`: newAccount missing a required EAB (HTTP 400)
50/// - `already_revoked`: Certificate already revoked (HTTP 400)
51/// - `bad_revocation_reason`: Unsupported `CRLReason` code (HTTP 400)
52/// - `key_change_conflict`: keyChange new key belongs to another account (HTTP 409)
53/// - `unsupported_media_type`: body was not `application/jose+json` (HTTP 415)
54/// - `method_not_allowed`: resource read with the wrong method, e.g. a bare GET (HTTP 405)
55/// - `not_found`: nothing routed at this path (HTTP 404)
56///
57/// ## Usage
58///
59/// Used both as the `AcmeRequest` extractor's rejection and as the error arm of
60/// handler results, so every failure reaches the client in the shape ACME
61/// clients expect.
62///
63/// ## Owned details
64///
65/// `detail` is a [`Cow<'static, str>`] rather than a `&'static str`: most
66/// call sites pass a literal (borrowed, no allocation), but the `filter`
67/// subsystem needs to name the offending value — "identifier evil.example.com
68/// is denied by policy" — which can only be built at runtime.
69#[derive(Debug)]
70pub struct Problem {
71    status: StatusCode,
72    /// The `urn:ietf:params:acme:error:*` problem type.
73    typ: &'static str,
74    detail: Cow<'static, str>,
75    /// The optional members, allocated only when one is actually used.
76    ///
77    /// Boxed because `Problem` is the `Err` of nearly every function in this
78    /// codebase, so its size is paid on every call — and all three of these are
79    /// empty for the great majority of problems, which are about the request
80    /// rather than about a list of identifiers. Inline they push the struct past
81    /// clippy's `result_large_err` threshold; behind an `Option<Box<_>>` the
82    /// common path costs one pointer and no allocation.
83    ext: Option<Box<ProblemExtensions>>,
84}
85
86impl std::fmt::Display for Problem {
87    /// The type and the detail, which is what an operator needs when a problem
88    /// travels inside another error rather than out to a client.
89    ///
90    /// Not the JSON — that is [`Problem::to_value`], and a log line is not a
91    /// place to put a document. Having any `Display` at all is what lets the
92    /// error types that carry a `Problem` derive `thiserror` (ADR 0010).
93    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(formatter, "{}: {}", self.typ, self.detail)
95    }
96}
97
98impl std::error::Error for Problem {}
99
100/// The parts of an RFC 8555 problem document beyond RFC 7807's three fields.
101#[derive(Debug, Default)]
102struct ProblemExtensions {
103    /// The identifier this problem is about (RFC 8555 §9.7.7), set only on a
104    /// *subproblem*: §6.7.1 says the field "MUST NOT be present at the top
105    /// level in ACME problem documents. It can only be present in subproblems."
106    /// [`Problem::to_value`] enforces that by rendering it nowhere else.
107    identifier: Option<Value>,
108    /// Per-identifier failures (RFC 8555 §6.7.1).
109    subproblems: Vec<Problem>,
110    /// Type-specific members some error types carry — today only
111    /// `badSignatureAlgorithm`'s `algorithms` (RFC 8555 §6.2).
112    extra: serde_json::Map<String, Value>,
113}
114
115/// Declares the problem constructors that are nothing but a status, a type URN
116/// and the caller's detail — which is 27 of the 29.
117///
118/// They were written out longhand, six lines each, and the repetition was the
119/// point of failure: a wrong `StatusCode` or a typo'd URN in one of twenty-odd
120/// near-identical bodies reads as correct code. Here each is one line against
121/// its own doc comment, and the shared body exists once.
122///
123/// The two that are absent stay hand-written because they are not this shape:
124/// [`Problem::compound`] takes its status from the caller, and
125/// [`Problem::bad_signature_algorithm`] carries the `algorithms` member RFC 8555
126/// §6.2 requires.
127macro_rules! problems {
128    ($(
129        $(#[$doc:meta])*
130        $name:ident => ($status:ident, $urn:literal);
131    )*) => {
132        impl Problem {
133            $(
134                $(#[$doc])*
135                pub fn $name(detail: impl Into<Cow<'static, str>>) -> Self {
136                    Self::build(StatusCode::$status, $urn, detail)
137                }
138            )*
139        }
140    };
141}
142
143problems! {
144    /// The request was unacceptable for some reason (bad JSON, base64, JWS
145    /// shape, unexpected payload, wrong algorithm…). HTTP 400.
146    malformed => (BAD_REQUEST, "urn:ietf:params:acme:error:malformed");
147
148    /// The client sent an unacceptable anti-replay nonce (unknown or expired).
149    /// The client should retry with a fresh nonce. HTTP 400.
150    bad_nonce => (BAD_REQUEST, "urn:ietf:params:acme:error:badNonce");
151
152    /// The client lacks authorization to perform the request — here, a JWS
153    /// signature that fails verification. HTTP 401.
154    unauthorized => (UNAUTHORIZED, "urn:ietf:params:acme:error:unauthorized");
155
156    /// The server experienced an internal failure (e.g. the database was
157    /// unreachable). HTTP 500.
158    server_internal => (INTERNAL_SERVER_ERROR, "urn:ietf:params:acme:error:serverInternal");
159
160    /// The request referenced an account that does not exist. HTTP 400.
161    account_does_not_exist => (BAD_REQUEST, "urn:ietf:params:acme:error:accountDoesNotExist");
162
163    /// A finalize was attempted on an order that is not in the `ready` state
164    /// (RFC 8555 §7.4). HTTP 403.
165    order_not_ready => (FORBIDDEN, "urn:ietf:params:acme:error:orderNotReady");
166
167    /// The CSR in a finalize request was unacceptable (unparsable, its
168    /// identifiers do not match the order, or a filter refused one of the
169    /// names it requests). HTTP 400.
170    bad_csr => (BAD_REQUEST, "urn:ietf:params:acme:error:badCSR");
171
172    /// A newOrder identifier used a type the server does not support (only
173    /// `dns` is supported here). HTTP 400.
174    unsupported_identifier => (BAD_REQUEST, "urn:ietf:params:acme:error:unsupportedIdentifier");
175
176    /// The server will not issue for an identifier it otherwise supports,
177    /// because policy refuses it (RFC 8555 §6.7). Raised by the `filter`
178    /// subsystem at newOrder. HTTP 403.
179    rejected_identifier => (FORBIDDEN, "urn:ietf:params:acme:error:rejectedIdentifier");
180
181    /// The request was blocked by a connection-level filter (IP allowlist,
182    /// reverse DNS…), or a challenge responder answered with something that is
183    /// not the key authorization. HTTP 403.
184    ///
185    /// RFC 8555 has no dedicated "blocked by policy" code, so this reuses the
186    /// `unauthorized` type — but with 403 rather than the 401 that
187    /// [`Problem::unauthorized`] returns for a failed signature check, since
188    /// no credential could make this request succeed. RFC 8555 §8.3's own
189    /// example uses the same type for an `http-01` body mismatch.
190    access_denied => (FORBIDDEN, "urn:ietf:params:acme:error:unauthorized");
191
192    /// The server could not reach the client's validation target — TCP refused,
193    /// no route, a redirect chain that never terminated. HTTP 400.
194    ///
195    /// One of the four challenge-validation error types of RFC 8555 §6.7. The
196    /// client can fix the situation and retry with a new order.
197    connection => (BAD_REQUEST, "urn:ietf:params:acme:error:connection");
198
199    /// A DNS query the server needed failed or returned nothing (RFC 8555 §6.7).
200    /// HTTP 400.
201    ///
202    /// Distinct from [`Problem::incorrect_response`]: this says the lookup did
203    /// not produce an answer, not that the answer was wrong.
204    dns => (BAD_REQUEST, "urn:ietf:params:acme:error:dns");
205
206    /// The validation target answered, but not with what the challenge requires
207    /// — a TXT record that does not match, a certificate without the expected
208    /// `acmeIdentifier` extension (RFC 8555 §6.7). HTTP 403.
209    incorrect_response => (FORBIDDEN, "urn:ietf:params:acme:error:incorrectResponse");
210
211    /// A TLS-level failure while validating a `tls-alpn-01` challenge — no ALPN
212    /// negotiated, a handshake alert, no certificate presented (RFC 8555 §6.7).
213    /// HTTP 400.
214    tls => (BAD_REQUEST, "urn:ietf:params:acme:error:tls");
215
216    /// The server requires External Account Binding (RFC 8555 §6.7 / §7.3.4)
217    /// but the request did not include one. HTTP 400.
218    external_account_required => (BAD_REQUEST, "urn:ietf:params:acme:error:externalAccountRequired");
219
220    /// The certificate identified by a `POST /revokeCert` request has already
221    /// been revoked (RFC 8555 §7.6). HTTP 400.
222    already_revoked => (BAD_REQUEST, "urn:ietf:params:acme:error:alreadyRevoked");
223
224    /// The `reason` a `POST /revokeCert` request gave is not one of the
225    /// `CRLReason` codes RFC 8555 §7.6 permits (RFC 5280 §5.3.1, excluding the
226    /// reserved code `7`). HTTP 400.
227    bad_revocation_reason => (BAD_REQUEST, "urn:ietf:params:acme:error:badRevocationReason");
228
229    /// The new key in a `keyChange` (RFC 8555 §7.3.5) request is already
230    /// associated with a different account. HTTP 409.
231    ///
232    /// RFC 8555 defines no dedicated error type for this case, so this
233    /// reuses the `malformed` urn — mirroring how [`Problem::access_denied`]
234    /// already reuses `unauthorized`'s urn under a different status. Unlike
235    /// every other `Problem`, the caller also attaches a `Location` header
236    /// naming the conflicting account (RFC 8555 §7.3.5), which requires
237    /// building the `Response` by hand rather than through `IntoResponse`
238    /// (see `to_value`).
239    key_change_conflict => (CONFLICT, "urn:ietf:params:acme:error:malformed");
240
241    /// The request body did not carry `Content-Type: application/jose+json`.
242    /// HTTP 415.
243    ///
244    /// RFC 8555 §6.2 makes the media type mandatory and names the status
245    /// itself: "If a request does not meet this requirement, then the server
246    /// MUST return a response with status code 415 (Unsupported Media Type)".
247    /// It defines no error *type* for the case, so this reuses `malformed`'s
248    /// urn under a different status — the same pattern as
249    /// [`Problem::access_denied`] and [`Problem::key_change_conflict`].
250    unsupported_media_type => (UNSUPPORTED_MEDIA_TYPE, "urn:ietf:params:acme:error:malformed");
251
252    /// The request body was larger than `server.max_body_bytes`. HTTP 413.
253    ///
254    /// Distinct from `malformed` on purpose: the body was never read, so
255    /// nothing is known about whether it was a well-formed JWS, and telling a
256    /// client its JWS is malformed would send it rebuilding the one thing that
257    /// is not the problem. RFC 8555 defines no type for this, so it reuses
258    /// `malformed`'s urn under its own status — the same pattern as
259    /// [`Problem::unsupported_media_type`].
260    payload_too_large => (PAYLOAD_TOO_LARGE, "urn:ietf:params:acme:error:malformed");
261
262    /// The server is at capacity and refused the request without doing any of
263    /// the work. HTTP 503.
264    ///
265    /// Deliberately *not* `rateLimited`/429: that type says "you asked too
266    /// often", which is a statement about the client, and here the client may
267    /// have made its first request of the day. RFC 8555 defines no type for
268    /// server-side saturation, so this reuses `serverInternal`'s urn under a
269    /// different status — the same pattern as [`Problem::access_denied`],
270    /// [`Problem::key_change_conflict`] and [`Problem::unsupported_media_type`].
271    ///
272    /// The caller attaches `Retry-After`; every ACME client already understands
273    /// it from the rate-limit case.
274    service_unavailable => (SERVICE_UNAVAILABLE, "urn:ietf:params:acme:error:serverInternal");
275
276    /// The resource exists but not for this HTTP method — in practice, a bare
277    /// `GET` of a resource that RFC 8555 §6.3 requires be read with
278    /// POST-as-GET. HTTP 405.
279    ///
280    /// §6.3 pins both halves: "if the server receives a GET request, it MUST
281    /// return an error with status code 405 (Method Not Allowed) and type
282    /// `malformed`". axum's own method-not-allowed response carries the right
283    /// status but an empty body, so this supplies the problem document.
284    method_not_allowed => (METHOD_NOT_ALLOWED, "urn:ietf:params:acme:error:malformed");
285
286    /// A newOrder named a predecessor certificate that another order already
287    /// claims to replace (RFC 9773 §7.4). HTTP 409.
288    ///
289    /// The one status RFC 9773 pins by name: §5 says the server "MUST return an
290    /// HTTP 409 (Conflict) with a problem document of type `alreadyReplaced`" —
291    /// unlike the other §5 checks, which only say "SHOULD reject".
292    already_replaced => (CONFLICT, "urn:ietf:params:acme:error:alreadyReplaced");
293
294    /// A `contact` URL used a scheme this server does not support
295    /// (RFC 8555 §7.3). HTTP 400.
296    unsupported_contact => (BAD_REQUEST, "urn:ietf:params:acme:error:unsupportedContact");
297
298    /// A `contact` URL was of a supported scheme but not usable — a `mailto:`
299    /// carrying `hfields` or more than one address (RFC 8555 §7.3). HTTP 400.
300    invalid_contact => (BAD_REQUEST, "urn:ietf:params:acme:error:invalidContact");
301
302    /// The client must take an out-of-band action before the request can
303    /// succeed — here, agreeing to the terms of service (RFC 8555 §7.3.3).
304    /// HTTP 403.
305    ///
306    /// The caller attaches a `Link: <tos-url>;rel="terms-of-service"` header,
307    /// as §6.7 requires for this type.
308    user_action_required => (FORBIDDEN, "urn:ietf:params:acme:error:userActionRequired");
309
310    /// The client asked for more than this server will do for it at once
311    /// (RFC 8555 §6.6). HTTP 429.
312    ///
313    /// The caller attaches a `Retry-After`, which §6.6 recommends for this
314    /// type: the limit is on work in flight, so waiting is what clears it.
315    rate_limited => (TOO_MANY_REQUESTS, "urn:ietf:params:acme:error:rateLimited");
316
317    /// No resource is routed at the requested path. HTTP 404.
318    ///
319    /// RFC 8555 defines no type for this either; `malformed` keeps an unknown
320    /// path answering in the `application/problem+json` shape every other
321    /// failure uses, rather than axum's empty-bodied default.
322    not_found => (NOT_FOUND, "urn:ietf:params:acme:error:malformed");
323}
324
325impl Problem {
326    /// The shared constructor every named one funnels through, so a new field
327    /// on the struct does not have to be threaded through twenty-odd literals.
328    fn build(status: StatusCode, typ: &'static str, detail: impl Into<Cow<'static, str>>) -> Self {
329        Self {
330            status,
331            typ,
332            detail: detail.into(),
333            ext: None,
334        }
335    }
336
337    /// The extensions block, created on first use.
338    fn ext_mut(&mut self) -> &mut ProblemExtensions {
339        self.ext.get_or_insert_with(Box::default)
340    }
341    /// Several errors at once, each attributed to its own identifier
342    /// (RFC 8555 §6.7.1). Pair with [`Problem::with_subproblems`].
343    ///
344    /// The status is the caller's to choose, since §6.7.1 puts no constraint on
345    /// it and a compound of rejections (403) reads differently from a compound
346    /// of malformed names (400).
347    pub fn compound(status: StatusCode, detail: impl Into<Cow<'static, str>>) -> Self {
348        Self::build(status, "urn:ietf:params:acme:error:compound", detail)
349    }
350
351    /// The JWS was signed with an algorithm this server does not support
352    /// (RFC 8555 §6.2). HTTP 400.
353    ///
354    /// §6.2 requires the response to carry the supported list: "an
355    /// `algorithms` field […] listing the JWS algorithms the server supports",
356    /// so the client can retry with one instead of guessing. Attached here
357    /// rather than left to the caller, since the list is a property of this
358    /// server's verifier, not of the call site.
359    pub fn bad_signature_algorithm(detail: impl Into<Cow<'static, str>>) -> Self {
360        Self::build(
361            StatusCode::BAD_REQUEST,
362            "urn:ietf:params:acme:error:badSignatureAlgorithm",
363            detail,
364        )
365        .with_extra("algorithms", json!(["ES256", "RS256"]))
366    }
367}
368
369impl Problem {
370    /// The HTTP status this problem renders as.
371    ///
372    /// Exposed so a caller assembling a `compound` (RFC 8555 §6.7.1) can pick a
373    /// status for the wrapper from the parts it is wrapping.
374    #[must_use]
375    pub fn status(&self) -> StatusCode {
376        self.status
377    }
378
379    /// The human-readable detail, as text — for a caller that carries a
380    /// problem into an error of its own. Reading it back out of
381    /// [`Problem::to_value`] gives a JSON string, quotes included.
382    #[must_use]
383    pub fn detail(&self) -> &str {
384        &self.detail
385    }
386
387    /// Attaches the identifier this problem is about (RFC 8555 §9.7.7).
388    ///
389    /// Only meaningful on a problem destined to become a *subproblem*: §6.7.1
390    /// forbids the field at the top level, and [`Problem::to_value`] drops it
391    /// there, so calling this on a problem that is then returned directly is a
392    /// no-op rather than a violation.
393    #[must_use]
394    pub fn with_identifier(mut self, identifier: &crate::identifier::Identifier) -> Self {
395        self.ext_mut().identifier = serde_json::to_value(identifier).ok();
396        self
397    }
398
399    /// Attaches per-identifier failures (RFC 8555 §6.7.1).
400    ///
401    /// §6.7.1: "Subproblems need not all have the same type, and they do not
402    /// need to match the top level type."
403    #[must_use]
404    pub fn with_subproblems(mut self, subproblems: Vec<Problem>) -> Self {
405        self.ext_mut().subproblems = subproblems;
406        self
407    }
408
409    /// Attaches a type-specific member, e.g. `badSignatureAlgorithm`'s
410    /// `algorithms` list (RFC 8555 §6.2).
411    #[must_use]
412    pub fn with_extra(mut self, key: &str, value: Value) -> Self {
413        self.ext_mut().extra.insert(key.to_string(), value);
414        self
415    }
416
417    /// The RFC 8555 problem document as a JSON value (`{type, detail, status}`,
418    /// plus `subproblems` and any type-specific members when present).
419    ///
420    /// Shared by [`IntoResponse`] (the response body) and callers that need to
421    /// *persist* the same document — e.g. an order's `error` field on a failed
422    /// finalize renders exactly what the client is told.
423    ///
424    /// `identifier` is deliberately absent: §6.7.1 makes it a subproblem-only
425    /// field, and [`Problem::to_subproblem_value`] is where it appears.
426    #[must_use]
427    pub fn to_value(&self) -> Value {
428        let mut object = serde_json::Map::new();
429        object.insert("type".to_string(), Value::String(self.typ.to_string()));
430        object.insert("detail".to_string(), json!(self.detail));
431        object.insert("status".to_string(), json!(self.status.as_u16()));
432
433        if let Some(ext) = &self.ext {
434            for (key, value) in &ext.extra {
435                object.insert(key.clone(), value.clone());
436            }
437
438            if !ext.subproblems.is_empty() {
439                object.insert(
440                    "subproblems".to_string(),
441                    Value::Array(
442                        ext.subproblems
443                            .iter()
444                            .map(Problem::to_subproblem_value)
445                            .collect(),
446                    ),
447                );
448            }
449        }
450
451        Value::Object(object)
452    }
453
454    /// This problem as it appears *inside* another's `subproblems` array
455    /// (RFC 8555 §6.7.1): `type` and `detail`, plus the `identifier` it is
456    /// about. No `status` — the HTTP status belongs to the response, and the
457    /// RFC's own example omits it here.
458    #[must_use]
459    fn to_subproblem_value(&self) -> Value {
460        let mut object = serde_json::Map::new();
461        object.insert("type".to_string(), Value::String(self.typ.to_string()));
462        object.insert("detail".to_string(), json!(self.detail));
463        if let Some(identifier) = self.ext.as_ref().and_then(|ext| ext.identifier.as_ref()) {
464            object.insert("identifier".to_string(), identifier.clone());
465        }
466        Value::Object(object)
467    }
468}
469
470impl IntoResponse for Problem {
471    fn into_response(self) -> Response {
472        let body = Json(self.to_value());
473
474        (
475            self.status,
476            [(header::CONTENT_TYPE, "application/problem+json")],
477            body,
478        )
479            .into_response()
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use http_body_util::BodyExt;
487
488    /// Renders a `Problem` and asserts its status, `content-type`, and the three
489    /// JSON fields of the RFC 8555 problem document.
490    async fn assert_problem(problem: Problem, expected_status: u16, expected_type: &str) {
491        let response = problem.into_response();
492
493        assert_eq!(response.status().as_u16(), expected_status);
494        assert_eq!(
495            response
496                .headers()
497                .get(header::CONTENT_TYPE)
498                .and_then(|v| v.to_str().ok()),
499            Some("application/problem+json"),
500        );
501
502        let bytes = response.into_body().collect().await.unwrap().to_bytes();
503        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
504        assert_eq!(json["type"], expected_type);
505        assert_eq!(json["status"], expected_status);
506        assert_eq!(json["detail"], "boom");
507    }
508
509    #[tokio::test]
510    async fn malformed_renders_400_problem_json() {
511        assert_problem(
512            Problem::malformed("boom"),
513            400,
514            "urn:ietf:params:acme:error:malformed",
515        )
516        .await;
517    }
518
519    #[tokio::test]
520    async fn bad_nonce_renders_400_problem_json() {
521        assert_problem(
522            Problem::bad_nonce("boom"),
523            400,
524            "urn:ietf:params:acme:error:badNonce",
525        )
526        .await;
527    }
528
529    #[tokio::test]
530    async fn unauthorized_renders_401_problem_json() {
531        assert_problem(
532            Problem::unauthorized("boom"),
533            401,
534            "urn:ietf:params:acme:error:unauthorized",
535        )
536        .await;
537    }
538
539    #[tokio::test]
540    async fn server_internal_renders_500_problem_json() {
541        assert_problem(
542            Problem::server_internal("boom"),
543            500,
544            "urn:ietf:params:acme:error:serverInternal",
545        )
546        .await;
547    }
548
549    #[tokio::test]
550    async fn account_does_not_exist_renders_400_problem_json() {
551        assert_problem(
552            Problem::account_does_not_exist("boom"),
553            400,
554            "urn:ietf:params:acme:error:accountDoesNotExist",
555        )
556        .await;
557    }
558
559    #[tokio::test]
560    async fn order_not_ready_renders_403_problem_json() {
561        assert_problem(
562            Problem::order_not_ready("boom"),
563            403,
564            "urn:ietf:params:acme:error:orderNotReady",
565        )
566        .await;
567    }
568
569    #[tokio::test]
570    async fn bad_csr_renders_400_problem_json() {
571        assert_problem(
572            Problem::bad_csr("boom"),
573            400,
574            "urn:ietf:params:acme:error:badCSR",
575        )
576        .await;
577    }
578
579    #[tokio::test]
580    async fn unsupported_identifier_renders_400_problem_json() {
581        assert_problem(
582            Problem::unsupported_identifier("boom"),
583            400,
584            "urn:ietf:params:acme:error:unsupportedIdentifier",
585        )
586        .await;
587    }
588
589    #[tokio::test]
590    async fn rejected_identifier_renders_403_problem_json() {
591        assert_problem(
592            Problem::rejected_identifier("boom"),
593            403,
594            "urn:ietf:params:acme:error:rejectedIdentifier",
595        )
596        .await;
597    }
598
599    #[tokio::test]
600    async fn access_denied_renders_403_problem_json() {
601        assert_problem(
602            Problem::access_denied("boom"),
603            403,
604            "urn:ietf:params:acme:error:unauthorized",
605        )
606        .await;
607    }
608
609    #[tokio::test]
610    async fn connection_renders_400_problem_json() {
611        assert_problem(
612            Problem::connection("boom"),
613            400,
614            "urn:ietf:params:acme:error:connection",
615        )
616        .await;
617    }
618
619    #[tokio::test]
620    async fn dns_renders_400_problem_json() {
621        assert_problem(Problem::dns("boom"), 400, "urn:ietf:params:acme:error:dns").await;
622    }
623
624    #[tokio::test]
625    async fn incorrect_response_renders_403_problem_json() {
626        assert_problem(
627            Problem::incorrect_response("boom"),
628            403,
629            "urn:ietf:params:acme:error:incorrectResponse",
630        )
631        .await;
632    }
633
634    #[tokio::test]
635    async fn tls_renders_400_problem_json() {
636        assert_problem(Problem::tls("boom"), 400, "urn:ietf:params:acme:error:tls").await;
637    }
638
639    #[tokio::test]
640    async fn external_account_required_renders_400_problem_json() {
641        assert_problem(
642            Problem::external_account_required("boom"),
643            400,
644            "urn:ietf:params:acme:error:externalAccountRequired",
645        )
646        .await;
647    }
648
649    #[tokio::test]
650    async fn already_revoked_renders_400_problem_json() {
651        assert_problem(
652            Problem::already_revoked("boom"),
653            400,
654            "urn:ietf:params:acme:error:alreadyRevoked",
655        )
656        .await;
657    }
658
659    #[tokio::test]
660    async fn bad_revocation_reason_renders_400_problem_json() {
661        assert_problem(
662            Problem::bad_revocation_reason("boom"),
663            400,
664            "urn:ietf:params:acme:error:badRevocationReason",
665        )
666        .await;
667    }
668
669    #[tokio::test]
670    async fn key_change_conflict_renders_409_problem_json() {
671        assert_problem(
672            Problem::key_change_conflict("boom"),
673            409,
674            "urn:ietf:params:acme:error:malformed",
675        )
676        .await;
677    }
678
679    #[test]
680    fn to_value_matches_rendered_body() {
681        let value = Problem::server_internal("boom").to_value();
682        assert_eq!(value["type"], "urn:ietf:params:acme:error:serverInternal");
683        assert_eq!(value["detail"], "boom");
684        assert_eq!(value["status"], 500);
685    }
686
687    /// A `String` detail (what the filters build) renders like a literal one.
688    #[test]
689    fn owned_detail_is_accepted_and_rendered() {
690        let name = "evil.example.com";
691        let value = Problem::rejected_identifier(format!("identifier {name} is denied")).to_value();
692        assert_eq!(value["detail"], "identifier evil.example.com is denied");
693    }
694
695    /// The text itself. Reading it through `to_value()["detail"].to_string()`
696    /// — which an operator's revoke error once did — keeps the JSON quotes.
697    #[test]
698    fn detail_is_the_text_without_json_quoting() {
699        let problem = Problem::unauthorized("not \"yours\"");
700        assert_eq!(problem.detail(), "not \"yours\"");
701        assert_ne!(problem.to_value()["detail"].to_string(), problem.detail());
702    }
703}