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