Skip to main content

acme_proxy/admin/
ops.rs

1use std::collections::{BTreeSet, HashMap};
2use std::io::BufRead;
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::admin::prompt::confirm;
7use crate::audit::{Actor, AuditEvent, AuditRecord, ClientContext};
8use crate::config::Config;
9use crate::signer::{SignerBackend, SignerError};
10use crate::sqlite::account::Account;
11use crate::sqlite::audit::{AuditEntry, AuditQuery};
12use crate::sqlite::authz::{Authorization, Challenge};
13use crate::sqlite::db::Database;
14use crate::sqlite::nonce::{Nonce, now_secs};
15use crate::sqlite::order::{Order, UNPARSABLE_NOT_AFTER};
16
17/// Outcome of a confirm-gated hard delete.
18///
19/// `Cancelled` only exists on the `confirm_*` wrappers: a caller with nobody
20/// to ask -- the web admin -- uses the bare function below, whose return type
21/// has no such variant to leave unhandled.
22#[derive(Debug, PartialEq, Eq)]
23pub enum DeleteOutcome {
24    NotFound,
25    Cancelled,
26    Deleted,
27}
28
29/// What a hard delete took with it.
30///
31/// The count is already computed to word the confirmation prompt, so returning
32/// it costs nothing and lets an API caller report what it removed rather than
33/// answering a bare `204`.
34#[derive(Debug, PartialEq, Eq)]
35pub struct Deleted {
36    /// Rows the schema's `ON DELETE CASCADE` removed along with the row named:
37    /// orders for an account, authorizations for an order.
38    pub cascaded: u64,
39}
40
41/// An order plus every authorization (each with its challenges).
42#[derive(Debug)]
43pub struct OrderDetail {
44    pub order: Order,
45    pub authorizations: Vec<(Authorization, Vec<Challenge>)>,
46}
47
48/// Outcome of [`revoke_order`].
49#[derive(Debug)]
50pub enum RevokeOutcome {
51    NotFound,
52    NotIssued,
53    AlreadyRevoked,
54    Revoked(Box<Order>),
55}
56
57/// How a [`SignerError`] reads inside a [`RevokeError`].
58///
59/// `BadCsr` is not a thing `revoke` can legitimately answer — the hook takes a
60/// certificate, not a CSR — so it is reported as the contract violation it is
61/// rather than passed through as if it meant something here.
62fn signer_detail(error: &SignerError) -> String {
63    match error {
64        SignerError::Internal(detail) => detail.clone(),
65        SignerError::BadCsr => "unexpected badCsr from revoke".to_string(),
66    }
67}
68
69/// Why [`revoke_order`] failed.
70#[derive(Debug, thiserror::Error)]
71pub enum RevokeError {
72    #[error("database error: {0}")]
73    Database(sqlx::Error),
74    #[error("signer error: {}", signer_detail(.0))]
75    Signer(SignerError),
76    #[error("internal error: {0}")]
77    Internal(String),
78    #[error("unsupported revocation reason code {0}")]
79    BadReason(u32),
80}
81
82impl From<sqlx::Error> for RevokeError {
83    fn from(error: sqlx::Error) -> Self {
84        Self::Database(error)
85    }
86}
87
88impl From<SignerError> for RevokeError {
89    fn from(error: SignerError) -> Self {
90        Self::Signer(error)
91    }
92}
93
94// Each of the three destructive operations comes in two forms: a bare one that
95// simply does the thing, and a `confirm_*` wrapper that asks first. The split
96// exists because the wrapper's `assume_yes: bool` + `reader: &mut impl BufRead`
97// are a terminal's concerns, and a caller with no terminal -- the web admin --
98// had to pass `true` and an empty reader, asserting a confirmation that never
99// happened. The generic also makes the wrapper non-object-safe for no benefit
100// on that path. The CLI calls the wrapper; everything else calls the bare form.
101
102/// Hard-deletes an account. `None` when there is no such account; otherwise
103/// how many orders cascaded with it.
104pub async fn delete_account(
105    id: &str,
106    database: Arc<Database>,
107) -> Result<Option<Deleted>, sqlx::Error> {
108    let Some(cascaded) = account_cascade(id, database.clone()).await? else {
109        return Ok(None);
110    };
111    Account::delete(id, &database).await?;
112    Ok(Some(Deleted { cascaded }))
113}
114
115/// Looks up the account, shows what will cascade, confirms, then hard-deletes it.
116pub async fn confirm_delete_account(
117    id: &str,
118    assume_yes: bool,
119    reader: &mut impl BufRead,
120    database: Arc<Database>,
121) -> Result<DeleteOutcome, sqlx::Error> {
122    let Some(account) = Account::find_any_by_id(id, &database).await? else {
123        return Ok(DeleteOutcome::NotFound);
124    };
125    let order_count = Order::count_by_account(id, &database).await?;
126    let prompt = format!(
127        "Delete account {id} (status: {}, {order_count} order(s) will cascade)?",
128        account.status
129    );
130    if !confirm(&prompt, assume_yes, reader) {
131        return Ok(DeleteOutcome::Cancelled);
132    }
133    Account::delete(id, &database).await?;
134    Ok(DeleteOutcome::Deleted)
135}
136
137/// Hard-deletes an order. `None` when there is no such order; otherwise how
138/// many authorizations cascaded with it.
139pub async fn delete_order(
140    id: &str,
141    database: Arc<Database>,
142) -> Result<Option<Deleted>, sqlx::Error> {
143    let Some(cascaded) = order_cascade(id, database.clone()).await? else {
144        return Ok(None);
145    };
146    Order::delete(id, &database).await?;
147    Ok(Some(Deleted { cascaded }))
148}
149
150/// Same shape as [`confirm_delete_account`], for an order.
151pub async fn confirm_delete_order(
152    id: &str,
153    assume_yes: bool,
154    reader: &mut impl BufRead,
155    database: Arc<Database>,
156) -> Result<DeleteOutcome, sqlx::Error> {
157    let Some(order) = Order::find_by_id(id, &database).await? else {
158        return Ok(DeleteOutcome::NotFound);
159    };
160    let authz_count = Authorization::count_by_order(id, &database).await?;
161    let prompt = format!(
162        "Delete order {id} (status: {}, {authz_count} authorization(s) will cascade)?",
163        order.status
164    );
165    if !confirm(&prompt, assume_yes, reader) {
166        return Ok(DeleteOutcome::Cancelled);
167    }
168    Order::delete(id, &database).await?;
169    Ok(DeleteOutcome::Deleted)
170}
171
172/// Runs [`Nonce::cleanup`], returning how many were removed.
173pub async fn cleanup_nonces(ttl: Duration, database: Arc<Database>) -> Result<u64, sqlx::Error> {
174    Nonce::cleanup(&database, ttl).await
175}
176
177/// Confirms, then runs [`cleanup_nonces`]. `None` when the operator declined.
178pub async fn confirm_cleanup_nonces(
179    ttl: Duration,
180    assume_yes: bool,
181    reader: &mut impl BufRead,
182    database: Arc<Database>,
183) -> Result<Option<u64>, sqlx::Error> {
184    let prompt = format!("Delete all nonces older than {}s?", ttl.as_secs());
185    if !confirm(&prompt, assume_yes, reader) {
186        return Ok(None);
187    }
188    Ok(Some(cleanup_nonces(ttl, database).await?))
189}
190
191/// How many orders an account delete would cascade, or `None` if there is no
192/// such account. Shared so the prompt and the bare delete agree on the count.
193async fn account_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
194    if Account::find_any_by_id(id, &database).await?.is_none() {
195        return Ok(None);
196    }
197    Ok(Some(Order::count_by_account(id, &database).await? as u64))
198}
199
200/// The [`account_cascade`] counterpart for an order's authorizations.
201async fn order_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
202    if Order::find_by_id(id, &database).await?.is_none() {
203        return Ok(None);
204    }
205    Ok(Some(
206        Authorization::count_by_order(id, &database).await? as u64,
207    ))
208}
209
210/// Updates an account's contact list.
211pub async fn update_account_contact(
212    id: &str,
213    contact: Vec<String>,
214    database: Arc<Database>,
215) -> Result<Option<Account>, sqlx::Error> {
216    let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
217        return Ok(None);
218    };
219    account.update_contact(contact, &database).await?;
220    Ok(Some(account))
221}
222
223/// Deactivates an account.
224pub async fn deactivate_account(
225    id: &str,
226    database: Arc<Database>,
227) -> Result<Option<Account>, sqlx::Error> {
228    let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
229        return Ok(None);
230    };
231    account.deactivate(&database).await?;
232    Ok(Some(account))
233}
234
235/// Revokes an order's issued certificate at the signer backend and records it on the order.
236///
237/// `actor`/`client` say who asked and from where. Both front ends supply their
238/// own: [`Actor::cli`] with an empty [`ClientContext`] from the command line,
239/// [`Actor::admin`] with the operator's address from the web admin. Passed in
240/// rather than derived here because this layer is deliberately front-end
241/// agnostic — it is the same reason the destructive operations come in a bare
242/// and a `confirm_*` form.
243///
244/// The four outcomes that are *not* a revocation (`NotFound`, `NotIssued`,
245/// `AlreadyRevoked`, a bad reason code) write no audit row. They are the
246/// operator being told the state of things, not the CA refusing something it
247/// might have done — unlike `POST /revokeCert`'s refusals, which are a remote
248/// party being turned away and are audited for exactly that reason.
249pub async fn revoke_order(
250    id: &str,
251    reason: Option<u32>,
252    actor: Actor,
253    client: ClientContext,
254    database: Arc<Database>,
255    signer: Arc<dyn SignerBackend>,
256) -> Result<RevokeOutcome, RevokeError> {
257    let Some(mut order) = Order::find_by_id(id, &database).await? else {
258        return Ok(RevokeOutcome::NotFound);
259    };
260    let Some(chain) = order.certificate.clone() else {
261        return Ok(RevokeOutcome::NotIssued);
262    };
263    if order.revoked_at.is_some() {
264        return Ok(RevokeOutcome::AlreadyRevoked);
265    }
266    if let Some(r) = reason
267        && !crate::cert::is_valid_revocation_reason(r)
268    {
269        return Err(RevokeError::BadReason(r));
270    }
271
272    let cert_der = crate::cert::leaf_der_from_chain(&chain).map_err(|error| {
273        RevokeError::Internal(format!("stored certificate chain is unparsable: {error}"))
274    })?;
275    let mut record = AuditRecord::new(
276        AuditEvent::CertificateRevoked,
277        &order.profile,
278        actor.clone(),
279    )
280    .with_order(&order)
281    .with_client(client.clone());
282    if let Some(serial) = order.cert_serial.clone() {
283        record = record.with_serial(serial);
284    }
285    // Absent rather than empty when no reason was given — see the same rule in
286    // `post_revoke_cert`.
287    if let Some(reason) = reason {
288        record = record.with_reason(reason.to_string());
289    }
290
291    // The signer first, as on the ACME path: the CA-side action is
292    // authoritative, so a failure there must leave the order un-revoked for a
293    // retry — and must be audited as the attempt it was.
294    if let Err(error) = signer.revoke(&cert_der, reason).await {
295        crate::audit::write(
296            AuditRecord::new(AuditEvent::CertificateRevokeFailed, &order.profile, actor)
297                .with_order(&order)
298                .with_client(client)
299                .with_reason("serverInternal")
300                .with_detail(error.to_string()),
301            &database,
302        )
303        .await;
304        return Err(error.into());
305    }
306    order.revoke(reason.map(i64::from), &database).await?;
307    crate::audit::write(record, &database).await;
308    Ok(RevokeOutcome::Revoked(Box::new(order)))
309}
310
311/// The `created_at` below which an audit row is past `retention_days`.
312///
313/// One function so `acme-proxy audit cleanup --older-than` and the
314/// `audit.retention_days` sweep delete the identical set — a CLI that computed
315/// its own cutoff would eventually disagree with the timer by a rounding rule.
316#[must_use]
317pub fn audit_cutoff(days: u64) -> i64 {
318    let seconds = i64::try_from(days.saturating_mul(24 * 60 * 60)).unwrap_or(i64::MAX);
319    crate::sqlite::nonce::now_secs().saturating_sub(seconds)
320}
321
322/// One page of audit rows, plus the unpaged total the same filters match.
323pub async fn list_audit(
324    query: &AuditQuery,
325    database: Arc<Database>,
326) -> Result<(Vec<AuditEntry>, i64), sqlx::Error> {
327    AuditEntry::search(query, &database).await
328}
329
330/// One audit row by id.
331pub async fn find_audit(
332    id: i64,
333    database: Arc<Database>,
334) -> Result<Option<AuditEntry>, sqlx::Error> {
335    AuditEntry::find_by_id(id, &database).await
336}
337
338/// Deletes audit rows older than `days`, returning how many went.
339pub async fn cleanup_audit(days: u64, database: Arc<Database>) -> Result<u64, sqlx::Error> {
340    AuditEntry::cleanup(audit_cutoff(days), &database).await
341}
342
343/// Confirms, then runs [`cleanup_audit`]. `None` when the operator declined.
344///
345/// Confirm-gated, unlike `revoke_order`: this is the one operation in the crate
346/// that destroys audit history, and the prompt names how many rows are about to
347/// go — a number the operator usually did not expect.
348pub async fn confirm_cleanup_audit(
349    days: u64,
350    assume_yes: bool,
351    reader: &mut impl BufRead,
352    database: Arc<Database>,
353) -> Result<Option<u64>, sqlx::Error> {
354    let cutoff = audit_cutoff(days);
355    let doomed = AuditEntry::count_older_than(cutoff, &database).await?;
356    let prompt =
357        format!("Delete {doomed} audit row(s) older than {days} day(s)? This cannot be undone.");
358    if !confirm(&prompt, assume_yes, reader) {
359        return Ok(None);
360    }
361    Ok(Some(AuditEntry::cleanup(cutoff, &database).await?))
362}
363
364/// Loads order detail.
365pub async fn load_order_detail(
366    id: &str,
367    database: Arc<Database>,
368) -> Result<Option<OrderDetail>, sqlx::Error> {
369    let Some(order) = Order::find_by_id(id, &database).await? else {
370        return Ok(None);
371    };
372    let authzs = Authorization::find_by_order(&order.id, &database).await?;
373    let mut authorizations = Vec::with_capacity(authzs.len());
374    for authz in authzs {
375        let challenges = Challenge::find_by_authz(&authz.id, &database).await?;
376        authorizations.push((authz, challenges));
377    }
378    Ok(Some(OrderDetail {
379        order,
380        authorizations,
381    }))
382}
383
384// ---------------------------------------------------------------------------
385// The expiry list
386//
387// One query (`Order::find_expiring`), one annotator (`annotate_expiring`) and
388// three consumers: the `[notify.expiry]` digest, the panel (`GET /api/expiring`
389// and `/ui/expiring`) and `order list --expiring-in`. The annotation used to
390// live inside the digest's job type, where the panel could not reach it — two
391// answers to "has this been replaced?" was exactly one too many.
392// ---------------------------------------------------------------------------
393
394/// The window the panel opens on when the caller names no `days`.
395///
396/// Only reached when `[notify.expiry]` is off (`lead_days = 0`): a deployment
397/// that has chosen a lead time gets that one, since the operator reading the
398/// page is the operator who set it.
399const DEFAULT_LEAD_DAYS: u64 = 30;
400
401/// The certificate that has taken an expiring one's place, and how that was
402/// established.
403///
404/// `via` is carried rather than inferred because the two signals do not mean
405/// the same thing to an operator: `replaces` is the client *saying* it renewed
406/// (RFC 9773 §5, exact but only from clients that send one), where
407/// `identifiers` is this server noticing a later certificate covering the same
408/// names — a good inference, and still an inference.
409#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
410pub struct SupersededBy {
411    pub order_id: String,
412    pub cert_serial: String,
413    pub not_after: i64,
414    /// `"replaces"` or `"identifiers"`.
415    pub via: String,
416}
417
418/// One expiring certificate, annotated: the order row, how long it has left,
419/// and whether anything has replaced it.
420#[derive(Debug)]
421pub struct ExpiringEntry {
422    pub order: Order,
423    pub days_remaining: i64,
424    pub superseded_by: Option<SupersededBy>,
425}
426
427/// The window [`list_expiring`] answers.
428pub struct ExpiringQuery {
429    /// `None` is every endpoint, the panel's default. The digest names one.
430    pub profile: Option<String>,
431    /// The `cert_not_after` at or below which a row is expiring — build it with
432    /// [`expiring_horizon`] rather than computing it a second time.
433    pub before: i64,
434    /// Whether rows something has already replaced stay in the answer. They do
435    /// by default, and the digest never turns them off: see [`list_expiring`]
436    /// for what this cannot do to `total`.
437    pub include_superseded: bool,
438    pub limit: i64,
439    pub offset: i64,
440}
441
442/// The `cert_not_after` at or below which a certificate counts as expiring.
443///
444/// [`audit_cutoff`]'s twin, and for its reason: one function so the digest,
445/// the panel and `order list --expiring-in` cannot come to disagree by a
446/// rounding rule.
447#[must_use]
448pub fn expiring_horizon(days: u64) -> i64 {
449    let seconds = i64::try_from(days.saturating_mul(24 * 60 * 60)).unwrap_or(i64::MAX);
450    now_secs().saturating_add(seconds)
451}
452
453/// Whole days from `now` to `not_after`, floored, and never negative — a
454/// certificate that lapsed between the query and here is "0 days", not "-1".
455///
456/// Hoisted out of the digest so the mail, the page and the terminal round the
457/// same way; computing it in a Jinja template from two epoch seconds is
458/// arithmetic no template should carry.
459#[must_use]
460pub fn days_remaining(not_after: i64, now: i64) -> i64 {
461    not_after.saturating_sub(now).max(0) / (24 * 60 * 60)
462}
463
464/// The lead time a surface should default to: the configured one, or
465/// [`DEFAULT_LEAD_DAYS`] when the digest is switched off.
466#[must_use]
467pub fn default_lead_days(config: &Config) -> u64 {
468    match config.notify.expiry.lead_days {
469        0 => DEFAULT_LEAD_DAYS,
470        days => days,
471    }
472}
473
474/// Whether something has taken `order`'s certificate's place, and how that was
475/// established.
476///
477/// Two signals, tried strongest first, and both deliberately narrow. The
478/// annotation errs towards `None` throughout: a wrong "already renewed" is an
479/// operator ignoring a certificate that really is about to lapse, where a
480/// missing one is only noise. `crate::notify::expiry`'s module docs carry that
481/// argument in full.
482///
483/// `candidates` is the account's own orders, passed in rather than fetched, so
484/// [`annotate_expiring`] can read them once for a whole listing. A caller with
485/// one order and no cache hands it [`Order::find_by_account`]'s result.
486pub async fn superseded_by(
487    order: &Order,
488    candidates: &[Order],
489    database: &Database,
490) -> Result<Option<SupersededBy>, sqlx::Error> {
491    // 1. The client said so (RFC 9773 §5). Exact when it is there at all,
492    //    but only clients that send `replaces` produce it.
493    //
494    //    `find_by_replaces` excludes only `invalid`, because its own
495    //    question is "has this predecessor been claimed" — a *pending*
496    //    claim still holds the claim. That is the wrong answer here: an
497    //    order that has not issued anything has replaced nothing, and
498    //    reporting its predecessor as renewed would silence the one
499    //    certificate still doing the work.
500    if let Some(chain) = order.certificate.as_deref()
501        && let Some(cert_id) = ari_cert_id(chain)
502        && let Some(successor) = Order::find_by_replaces(&order.profile, &cert_id, database).await?
503        && successor.certificate.is_some()
504        && successor.revoked_at.is_none()
505    {
506        return Ok(Some(SupersededBy {
507            order_id: successor.id,
508            cert_serial: successor.cert_serial.unwrap_or_default(),
509            not_after: successor.cert_not_after.unwrap_or_default(),
510            via: "replaces".to_string(),
511        }));
512    }
513
514    // 2. This server noticed a later certificate covering the same names.
515    //    Scoped to the *same account*, and requiring a superset rather than
516    //    an intersection: a certificate held by somebody else is not this
517    //    subscriber's renewal, and one covering only some of these names
518    //    leaves the rest uncovered.
519    let names: BTreeSet<&str> = order
520        .identifiers
521        .iter()
522        .map(|identifier| identifier.value.as_str())
523        .collect();
524    let expires = order.cert_not_after.unwrap_or_default();
525    for candidate in candidates {
526        if candidate.id == order.id
527            || candidate.certificate.is_none()
528            || candidate.revoked_at.is_some()
529            || candidate.cert_not_after.unwrap_or(UNPARSABLE_NOT_AFTER) <= expires
530        {
531            continue;
532        }
533        let covered: BTreeSet<&str> = candidate
534            .identifiers
535            .iter()
536            .map(|identifier| identifier.value.as_str())
537            .collect();
538        if names.is_subset(&covered) {
539            return Ok(Some(SupersededBy {
540                order_id: candidate.id.clone(),
541                cert_serial: candidate.cert_serial.clone().unwrap_or_default(),
542                not_after: candidate.cert_not_after.unwrap_or_default(),
543                via: "identifiers".to_string(),
544            }));
545        }
546    }
547
548    Ok(None)
549}
550
551/// Annotates a whole listing with [`days_remaining`] and [`superseded_by`].
552///
553/// The per-account cache is load-bearing rather than an optimisation.
554/// [`Order::find_by_account`] is unbounded, and the identifier signal needs it
555/// per row: a fifty-row page over one account read that account's entire order
556/// history fifty times, and `order list --expiring-in` is unpaged, so the same
557/// shape over a year-old CA is arbitrarily worse. One read per *distinct*
558/// account is the same answer for a bounded amount of work.
559///
560/// The `replaces` signal stays per row: it is a keyed lookup and a chain parse,
561/// and there is nothing to share between two rows.
562pub async fn annotate_expiring(
563    orders: Vec<Order>,
564    database: &Database,
565) -> Result<Vec<ExpiringEntry>, sqlx::Error> {
566    let now = now_secs();
567    let mut by_account: HashMap<String, Vec<Order>> = HashMap::new();
568    let mut entries = Vec::with_capacity(orders.len());
569
570    for order in orders {
571        if !by_account.contains_key(&order.account_id) {
572            let candidates = Order::find_by_account(&order.account_id, database).await?;
573            by_account.insert(order.account_id.clone(), candidates);
574        }
575        // Present by construction — inserted directly above when absent, so
576        // the empty slice is unreachable rather than a fallback.
577        let candidates = by_account
578            .get(&order.account_id)
579            .map_or(&[][..], Vec::as_slice);
580        let superseded = superseded_by(&order, candidates, database).await?;
581        entries.push(ExpiringEntry {
582            days_remaining: days_remaining(order.cert_not_after.unwrap_or_default(), now),
583            superseded_by: superseded,
584            order,
585        });
586    }
587
588    Ok(entries)
589}
590
591/// One page of expiring certificates, annotated, with the unpaged total and
592/// the number of rows this page suppressed.
593///
594/// **`total` counts the window, not the answer, and that is a limit worth
595/// stating rather than papering over.** Supersession is computed in Rust — two
596/// queries and an X.509 parse per row — so `include_superseded = false` cannot
597/// become a SQL predicate and the `COUNT(*)` beside the page cannot shrink to
598/// match it. The third member is therefore how many rows *this page* hid, and
599/// both front ends show both numbers. The alternative was a pager whose
600/// arithmetic quietly disagreed with the rows under it, which is the one bug a
601/// page control makes visible and nothing else does.
602pub async fn list_expiring(
603    query: &ExpiringQuery,
604    database: Arc<Database>,
605) -> Result<(Vec<ExpiringEntry>, i64, i64), sqlx::Error> {
606    let (orders, total) = Order::find_expiring(
607        query.profile.as_deref(),
608        query.before,
609        query.limit,
610        query.offset,
611        &database,
612    )
613    .await?;
614    let entries = annotate_expiring(orders, &database).await?;
615
616    if query.include_superseded {
617        return Ok((entries, total, 0));
618    }
619    // Named for what it counts, not for `query.before`, which is the horizon.
620    let annotated = i64::try_from(entries.len()).unwrap_or(i64::MAX);
621    let kept: Vec<ExpiringEntry> = entries
622        .into_iter()
623        .filter(|entry| entry.superseded_by.is_none())
624        .collect();
625    let hidden = annotated.saturating_sub(i64::try_from(kept.len()).unwrap_or(i64::MAX));
626    Ok((kept, total, hidden))
627}
628
629/// The RFC 9773 certID of a stored chain's leaf, for the `replaces` lookup.
630fn ari_cert_id(chain: &str) -> Option<String> {
631    crate::cert::leaf_der_from_chain(chain)
632        .ok()
633        .and_then(|der| crate::cert::ari_cert_id(&der).ok())
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use crate::sqlite::order::Identifier;
640    use crate::testutil::{account_id, issued_order};
641
642    const DAY: i64 = 24 * 60 * 60;
643
644    async fn db() -> Arc<Database> {
645        Arc::new(Database::connect_in_memory().await.unwrap())
646    }
647
648    /// An order with a chain a certID can be derived from, on `default`.
649    async fn issued(db: &Database, account: &str, names: &[&str], not_after_days: i64) -> Order {
650        issued_order(db, "default", account, names, not_after_days).await
651    }
652
653    /// [`superseded_by`] with the candidate list it would fetch for itself —
654    /// what a caller holding one order and no cache does.
655    async fn annotation(order: &Order, db: &Database) -> Option<SupersededBy> {
656        let candidates = Order::find_by_account(&order.account_id, db).await.unwrap();
657        superseded_by(order, &candidates, db).await.unwrap()
658    }
659
660    /// The actor the CLI supplies, which is what these tests stand in for.
661    /// `Actor::cli` reads `$USER`, so it is called rather than hard-coded — the
662    /// point of the tests below is the revocation, not the name on the row.
663    fn cli_actor() -> Actor {
664        Actor::cli()
665    }
666
667    async fn audit_rows(db: &Arc<Database>) -> Vec<AuditEntry> {
668        AuditEntry::search(
669            &AuditQuery {
670                limit: 50,
671                ..AuditQuery::default()
672            },
673            db,
674        )
675        .await
676        .unwrap()
677        .0
678    }
679
680    /// One cutoff function, so `audit cleanup --older-than` and the
681    /// `audit.retention_days` sweep delete the identical set.
682    #[test]
683    fn the_audit_cutoff_is_days_before_now_and_saturates_rather_than_overflowing() {
684        let now = crate::sqlite::nonce::now_secs();
685        assert!((audit_cutoff(0) - now).abs() <= 1);
686        let week = audit_cutoff(7);
687        assert!((now - week - 7 * 24 * 60 * 60).abs() <= 1, "{week}");
688        // A nonsense retention must not panic in a debug build.
689        assert!(audit_cutoff(u64::MAX) <= now);
690    }
691
692    /// The confirm gate: declined leaves the trail intact, accepted prunes by
693    /// age and nothing else.
694    #[tokio::test]
695    async fn cleaning_the_audit_trail_is_confirm_gated_and_bounded_by_age() {
696        let db = Arc::new(Database::connect_in_memory().await.unwrap());
697        AuditEntry::insert(
698            AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
699            &db,
700        )
701        .await
702        .unwrap();
703
704        let mut declined: &[u8] = b"n\n";
705        assert_eq!(
706            confirm_cleanup_audit(0, false, &mut declined, db.clone())
707                .await
708                .unwrap(),
709            None
710        );
711        assert_eq!(audit_rows(&db).await.len(), 1);
712
713        // Nothing is a week old yet.
714        let mut reader: &[u8] = &[];
715        assert_eq!(
716            confirm_cleanup_audit(7, true, &mut reader, db.clone())
717                .await
718                .unwrap(),
719            Some(0)
720        );
721        assert_eq!(audit_rows(&db).await.len(), 1);
722
723        assert_eq!(cleanup_audit(0, db.clone()).await.unwrap(), 0);
724
725        // A cutoff in the future takes it.
726        assert_eq!(
727            AuditEntry::cleanup(audit_cutoff(0) + 3600, &db)
728                .await
729                .unwrap(),
730            1
731        );
732        assert!(audit_rows(&db).await.is_empty());
733    }
734
735    /// `list_audit`/`find_audit` are the thin pass-throughs both front ends
736    /// share; this pins that they page and look up rather than doing anything
737    /// of their own.
738    #[tokio::test]
739    async fn listing_and_finding_audit_rows_pages_and_resolves() {
740        let db = Arc::new(Database::connect_in_memory().await.unwrap());
741        let mut ids = Vec::new();
742        for _ in 0..3 {
743            ids.push(
744                AuditEntry::insert(
745                    AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
746                    &db,
747                )
748                .await
749                .unwrap(),
750            );
751        }
752
753        let (page, total) = list_audit(
754            &AuditQuery {
755                limit: 2,
756                ..AuditQuery::default()
757            },
758            db.clone(),
759        )
760        .await
761        .unwrap();
762        assert_eq!(total, 3);
763        assert_eq!(page.len(), 2);
764
765        assert!(find_audit(ids[0], db.clone()).await.unwrap().is_some());
766        assert!(find_audit(9_999, db).await.unwrap().is_none());
767    }
768
769    /// A revocation through this layer writes exactly one row, naming the
770    /// actor the caller supplied rather than the order's own account — which
771    /// is the whole point of the parameter.
772    #[tokio::test]
773    async fn revoking_writes_one_audit_row_naming_the_caller() {
774        let db = Arc::new(Database::connect_in_memory().await.unwrap());
775        let signer = in_memory_ca();
776        let order = finalized_order(db.clone(), &signer).await;
777
778        let outcome = revoke_order(
779            &order.id,
780            Some(1),
781            Actor::admin("root"),
782            ClientContext {
783                ip: Some("203.0.113.7".to_string()),
784                ptr: Some("desk.example.com".to_string()),
785                ..ClientContext::default()
786            },
787            db.clone(),
788            signer.clone(),
789        )
790        .await
791        .unwrap();
792        assert!(matches!(outcome, RevokeOutcome::Revoked(_)));
793
794        let rows = audit_rows(&db).await;
795        assert_eq!(rows.len(), 1, "{rows:?}");
796        let row = &rows[0];
797        assert_eq!(row.event, "certificate_revoked");
798        assert_eq!(row.outcome, "success");
799        assert_eq!(row.actor_kind, "admin");
800        assert_eq!(row.actor_id.as_deref(), Some("root"));
801        assert_eq!(row.account_id.as_deref(), Some(order.account_id.as_str()));
802        assert_eq!(row.order_id.as_deref(), Some(order.id.as_str()));
803        assert_eq!(row.cert_serial, order.cert_serial);
804        assert_eq!(row.client_ip.as_deref(), Some("203.0.113.7"));
805        assert_eq!(row.client_ptr.as_deref(), Some("desk.example.com"));
806        assert_eq!(row.reason.as_deref(), Some("1"));
807
808        // Revoking again is `AlreadyRevoked` and writes nothing: the operator
809        // is being told the state of things, not refused a CA action.
810        let outcome = revoke_order(
811            &order.id,
812            None,
813            Actor::admin("root"),
814            ClientContext::default(),
815            db.clone(),
816            signer,
817        )
818        .await
819        .unwrap();
820        assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
821        assert_eq!(audit_rows(&db).await.len(), 1);
822    }
823
824    /// No reason given is an **absent** `reason`, not an empty one: RFC 8555
825    /// §7.6 allows omitting it, and that is not the same as `unspecified` (0).
826    #[tokio::test]
827    async fn a_revocation_with_no_reason_leaves_the_column_absent() {
828        let db = Arc::new(Database::connect_in_memory().await.unwrap());
829        let signer = in_memory_ca();
830        let order = finalized_order(db.clone(), &signer).await;
831
832        revoke_order(
833            &order.id,
834            None,
835            cli_actor(),
836            ClientContext::default(),
837            db.clone(),
838            signer,
839        )
840        .await
841        .unwrap();
842
843        let rows = audit_rows(&db).await;
844        assert_eq!(rows[0].reason, None);
845        assert_eq!(rows[0].actor_kind, "cli");
846        // A CLI revocation genuinely has no client, and says so.
847        assert_eq!(rows[0].client_ip, None);
848        assert_eq!(rows[0].client_ptr, None);
849    }
850
851    #[tokio::test]
852    async fn delete_account_not_found() {
853        let db = Arc::new(Database::connect_in_memory().await.unwrap());
854        let mut reader: &[u8] = &[];
855        let outcome = confirm_delete_account("nope", true, &mut reader, db)
856            .await
857            .unwrap();
858        assert_eq!(outcome, DeleteOutcome::NotFound);
859    }
860
861    #[tokio::test]
862    async fn delete_account_cancelled_leaves_row() {
863        let db = Arc::new(Database::connect_in_memory().await.unwrap());
864        let acct = account_id(&db).await;
865
866        let mut reader = b"n\n".as_slice();
867        let outcome = confirm_delete_account(&acct, false, &mut reader, db.clone())
868            .await
869            .unwrap();
870        assert_eq!(outcome, DeleteOutcome::Cancelled);
871        assert!(
872            Account::find_by_id("default", &acct, &db)
873                .await
874                .unwrap()
875                .is_some()
876        );
877    }
878
879    #[tokio::test]
880    async fn delete_account_confirmed_deletes_and_cascades() {
881        let db = Arc::new(Database::connect_in_memory().await.unwrap());
882        let acct = account_id(&db).await;
883        let order = Order::create(
884            "default",
885            &acct,
886            vec![Identifier::dns("example.com")],
887            crate::sqlite::nonce::now_secs() + 3600,
888            None,
889            None,
890            &db,
891        )
892        .await
893        .unwrap();
894
895        let mut reader: &[u8] = &[];
896        let outcome = confirm_delete_account(&acct, true, &mut reader, db.clone())
897            .await
898            .unwrap();
899        assert_eq!(outcome, DeleteOutcome::Deleted);
900        assert!(
901            Account::find_by_id("default", &acct, &db)
902                .await
903                .unwrap()
904                .is_none()
905        );
906        assert!(Order::find_by_id(&order.id, &db).await.unwrap().is_none());
907    }
908
909    #[tokio::test]
910    async fn delete_order_not_found() {
911        let db = Arc::new(Database::connect_in_memory().await.unwrap());
912        let mut reader: &[u8] = &[];
913        let outcome = confirm_delete_order("nope", true, &mut reader, db)
914            .await
915            .unwrap();
916        assert_eq!(outcome, DeleteOutcome::NotFound);
917    }
918
919    #[tokio::test]
920    async fn delete_order_cancelled_leaves_row() {
921        let db = Arc::new(Database::connect_in_memory().await.unwrap());
922        let acct = account_id(&db).await;
923        let order = Order::create(
924            "default",
925            &acct,
926            vec![Identifier::dns("example.com")],
927            crate::sqlite::nonce::now_secs() + 3600,
928            None,
929            None,
930            &db,
931        )
932        .await
933        .unwrap();
934
935        let mut reader = b"no\n".as_slice();
936        let outcome = confirm_delete_order(&order.id, false, &mut reader, db.clone())
937            .await
938            .unwrap();
939        assert_eq!(outcome, DeleteOutcome::Cancelled);
940        assert!(Order::find_by_id(&order.id, &db).await.unwrap().is_some());
941    }
942
943    #[tokio::test]
944    async fn delete_order_confirmed_deletes_and_cascades() {
945        let db = Arc::new(Database::connect_in_memory().await.unwrap());
946        let acct = account_id(&db).await;
947        let order = Order::create(
948            "default",
949            &acct,
950            vec![Identifier::dns("example.com")],
951            crate::sqlite::nonce::now_secs() + 3600,
952            None,
953            None,
954            &db,
955        )
956        .await
957        .unwrap();
958        let authz = Authorization::create(
959            &order.id,
960            Identifier::dns("example.com"),
961            crate::sqlite::nonce::now_secs() + 3600,
962            &db,
963        )
964        .await
965        .unwrap();
966
967        let mut reader: &[u8] = &[];
968        let outcome = confirm_delete_order(&order.id, true, &mut reader, db.clone())
969            .await
970            .unwrap();
971        assert_eq!(outcome, DeleteOutcome::Deleted);
972        assert!(Order::find_by_id(&order.id, &db).await.unwrap().is_none());
973        assert!(
974            Authorization::find_by_id(&authz.id, &db)
975                .await
976                .unwrap()
977                .is_none()
978        );
979    }
980
981    // The bare forms below are what the web admin calls: no prompt, no reader,
982    // and a cascade count to report back instead of a bare acknowledgement.
983
984    #[tokio::test]
985    async fn bare_delete_account_reports_none_for_an_unknown_id() {
986        let db = Arc::new(Database::connect_in_memory().await.unwrap());
987        assert_eq!(delete_account("nope", db).await.unwrap(), None);
988    }
989
990    #[tokio::test]
991    async fn bare_delete_account_deletes_and_counts_the_cascade() {
992        let db = Arc::new(Database::connect_in_memory().await.unwrap());
993        let acct = account_id(&db).await;
994        for _ in 0..2 {
995            Order::create(
996                "default",
997                &acct,
998                vec![Identifier::dns("example.com")],
999                crate::sqlite::nonce::now_secs() + 3600,
1000                None,
1001                None,
1002                &db,
1003            )
1004            .await
1005            .unwrap();
1006        }
1007
1008        assert_eq!(
1009            delete_account(&acct, db.clone()).await.unwrap(),
1010            Some(Deleted { cascaded: 2 })
1011        );
1012        assert!(
1013            Account::find_by_id("default", &acct, &db)
1014                .await
1015                .unwrap()
1016                .is_none()
1017        );
1018    }
1019
1020    #[tokio::test]
1021    async fn bare_delete_order_reports_none_for_an_unknown_id() {
1022        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1023        assert_eq!(delete_order("nope", db).await.unwrap(), None);
1024    }
1025
1026    #[tokio::test]
1027    async fn bare_delete_order_deletes_and_counts_the_cascade() {
1028        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1029        let acct = account_id(&db).await;
1030        let order = Order::create(
1031            "default",
1032            &acct,
1033            vec![Identifier::dns("example.com")],
1034            crate::sqlite::nonce::now_secs() + 3600,
1035            None,
1036            None,
1037            &db,
1038        )
1039        .await
1040        .unwrap();
1041        Authorization::create(
1042            &order.id,
1043            Identifier::dns("example.com"),
1044            crate::sqlite::nonce::now_secs() + 3600,
1045            &db,
1046        )
1047        .await
1048        .unwrap();
1049
1050        assert_eq!(
1051            delete_order(&order.id, db.clone()).await.unwrap(),
1052            Some(Deleted { cascaded: 1 })
1053        );
1054        assert!(Order::find_by_id(&order.id, &db).await.unwrap().is_none());
1055    }
1056
1057    #[tokio::test]
1058    async fn bare_cleanup_nonces_removes_stale_rows_without_asking() {
1059        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1060        let stale = Nonce {
1061            value: "stale".to_string(),
1062            created_at: crate::sqlite::nonce::now_secs() - 10_000,
1063        };
1064        stale.save(&db).await.unwrap();
1065        Nonce::new().save(&db).await.unwrap();
1066
1067        assert_eq!(
1068            cleanup_nonces(Duration::from_secs(300), db.clone())
1069                .await
1070                .unwrap(),
1071            1
1072        );
1073        assert!(
1074            !Nonce::verify("stale", &db, Duration::from_secs(300))
1075                .await
1076                .unwrap()
1077        );
1078    }
1079
1080    fn in_memory_ca() -> Arc<dyn SignerBackend> {
1081        Arc::new(
1082            crate::signer::local_ca::LocalCa::generate_in_memory("ecdsa-p256", 90)
1083                .expect("in-memory CA"),
1084        )
1085    }
1086
1087    async fn finalized_order(db: Arc<Database>, signer: &Arc<dyn SignerBackend>) -> Order {
1088        let acct = account_id(&db).await;
1089        let mut order = Order::create(
1090            "default",
1091            &acct,
1092            vec![Identifier::dns("example.com")],
1093            crate::sqlite::nonce::now_secs() + 3600,
1094            None,
1095            None,
1096            &db,
1097        )
1098        .await
1099        .unwrap();
1100
1101        let key_pair = rcgen::KeyPair::generate().unwrap();
1102        let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
1103        let csr = params.serialize_request(&key_pair).unwrap();
1104        let chain = match signer
1105            .issue(
1106                &order.id,
1107                csr.der(),
1108                &order.identifiers,
1109                crate::signer::RequestedValidity::default(),
1110            )
1111            .await
1112            .unwrap()
1113        {
1114            crate::signer::IssueOutcome::Issued(chain) => chain,
1115            crate::signer::IssueOutcome::Processing => {
1116                panic!("the in-memory local CA issues synchronously")
1117            }
1118        };
1119        let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
1120        let (serial, pubkey) = crate::cert::cert_serial_and_spki(&leaf).unwrap();
1121        let not_after = crate::cert::cert_validity(&leaf).ok().map(|(_, na)| na);
1122        order
1123            .finalize(chain, serial, pubkey, not_after, &db)
1124            .await
1125            .unwrap();
1126        order
1127    }
1128
1129    #[tokio::test]
1130    async fn revoke_order_not_found() {
1131        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1132        let outcome = revoke_order(
1133            "nope",
1134            None,
1135            cli_actor(),
1136            ClientContext::default(),
1137            db,
1138            in_memory_ca(),
1139        )
1140        .await
1141        .unwrap();
1142        assert!(matches!(outcome, RevokeOutcome::NotFound));
1143    }
1144
1145    #[tokio::test]
1146    async fn revoke_order_without_a_certificate_is_refused() {
1147        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1148        let acct = account_id(&db).await;
1149        let order = Order::create(
1150            "default",
1151            &acct,
1152            vec![Identifier::dns("example.com")],
1153            crate::sqlite::nonce::now_secs() + 3600,
1154            None,
1155            None,
1156            &db,
1157        )
1158        .await
1159        .unwrap();
1160
1161        let outcome = revoke_order(
1162            &order.id,
1163            None,
1164            cli_actor(),
1165            ClientContext::default(),
1166            db,
1167            in_memory_ca(),
1168        )
1169        .await
1170        .unwrap();
1171        assert!(matches!(outcome, RevokeOutcome::NotIssued));
1172    }
1173
1174    #[tokio::test]
1175    async fn revoke_order_persists() {
1176        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1177        let signer = in_memory_ca();
1178        let order = finalized_order(db.clone(), &signer).await;
1179
1180        let outcome = revoke_order(
1181            &order.id,
1182            Some(1),
1183            cli_actor(),
1184            ClientContext::default(),
1185            db.clone(),
1186            signer.clone(),
1187        )
1188        .await
1189        .unwrap();
1190        let RevokeOutcome::Revoked(revoked) = outcome else {
1191            panic!("expected Revoked, got {outcome:?}");
1192        };
1193        assert!(revoked.revoked_at.is_some());
1194        assert_eq!(revoked.revocation_reason, Some(1));
1195
1196        let reloaded = Order::find_by_id(&order.id, &db).await.unwrap().unwrap();
1197        assert!(reloaded.revoked_at.is_some());
1198
1199        use x509_parser::prelude::FromDer;
1200        let der = signer.crl_der().await.unwrap();
1201        let (_, crl) =
1202            x509_parser::revocation_list::CertificateRevocationList::from_der(&der).unwrap();
1203        assert_eq!(crl.iter_revoked_certificates().count(), 1);
1204    }
1205
1206    #[tokio::test]
1207    async fn revoke_order_already_revoked() {
1208        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1209        let signer = in_memory_ca();
1210        let order = finalized_order(db.clone(), &signer).await;
1211
1212        revoke_order(
1213            &order.id,
1214            None,
1215            cli_actor(),
1216            ClientContext::default(),
1217            db.clone(),
1218            signer.clone(),
1219        )
1220        .await
1221        .unwrap();
1222        let outcome = revoke_order(
1223            &order.id,
1224            None,
1225            cli_actor(),
1226            ClientContext::default(),
1227            db,
1228            signer,
1229        )
1230        .await
1231        .unwrap();
1232        assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
1233    }
1234
1235    #[tokio::test]
1236    async fn revoke_order_bad_reason_is_refused() {
1237        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1238        let signer = in_memory_ca();
1239        let order = finalized_order(db.clone(), &signer).await;
1240
1241        let error = revoke_order(
1242            &order.id,
1243            Some(999),
1244            cli_actor(),
1245            ClientContext::default(),
1246            db,
1247            signer,
1248        )
1249        .await
1250        .unwrap_err();
1251        assert!(matches!(error, RevokeError::BadReason(999)));
1252    }
1253
1254    #[tokio::test]
1255    async fn cleanup_nonces_cancelled_leaves_nonces() {
1256        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1257        Nonce {
1258            value: "stale".to_string(),
1259            created_at: crate::sqlite::nonce::now_secs() - 600,
1260        }
1261        .save(&db)
1262        .await
1263        .unwrap();
1264
1265        let mut reader = b"n\n".as_slice();
1266        let outcome =
1267            confirm_cleanup_nonces(Duration::from_secs(300), false, &mut reader, db.clone())
1268                .await
1269                .unwrap();
1270        assert_eq!(outcome, None);
1271
1272        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
1273            .fetch_one(&db.pool)
1274            .await
1275            .unwrap();
1276        assert_eq!(count, 1);
1277    }
1278
1279    #[tokio::test]
1280    async fn cleanup_nonces_confirmed_removes_stale_and_reports_count() {
1281        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1282        Nonce {
1283            value: "stale".to_string(),
1284            created_at: crate::sqlite::nonce::now_secs() - 600,
1285        }
1286        .save(&db)
1287        .await
1288        .unwrap();
1289
1290        let mut reader: &[u8] = &[];
1291        let outcome =
1292            confirm_cleanup_nonces(Duration::from_secs(300), true, &mut reader, db.clone())
1293                .await
1294                .unwrap();
1295        assert_eq!(outcome, Some(1));
1296
1297        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
1298            .fetch_one(&db.pool)
1299            .await
1300            .unwrap();
1301        assert_eq!(count, 0);
1302    }
1303
1304    #[tokio::test]
1305    async fn update_account_contact_not_found() {
1306        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1307        assert!(
1308            update_account_contact("nope", vec![], db)
1309                .await
1310                .unwrap()
1311                .is_none()
1312        );
1313    }
1314
1315    #[tokio::test]
1316    async fn update_account_contact_persists() {
1317        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1318        let acct = account_id(&db).await;
1319
1320        let contact = vec!["mailto:a@example.com".to_string()];
1321        let updated = update_account_contact(&acct, contact.clone(), db.clone())
1322            .await
1323            .unwrap()
1324            .unwrap();
1325        assert_eq!(updated.contact, contact);
1326
1327        let reloaded = Account::find_by_id("default", &acct, &db)
1328            .await
1329            .unwrap()
1330            .unwrap();
1331        assert_eq!(reloaded.contact, contact);
1332    }
1333
1334    #[tokio::test]
1335    async fn deactivate_account_not_found() {
1336        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1337        assert!(deactivate_account("nope", db).await.unwrap().is_none());
1338    }
1339
1340    #[tokio::test]
1341    async fn deactivate_account_persists() {
1342        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1343        let acct = account_id(&db).await;
1344
1345        let updated = deactivate_account(&acct, db.clone())
1346            .await
1347            .unwrap()
1348            .unwrap();
1349        assert_eq!(updated.status, "deactivated");
1350
1351        let reloaded = Account::find_by_id("default", &acct, &db)
1352            .await
1353            .unwrap()
1354            .unwrap();
1355        assert_eq!(reloaded.status, "deactivated");
1356    }
1357
1358    #[tokio::test]
1359    async fn load_order_detail_not_found() {
1360        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1361        assert!(load_order_detail("nope", db).await.unwrap().is_none());
1362    }
1363
1364    #[tokio::test]
1365    async fn load_order_detail_nests_authorizations_and_challenges() {
1366        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1367        let acct = account_id(&db).await;
1368        let order = Order::create(
1369            "default",
1370            &acct,
1371            vec![Identifier::dns("example.com")],
1372            crate::sqlite::nonce::now_secs() + 3600,
1373            None,
1374            None,
1375            &db,
1376        )
1377        .await
1378        .unwrap();
1379        let authz = Authorization::create(
1380            &order.id,
1381            Identifier::dns("example.com"),
1382            crate::sqlite::nonce::now_secs() + 3600,
1383            &db,
1384        )
1385        .await
1386        .unwrap();
1387        Challenge::create(&authz.id, "http-01", &db).await.unwrap();
1388
1389        let detail = load_order_detail(&order.id, db).await.unwrap().unwrap();
1390        assert_eq!(detail.order.id, order.id);
1391        assert_eq!(detail.authorizations.len(), 1);
1392        assert_eq!(detail.authorizations[0].0.id, authz.id);
1393        assert_eq!(detail.authorizations[0].1.len(), 1);
1394        assert_eq!(detail.authorizations[0].1[0].typ, "http-01");
1395    }
1396
1397    #[test]
1398    fn revoke_error_display_formatting() {
1399        let db_err: RevokeError = sqlx::Error::RowNotFound.into();
1400        assert!(format!("{db_err}").contains("database error"));
1401
1402        let signer_internal: RevokeError = SignerError::Internal("test".to_string()).into();
1403        assert!(format!("{signer_internal}").contains("signer error: test"));
1404
1405        let signer_bad_csr: RevokeError = SignerError::BadCsr.into();
1406        assert!(format!("{signer_bad_csr}").contains("unexpected badCsr"));
1407
1408        let internal = RevokeError::Internal("detail".to_string());
1409        assert!(format!("{internal}").contains("internal error: detail"));
1410
1411        let bad_reason = RevokeError::BadReason(7);
1412        assert!(format!("{bad_reason}").contains("unsupported revocation reason code 7"));
1413    }
1414
1415    /// The client said it renewed (RFC 9773 §5).
1416    #[tokio::test]
1417    async fn a_replaces_claim_marks_the_predecessor_superseded() {
1418        let db = db().await;
1419        let acct = account_id(&db).await;
1420        let old = issued(&db, &acct, &["a.example.com"], 3).await;
1421
1422        let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
1423        let successor = issued(&db, &acct, &["a.example.com"], 90).await;
1424        sqlx::query("UPDATE orders SET replaces = ? WHERE id = ?;")
1425            .bind(&cert_id)
1426            .bind(&successor.id)
1427            .execute(&db.pool)
1428            .await
1429            .unwrap();
1430
1431        let reloaded = Order::find_by_id(&old.id, &db).await.unwrap().unwrap();
1432        let superseded = annotation(&reloaded, &db).await.unwrap();
1433        assert_eq!(superseded.order_id, successor.id);
1434        assert_eq!(superseded.via, "replaces");
1435    }
1436
1437    /// **A `replaces` claim from an order that never issued anything replaces
1438    /// nothing.** `find_by_replaces` excludes only `invalid`, because its own
1439    /// question is whether the claim is held; here a pending claim would
1440    /// silence the one certificate still doing the work.
1441    #[tokio::test]
1442    async fn a_pending_replaces_claim_supersedes_nothing() {
1443        let db = db().await;
1444        let acct = account_id(&db).await;
1445        let old = issued(&db, &acct, &["a.example.com"], 3).await;
1446        let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
1447
1448        // A claim on the predecessor, from an order with no certificate.
1449        let pending = Order::create(
1450            "default",
1451            &acct,
1452            vec![Identifier::dns("a.example.com")],
1453            now_secs() + 3600,
1454            None,
1455            None,
1456            &db,
1457        )
1458        .await
1459        .unwrap();
1460        sqlx::query("UPDATE orders SET replaces = ? WHERE id = ?;")
1461            .bind(&cert_id)
1462            .bind(&pending.id)
1463            .execute(&db.pool)
1464            .await
1465            .unwrap();
1466
1467        let reloaded = Order::find_by_id(&old.id, &db).await.unwrap().unwrap();
1468        assert!(annotation(&reloaded, &db).await.is_none());
1469    }
1470
1471    /// The inference: a later certificate covering the same names.
1472    #[tokio::test]
1473    async fn a_later_certificate_over_the_same_names_supersedes() {
1474        let db = db().await;
1475        let acct = account_id(&db).await;
1476        let old = issued(&db, &acct, &["a.example.com"], 3).await;
1477        let new = issued(&db, &acct, &["a.example.com", "b.example.com"], 90).await;
1478
1479        let superseded = annotation(&old, &db).await.unwrap();
1480        assert_eq!(superseded.order_id, new.id);
1481        assert_eq!(
1482            superseded.via, "identifiers",
1483            "a superset covers these names, so it is a renewal"
1484        );
1485    }
1486
1487    /// The three the inference must **not** draw. Each would silence a
1488    /// certificate that really is about to lapse, which is the failure this
1489    /// whole annotation is written conservatively to avoid.
1490    #[tokio::test]
1491    async fn a_partial_a_revoked_and_another_accounts_certificate_supersede_nothing() {
1492        let db = db().await;
1493        let acct = account_id(&db).await;
1494        let old = issued(&db, &acct, &["a.example.com", "b.example.com"], 3).await;
1495
1496        // Covers only some of the names: the rest would go uncovered.
1497        issued(&db, &acct, &["a.example.com"], 90).await;
1498        assert!(
1499            annotation(&old, &db).await.is_none(),
1500            "a subset is not a renewal"
1501        );
1502
1503        // Covers them all, but has itself been withdrawn.
1504        let mut revoked = issued(&db, &acct, &["a.example.com", "b.example.com"], 90).await;
1505        revoked.revoke(Some(1), &db).await.unwrap();
1506        assert!(
1507            annotation(&old, &db).await.is_none(),
1508            "a revoked certificate covers nothing"
1509        );
1510
1511        // Covers them all and is live, but belongs to somebody else.
1512        let (other, _created) = crate::sqlite::account::Account::find_or_create(
1513            "default",
1514            b"other-key",
1515            Vec::new(),
1516            &crate::audit::ClientContext::default(),
1517            &db,
1518        )
1519        .await
1520        .unwrap();
1521        issued(&db, &other.id, &["a.example.com", "b.example.com"], 90).await;
1522        assert!(
1523            annotation(&old, &db).await.is_none(),
1524            "another subscriber's certificate is not this one's renewal"
1525        );
1526    }
1527
1528    /// The two boundaries the three surfaces share. Computed once, here, so
1529    /// the digest, the panel and the terminal cannot disagree about what
1530    /// "within 7 days" means.
1531    #[test]
1532    fn the_horizon_and_the_day_count_agree_on_a_whole_day() {
1533        let now = now_secs();
1534        assert!((expiring_horizon(7) - now - 7 * DAY).abs() <= 1);
1535        // Saturating rather than overflowing: `--expiring-in` takes a `u64`
1536        // and nothing bounds what an operator types.
1537        assert_eq!(expiring_horizon(u64::MAX), i64::MAX);
1538
1539        // Floored, never rounded: an operator told "4 days" about a
1540        // certificate that lapses in three and a half has been told the wrong
1541        // week.
1542        assert_eq!(days_remaining(1_000 + 3 * DAY + DAY / 2, 1_000), 3);
1543        assert_eq!(days_remaining(1_000 + DAY - 1, 1_000), 0);
1544        // Never negative: one that lapsed between the query and here is "0
1545        // days", not "-1".
1546        assert_eq!(days_remaining(1_000, 1_000 + 5 * DAY), 0);
1547        assert_eq!(days_remaining(i64::MIN, i64::MAX), 0);
1548    }
1549
1550    /// The panel needs a window even where the digest is switched off, which
1551    /// `lead_days = 0` is.
1552    #[test]
1553    fn the_default_window_falls_back_only_when_the_digest_is_off() {
1554        let mut config = Config::default();
1555        assert_eq!(config.notify.expiry.lead_days, 0, "off by default");
1556        assert_eq!(default_lead_days(&config), DEFAULT_LEAD_DAYS);
1557
1558        config.notify.expiry.lead_days = 3;
1559        assert_eq!(
1560            default_lead_days(&config),
1561            3,
1562            "a deployment that chose a lead time gets it"
1563        );
1564    }
1565
1566    /// One `find_by_account` per *distinct* account, not per row.
1567    ///
1568    /// The listing is unpaged from `order list --expiring-in`, and that query
1569    /// is unbounded, so the un-cached shape reads a busy account's whole order
1570    /// history once per certificate it holds. Asserted through the annotation
1571    /// staying correct across a page where one account holds several rows —
1572    /// the cache is only safe if a candidate list is the same answer for every
1573    /// row of the account it belongs to.
1574    #[tokio::test]
1575    async fn a_listing_reads_each_accounts_orders_once_and_still_annotates_each_row() {
1576        let db = db().await;
1577        let acct = account_id(&db).await;
1578
1579        let a = issued(&db, &acct, &["a.example.com"], 3).await;
1580        let b = issued(&db, &acct, &["b.example.com"], 5).await;
1581        // Renews `a` only. `b` must stay un-annotated even though it shares
1582        // the cached candidate list that contains this row.
1583        let renewal = issued(&db, &acct, &["a.example.com"], 90).await;
1584
1585        let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
1586            .await
1587            .unwrap();
1588        let entries = annotate_expiring(orders, &db).await.unwrap();
1589
1590        let annotated = |id: &str| -> Option<SupersededBy> {
1591            entries
1592                .iter()
1593                .find(|entry| entry.order.id == id)
1594                .and_then(|entry| entry.superseded_by.clone())
1595        };
1596        assert_eq!(annotated(&a.id).unwrap().order_id, renewal.id);
1597        assert!(
1598            annotated(&b.id).is_none(),
1599            "a shared candidate list must not leak one row's renewal onto another"
1600        );
1601        // And the days came out of the same helper the digest uses. Stamped
1602        // half a day past the three so the assertion distinguishes a floor
1603        // from a round without racing the clock at the boundary.
1604        Order::set_cert_not_after(&a.id, now_secs() + 3 * DAY + DAY / 2, &db)
1605            .await
1606            .unwrap();
1607        let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
1608            .await
1609            .unwrap();
1610        let entries = annotate_expiring(orders, &db).await.unwrap();
1611        let a_entry = entries.iter().find(|e| e.order.id == a.id).unwrap();
1612        assert_eq!(a_entry.days_remaining, 3, "floored, not rounded");
1613    }
1614
1615    /// `include_superseded` hides rows from the *page* and says how many, and
1616    /// deliberately leaves `total` alone — the annotation is not a SQL
1617    /// predicate, so the count beside the page cannot follow it down.
1618    #[tokio::test]
1619    async fn hiding_superseded_rows_reports_the_count_rather_than_shrinking_the_total() {
1620        let db = db().await;
1621        let acct = account_id(&db).await;
1622        let a = issued(&db, &acct, &["a.example.com"], 3).await;
1623        issued(&db, &acct, &["b.example.com"], 5).await;
1624        issued(&db, &acct, &["a.example.com"], 90).await;
1625
1626        let query = |include: bool| ExpiringQuery {
1627            profile: None,
1628            before: expiring_horizon(30),
1629            include_superseded: include,
1630            limit: 50,
1631            offset: 0,
1632        };
1633
1634        let (shown, total, hidden) = list_expiring(&query(true), db.clone()).await.unwrap();
1635        assert_eq!(shown.len(), 2, "both expiring rows, annotated");
1636        assert_eq!(total, 2);
1637        assert_eq!(hidden, 0);
1638
1639        let (kept, total, hidden) = list_expiring(&query(false), db.clone()).await.unwrap();
1640        assert_eq!(kept.len(), 1);
1641        assert!(kept.iter().all(|entry| entry.superseded_by.is_none()));
1642        assert_ne!(kept[0].order.id, a.id, "the replaced row is the one hidden");
1643        assert_eq!(hidden, 1);
1644        assert_eq!(
1645            total, 2,
1646            "the total counts the window, not the answer -- documented on list_expiring"
1647        );
1648    }
1649
1650    /// The profile filter reaches through the operation layer, and the ordering
1651    /// is the query's: soonest first.
1652    #[tokio::test]
1653    async fn the_listing_scopes_by_profile_and_answers_soonest_first() {
1654        let db = db().await;
1655        let acct = account_id(&db).await;
1656        let here = issued_order(&db, "default", &acct, &["a.example.com"], 5).await;
1657        let sooner = issued_order(&db, "default", &acct, &["b.example.com"], 2).await;
1658        issued_order(&db, "other", &acct, &["c.example.com"], 1).await;
1659
1660        let scoped = ExpiringQuery {
1661            profile: Some("default".to_string()),
1662            before: expiring_horizon(30),
1663            include_superseded: true,
1664            limit: 50,
1665            offset: 0,
1666        };
1667        let (entries, total, _) = list_expiring(&scoped, db.clone()).await.unwrap();
1668        let ids: Vec<&str> = entries
1669            .iter()
1670            .map(|entry| entry.order.id.as_str())
1671            .collect();
1672        assert_eq!(ids, vec![sooner.id.as_str(), here.id.as_str()]);
1673        assert_eq!(total, 2);
1674
1675        let unscoped = ExpiringQuery {
1676            profile: None,
1677            ..scoped
1678        };
1679        let (entries, total, _) = list_expiring(&unscoped, db).await.unwrap();
1680        assert_eq!(entries.len(), 3);
1681        assert_eq!(total, 3);
1682    }
1683}