Skip to main content

acme_proxy_store/
authz.rs

1//! ACME authorizations (RFC 8555 §7.1.4) and their challenges (§8) — the
2//! `authorizations` and `challenges` tables.
3//!
4//! Every state change here is a guarded write whose `rows_affected` decides a
5//! race, because validation runs in a queued job and the rows it read may have
6//! moved under it. [`Challenge::claim_for_validation`] is the one to read
7//! first: it decides, in one statement, whether a triggered challenge is
8//! validated at all. Wildcards are stored in wildcard form
9//! ([`Authorization`] says why).
10
11use crate::sql::Row;
12use serde_json::Value;
13use tracing::{debug, info};
14use uuid::Uuid;
15
16use crate::db::Database;
17use crate::nonce::now_secs;
18use crate::status::{self, AuthzStatus, ChallengeStatus};
19use acme_proxy_core::datetime::rfc3339;
20use acme_proxy_core::identifier::Identifier;
21use acme_proxy_core::random::random_token;
22
23/// An ACME authorization (RFC 8555 §7.1.4). One authorization is created per
24/// order identifier when the order is created, starting in the `pending` state
25/// and carrying the challenges the client can satisfy to prove control of the
26/// identifier.
27///
28/// ## Storage Details
29///
30/// - `identifier` is persisted as a JSON `{type, value}` object.
31/// - Timestamps are epoch seconds (matching orders/accounts/nonces) and rendered
32///   as RFC3339 strings in [`Authorization::to_json`].
33/// - The authorization URL is **derived** from the id + base URL (like the
34///   order's `finalize`/`certificate` URLs), never stored.
35///
36/// ## Wildcards
37///
38/// A wildcard authorization stores its identifier in the **wildcard form**
39/// (`*.example.com`), while the ACME object shows the base name plus a separate
40/// `"wildcard": true` member (RFC 8555 §7.1.4). Storing the base name instead
41/// would collide: the canonical wildcard order `["example.com", "*.example.com"]`
42/// creates two authorizations, and `UNIQUE(order_id, identifier)` compares the
43/// serialized JSON — both rows would be identical and the order would fail to
44/// persist. Deriving with [`Authorization::base_identifier`] costs a
45/// `strip_prefix` and no migration.
46#[derive(Debug)]
47pub struct Authorization {
48    pub id: Uuid,
49    pub order_id: Uuid,
50    pub identifier: Identifier,
51    pub status: AuthzStatus,
52    pub expires: i64,
53    pub created_at: i64,
54}
55
56/// What [`Challenge::claim_for_validation`] did.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ValidationClaim {
59    /// This caller owns the validation and owes it a verdict.
60    Claimed,
61    /// Nothing to do: the challenge, its authorization or its order is already
62    /// decided, here or by a sibling.
63    Decided,
64    /// The account has `challenge.max_in_flight_per_account` validations in
65    /// flight already. The challenge is untouched, so the client may trigger it
66    /// again once one of them has settled.
67    Limited,
68}
69
70/// An ACME challenge (RFC 8555 §8): one proof the client may offer for its
71/// authorization's identifier.
72///
73/// Which types an authorization carries is decided by
74/// `ChallengeRegistry::types_for`
75/// — `UNIQUE(authz_id, type)` allows several, one per type. Whether triggering
76/// one performs a real network check or is accepted outright is the registry's
77/// `bypass` setting, not this model's business.
78///
79/// A failed challenge stores the problem document that explains why in `error`,
80/// which the client reads back from the challenge object.
81#[derive(Debug)]
82pub struct Challenge {
83    pub id: Uuid,
84    pub authz_id: Uuid,
85    pub typ: String,
86    pub token: String,
87    pub status: ChallengeStatus,
88    pub validated: Option<i64>,
89    /// The RFC 8555 problem document of a failed validation. `None` until one
90    /// fails.
91    pub error: Option<Value>,
92    pub created_at: i64,
93}
94
95/// Every column of `authorizations`, in one place: each read must select the same set
96/// or `from_row` fails on whichever forgot one.
97///
98/// A `macro_rules!` rather than a `const` so the expansion is a string
99/// *literal*, which is what `sqlx::query`'s `SqlSafeStr` bound requires.
100macro_rules! authz_columns {
101    () => {
102        "id, order_id, identifier, status, expires, created_at"
103    };
104}
105
106impl Authorization {
107    fn from_row(row: Row) -> Result<Self, sqlx::Error> {
108        let identifier_json: String = row.try_get("identifier")?;
109        let identifier: Identifier =
110            serde_json::from_str(&identifier_json).map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
111
112        Ok(Authorization {
113            id: row.try_get("id")?,
114            order_id: row.try_get("order_id")?,
115            identifier,
116            status: status::from_column(row.try_get::<String>("status")?.as_str())?,
117            expires: row.try_get("expires")?,
118            created_at: row.try_get("created_at")?,
119        })
120    }
121
122    /// Builds a new `pending` authorization for `identifier`. Pure — nothing is
123    /// persisted until [`Authorization::insert`] runs.
124    pub fn new(order_id: Uuid, identifier: Identifier, expires: i64) -> Authorization {
125        Authorization {
126            id: crate::id::mint(),
127            order_id,
128            identifier,
129            status: AuthzStatus::Pending,
130            expires,
131            created_at: now_secs(),
132        }
133    }
134
135    /// Inserts the authorization using any executor — a pool, or a transaction
136    /// (see [`crate::order::Order::insert`] for why that matters).
137    pub async fn insert<'e>(
138        &self,
139        executor: impl Into<crate::sql::Exec<'e>>,
140    ) -> Result<(), sqlx::Error> {
141        // `Identifier` derives `Serialize`, so this never fails in practice.
142        let identifier_json = serde_json::to_string(&self.identifier)
143            .map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
144
145        debug!(event = "db_authz_create_started", outcome = "progress", authz_id = ?self.id, order_id = ?self.order_id);
146        crate::sql::query(
147            "INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) \
148             VALUES (?, ?, ?, ?, ?, ?);",
149        )
150        .bind(self.id)
151        .bind(self.order_id)
152        .bind(identifier_json)
153        .bind(self.status.as_str())
154        .bind(self.expires)
155        .bind(self.created_at)
156        .execute(executor)
157        .await?;
158
159        info!(event = "db_authz_created", outcome = "success", authz_id = ?self.id, order_id = ?self.order_id);
160        Ok(())
161    }
162
163    /// Creates a new authorization for `identifier` in the `pending` state.
164    pub async fn create(
165        order_id: Uuid,
166        identifier: Identifier,
167        expires: i64,
168        database: &Database,
169    ) -> Result<Authorization, sqlx::Error> {
170        let authz = Authorization::new(order_id, identifier, expires);
171        authz.insert(database).await?;
172        Ok(authz)
173    }
174
175    /// Looks an authorization up by id. An id that does not parse as one is
176    /// `None`, like an unknown one: it came from a URL.
177    pub async fn find_by_id(
178        id: &str,
179        database: &Database,
180    ) -> Result<Option<Authorization>, sqlx::Error> {
181        debug!(event = "db_authz_find_by_id_started", outcome = "progress", authz_id = ?id);
182        let Some(id) = crate::id::parse(id) else {
183            return Ok(None);
184        };
185        let row = crate::sql::query(concat!(
186            "SELECT ",
187            authz_columns!(),
188            " FROM authorizations WHERE id = ?;"
189        ))
190        .bind(id)
191        .fetch_optional(database)
192        .await?;
193
194        row.map(Authorization::from_row).transpose()
195    }
196
197    /// Lists an order's authorizations, oldest first (creation order), for the
198    /// order object's `authorizations` array and the all-valid readiness check.
199    pub async fn find_by_order(
200        order_id: Uuid,
201        database: &Database,
202    ) -> Result<Vec<Authorization>, sqlx::Error> {
203        Self::find_by_order_with(order_id, database).await
204    }
205
206    /// How many authorizations an order has. [`crate::order::Order::count_by_account`]'s
207    /// counterpart, and for the same reason.
208    pub async fn count_by_order(order_id: Uuid, database: &Database) -> Result<i64, sqlx::Error> {
209        let row = crate::sql::query("SELECT COUNT(*) FROM authorizations WHERE order_id = ?;")
210            .bind(order_id)
211            .fetch_one(database)
212            .await?;
213        row.try_get::<i64>(0)
214    }
215
216    /// The authorization ids of several orders at once, keyed by order id.
217    ///
218    /// The listing paths need nothing but the ids — `Order::to_json` builds its
219    /// `authorizations` URLs from them — and were calling
220    /// [`Authorization::find_by_order`] once per row: 51 queries for a default
221    /// page of 50. One `IN (…)` instead.
222    ///
223    /// An order with no authorizations is simply absent from the map, which is
224    /// what a caller wants: `map.remove(id).unwrap_or_default()`.
225    pub async fn find_ids_by_orders(
226        order_ids: &[Uuid],
227        database: &Database,
228    ) -> Result<std::collections::HashMap<Uuid, Vec<Uuid>>, sqlx::Error> {
229        let mut grouped: std::collections::HashMap<Uuid, Vec<Uuid>> =
230            std::collections::HashMap::new();
231        if order_ids.is_empty() {
232            return Ok(grouped);
233        }
234
235        // `QueryBuilder` rather than a formatted `IN` list: `push_bind` is what
236        // keeps the ids parameters instead of interpolated SQL, the same rule
237        // `OrderQuery::push_predicates` follows.
238        let mut builder = crate::sql::Builder::new(
239            database.dialect(),
240            "SELECT id, order_id FROM authorizations WHERE order_id IN (",
241        );
242        let mut separated = builder.separated(", ");
243        for id in order_ids {
244            separated.push_bind(*id);
245        }
246        builder.push(") ORDER BY created_at ASC;");
247
248        debug!(
249            event = "db_authz_find_ids_by_orders",
250            outcome = "success",
251            orders = order_ids.len()
252        );
253        for row in builder.build().fetch_all(database).await? {
254            let order_id: Uuid = row.try_get("order_id")?;
255            let id: Uuid = row.try_get("id")?;
256            grouped.entry(order_id).or_default().push(id);
257        }
258        Ok(grouped)
259    }
260
261    /// [`Authorization::find_by_order`] over any executor — a pool, or a
262    /// transaction.
263    ///
264    /// Reading inside the transaction that just wrote is what makes
265    /// "is every authorization of this order valid now?" answerable at all: from
266    /// the pool, two concurrent validations of two authorizations of one order
267    /// can each read before the other's write commits, so neither sees a
268    /// complete set and neither promotes the order.
269    pub async fn find_by_order_with<'e>(
270        order_id: Uuid,
271        executor: impl Into<crate::sql::Exec<'e>>,
272    ) -> Result<Vec<Authorization>, sqlx::Error> {
273        debug!(event = "db_authz_find_by_order_started", outcome = "progress", order_id = ?order_id);
274        let rows = crate::sql::query(concat!(
275            "SELECT ",
276            authz_columns!(),
277            " FROM authorizations WHERE order_id = ? ORDER BY created_at ASC;"
278        ))
279        .bind(order_id)
280        .fetch_all(executor)
281        .await?;
282
283        rows.into_iter().map(Authorization::from_row).collect()
284    }
285
286    /// The `valid` transition as a bare statement, over any executor, returning
287    /// whether it happened.
288    ///
289    /// Split from [`Authorization::mark_valid`] so a validation verdict can
290    /// compose the challenge, authorization and order transitions into one
291    /// transaction. The in-memory sync stays in `mark_valid`: it must not happen
292    /// until the transaction has committed, or a rollback leaves the object
293    /// claiming a status the database never took.
294    ///
295    /// **Guarded on `pending`.** The verdict is computed by a queued job, long
296    /// after the request that claimed the challenge read this row: by then the
297    /// client may have deactivated the authorization (RFC 8555 §7.5.2), or a
298    /// sibling challenge may have decided it. An unguarded write would walk a
299    /// `deactivated` or `invalid` authorization back to `valid`.
300    pub async fn set_valid<'e>(
301        id: Uuid,
302        executor: impl Into<crate::sql::Exec<'e>>,
303    ) -> Result<bool, sqlx::Error> {
304        let written = crate::sql::query(
305            "UPDATE authorizations SET status = 'valid' WHERE id = ? AND status = 'pending';",
306        )
307        .bind(id)
308        .execute(executor)
309        .await?
310        .rows_affected();
311        Ok(written == 1)
312    }
313
314    /// The `invalid` transition as a bare statement, guarded on `pending` for
315    /// the same reason; see [`Authorization::set_valid`].
316    pub async fn set_invalid<'e>(
317        id: Uuid,
318        executor: impl Into<crate::sql::Exec<'e>>,
319    ) -> Result<bool, sqlx::Error> {
320        let written = crate::sql::query(
321            "UPDATE authorizations SET status = 'invalid' WHERE id = ? AND status = 'pending';",
322        )
323        .bind(id)
324        .execute(executor)
325        .await?
326        .rows_affected();
327        Ok(written == 1)
328    }
329
330    /// The `deactivated` transition as a bare statement; see [`Authorization::set_valid`].
331    ///
332    /// The terminal state a client asks for itself (RFC 8555 §7.5.2). Like
333    /// [`Authorization::mark_invalid`] it does not stamp `validated`: §8 defines
334    /// that as the time of a *successful* validation, and relinquishing an
335    /// authorization is the opposite.
336    ///
337    /// Bare-statement only: §7.5.2's deactivate-and-demote pair is committed in
338    /// one transaction (`handlers::authz`), so there is no persist-and-sync twin
339    /// to go with it.
340    ///
341    /// Guarded on the two states §7.5.2 lets a client leave, so a verdict
342    /// that landed since the caller read the row is never overwritten.
343    pub async fn set_deactivated<'e>(
344        id: Uuid,
345        executor: impl Into<crate::sql::Exec<'e>>,
346    ) -> Result<bool, sqlx::Error> {
347        let written = crate::sql::query(
348            "UPDATE authorizations SET status = 'deactivated' \
349             WHERE id = ? AND status IN ('pending', 'valid');",
350        )
351        .bind(id)
352        .execute(executor)
353        .await?
354        .rows_affected();
355        Ok(written == 1)
356    }
357
358    /// Moves a `pending` authorization to the `valid` state and keeps `self` in
359    /// sync (the same persist-and-sync pattern as
360    /// [`crate::order::Order::finalize`]); `false` when it was not `pending`.
361    pub async fn mark_valid(&mut self, database: &Database) -> Result<bool, sqlx::Error> {
362        debug!(event = "db_authz_mark_valid_started", outcome = "progress", authz_id = ?self.id);
363        if !Self::set_valid(self.id, database).await? {
364            return Ok(false);
365        }
366
367        self.status = AuthzStatus::Valid;
368        info!(event = "db_authz_marked_valid", outcome = "success", authz_id = ?self.id);
369        Ok(true)
370    }
371
372    /// Moves the authorization to the terminal `invalid` state, after one of its
373    /// challenges failed validation (RFC 8555 §7.1.6).
374    ///
375    /// No `error` is stored: the RFC puts the problem document on the
376    /// *challenge*, and the authorization object has no `error` member — a client
377    /// reads the reason from the challenge it triggered.
378    pub async fn mark_invalid(&mut self, database: &Database) -> Result<bool, sqlx::Error> {
379        debug!(event = "db_authz_mark_invalid_started", outcome = "progress", authz_id = ?self.id);
380        if !Self::set_invalid(self.id, database).await? {
381            return Ok(false);
382        }
383
384        self.status = AuthzStatus::Invalid;
385        info!(event = "db_authz_marked_invalid", outcome = "failure", authz_id = ?self.id);
386        Ok(true)
387    }
388
389    /// Whether this authorization covers the wildcard of its identifier.
390    ///
391    /// Derived from the stored value rather than stored separately — see the
392    /// type's doc comment for why the row keeps the `*.` prefix.
393    #[must_use]
394    pub fn is_wildcard(&self) -> bool {
395        self.identifier.value.starts_with("*.")
396    }
397
398    /// The name to actually prove control of: the identifier with any `*.`
399    /// stripped.
400    ///
401    /// A wildcard is proved by controlling the zone, so the DNS record lives at
402    /// `_acme-challenge.example.com`, not at `_acme-challenge.*.example.com`.
403    #[must_use]
404    pub fn base_identifier(&self) -> &str {
405        self.identifier
406            .value
407            .strip_prefix("*.")
408            .unwrap_or(&self.identifier.value)
409    }
410
411    /// The RFC 8555 authorization object: `identifier`, `status`, `expires`
412    /// (RFC3339), the `challenges` array (each rendered by
413    /// [`Challenge::to_json`]), and `wildcard` when the authorization covers one.
414    #[must_use]
415    pub fn to_json(&self, base_url: &str, challenges: &[Challenge]) -> Value {
416        let mut object = serde_json::Map::new();
417        // RFC 8555 §7.1.4: the identifier of a wildcard authorization is the
418        // *base* name, with the wildcard signalled by its own member. The stored
419        // row keeps the `*.` prefix; only this rendering strips it.
420        object.insert(
421            "identifier".to_string(),
422            serde_json::to_value(Identifier::new(
423                self.identifier.typ.clone(),
424                self.base_identifier().to_string(),
425            ))
426            .expect("Identifier is always serializable"),
427        );
428        object.insert(
429            "status".to_string(),
430            Value::String(self.status.as_str().to_string()),
431        );
432        object.insert("expires".to_string(), Value::String(rfc3339(self.expires)));
433        let challenges: Vec<Value> = challenges.iter().map(|c| c.to_json(base_url)).collect();
434        object.insert("challenges".to_string(), Value::Array(challenges));
435        if self.is_wildcard() {
436            object.insert("wildcard".to_string(), Value::Bool(true));
437        }
438        Value::Object(object)
439    }
440}
441
442/// Every column of `challenges`, in one place: each read must select the same set
443/// or `from_row` fails on whichever forgot one.
444///
445/// A `macro_rules!` rather than a `const` so the expansion is a string
446/// *literal*, which is what `sqlx::query`'s `SqlSafeStr` bound requires.
447macro_rules! challenge_columns {
448    () => {
449        "id, authz_id, type, token, status, validated, error, created_at"
450    };
451}
452
453impl Challenge {
454    fn from_row(row: Row) -> Result<Self, sqlx::Error> {
455        let error_json: Option<String> = row.try_get("error")?;
456        let error = error_json
457            .map(|json| serde_json::from_str(&json))
458            .transpose()
459            .map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
460
461        Ok(Challenge {
462            id: row.try_get("id")?,
463            authz_id: row.try_get("authz_id")?,
464            typ: row.try_get("type")?,
465            token: row.try_get("token")?,
466            status: status::from_column(row.try_get::<String>("status")?.as_str())?,
467            validated: row.try_get("validated")?,
468            error,
469            created_at: row.try_get("created_at")?,
470        })
471    }
472
473    /// Builds a new `pending` challenge of type `typ` with a fresh random token.
474    /// Pure — nothing is persisted until [`Challenge::insert`] runs.
475    ///
476    /// **Each challenge gets its own token**, even when several are offered for
477    /// one authorization: RFC 8555 §8 describes the token as a per-challenge
478    /// value, and every key authorization derives from it.
479    pub fn new(authz_id: Uuid, typ: &str) -> Challenge {
480        Challenge {
481            id: crate::id::mint(),
482            authz_id,
483            typ: typ.to_string(),
484            token: random_token(),
485            status: ChallengeStatus::Pending,
486            validated: None,
487            error: None,
488            created_at: now_secs(),
489        }
490    }
491
492    /// Inserts the challenge using any executor — a pool, or a transaction
493    /// (see [`crate::order::Order::insert`] for why that matters).
494    pub async fn insert<'e>(
495        &self,
496        executor: impl Into<crate::sql::Exec<'e>>,
497    ) -> Result<(), sqlx::Error> {
498        debug!(event = "db_challenge_create_started", outcome = "progress", challenge_id = ?self.id, authz_id = ?self.authz_id);
499        crate::sql::query(
500            "INSERT INTO challenges (id, authz_id, type, token, status, validated, created_at) \
501             VALUES (?, ?, ?, ?, ?, NULL, ?);",
502        )
503        .bind(self.id)
504        .bind(self.authz_id)
505        .bind(&self.typ)
506        .bind(&self.token)
507        .bind(self.status.as_str())
508        .bind(self.created_at)
509        .execute(executor)
510        .await?;
511
512        info!(event = "db_challenge_created", outcome = "success", challenge_id = ?self.id, authz_id = ?self.authz_id);
513        Ok(())
514    }
515
516    /// Creates a new challenge of type `typ`, with a fresh random token, in the
517    /// `pending` state.
518    pub async fn create(
519        authz_id: Uuid,
520        typ: &str,
521        database: &Database,
522    ) -> Result<Challenge, sqlx::Error> {
523        let challenge = Challenge::new(authz_id, typ);
524        challenge.insert(database).await?;
525        Ok(challenge)
526    }
527
528    /// Looks a challenge up by id. An id that does not parse as one is `None`,
529    /// like an unknown one: it came from a URL.
530    pub async fn find_by_id(
531        id: &str,
532        database: &Database,
533    ) -> Result<Option<Challenge>, sqlx::Error> {
534        debug!(event = "db_challenge_find_by_id_started", outcome = "progress", challenge_id = ?id);
535        let Some(id) = crate::id::parse(id) else {
536            return Ok(None);
537        };
538        let row = crate::sql::query(concat!(
539            "SELECT ",
540            challenge_columns!(),
541            " FROM challenges WHERE id = ?;"
542        ))
543        .bind(id)
544        .fetch_optional(database)
545        .await?;
546
547        row.map(Challenge::from_row).transpose()
548    }
549
550    /// Lists an authorization's challenges (creation order), for the
551    /// authorization object's `challenges` array.
552    pub async fn find_by_authz(
553        authz_id: Uuid,
554        database: &Database,
555    ) -> Result<Vec<Challenge>, sqlx::Error> {
556        debug!(event = "db_challenge_find_by_authz_started", outcome = "progress", authz_id = ?authz_id);
557        let rows = crate::sql::query(concat!(
558            "SELECT ",
559            challenge_columns!(),
560            " FROM challenges WHERE authz_id = ? ORDER BY created_at ASC;"
561        ))
562        .bind(authz_id)
563        .fetch_all(database)
564        .await?;
565
566        rows.into_iter().map(Challenge::from_row).collect()
567    }
568
569    /// Takes this challenge for validation, moving `pending` to `processing`.
570    ///
571    /// Returns whether the claim was won. `Order::claim_for_finalize`'s
572    /// primitive, applied one table down and for a sharper reason: the
573    /// validator reaches out to an address the *client* chose, so two triggers
574    /// that both pass a status check in memory become two probes of somebody
575    /// else's host. Deciding it in the `UPDATE` is what makes "exactly one
576    /// validation per challenge" a property of the row rather than of how the
577    /// handler happens to be scheduled.
578    ///
579    /// A losing caller is not an error: its challenge is already being decided,
580    /// and the answer it owes the client is the object as it stands. That
581    /// object now says `processing`, which is exactly what §7.1.6 asks for —
582    /// "they transition to the `processing` state when the client responds to
583    /// the challenge" — and §8.2 pairs it with a `Retry-After`, which
584    /// `handlers::authz::add_pending_retry_after` supplies. A retry request is
585    /// explicitly *not* a state change there, so the loser's answer is a
586    /// conformant one rather than a consolation.
587    ///
588    /// A claim that is never settled (the process dies mid-validation) leaves
589    /// the row `processing`, which is the same trade `claim_for_finalize`
590    /// makes: the authorization's own `expires` retires it, and
591    /// `post_challenge` refuses an expired authorization before looking at the
592    /// challenge at all.
593    ///
594    /// The claim also requires the authorization and its order to still be
595    /// `pending`, read in the same statement. A challenge under an authorization
596    /// a sibling already decided, or under an order another authorization has
597    /// already failed, has nothing left to prove, and validating it would be an
598    /// outbound probe of a client-chosen host for no purpose.
599    /// `max_in_flight` is `challenge.max_in_flight_per_account`, counted in the
600    /// same statement — `0` is no limit. One account can create as many orders
601    /// as it likes, and every challenge under them is a probe of an address it
602    /// named; without the cap one account fills the runner while signings,
603    /// revocations and CRLs wait behind it.
604    pub async fn claim_for_validation(
605        &mut self,
606        max_in_flight: u32,
607        database: &Database,
608    ) -> Result<ValidationClaim, sqlx::Error> {
609        debug!(event = "db_challenge_claim_started", outcome = "progress", challenge_id = ?self.id);
610        let claimed = crate::sql::query(
611            "UPDATE challenges SET status = 'processing' \
612             WHERE id = ? AND status = 'pending' AND EXISTS ( \
613                 SELECT 1 FROM authorizations a JOIN orders o ON o.id = a.order_id \
614                 WHERE a.id = challenges.authz_id \
615                   AND a.status = 'pending' AND o.status = 'pending') \
616               AND (? = 0 OR ( \
617                 SELECT COUNT(*) FROM challenges c \
618                 JOIN authorizations a ON a.id = c.authz_id \
619                 JOIN orders o ON o.id = a.order_id \
620                 WHERE c.status = 'processing' AND o.account_id = ( \
621                     SELECT o2.account_id FROM authorizations a2 \
622                     JOIN orders o2 ON o2.id = a2.order_id \
623                     WHERE a2.id = challenges.authz_id)) < ?);",
624        )
625        .bind(self.id)
626        .bind(max_in_flight)
627        .bind(max_in_flight)
628        .execute(database)
629        .await?
630        .rows_affected()
631            == 1;
632
633        if !claimed {
634            // Which of the two conditions refused it decides what the client is
635            // told, and only the count can be answered with "come back later".
636            if max_in_flight > 0 && self.in_flight_for_account(database).await? >= max_in_flight {
637                debug!(event = "db_challenge_claim_limited", outcome = "advisory", challenge_id = ?self.id, limit = max_in_flight);
638                return Ok(ValidationClaim::Limited);
639            }
640            debug!(event = "db_challenge_claim_refused", outcome = "advisory", challenge_id = ?self.id);
641            return Ok(ValidationClaim::Decided);
642        }
643
644        self.status = ChallengeStatus::Processing;
645        debug!(event = "db_challenge_claimed", outcome = "success", challenge_id = ?self.id);
646        Ok(ValidationClaim::Claimed)
647    }
648
649    /// How many validations are in flight for the account this challenge
650    /// belongs to.
651    async fn in_flight_for_account(&self, database: &Database) -> Result<u32, sqlx::Error> {
652        let row = crate::sql::query(
653            "SELECT COUNT(*) FROM challenges c \
654             JOIN authorizations a ON a.id = c.authz_id \
655             JOIN orders o ON o.id = a.order_id \
656             WHERE c.status = 'processing' AND o.account_id = ( \
657                 SELECT o2.account_id FROM authorizations a2 \
658                 JOIN orders o2 ON o2.id = a2.order_id WHERE a2.id = ?);",
659        )
660        .bind(self.authz_id)
661        .fetch_one(database)
662        .await?;
663        Ok(u32::try_from(row.try_get::<i64>(0)?).unwrap_or(u32::MAX))
664    }
665
666    /// Gives a claim back, moving `processing` to `pending`.
667    ///
668    /// The guarded inverse of [`claim_for_validation`](Self::claim_for_validation),
669    /// named after `Order::release_finalize_claim` and owed for the same reason:
670    /// `post_challenge` claims the row and *then* queues the work, so a failed
671    /// enqueue would otherwise leave a challenge `processing` with nothing
672    /// coming — polled by its client until the authorization expired.
673    ///
674    /// Guarded on the status rather than fired blindly, so a claim another
675    /// runner has already settled is never walked back to `pending`.
676    pub async fn release_validation_claim(
677        &mut self,
678        database: &Database,
679    ) -> Result<bool, sqlx::Error> {
680        let released = crate::sql::query(
681            "UPDATE challenges SET status = 'pending' WHERE id = ? AND status = 'processing';",
682        )
683        .bind(self.id)
684        .execute(database)
685        .await?
686        .rows_affected()
687            == 1;
688
689        if released {
690            self.status = ChallengeStatus::Pending;
691            debug!(event = "db_challenge_claim_released", outcome = "success", challenge_id = ?self.id);
692        }
693        Ok(released)
694    }
695
696    /// Every challenge left `processing` whose authorization has not expired,
697    /// with that authorization's `expires`.
698    ///
699    /// The recovery read behind `ChallengeValidateJob::recover`: a process that
700    /// died between the claim and the enqueue leaves exactly these rows, and
701    /// nothing else re-derives them. Expired authorizations are excluded here
702    /// rather than filtered afterwards — a job queued past its own deadline is
703    /// retired without ever running, so queueing one would be work that exists
704    /// only to be thrown away.
705    pub async fn find_processing(
706        now: i64,
707        database: &Database,
708    ) -> Result<Vec<(Uuid, i64)>, sqlx::Error> {
709        debug!(
710            event = "db_challenge_find_processing_started",
711            outcome = "progress"
712        );
713        let rows = crate::sql::query(
714            "SELECT c.id, a.expires FROM challenges c \
715             JOIN authorizations a ON a.id = c.authz_id \
716             WHERE c.status = 'processing' AND a.expires > ?;",
717        )
718        .bind(now)
719        .fetch_all(database)
720        .await?;
721
722        rows.into_iter()
723            .map(|row| Ok((row.try_get("id")?, row.try_get("expires")?)))
724            .collect()
725    }
726
727    /// The `valid` transition as a bare statement, over any executor.
728    ///
729    /// `validated` is taken as an argument rather than read from the clock here,
730    /// so a caller composing this into a transaction stamps the challenge and
731    /// its in-memory copy with the same instant. See
732    /// [`Authorization::set_valid`] for why the sync is separate.
733    ///
734    /// Guarded on the challenge being undecided (`pending` or `processing`), and
735    /// returns whether it wrote: a verdict is terminal (§7.1.6), so a second
736    /// one — a redelivered job racing the first — leaves the first standing.
737    pub async fn set_valid<'e>(
738        id: Uuid,
739        validated: i64,
740        executor: impl Into<crate::sql::Exec<'e>>,
741    ) -> Result<bool, sqlx::Error> {
742        let written = crate::sql::query(
743            "UPDATE challenges SET status = 'valid', validated = ? \
744             WHERE id = ? AND status IN ('pending', 'processing');",
745        )
746        .bind(validated)
747        .bind(id)
748        .execute(executor)
749        .await?
750        .rows_affected();
751        Ok(written == 1)
752    }
753
754    /// The `invalid` transition as a bare statement, guarded the same way; see
755    /// [`Challenge::set_valid`].
756    pub async fn set_invalid<'e>(
757        id: Uuid,
758        error: &Value,
759        executor: impl Into<crate::sql::Exec<'e>>,
760    ) -> Result<bool, sqlx::Error> {
761        let error_json =
762            serde_json::to_string(error).map_err(|e| sqlx::Error::Encode(Box::new(e)))?;
763        let written = crate::sql::query(
764            "UPDATE challenges SET status = 'invalid', error = ? \
765             WHERE id = ? AND status IN ('pending', 'processing');",
766        )
767        .bind(error_json)
768        .bind(id)
769        .execute(executor)
770        .await?
771        .rows_affected();
772        Ok(written == 1)
773    }
774
775    /// Records a successful validation: moves the challenge to `valid`, stamps
776    /// `validated`, and keeps `self` in sync; `false` when it was already decided.
777    pub async fn mark_valid(&mut self, database: &Database) -> Result<bool, sqlx::Error> {
778        let validated = now_secs();
779        debug!(event = "db_challenge_mark_valid_started", outcome = "progress", challenge_id = ?self.id);
780        if !Self::set_valid(self.id, validated, database).await? {
781            return Ok(false);
782        }
783
784        self.status = ChallengeStatus::Valid;
785        self.validated = Some(validated);
786        info!(event = "db_challenge_marked_valid", outcome = "success", challenge_id = ?self.id);
787        Ok(true)
788    }
789
790    /// Records a failed validation: moves the challenge to the terminal
791    /// `invalid` state, stores the problem document explaining why, and keeps
792    /// `self` in sync.
793    ///
794    /// `validated` is deliberately **not** stamped — RFC 8555 §8 defines it as
795    /// the time the challenge was *successfully* validated.
796    pub async fn mark_invalid(
797        &mut self,
798        error: Value,
799        database: &Database,
800    ) -> Result<bool, sqlx::Error> {
801        debug!(event = "db_challenge_mark_invalid_started", outcome = "progress", challenge_id = ?self.id);
802        if !Self::set_invalid(self.id, &error, database).await? {
803            return Ok(false);
804        }
805
806        self.status = ChallengeStatus::Invalid;
807        self.error = Some(error);
808        info!(event = "db_challenge_marked_invalid", outcome = "failure", challenge_id = ?self.id);
809        Ok(true)
810    }
811
812    /// The RFC 8555 challenge object: `type`, the derived challenge `url`,
813    /// `status`, `token`, plus `validated` (RFC3339) and `error` once set.
814    #[must_use]
815    pub fn to_json(&self, base_url: &str) -> Value {
816        let mut object = serde_json::Map::new();
817        object.insert("type".to_string(), Value::String(self.typ.clone()));
818        object.insert(
819            "url".to_string(),
820            Value::String(format!("{base_url}/chall/{}", self.id)),
821        );
822        object.insert(
823            "status".to_string(),
824            Value::String(self.status.as_str().to_string()),
825        );
826        object.insert("token".to_string(), Value::String(self.token.clone()));
827        if let Some(validated) = self.validated {
828            object.insert("validated".to_string(), Value::String(rfc3339(validated)));
829        }
830        if let Some(error) = &self.error {
831            object.insert("error".to_string(), error.clone());
832        }
833        Value::Object(object)
834    }
835}
836
837#[cfg(test)]
838mod tests {
839
840    use super::*;
841    use crate::account::Account;
842    use crate::order::Order;
843    use crate::status::OrderStatus;
844    use crate::testutil::account_id;
845    use acme_proxy_core::audit::ClientContext;
846    use std::sync::Arc;
847
848    /// The listing paths' N+1 fix: one query for a whole page.
849    #[tokio::test]
850    async fn ids_for_several_orders_come_back_grouped_in_one_query() {
851        let db = Arc::new(Database::connect_for_test().await.unwrap());
852        let account = account_id(&db).await;
853
854        let mut expected = Vec::new();
855        for name in ["a.example.com", "b.example.com"] {
856            let order = Order::create(
857                "default",
858                account,
859                vec![Identifier::dns(name)],
860                now_secs() + 3600,
861                None,
862                None,
863                &db,
864            )
865            .await
866            .unwrap();
867            let first =
868                Authorization::create(order.id, Identifier::dns(name), now_secs() + 3600, &db)
869                    .await
870                    .unwrap();
871            expected.push((order.id, first.id));
872        }
873
874        let ids: Vec<Uuid> = expected.iter().map(|(o, _)| *o).collect();
875        let grouped = Authorization::find_ids_by_orders(&ids, &db).await.unwrap();
876        assert_eq!(grouped.len(), 2);
877        for (order_id, authz_id) in &expected {
878            assert_eq!(grouped[order_id], vec![*authz_id]);
879        }
880
881        // An order with no authorizations is simply absent, which is what
882        // `remove(..).unwrap_or_default()` at the call site relies on.
883        let grouped = Authorization::find_ids_by_orders(&[crate::id::mint()], &db)
884            .await
885            .unwrap();
886        assert!(grouped.is_empty());
887
888        // And an empty request does not build a `WHERE id IN ()`.
889        assert!(
890            Authorization::find_ids_by_orders(&[], &db)
891                .await
892                .unwrap()
893                .is_empty()
894        );
895    }
896
897    /// Builds account → order and returns the order id, so authorizations have a
898    /// real parent to reference.
899    async fn order_id(db: &Arc<Database>) -> String {
900        let (account, _) = Account::find_or_create(
901            "default",
902            &[1u8, 2, 3],
903            vec![],
904            &ClientContext::default(),
905            db,
906        )
907        .await
908        .unwrap();
909        let order = Order::create(
910            "default",
911            account.id,
912            vec![Identifier::dns("example.com")],
913            now_secs() + 3600,
914            None,
915            None,
916            db,
917        )
918        .await
919        .unwrap();
920        order.id.to_string()
921    }
922
923    /// The `set_*` twins exist so `post_challenge` can put the challenge,
924    /// authorization and order transitions in one transaction. This is the
925    /// property that buys: a failure part-way through leaves *nothing* applied.
926    ///
927    /// Without it the three were separate statements, and a stop between the
928    /// last two left an order `pending` with every authorization `valid` — a
929    /// state nothing re-derives, since the readiness check only ever ran from
930    /// the challenge trigger and the client has no challenge left to answer.
931    #[tokio::test]
932    async fn the_validation_transitions_roll_back_together() {
933        let db = Arc::new(Database::connect_for_test().await.unwrap());
934        let oid = order_id(&db).await;
935        let authz = Authorization::create(
936            oid.parse().unwrap(),
937            Identifier::dns("example.com"),
938            now_secs() + 3600,
939            &db,
940        )
941        .await
942        .unwrap();
943        let challenge = Challenge::create(authz.id, "http-01", &db).await.unwrap();
944
945        // Everything the success path does, then abandoned rather than
946        // committed — standing in for a failure after the first statement.
947        let mut tx = db.transaction().await.unwrap();
948        Challenge::set_valid(challenge.id, now_secs(), tx.conn())
949            .await
950            .unwrap();
951        Authorization::set_valid(authz.id, tx.conn()).await.unwrap();
952        Order::set_ready(oid.parse().unwrap(), tx.conn())
953            .await
954            .unwrap();
955        drop(tx); // rolls back, which is what this test is about
956
957        let reloaded_authz = Authorization::find_by_id(authz.id.to_string().as_str(), &db)
958            .await
959            .unwrap()
960            .unwrap();
961        let reloaded_challenge = Challenge::find_by_id(challenge.id.to_string().as_str(), &db)
962            .await
963            .unwrap()
964            .unwrap();
965        let reloaded_order = Order::find_by_id(&oid, &db).await.unwrap().unwrap();
966
967        assert_eq!(reloaded_challenge.status, ChallengeStatus::Pending);
968        assert_eq!(reloaded_authz.status, AuthzStatus::Pending);
969        assert_eq!(reloaded_order.status, OrderStatus::Pending);
970    }
971
972    /// And the same three, committed, do all land — so the test above is about
973    /// the rollback and not about the statements being no-ops.
974    #[tokio::test]
975    async fn the_validation_transitions_commit_together() {
976        let db = Arc::new(Database::connect_for_test().await.unwrap());
977        let oid = order_id(&db).await;
978        let authz = Authorization::create(
979            oid.parse().unwrap(),
980            Identifier::dns("example.com"),
981            now_secs() + 3600,
982            &db,
983        )
984        .await
985        .unwrap();
986        let challenge = Challenge::create(authz.id, "http-01", &db).await.unwrap();
987
988        let mut tx = db.transaction().await.unwrap();
989        Challenge::set_valid(challenge.id, now_secs(), tx.conn())
990            .await
991            .unwrap();
992        Authorization::set_valid(authz.id, tx.conn()).await.unwrap();
993        Order::set_ready(oid.parse().unwrap(), tx.conn())
994            .await
995            .unwrap();
996        tx.commit().await.unwrap();
997
998        assert_eq!(
999            Challenge::find_by_id(challenge.id.to_string().as_str(), &db)
1000                .await
1001                .unwrap()
1002                .unwrap()
1003                .status,
1004            ChallengeStatus::Valid
1005        );
1006        assert_eq!(
1007            Authorization::find_by_id(authz.id.to_string().as_str(), &db)
1008                .await
1009                .unwrap()
1010                .unwrap()
1011                .status,
1012            AuthzStatus::Valid
1013        );
1014        assert_eq!(
1015            Order::find_by_id(&oid, &db).await.unwrap().unwrap().status,
1016            OrderStatus::Ready
1017        );
1018    }
1019
1020    #[tokio::test]
1021    async fn authz_create_find_round_trip() {
1022        let db = Arc::new(Database::connect_for_test().await.unwrap());
1023        let oid = order_id(&db).await;
1024
1025        let authz = Authorization::create(
1026            oid.parse().unwrap(),
1027            Identifier::dns("example.com"),
1028            now_secs() + 3600,
1029            &db,
1030        )
1031        .await
1032        .unwrap();
1033        assert_eq!(authz.status, AuthzStatus::Pending);
1034
1035        let by_id = Authorization::find_by_id(authz.id.to_string().as_str(), &db)
1036            .await
1037            .unwrap()
1038            .unwrap();
1039        assert_eq!(by_id.identifier, Identifier::dns("example.com"));
1040        assert_eq!(by_id.order_id.to_string(), oid);
1041
1042        let by_order = Authorization::find_by_order(oid.parse().unwrap(), &db)
1043            .await
1044            .unwrap();
1045        assert_eq!(by_order.len(), 1);
1046    }
1047
1048    #[tokio::test]
1049    async fn authz_mark_valid_persists_and_syncs() {
1050        let db = Arc::new(Database::connect_for_test().await.unwrap());
1051        let oid = order_id(&db).await;
1052
1053        let mut authz = Authorization::create(
1054            oid.parse().unwrap(),
1055            Identifier::dns("example.com"),
1056            now_secs() + 3600,
1057            &db,
1058        )
1059        .await
1060        .unwrap();
1061        authz.mark_valid(&db).await.unwrap();
1062
1063        assert_eq!(authz.status, AuthzStatus::Valid);
1064        let reloaded = Authorization::find_by_id(authz.id.to_string().as_str(), &db)
1065            .await
1066            .unwrap()
1067            .unwrap();
1068        assert_eq!(reloaded.status, AuthzStatus::Valid);
1069    }
1070
1071    #[tokio::test]
1072    async fn authz_to_json_shape() {
1073        let db = Arc::new(Database::connect_for_test().await.unwrap());
1074        let oid = order_id(&db).await;
1075
1076        let authz = Authorization::create(
1077            oid.parse().unwrap(),
1078            Identifier::dns("example.com"),
1079            now_secs() + 3600,
1080            &db,
1081        )
1082        .await
1083        .unwrap();
1084        let challenge = Challenge::create(authz.id, "http-01", &db).await.unwrap();
1085
1086        let json = authz.to_json("http://localhost:3000", std::slice::from_ref(&challenge));
1087        assert_eq!(json["status"], "pending");
1088        assert_eq!(
1089            json["identifier"],
1090            serde_json::json!({"type":"dns","value":"example.com"})
1091        );
1092        assert!(json["expires"].as_str().unwrap().ends_with('Z'));
1093        assert_eq!(json["challenges"].as_array().unwrap().len(), 1);
1094        assert_eq!(json["challenges"][0]["type"], "http-01");
1095    }
1096
1097    #[tokio::test]
1098    async fn challenge_create_find_round_trip() {
1099        let db = Arc::new(Database::connect_for_test().await.unwrap());
1100        let oid = order_id(&db).await;
1101        let authz = Authorization::create(
1102            oid.parse().unwrap(),
1103            Identifier::dns("example.com"),
1104            now_secs() + 3600,
1105            &db,
1106        )
1107        .await
1108        .unwrap();
1109
1110        let challenge = Challenge::create(authz.id, "http-01", &db).await.unwrap();
1111        assert_eq!(challenge.typ, "http-01");
1112        assert_eq!(challenge.status, ChallengeStatus::Pending);
1113        assert!(!challenge.token.is_empty());
1114        assert!(challenge.validated.is_none());
1115
1116        let by_id = Challenge::find_by_id(challenge.id.to_string().as_str(), &db)
1117            .await
1118            .unwrap()
1119            .unwrap();
1120        assert_eq!(by_id.token, challenge.token);
1121
1122        let by_authz = Challenge::find_by_authz(authz.id, &db).await.unwrap();
1123        assert_eq!(by_authz.len(), 1);
1124    }
1125
1126    #[tokio::test]
1127    async fn challenge_mark_valid_persists_and_syncs() {
1128        let db = Arc::new(Database::connect_for_test().await.unwrap());
1129        let oid = order_id(&db).await;
1130        let authz = Authorization::create(
1131            oid.parse().unwrap(),
1132            Identifier::dns("example.com"),
1133            now_secs() + 3600,
1134            &db,
1135        )
1136        .await
1137        .unwrap();
1138
1139        let mut challenge = Challenge::create(authz.id, "http-01", &db).await.unwrap();
1140        challenge.mark_valid(&db).await.unwrap();
1141
1142        assert_eq!(challenge.status, ChallengeStatus::Valid);
1143        assert!(challenge.validated.is_some());
1144
1145        let reloaded = Challenge::find_by_id(challenge.id.to_string().as_str(), &db)
1146            .await
1147            .unwrap()
1148            .unwrap();
1149        assert_eq!(reloaded.status, ChallengeStatus::Valid);
1150        let json = reloaded.to_json("http://localhost:3000");
1151        assert_eq!(json["status"], "valid");
1152        assert_eq!(
1153            json["url"],
1154            format!("http://localhost:3000/chall/{}", challenge.id)
1155        );
1156        assert!(json["validated"].as_str().unwrap().ends_with('Z'));
1157    }
1158
1159    #[tokio::test]
1160    async fn challenge_mark_invalid_persists_the_problem_document() {
1161        let db = Arc::new(Database::connect_for_test().await.unwrap());
1162        let oid = order_id(&db).await;
1163        let authz = Authorization::create(
1164            oid.parse().unwrap(),
1165            Identifier::dns("example.com"),
1166            now_secs() + 3600,
1167            &db,
1168        )
1169        .await
1170        .unwrap();
1171
1172        let mut challenge = Challenge::create(authz.id, "http-01", &db).await.unwrap();
1173        let problem = serde_json::json!({
1174            "type": "urn:ietf:params:acme:error:incorrectResponse",
1175            "detail": "response body does not match the key authorization",
1176            "status": 403,
1177        });
1178        challenge.mark_invalid(problem.clone(), &db).await.unwrap();
1179
1180        assert_eq!(challenge.status, ChallengeStatus::Invalid);
1181        assert_eq!(challenge.error.as_ref(), Some(&problem));
1182        // RFC 8555 §8 stamps `validated` only on success.
1183        assert!(challenge.validated.is_none());
1184
1185        let reloaded = Challenge::find_by_id(challenge.id.to_string().as_str(), &db)
1186            .await
1187            .unwrap()
1188            .unwrap();
1189        assert_eq!(reloaded.status, ChallengeStatus::Invalid);
1190        assert_eq!(reloaded.error.as_ref(), Some(&problem));
1191        let json = reloaded.to_json("http://localhost:3000");
1192        assert_eq!(json["error"], problem);
1193        assert!(json.get("validated").is_none());
1194    }
1195
1196    #[tokio::test]
1197    async fn authz_mark_invalid_persists_and_syncs() {
1198        let db = Arc::new(Database::connect_for_test().await.unwrap());
1199        let oid = order_id(&db).await;
1200
1201        let mut authz = Authorization::create(
1202            oid.parse().unwrap(),
1203            Identifier::dns("example.com"),
1204            now_secs() + 3600,
1205            &db,
1206        )
1207        .await
1208        .unwrap();
1209        authz.mark_invalid(&db).await.unwrap();
1210
1211        assert_eq!(authz.status, AuthzStatus::Invalid);
1212        let reloaded = Authorization::find_by_id(authz.id.to_string().as_str(), &db)
1213            .await
1214            .unwrap()
1215            .unwrap();
1216        assert_eq!(reloaded.status, AuthzStatus::Invalid);
1217    }
1218
1219    /// `UNIQUE(authz_id, type)` is what lets one authorization offer several
1220    /// challenges — one per type, and no more.
1221    #[tokio::test]
1222    async fn an_authorization_holds_one_challenge_per_type() {
1223        let db = Arc::new(Database::connect_for_test().await.unwrap());
1224        let oid = order_id(&db).await;
1225        let authz = Authorization::create(
1226            oid.parse().unwrap(),
1227            Identifier::dns("example.com"),
1228            now_secs() + 3600,
1229            &db,
1230        )
1231        .await
1232        .unwrap();
1233
1234        let http = Challenge::create(authz.id, "http-01", &db).await.unwrap();
1235        let dns_01 = Challenge::create(authz.id, "dns-01", &db).await.unwrap();
1236        Challenge::create(authz.id, "tls-alpn-01", &db)
1237            .await
1238            .unwrap();
1239
1240        // Each carries its own token: a key authorization is per challenge.
1241        assert_ne!(http.token, dns_01.token);
1242
1243        let challenges = Challenge::find_by_authz(authz.id, &db).await.unwrap();
1244        assert_eq!(challenges.len(), 3);
1245
1246        // A second challenge of a type already offered is refused by the schema.
1247        assert!(Challenge::create(authz.id, "http-01", &db).await.is_err());
1248    }
1249
1250    /// A wildcard authorization stores the `*.` form but renders the base name
1251    /// plus a `wildcard` member (RFC 8555 §7.1.4), and proves control of the
1252    /// base name.
1253    #[tokio::test]
1254    async fn a_wildcard_authorization_stores_the_prefix_and_renders_the_base_name() {
1255        let db = Arc::new(Database::connect_for_test().await.unwrap());
1256        let oid = order_id(&db).await;
1257
1258        let authz = Authorization::create(
1259            oid.parse().unwrap(),
1260            Identifier::dns("*.example.com"),
1261            now_secs() + 3600,
1262            &db,
1263        )
1264        .await
1265        .unwrap();
1266        let challenge = Challenge::create(authz.id, "dns-01", &db).await.unwrap();
1267
1268        // The row keeps the wildcard form, so the canonical two-authorization
1269        // order does not collide on `UNIQUE(order_id, identifier)`.
1270        let reloaded = Authorization::find_by_id(authz.id.to_string().as_str(), &db)
1271            .await
1272            .unwrap()
1273            .unwrap();
1274        assert_eq!(reloaded.identifier.value, "*.example.com");
1275        assert!(reloaded.is_wildcard());
1276        assert_eq!(reloaded.base_identifier(), "example.com");
1277
1278        let json = reloaded.to_json("http://localhost:3000", std::slice::from_ref(&challenge));
1279        assert_eq!(
1280            json["identifier"],
1281            serde_json::json!({"type":"dns","value":"example.com"})
1282        );
1283        assert_eq!(json["wildcard"], true);
1284        assert_eq!(json["challenges"][0]["type"], "dns-01");
1285    }
1286
1287    /// The non-wildcard object has no `wildcard` member at all — RFC 8555 makes
1288    /// it optional and clients treat its absence as false.
1289    #[tokio::test]
1290    async fn a_plain_authorization_has_no_wildcard_member() {
1291        let db = Arc::new(Database::connect_for_test().await.unwrap());
1292        let oid = order_id(&db).await;
1293
1294        let authz = Authorization::create(
1295            oid.parse().unwrap(),
1296            Identifier::dns("example.com"),
1297            now_secs() + 3600,
1298            &db,
1299        )
1300        .await
1301        .unwrap();
1302        assert!(!authz.is_wildcard());
1303        assert_eq!(authz.base_identifier(), "example.com");
1304        assert!(
1305            authz
1306                .to_json("http://localhost:3000", &[])
1307                .get("wildcard")
1308                .is_none()
1309        );
1310    }
1311
1312    #[tokio::test]
1313    async fn absent_lookups_return_none() {
1314        let db = Arc::new(Database::connect_for_test().await.unwrap());
1315        assert!(
1316            Authorization::find_by_id("nope", &db)
1317                .await
1318                .unwrap()
1319                .is_none()
1320        );
1321        assert!(Challenge::find_by_id("nope", &db).await.unwrap().is_none());
1322    }
1323}