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}