Skip to main content

acme_proxy_admin/admin/
ops.rs

1//! The operator operation layer: what an operator may do to a stored row,
2//! independent of which front end asked.
3//!
4//! The counterpart of `acme_proxy_protocol::acme` for the operator surfaces.
5//! `/api`, `/ui` and the host CLI all reach these functions, so a listing
6//! filters the same way, a delete refuses the same way, and a revocation takes
7//! the same route whichever one was used. What stays with each front end is its
8//! rendering and its authorization.
9//!
10//! Two rules hold throughout:
11//!
12//! - **A delete that would remove the only record of a live certificate is
13//!   refused**, on every surface, with no override —
14//!   [`live_certificates_refusal`] is the one wording of it.
15//! - **Nothing here builds a signing backend.** A revocation is a route
16//!   (`acme_proxy_signer::revocation_route`) and, where the key is elsewhere, a
17//!   queued job — the same path `POST /revokeCert` takes.
18
19use std::io::BufRead;
20use std::sync::Arc;
21use std::time::Duration;
22
23use crate::admin::prompt::confirm;
24use acme_proxy_core::audit::Actor;
25use acme_proxy_core::audit::ClientContext;
26use acme_proxy_jobs::auditor::Auditor;
27use acme_proxy_signer::SignerError;
28use acme_proxy_signer::relay::RELAY_JOB_KIND;
29use acme_proxy_signer::relay::abandon_relayed_order;
30use acme_proxy_store::account::Account;
31use acme_proxy_store::audit::AuditEntry;
32use acme_proxy_store::audit::AuditQuery;
33use acme_proxy_store::authz::Authorization;
34use acme_proxy_store::authz::Challenge;
35use acme_proxy_store::db::Database;
36use acme_proxy_store::eab::BoundAccounts;
37use acme_proxy_store::eab::DeletedEab;
38use acme_proxy_store::eab::Eab;
39use acme_proxy_store::eab::EabDeletion;
40use acme_proxy_store::job::Job;
41use acme_proxy_store::nonce::Nonce;
42use acme_proxy_store::order::GuardedDelete;
43use acme_proxy_store::order::Order;
44use acme_proxy_store::status::JobStatus;
45use acme_proxy_store::upstream_order::UpstreamOrder;
46use acme_proxy_store::upstream_order::UpstreamOrderRow;
47
48/// Outcome of a confirm-gated hard delete.
49///
50/// `Cancelled` only exists on the `confirm_*` wrappers: a caller with nobody
51/// to ask -- the web admin -- uses the bare function below, whose return type
52/// has no such variant to leave unhandled.
53#[derive(Debug, PartialEq, Eq)]
54pub enum DeleteOutcome {
55    NotFound,
56    Cancelled,
57    /// Deleted, carrying what went with it.
58    ///
59    /// The count travels **out of** the confirmation rather than being read
60    /// back afterwards, and that is the point: it is already computed here to
61    /// word the prompt, and by the time the caller returns, the
62    /// `ON DELETE CASCADE` has removed the very rows a second count would
63    /// count. `acme-proxy account delete` did exactly that, so every one of its
64    /// audit rows read `0 order(s) cascaded` however many there were.
65    Deleted(Deleted),
66    /// Refused: the subject holds this many live certificates, and deleting its
67    /// order rows would leave them impossible to revoke. See
68    /// [`live_certificates_refusal`] for what to tell the operator, and
69    /// `live_certificate!` in `crates/store/src/order.rs` for what "live" means.
70    ///
71    /// Only an account or an order can answer this; an operator's delete
72    /// answers `admin::users::UserDeleteOutcome`, which has no such variant.
73    LiveCertificates(u64),
74}
75
76/// Outcome of a bare hard delete of something that can hold a certificate —
77/// [`DeleteOutcome`] without `Cancelled`, for the reason given there.
78#[derive(Debug, PartialEq, Eq)]
79pub enum Deletion {
80    NotFound,
81    /// Refused, as [`DeleteOutcome::LiveCertificates`].
82    LiveCertificates(u64),
83    Deleted(Deleted),
84}
85
86impl Deletion {
87    fn from_guarded(outcome: GuardedDelete, cascaded: u64) -> Self {
88        match outcome {
89            GuardedDelete::NotFound => Self::NotFound,
90            GuardedDelete::LiveCertificates(live) => Self::LiveCertificates(live),
91            GuardedDelete::Deleted => Self::Deleted(Deleted { cascaded }),
92        }
93    }
94}
95
96impl From<Deletion> for DeleteOutcome {
97    fn from(deletion: Deletion) -> Self {
98        match deletion {
99            Deletion::NotFound => Self::NotFound,
100            Deletion::LiveCertificates(live) => Self::LiveCertificates(live),
101            Deletion::Deleted(deleted) => Self::Deleted(deleted),
102        }
103    }
104}
105
106/// What every front end tells an operator whose delete was refused for
107/// holding live certificates. One wording, so the CLI, the API and the page
108/// cannot describe one refusal three ways.
109///
110/// `subject` names what was being deleted ("account …", "order …"). The
111/// remedy is phrased for no particular front end: each has its own way to
112/// revoke.
113#[must_use]
114pub fn live_certificates_refusal(subject: &str, live: u64) -> String {
115    format!(
116        "{subject} holds {live} live certificate(s) (issued, not revoked, not expired); \
117         deleting it would leave them impossible to revoke — revoke them first, or wait \
118         for them to expire"
119    )
120}
121
122/// [`live_certificates_refusal`] for `eab delete` with its accounts, which has
123/// a third way out the other two lack.
124#[must_use]
125pub fn eab_live_certificates_refusal(kid: &str, accounts: u64, certificates: u64) -> String {
126    format!(
127        "{accounts} account(s) bound to EAB credential {kid} hold {certificates} live \
128         certificate(s) (issued, not revoked, not expired); deleting them would leave those \
129         certificates impossible to revoke — revoke them first, wait for them to expire, or \
130         deactivate the accounts instead of deleting them"
131    )
132}
133
134/// The refusal for an EAB credential scoped to a profile nothing mounts, if it
135/// is one — `None` when the profile is mounted, or when the credential is for
136/// every endpoint.
137///
138/// Every surface that mints a credential makes this check, and they must not
139/// word it three ways: such a credential is accepted and then never usable,
140/// which is worth catching while the operator is still looking at what they
141/// typed. `hint` is the one part that is legitimately per-front-end — a JSON
142/// caller omits a field, someone at a form leaves an input blank, someone at a
143/// terminal drops a flag.
144#[must_use]
145pub fn unmounted_profile_refusal(
146    mounted: impl Fn(&str) -> bool,
147    profile: Option<&str>,
148    hint: &str,
149) -> Option<String> {
150    let name = profile?;
151    if mounted(name) {
152        return None;
153    }
154    Some(format!(
155        "no profile named `{name}` is mounted; {hint} for a credential valid at every endpoint"
156    ))
157}
158
159/// What a hard delete took with it.
160///
161/// The count is already computed to word the confirmation prompt, so returning
162/// it costs nothing and lets an API caller report what it removed rather than
163/// answering a bare `204`.
164#[derive(Debug, PartialEq, Eq)]
165pub struct Deleted {
166    /// Rows the schema's `ON DELETE CASCADE` removed along with the row named:
167    /// orders for an account, authorizations for an order.
168    pub cascaded: u64,
169}
170
171/// An order plus every authorization (each with its challenges).
172#[derive(Debug)]
173pub struct OrderDetail {
174    pub order: Order,
175    pub authorizations: Vec<(Authorization, Vec<Challenge>)>,
176}
177
178/// Outcome of [`revoke_order`].
179#[derive(Debug)]
180pub enum RevokeOutcome {
181    NotFound,
182    NotIssued,
183    AlreadyRevoked,
184    Revoked(Box<Order>),
185    /// Queued for the worker as this job, which had not answered by the end of
186    /// the wait. Not a failure: the revocation carries on, and the operator is
187    /// told where to follow it.
188    Queued(uuid::Uuid),
189}
190
191/// Why [`revoke_order`] failed.
192#[derive(Debug, thiserror::Error)]
193pub enum RevokeError {
194    #[error("database error: {0}")]
195    Database(sqlx::Error),
196    #[error("signer error: {}", acme_proxy_protocol::acme::revoke::signer_detail(.0))]
197    Signer(SignerError),
198    #[error("internal error: {0}")]
199    Internal(String),
200    #[error("unsupported revocation reason code {0}")]
201    BadReason(u32),
202    /// The queued revocation was retired without revoking: its backend kept
203    /// failing, or an operator cancelled it.
204    #[error("the revocation failed (job {job}): {reason}")]
205    Abandoned { job: uuid::Uuid, reason: String },
206}
207impl From<sqlx::Error> for RevokeError {
208    fn from(error: sqlx::Error) -> Self {
209        Self::Database(error)
210    }
211}
212
213impl From<SignerError> for RevokeError {
214    fn from(error: SignerError) -> Self {
215        Self::Signer(error)
216    }
217}
218
219// Each of the three destructive operations comes in two forms: a bare one that
220// simply does the thing, and a `confirm_*` wrapper that asks first. The split
221// exists because the wrapper's `assume_yes: bool` + `reader: &mut impl BufRead`
222// are a terminal's concerns, and a caller with no terminal -- the web admin --
223// had to pass `true` and an empty reader, asserting a confirmation that never
224// happened. The generic also makes the wrapper non-object-safe for no benefit
225// on that path. The CLI calls the wrapper; everything else calls the bare form.
226
227/// Hard-deletes an account unless it holds a live certificate. Carries how
228/// many orders cascaded with it.
229pub async fn delete_account(id: &str, database: Arc<Database>) -> Result<Deletion, sqlx::Error> {
230    let Some(cascaded) = account_cascade(id, database.clone()).await? else {
231        return Ok(Deletion::NotFound);
232    };
233    let outcome = Account::delete(id, &database).await?;
234    Ok(Deletion::from_guarded(outcome, cascaded))
235}
236
237/// Looks up the account, shows what will cascade, confirms, then hard-deletes it.
238///
239/// A live certificate refuses **before** the prompt — asking to confirm a
240/// delete that can only be refused is asking for nothing — and again inside the
241/// delete, which is the check that holds if one is issued while the operator
242/// reads the question.
243pub async fn confirm_delete_account(
244    id: &str,
245    assume_yes: bool,
246    reader: &mut impl BufRead,
247    database: Arc<Database>,
248) -> Result<DeleteOutcome, sqlx::Error> {
249    let Some(account) = Account::find_any_by_id(id, &database).await? else {
250        return Ok(DeleteOutcome::NotFound);
251    };
252    let live = Account::count_live_certificates(account.id, &database).await?;
253    if live > 0 {
254        return Ok(DeleteOutcome::LiveCertificates(live));
255    }
256    let order_count = Order::count_by_account(account.id, &database).await?;
257    let prompt = format!(
258        "Delete account {id} (status: {}, {order_count} order(s) will cascade)?",
259        account.status
260    );
261    if !confirm(&prompt, assume_yes, reader) {
262        return Ok(DeleteOutcome::Cancelled);
263    }
264    let outcome = Account::delete(id, &database).await?;
265    Ok(Deletion::from_guarded(outcome, order_count as u64).into())
266}
267
268/// Hard-deletes an order unless it holds a live certificate. Carries how many
269/// authorizations cascaded with it.
270pub async fn delete_order(id: &str, database: Arc<Database>) -> Result<Deletion, sqlx::Error> {
271    let Some(cascaded) = order_cascade(id, database.clone()).await? else {
272        return Ok(Deletion::NotFound);
273    };
274    let outcome = Order::delete(id, &database).await?;
275    Ok(Deletion::from_guarded(outcome, cascaded))
276}
277
278/// Same shape as [`confirm_delete_account`], for an order.
279pub async fn confirm_delete_order(
280    id: &str,
281    assume_yes: bool,
282    reader: &mut impl BufRead,
283    database: Arc<Database>,
284) -> Result<DeleteOutcome, sqlx::Error> {
285    let Some(order) = Order::find_by_id(id, &database).await? else {
286        return Ok(DeleteOutcome::NotFound);
287    };
288    let live = Order::count_live_certificates(order.id, &database).await?;
289    if live > 0 {
290        return Ok(DeleteOutcome::LiveCertificates(live));
291    }
292    let authz_count = Authorization::count_by_order(order.id, &database).await?;
293    let prompt = format!(
294        "Delete order {id} (status: {}, {authz_count} authorization(s) will cascade)?",
295        order.status
296    );
297    if !confirm(&prompt, assume_yes, reader) {
298        return Ok(DeleteOutcome::Cancelled);
299    }
300    let outcome = Order::delete(id, &database).await?;
301    Ok(Deletion::from_guarded(outcome, authz_count as u64).into())
302}
303
304/// Outcome of [`confirm_delete_eab`]: [`EabDeletion`] plus `Cancelled`.
305#[derive(Debug)]
306pub enum EabDeleteOutcome {
307    NotFound,
308    Cancelled,
309    /// Refused; see [`eab_live_certificates_refusal`].
310    LiveCertificates {
311        accounts: u64,
312        certificates: u64,
313    },
314    Deleted(DeletedEab),
315}
316
317impl From<EabDeletion> for EabDeleteOutcome {
318    fn from(deletion: EabDeletion) -> Self {
319        match deletion {
320            EabDeletion::NotFound => Self::NotFound,
321            EabDeletion::LiveCertificates {
322                accounts,
323                certificates,
324            } => Self::LiveCertificates {
325                accounts,
326                certificates,
327            },
328            EabDeletion::Deleted(deleted) => Self::Deleted(deleted),
329        }
330    }
331}
332
333/// Deletes an EAB credential, doing `accounts` to the accounts it bound. The
334/// web admin's form; the refusal and the transaction live in [`Eab::delete`].
335pub async fn delete_eab(
336    kid: &str,
337    accounts: BoundAccounts,
338    database: Arc<Database>,
339) -> Result<EabDeletion, sqlx::Error> {
340    Eab::delete(kid, accounts, &database).await
341}
342
343/// [`delete_eab`], asking first and naming what happens to the accounts.
344///
345/// Refuses `BoundAccounts::Delete` before the prompt when a live certificate
346/// would be lost, as [`confirm_delete_account`] does; [`Eab::delete`] checks
347/// again inside its transaction.
348pub async fn confirm_delete_eab(
349    kid: &str,
350    accounts: BoundAccounts,
351    assume_yes: bool,
352    reader: &mut impl BufRead,
353    database: Arc<Database>,
354) -> Result<EabDeleteOutcome, sqlx::Error> {
355    let Some(eab) = Eab::find_any_by_kid(kid, &database).await? else {
356        return Ok(EabDeleteOutcome::NotFound);
357    };
358    let bound = Account::eab_summary(eab.kid, &database).await?;
359    if accounts == BoundAccounts::Delete && bound.live_certificates > 0 {
360        return Ok(EabDeleteOutcome::LiveCertificates {
361            accounts: bound.accounts_with_live_certificates,
362            certificates: bound.live_certificates,
363        });
364    }
365    let prompt = match accounts {
366        BoundAccounts::Keep => format!(
367            "Delete EAB credential {kid} (status: {})? Its {} account(s) are kept, and will \
368             fail any eab filter check from now on.",
369            eab.status, bound.accounts
370        ),
371        BoundAccounts::Deactivate => format!(
372            "Delete EAB credential {kid} and deactivate its {} account(s)? Their {} order(s) \
373             are kept.",
374            bound.accounts, bound.orders
375        ),
376        BoundAccounts::Delete => format!(
377            "Delete EAB credential {kid} and its {} account(s) ({} order(s) will cascade)?",
378            bound.accounts, bound.orders
379        ),
380    };
381    if !confirm(&prompt, assume_yes, reader) {
382        return Ok(EabDeleteOutcome::Cancelled);
383    }
384    Ok(Eab::delete(kid, accounts, &database).await?.into())
385}
386
387/// Runs [`Nonce::cleanup`], returning how many were removed.
388pub async fn cleanup_nonces(ttl: Duration, database: Arc<Database>) -> Result<u64, sqlx::Error> {
389    Nonce::cleanup(&database, ttl).await
390}
391
392/// Confirms, then runs [`cleanup_nonces`]. `None` when the operator declined.
393pub async fn confirm_cleanup_nonces(
394    ttl: Duration,
395    assume_yes: bool,
396    reader: &mut impl BufRead,
397    database: Arc<Database>,
398) -> Result<Option<u64>, sqlx::Error> {
399    let prompt = format!("Delete all nonces older than {}s?", ttl.as_secs());
400    if !confirm(&prompt, assume_yes, reader) {
401        return Ok(None);
402    }
403    Ok(Some(cleanup_nonces(ttl, database).await?))
404}
405
406/// How many orders an account delete would cascade, or `None` if there is no
407/// such account. Shared so the prompt and the bare delete agree on the count.
408async fn account_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
409    let Some(account) = Account::find_any_by_id(id, &database).await? else {
410        return Ok(None);
411    };
412    Ok(Some(
413        Order::count_by_account(account.id, &database).await? as u64,
414    ))
415}
416
417/// The [`account_cascade`] counterpart for an order's authorizations.
418async fn order_cascade(id: &str, database: Arc<Database>) -> Result<Option<u64>, sqlx::Error> {
419    let Some(order) = Order::find_by_id(id, &database).await? else {
420        return Ok(None);
421    };
422    Ok(Some(
423        Authorization::count_by_order(order.id, &database).await? as u64,
424    ))
425}
426
427/// Why [`update_account_contact`] did not write.
428#[derive(Debug, thiserror::Error)]
429pub enum ContactError {
430    /// A contact `newAccount` would refuse, with the reason.
431    #[error("{0}")]
432    Invalid(String),
433    #[error("database error: {0}")]
434    Database(#[from] sqlx::Error),
435}
436
437/// Updates an account's contact list, refusing what `newAccount` would refuse
438/// ([`acme_proxy_protocol::acme::account::update_contact`], the one check every surface
439/// shares).
440pub async fn update_account_contact(
441    id: &str,
442    contact: Vec<String>,
443    database: Arc<Database>,
444) -> Result<Option<Account>, ContactError> {
445    use acme_proxy_protocol::acme::account::ContactUpdateError;
446    use acme_proxy_protocol::acme::account::update_contact;
447
448    let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
449        return Ok(None);
450    };
451    update_contact(&mut account, contact, &database)
452        .await
453        .map_err(|error| match error {
454            ContactUpdateError::Refused(problem) => ContactError::Invalid(
455                problem.to_value()["detail"]
456                    .as_str()
457                    .unwrap_or_default()
458                    .to_string(),
459            ),
460            ContactUpdateError::Database(error) => ContactError::Database(error),
461        })?;
462    Ok(Some(account))
463}
464
465/// Deactivates an account, and queues `account_deactivated` through its
466/// profile's dispatcher — `notifier` looks one up by profile name, `None` where
467/// this process has none — naming `client_ip` as whoever asked.
468///
469/// The same [`acme_proxy_protocol::acme::account::deactivate`] the account's own request runs,
470/// so the notification goes out however the account was shut.
471pub async fn deactivate_account(
472    id: &str,
473    database: Arc<Database>,
474    notifier: impl Fn(&str) -> Option<Arc<acme_proxy_jobs::notify::NotifyDispatcher>>,
475    client_ip: Option<String>,
476) -> Result<Option<Account>, sqlx::Error> {
477    let Some(mut account) = Account::find_any_by_id(id, &database).await? else {
478        return Ok(None);
479    };
480    let dispatcher = notifier(&account.profile);
481    acme_proxy_protocol::acme::account::deactivate(
482        &mut account,
483        &database,
484        dispatcher.as_deref(),
485        client_ip,
486    )
487    .await?;
488    Ok(Some(account))
489}
490
491/// Revokes an order's issued certificate at the signer backend and records it on the order.
492///
493/// `actor`/`client` say who asked and from where. Both front ends supply their
494/// own: [`Actor::cli`] with an empty [`ClientContext`] from the command line,
495/// [`Actor::admin`] with the operator's address from the web admin. Passed in
496/// rather than derived here because this layer is deliberately front-end
497/// agnostic — it is the same reason the destructive operations come in a bare
498/// and a `confirm_*` form.
499///
500/// `revocations` carries the database, the auditor, the order's profile's
501/// dispatcher and the revoker. The revoker is what withdraws the trust — for
502/// every front end,
503/// [`Revoker::for_route`](acme_proxy_protocol::acme::revoke::Revoker::for_route):
504/// a local CA's ledger, or the queue a worker drains. Neither front end holds a
505/// backend.
506///
507/// The operation itself is [`acme_proxy_protocol::acme::revoke::Revocations::revoke_order`],
508/// the same tail `POST /revokeCert` runs; this wrapper only sorts its answers
509/// into the outcomes an operator front end reports.
510pub async fn revoke_order(
511    id: &str,
512    reason: Option<u32>,
513    actor: Actor,
514    client: ClientContext,
515    revocations: acme_proxy_protocol::acme::revoke::Revocations<'_>,
516) -> Result<RevokeOutcome, RevokeError> {
517    use acme_proxy_protocol::acme::revoke::RevokeError as Refusal;
518
519    match revocations.revoke_order(id, reason, actor, client).await {
520        Ok(order) => Ok(RevokeOutcome::Revoked(Box::new(order))),
521        Err(Refusal::NotFound) => Ok(RevokeOutcome::NotFound),
522        Err(Refusal::NotIssued) => Ok(RevokeOutcome::NotIssued),
523        Err(Refusal::AlreadyRevoked) => Ok(RevokeOutcome::AlreadyRevoked),
524        Err(Refusal::BadReason(code)) => Err(RevokeError::BadReason(code)),
525        Err(Refusal::Database(error)) => Err(RevokeError::Database(error)),
526        Err(Refusal::Internal(detail)) => Err(RevokeError::Internal(detail)),
527        Err(Refusal::Signer(error)) => Err(RevokeError::Signer(error)),
528        Err(Refusal::Pending { job }) => Ok(RevokeOutcome::Queued(job)),
529        Err(Refusal::Abandoned { job, reason }) => Err(RevokeError::Abandoned { job, reason }),
530        // Only the ACME door refuses this way; an operator is never turned away.
531        Err(Refusal::Refused(problem)) => Err(RevokeError::Internal(problem.detail().to_owned())),
532    }
533}
534
535/// One page of audit rows, plus the unpaged total the same filters match.
536pub async fn list_audit(
537    query: &AuditQuery,
538    database: Arc<Database>,
539) -> Result<(Vec<AuditEntry>, i64), sqlx::Error> {
540    AuditEntry::search(query, &database).await
541}
542
543/// One audit row by id.
544pub async fn find_audit(
545    id: i64,
546    database: Arc<Database>,
547) -> Result<Option<AuditEntry>, sqlx::Error> {
548    AuditEntry::find_by_id(id, &database).await
549}
550
551/// Deletes audit rows older than `days`, returning how many went.
552pub async fn cleanup_audit(days: u64, database: Arc<Database>) -> Result<u64, sqlx::Error> {
553    AuditEntry::cleanup(acme_proxy_store::audit::audit_cutoff(days), &database).await
554}
555
556/// Confirms, then runs [`cleanup_audit`]. `None` when the operator declined.
557///
558/// Confirm-gated, unlike `revoke_order`: this is the one operation in the crate
559/// that destroys audit history, and the prompt names how many rows are about to
560/// go — a number the operator usually did not expect.
561pub async fn confirm_cleanup_audit(
562    days: u64,
563    assume_yes: bool,
564    reader: &mut impl BufRead,
565    database: Arc<Database>,
566) -> Result<Option<u64>, sqlx::Error> {
567    let cutoff = acme_proxy_store::audit::audit_cutoff(days);
568    let doomed = AuditEntry::count_older_than(cutoff, &database).await?;
569    let prompt =
570        format!("Delete {doomed} audit row(s) older than {days} day(s)? This cannot be undone.");
571    if !confirm(&prompt, assume_yes, reader) {
572        return Ok(None);
573    }
574    Ok(Some(AuditEntry::cleanup(cutoff, &database).await?))
575}
576
577/// A page of orders as their admin JSON, each with its authorization ids.
578///
579/// One query for the whole page rather than one per row — a default page of
580/// 50 used to cost 51. The CLI's `order list --json`, `/api/orders`,
581/// `/api/accounts/{id}/orders` and the `/ui` order lists all render through
582/// here, so they cannot disagree on the shape or on the cost.
583pub async fn orders_json(
584    orders: &[Order],
585    base_url: &str,
586    database: &Database,
587) -> Result<Vec<serde_json::Value>, sqlx::Error> {
588    let ids: Vec<uuid::Uuid> = orders.iter().map(|order| order.id).collect();
589    let mut grouped = Authorization::find_ids_by_orders(&ids, database).await?;
590    Ok(orders
591        .iter()
592        .map(|order| {
593            crate::admin::render_order_json(
594                order,
595                base_url,
596                &grouped.remove(&order.id).unwrap_or_default(),
597            )
598        })
599        .collect())
600}
601
602/// Loads order detail.
603pub async fn load_order_detail(
604    id: &str,
605    database: Arc<Database>,
606) -> Result<Option<OrderDetail>, sqlx::Error> {
607    let Some(order) = Order::find_by_id(id, &database).await? else {
608        return Ok(None);
609    };
610    let authzs = Authorization::find_by_order(order.id, &database).await?;
611    let mut authorizations = Vec::with_capacity(authzs.len());
612    for authz in authzs {
613        let challenges = Challenge::find_by_authz(authz.id, &database).await?;
614        authorizations.push((authz, challenges));
615    }
616    Ok(Some(OrderDetail {
617        order,
618        authorizations,
619    }))
620}
621
622// ---------------------------------------------------------------------------
623// The job queue operator surface
624//
625// Read (`load_job_detail`, `load_upstream_order_detail`) and two mutations
626// (`cancel_job`, `run_job_now`). Both front ends call `Job::search` /
627// `UpstreamOrder::search` directly — only the composed detail loaders and the
628// mutations, which have a relay special case and an audit row, live here.
629// ---------------------------------------------------------------------------
630
631/// A job plus, when it is a relay issuance, the `upstream_orders` row it drives
632/// — the cross-link the detail view renders as a panel.
633#[derive(Debug)]
634pub struct JobDetail {
635    pub job: Job,
636    pub upstream_order: Option<UpstreamOrderRow>,
637}
638
639/// An `upstream_orders` row plus, when one exists, the relay job that drives
640/// (or last drove) it — the reverse cross-link.
641#[derive(Debug)]
642pub struct UpstreamOrderDetail {
643    pub upstream_order: UpstreamOrderRow,
644    pub job: Option<Job>,
645}
646
647/// The `jobs.kind` values whose handler re-enqueues itself on a cadence
648/// (`Reschedule`). Cancelling one stops that sweep until the server restarts,
649/// which the CLI/UI warns about — a retired periodic job does not come back.
650const PERIODIC_JOB_KINDS: &[&str] = &[
651    acme_proxy_jobs::jobs::sweep::RETENTION_JOB_KIND,
652    acme_proxy_jobs::jobs::sweep::NONCE_SWEEP_KIND,
653    acme_proxy_jobs::jobs::sweep::AUDIT_SWEEP_KIND,
654    acme_proxy_jobs::jobs::sweep::ADMIN_SESSION_SWEEP_KIND,
655    acme_proxy_jobs::jobs::sweep::ORDER_SWEEP_KIND,
656    acme_proxy_signer::local_ca::sweep::CRL_SWEEP_KIND,
657    acme_proxy_jobs::notify::expiry::EXPIRY_JOB_KIND,
658];
659
660/// Whether cancelling a job of this kind silently stops a periodic sweep.
661#[must_use]
662pub fn is_periodic_job_kind(kind: &str) -> bool {
663    PERIODIC_JOB_KINDS.contains(&kind)
664}
665
666/// Loads a job by id, attaching its `upstream_orders` row when it is a relay
667/// issuance. `None` for a junk id or an unknown job.
668pub async fn load_job_detail(
669    id: &str,
670    database: Arc<Database>,
671) -> Result<Option<JobDetail>, sqlx::Error> {
672    let Some(job_id) = acme_proxy_store::id::parse(id) else {
673        return Ok(None);
674    };
675    let Some(job) = Job::find_by_id(job_id, &database).await? else {
676        return Ok(None);
677    };
678    let upstream_order = if job.kind == RELAY_JOB_KIND {
679        UpstreamOrder::find_row_by_order_id(&job.dedup_key, &database).await?
680    } else {
681        None
682    };
683    Ok(Some(JobDetail {
684        job,
685        upstream_order,
686    }))
687}
688
689/// Loads an `upstream_orders` row by its local order id, attaching the most
690/// recent relay job for it (live or terminal). `None` for a junk id or an
691/// order no relay was opened for.
692pub async fn load_upstream_order_detail(
693    order_id: &str,
694    database: Arc<Database>,
695) -> Result<Option<UpstreamOrderDetail>, sqlx::Error> {
696    let Some(upstream_order) = UpstreamOrder::find_row_by_order_id(order_id, &database).await?
697    else {
698        return Ok(None);
699    };
700    let job = Job::find_latest_by_dedup(
701        RELAY_JOB_KIND,
702        &upstream_order.order_id.to_string(),
703        &database,
704    )
705    .await?;
706    Ok(Some(UpstreamOrderDetail {
707        upstream_order,
708        job,
709    }))
710}
711
712/// Outcome of [`cancel_job`] / [`confirm_cancel_job`].
713#[derive(Debug)]
714pub enum CancelJobOutcome {
715    NotFound,
716    /// `running`, `done`, or already `cancelled` — the guard refused. Carries
717    /// the current status so the caller can name it.
718    NotCancellable(String),
719    /// Cancelled. The job row is the one the guard returned.
720    Cancelled(Box<Job>),
721    /// Cancelled, and because it was an in-flight relay issuance the local
722    /// order was marked `invalid` and the upstream mapping abandoned so
723    /// recovery will not resurrect it.
724    CancelledAndOrderAbandoned {
725        job: Box<Job>,
726        order_id: String,
727    },
728}
729
730/// Outcome of [`run_job_now`].
731#[derive(Debug)]
732pub enum RunJobNowOutcome {
733    NotFound,
734    /// `running`, `done`, or `cancelled`: run-now is meaningless. Carries the
735    /// status.
736    Refused(String),
737    /// A live `ready` job whose `run_at` was pulled forward.
738    Nudged(Box<Job>),
739    /// A `failed`/exhausted job revived for exactly one more attempt.
740    Revived(Box<Job>),
741}
742
743/// Why [`cancel_job`] failed irrecoverably.
744#[derive(Debug, thiserror::Error)]
745pub enum CancelJobError {
746    #[error("database error: {0}")]
747    Database(#[from] sqlx::Error),
748}
749
750/// Cancels a job. For an in-flight `signer_relay_issue` job this also abandons
751/// the ACME order — `Order::mark_invalid` (generic problem document, so the
752/// client stops polling), `UpstreamOrder::mark_invalid` (so `RelayJob::recover`
753/// does not resurrect it), and one `certificate_issue_failed` audit row
754/// attributed to `actor`/`client`. An in-flight `signer_issue` job does the
755/// same to its still-`processing` order, minus the mapping row it never had.
756///
757/// `actor`/`client` come from the caller, exactly as [`revoke_order`]: the CLI
758/// supplies [`Actor::cli`] with an empty context, the web admin
759/// [`Actor::admin`] with the operator's own address.
760///
761/// `audit` is what every row is written through, so the
762/// `certificate_issue_failed` row on the relay branch is counted into the same
763/// registry the trail describes — `/metrics` and `acme-proxy audit list` cannot
764/// disagree about how many issuances failed. The web admin passes the process's
765/// auditor; the CLI passes an [`Auditor::offline`] one, since a `serve`-less
766/// invocation has no exposition to keep honest.
767///
768/// **The job row is cancelled first, then the order is abandoned.** The guard
769/// (`status IN ('ready', 'failed')`) means at most one caller wins a race with
770/// another operator or with the runner claiming the row; a loser touches
771/// nothing else. Abandoning the order first and then finding the job already
772/// `running` would invalidate a client's order out from under a runner that
773/// might still succeed.
774pub async fn cancel_job(
775    id: &str,
776    actor: Actor,
777    client: ClientContext,
778    audit: &Auditor,
779    database: Arc<Database>,
780) -> Result<CancelJobOutcome, CancelJobError> {
781    let Some(job_id) = acme_proxy_store::id::parse(id) else {
782        return Ok(CancelJobOutcome::NotFound);
783    };
784
785    // `ready` first, then `failed`: two guarded statements rather than one over
786    // both, because only the first is a job still in flight. A `failed` relay
787    // job has already been through `runner::retire`, which called
788    // `RelayJob::abandon` — its order is `invalid`, its mapping row is
789    // `invalid`, and its `certificate_issue_failed` row is written. Abandoning
790    // it a second time would add a duplicate row for one issuance and overwrite
791    // `upstream_orders.error`, which is the upstream CA's own message and the
792    // whole reason the operator is looking at the row.
793    let (job, was_in_flight) = match Job::cancel_row(job_id, JobStatus::Ready, &database).await? {
794        Some(job) => (job, true),
795        None => match Job::cancel_row(job_id, JobStatus::Failed, &database).await? {
796            Some(job) => (job, false),
797            None => {
798                // Neither guard matched: say whether that is "no such job" or
799                // "not in a cancellable state".
800                return Ok(match Job::find_by_id(job_id, &database).await? {
801                    None => CancelJobOutcome::NotFound,
802                    Some(job) => CancelJobOutcome::NotCancellable(job.status),
803                });
804            }
805        },
806    };
807
808    if job.kind == RELAY_JOB_KIND && was_in_flight {
809        // An in-flight relay job: abandon the order it was driving. Its
810        // `dedup_key` is the local order id.
811        //
812        // The order row may be gone — `order.retention_days` sweeps expired
813        // orders — and then there is nothing to abandon and nothing to say
814        // about one. That case falls through to the plain administrative row
815        // below rather than reporting an abandonment that did not happen.
816        let order_id = job.dedup_key.clone();
817        if let Some(mut order) = Order::find_by_id(&order_id, &database).await? {
818            abandon_relayed_order(
819                &mut order,
820                "issuance cancelled by operator",
821                actor,
822                client,
823                audit,
824                &database,
825            )
826            .await?;
827            return Ok(CancelJobOutcome::CancelledAndOrderAbandoned {
828                job: Box::new(job),
829                order_id,
830            });
831        }
832    }
833
834    if job.kind == acme_proxy_protocol::acme::issue::SIGNER_ISSUE_KIND && was_in_flight {
835        // An issuance that never reached a backend: the order was claimed with
836        // this row and nothing else will settle it, so it goes `invalid` now
837        // rather than sitting `processing` until it expires. Only while still
838        // `processing` — a deactivation may have demoted it since.
839        let order_id = job.dedup_key.clone();
840        if let Some(mut order) = Order::find_by_id(&order_id, &database).await?
841            && order.status == acme_proxy_store::status::OrderStatus::Processing
842        {
843            acme_proxy_signer::issuance::record_issue_failure(
844                &mut order,
845                &acme_proxy_core::error::Problem::server_internal("Certificate issuance failed"),
846                "issuance cancelled by operator",
847                actor,
848                client,
849                audit,
850                &database,
851            )
852            .await?;
853            return Ok(CancelJobOutcome::CancelledAndOrderAbandoned {
854                job: Box::new(job),
855                order_id,
856            });
857        }
858    }
859
860    // Everything else — every non-relay kind, a relay job that was already
861    // `failed`, and one whose order has been swept — gets a plain
862    // administrative row. `abandon_relayed_order` writes the
863    // `certificate_issue_failed` row on the branch above; this one is the only
864    // record the rest of them leave, so it must not be skipped.
865    audit
866        .record(acme_proxy_jobs::auditor::admin::job_cancelled(
867            actor,
868            client,
869            &job.kind,
870            &job.id.to_string(),
871        ))
872        .await;
873    Ok(CancelJobOutcome::Cancelled(Box::new(job)))
874}
875
876/// [`cancel_job`] with a confirmation prompt. `Ok(None)` when the operator
877/// declined — the `confirm_cleanup_*` shape.
878pub async fn confirm_cancel_job(
879    id: &str,
880    assume_yes: bool,
881    reader: &mut impl BufRead,
882    actor: Actor,
883    client: ClientContext,
884    audit: &Auditor,
885    database: Arc<Database>,
886) -> Result<Option<CancelJobOutcome>, CancelJobError> {
887    // Resolve the subject before prompting, the shape `confirm_delete_account`
888    // and `confirm_delete_order` already keep: an id that is not one at all
889    // used to be parsed as `Uuid::nil()`, so the operator was asked
890    // "Cancel job nope?" and only told there was no such job after answering.
891    let Some(job_id) = acme_proxy_store::id::parse(id) else {
892        return Ok(Some(CancelJobOutcome::NotFound));
893    };
894    let Some(job) = Job::find_by_id(job_id, &database).await? else {
895        return Ok(Some(CancelJobOutcome::NotFound));
896    };
897
898    // A prompt that names the kind, whether an order goes with it, and whether
899    // a periodic sweep stops. Only a job that is still `ready` will abandon an
900    // order — a `failed` one was abandoned when it was retired — so the prompt
901    // says so rather than promising a second invalidation.
902    let prompt = if job.kind == RELAY_JOB_KIND && job.status == JobStatus::Ready.as_str() {
903        format!(
904            "Cancel relay job {id}? Order {} will be marked invalid.",
905            job.dedup_key
906        )
907    } else if is_periodic_job_kind(&job.kind) {
908        format!(
909            "Cancel job {id} ({})? This periodic sweep will not run again until \
910             the server restarts.",
911            job.kind
912        )
913    } else {
914        format!("Cancel job {id} ({})?", job.kind)
915    };
916    if !confirm(&prompt, assume_yes, reader) {
917        return Ok(None);
918    }
919    Ok(Some(cancel_job(id, actor, client, audit, database).await?))
920}
921
922/// Makes a job eligible to run immediately: a live `ready` job's `run_at` is
923/// pulled forward; a `failed`/exhausted one is revived for exactly one more
924/// attempt (`attempts` set to `max_attempts - 1`). Refused on
925/// `running`/`done`/`cancelled`.
926///
927/// The runner picks the change up within `jobs.poll_interval_ms`: this writes
928/// the row and does not wake anything. Nudging the queue would be the panel's
929/// `JobQueue` to do, and this layer is below it — the CLI, which has no runner
930/// at all, calls the same function.
931///
932/// Takes `actor`/`client` and writes its own `job_advanced` audit row, the
933/// shape [`cancel_job`] and [`revoke_order`] already keep. It did not, and the
934/// three front ends each wrote that row for themselves — five call sites for
935/// one operation, on the opposite side of this layer from its sibling's, for no
936/// reason anybody had written down. Nothing is recorded when the job is not
937/// found or the transition is refused: an administrative row is a side effect
938/// of success.
939pub async fn run_job_now(
940    id: &str,
941    actor: Actor,
942    client: ClientContext,
943    audit: &Auditor,
944    database: Arc<Database>,
945) -> Result<RunJobNowOutcome, sqlx::Error> {
946    let Some(job_id) = acme_proxy_store::id::parse(id) else {
947        return Ok(RunJobNowOutcome::NotFound);
948    };
949    if let Some(job) = Job::advance_row(job_id, &database).await? {
950        audit
951            .record(acme_proxy_jobs::auditor::admin::job_advanced(
952                actor,
953                client,
954                &job.id.to_string(),
955                false,
956            ))
957            .await;
958        return Ok(RunJobNowOutcome::Nudged(Box::new(job)));
959    }
960    if let Some(job) = Job::revive_row(job_id, &database).await? {
961        audit
962            .record(acme_proxy_jobs::auditor::admin::job_advanced(
963                actor,
964                client,
965                &job.id.to_string(),
966                true,
967            ))
968            .await;
969        return Ok(RunJobNowOutcome::Revived(Box::new(job)));
970    }
971    Ok(match Job::find_by_id(job_id, &database).await? {
972        None => RunJobNowOutcome::NotFound,
973        Some(job) => RunJobNowOutcome::Refused(job.status),
974    })
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use acme_proxy_core::audit::AuditEvent;
981    use acme_proxy_core::audit::AuditRecord;
982    use acme_proxy_core::identifier::Identifier;
983    use acme_proxy_signer::SignerBackend;
984    use acme_proxy_store::nonce::now_secs;
985    use acme_proxy_store::testutil::account_id;
986
987    async fn db() -> Arc<Database> {
988        Arc::new(Database::connect_in_memory().await.unwrap())
989    }
990
991    /// The actor the CLI supplies, which is what these tests stand in for.
992    /// `Actor::cli` reads `$USER`, so it is called rather than hard-coded — the
993    /// point of the tests below is the revocation, not the name on the row.
994    fn cli_actor() -> Actor {
995        Actor::cli()
996    }
997
998    async fn audit_rows(db: &Arc<Database>) -> Vec<AuditEntry> {
999        AuditEntry::search(
1000            &AuditQuery {
1001                limit: 50,
1002                ..AuditQuery::default()
1003            },
1004            db,
1005        )
1006        .await
1007        .unwrap()
1008        .0
1009    }
1010
1011    /// The confirm gate: declined leaves the trail intact, accepted prunes by
1012    /// age and nothing else.
1013    #[tokio::test]
1014    async fn cleaning_the_audit_trail_is_confirm_gated_and_bounded_by_age() {
1015        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1016        AuditEntry::insert(
1017            AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
1018            &db,
1019        )
1020        .await
1021        .unwrap();
1022
1023        let mut declined: &[u8] = b"n\n";
1024        assert_eq!(
1025            confirm_cleanup_audit(0, false, &mut declined, db.clone())
1026                .await
1027                .unwrap(),
1028            None
1029        );
1030        assert_eq!(audit_rows(&db).await.len(), 1);
1031
1032        // Nothing is a week old yet.
1033        let mut reader: &[u8] = &[];
1034        assert_eq!(
1035            confirm_cleanup_audit(7, true, &mut reader, db.clone())
1036                .await
1037                .unwrap(),
1038            Some(0)
1039        );
1040        assert_eq!(audit_rows(&db).await.len(), 1);
1041
1042        // A day's retention keeps a row written moments ago. Not zero days:
1043        // that cutoff is "now", and a row stamped in the previous second is
1044        // older than it whenever the test straddles a second boundary.
1045        assert_eq!(cleanup_audit(1, db.clone()).await.unwrap(), 0);
1046
1047        // A cutoff in the future takes it.
1048        assert_eq!(
1049            AuditEntry::cleanup(acme_proxy_store::audit::audit_cutoff(0) + 3600, &db)
1050                .await
1051                .unwrap(),
1052            1
1053        );
1054        assert!(audit_rows(&db).await.is_empty());
1055    }
1056
1057    /// `list_audit`/`find_audit` are the thin pass-throughs both front ends
1058    /// share; this pins that they page and look up rather than doing anything
1059    /// of their own.
1060    #[tokio::test]
1061    async fn listing_and_finding_audit_rows_pages_and_resolves() {
1062        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1063        let mut ids = Vec::new();
1064        for _ in 0..3 {
1065            ids.push(
1066                AuditEntry::insert(
1067                    AuditRecord::new(AuditEvent::CertificateIssued, "default", Actor::system()),
1068                    &db,
1069                )
1070                .await
1071                .unwrap(),
1072            );
1073        }
1074
1075        let (page, total) = list_audit(
1076            &AuditQuery {
1077                limit: 2,
1078                ..AuditQuery::default()
1079            },
1080            db.clone(),
1081        )
1082        .await
1083        .unwrap();
1084        assert_eq!(total, 3);
1085        assert_eq!(page.len(), 2);
1086
1087        assert!(find_audit(ids[0], db.clone()).await.unwrap().is_some());
1088        assert!(find_audit(9_999, db).await.unwrap().is_none());
1089    }
1090
1091    /// A revocation through this layer writes exactly one row, naming the
1092    /// actor the caller supplied rather than the order's own account — which
1093    /// is the whole point of the parameter.
1094    #[tokio::test]
1095    async fn revoking_writes_one_audit_row_naming_the_caller() {
1096        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1097        let signer = in_memory_ca(&db);
1098        let order = finalized_order(db.clone(), &signer).await;
1099
1100        let outcome = revoke_order(
1101            order.id.to_string().as_str(),
1102            Some(1),
1103            Actor::admin("root"),
1104            ClientContext {
1105                ip: Some("203.0.113.7".to_string()),
1106                ptr: Some("desk.example.com".to_string()),
1107                ..ClientContext::default()
1108            },
1109            acme_proxy_protocol::acme::revoke::Revocations {
1110                database: &db,
1111                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1112                notify: None,
1113                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1114            },
1115        )
1116        .await
1117        .unwrap();
1118        assert!(matches!(outcome, RevokeOutcome::Revoked(_)));
1119
1120        let rows = audit_rows(&db).await;
1121        assert_eq!(rows.len(), 1, "{rows:?}");
1122        let row = &rows[0];
1123        assert_eq!(row.event, "certificate_revoked");
1124        assert_eq!(row.outcome, "success");
1125        assert_eq!(row.actor_kind, "admin");
1126        assert_eq!(row.actor_id.as_deref(), Some("root"));
1127        assert_eq!(row.account_id, Some(order.account_id.to_string()));
1128        assert_eq!(row.order_id, Some(order.id.to_string()));
1129        assert_eq!(row.cert_serial, order.cert_serial);
1130        assert_eq!(row.client_ip.as_deref(), Some("203.0.113.7"));
1131        assert_eq!(row.client_ptr.as_deref(), Some("desk.example.com"));
1132        assert_eq!(row.reason.as_deref(), Some("1"));
1133
1134        // Revoking again is `AlreadyRevoked` and writes nothing: the operator
1135        // is being told the state of things, not refused a CA action.
1136        let outcome = revoke_order(
1137            order.id.to_string().as_str(),
1138            None,
1139            Actor::admin("root"),
1140            ClientContext::default(),
1141            acme_proxy_protocol::acme::revoke::Revocations {
1142                database: &db,
1143                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1144                notify: None,
1145                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1146            },
1147        )
1148        .await
1149        .unwrap();
1150        assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
1151        assert_eq!(audit_rows(&db).await.len(), 1);
1152    }
1153
1154    /// A backend wanting every event, which never delivers — enough to see a
1155    /// `notify_deliver` row queued.
1156    struct Wanting;
1157
1158    #[async_trait::async_trait]
1159    impl acme_proxy_jobs::notify::NotifyBackend for Wanting {
1160        fn name(&self) -> &'static str {
1161            "custom"
1162        }
1163        async fn send(
1164            &self,
1165            _event: &acme_proxy_jobs::notify::NotifyEvent,
1166        ) -> Result<(), acme_proxy_jobs::notify::NotifyError> {
1167            Ok(())
1168        }
1169    }
1170
1171    /// An operator's revocation notifies the way `POST /revokeCert` does: the
1172    /// event is about the certificate, not about who withdrew it.
1173    #[tokio::test]
1174    async fn an_operator_revocation_queues_a_certificate_revoked_notification() {
1175        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1176        let signer = in_memory_ca(&db);
1177        let order = finalized_order(db.clone(), &signer).await;
1178        let dispatcher = acme_proxy_jobs::notify::NotifyDispatcher::new(
1179            "default",
1180            vec![acme_proxy_jobs::notify::BackendSlot::new(
1181                "custom:test",
1182                Arc::new(Wanting),
1183                &["certificate_revoked".to_string()],
1184            )],
1185            acme_proxy_jobs::testutil::idle_job_queue(db.clone()),
1186        );
1187
1188        let outcome = revoke_order(
1189            order.id.to_string().as_str(),
1190            None,
1191            cli_actor(),
1192            ClientContext::default(),
1193            acme_proxy_protocol::acme::revoke::Revocations {
1194                database: &db,
1195                audit: &Auditor::offline(db.clone()),
1196                notify: Some(&dispatcher),
1197                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1198            },
1199        )
1200        .await
1201        .unwrap();
1202        assert!(matches!(outcome, RevokeOutcome::Revoked(_)));
1203
1204        let queued = Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
1205            .await
1206            .unwrap();
1207        assert_eq!(queued, 1);
1208    }
1209
1210    /// No reason given is an **absent** `reason`, not an empty one: RFC 8555
1211    /// §7.6 allows omitting it, and that is not the same as `unspecified` (0).
1212    #[tokio::test]
1213    async fn a_revocation_with_no_reason_leaves_the_column_absent() {
1214        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1215        let signer = in_memory_ca(&db);
1216        let order = finalized_order(db.clone(), &signer).await;
1217
1218        revoke_order(
1219            order.id.to_string().as_str(),
1220            None,
1221            cli_actor(),
1222            ClientContext::default(),
1223            acme_proxy_protocol::acme::revoke::Revocations {
1224                database: &db,
1225                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1226                notify: None,
1227                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1228            },
1229        )
1230        .await
1231        .unwrap();
1232
1233        let rows = audit_rows(&db).await;
1234        assert_eq!(rows[0].reason, None);
1235        assert_eq!(rows[0].actor_kind, "cli");
1236        // A CLI revocation genuinely has no client, and says so.
1237        assert_eq!(rows[0].client_ip, None);
1238        assert_eq!(rows[0].client_ptr, None);
1239    }
1240
1241    #[tokio::test]
1242    async fn delete_account_not_found() {
1243        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1244        let mut reader: &[u8] = &[];
1245        let outcome = confirm_delete_account("nope", true, &mut reader, db)
1246            .await
1247            .unwrap();
1248        assert_eq!(outcome, DeleteOutcome::NotFound);
1249    }
1250
1251    #[tokio::test]
1252    async fn delete_account_cancelled_leaves_row() {
1253        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1254        let acct = account_id(&db).await;
1255
1256        let mut reader = b"n\n".as_slice();
1257        let outcome =
1258            confirm_delete_account(acct.to_string().as_str(), false, &mut reader, db.clone())
1259                .await
1260                .unwrap();
1261        assert_eq!(outcome, DeleteOutcome::Cancelled);
1262        assert!(
1263            Account::find_by_id("default", acct.to_string().as_str(), &db)
1264                .await
1265                .unwrap()
1266                .is_some()
1267        );
1268    }
1269
1270    #[tokio::test]
1271    async fn delete_account_confirmed_deletes_and_cascades() {
1272        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1273        let acct = account_id(&db).await;
1274        let order = Order::create(
1275            "default",
1276            acct,
1277            vec![Identifier::dns("example.com")],
1278            acme_proxy_store::nonce::now_secs() + 3600,
1279            None,
1280            None,
1281            &db,
1282        )
1283        .await
1284        .unwrap();
1285
1286        let mut reader: &[u8] = &[];
1287        let outcome =
1288            confirm_delete_account(acct.to_string().as_str(), true, &mut reader, db.clone())
1289                .await
1290                .unwrap();
1291        assert!(matches!(outcome, DeleteOutcome::Deleted(_)));
1292        assert!(
1293            Account::find_by_id("default", acct.to_string().as_str(), &db)
1294                .await
1295                .unwrap()
1296                .is_none()
1297        );
1298        assert!(
1299            Order::find_by_id(order.id.to_string().as_str(), &db)
1300                .await
1301                .unwrap()
1302                .is_none()
1303        );
1304    }
1305
1306    #[tokio::test]
1307    async fn delete_order_not_found() {
1308        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1309        let mut reader: &[u8] = &[];
1310        let outcome = confirm_delete_order("nope", true, &mut reader, db)
1311            .await
1312            .unwrap();
1313        assert_eq!(outcome, DeleteOutcome::NotFound);
1314    }
1315
1316    #[tokio::test]
1317    async fn delete_order_cancelled_leaves_row() {
1318        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1319        let acct = account_id(&db).await;
1320        let order = Order::create(
1321            "default",
1322            acct,
1323            vec![Identifier::dns("example.com")],
1324            acme_proxy_store::nonce::now_secs() + 3600,
1325            None,
1326            None,
1327            &db,
1328        )
1329        .await
1330        .unwrap();
1331
1332        let mut reader = b"no\n".as_slice();
1333        let outcome = confirm_delete_order(
1334            order.id.to_string().as_str(),
1335            false,
1336            &mut reader,
1337            db.clone(),
1338        )
1339        .await
1340        .unwrap();
1341        assert_eq!(outcome, DeleteOutcome::Cancelled);
1342        assert!(
1343            Order::find_by_id(order.id.to_string().as_str(), &db)
1344                .await
1345                .unwrap()
1346                .is_some()
1347        );
1348    }
1349
1350    #[tokio::test]
1351    async fn delete_order_confirmed_deletes_and_cascades() {
1352        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1353        let acct = account_id(&db).await;
1354        let order = Order::create(
1355            "default",
1356            acct,
1357            vec![Identifier::dns("example.com")],
1358            acme_proxy_store::nonce::now_secs() + 3600,
1359            None,
1360            None,
1361            &db,
1362        )
1363        .await
1364        .unwrap();
1365        let authz = Authorization::create(
1366            order.id,
1367            Identifier::dns("example.com"),
1368            acme_proxy_store::nonce::now_secs() + 3600,
1369            &db,
1370        )
1371        .await
1372        .unwrap();
1373
1374        let mut reader: &[u8] = &[];
1375        let outcome =
1376            confirm_delete_order(order.id.to_string().as_str(), true, &mut reader, db.clone())
1377                .await
1378                .unwrap();
1379        assert!(matches!(outcome, DeleteOutcome::Deleted(_)));
1380        assert!(
1381            Order::find_by_id(order.id.to_string().as_str(), &db)
1382                .await
1383                .unwrap()
1384                .is_none()
1385        );
1386        assert!(
1387            Authorization::find_by_id(authz.id.to_string().as_str(), &db)
1388                .await
1389                .unwrap()
1390                .is_none()
1391        );
1392    }
1393
1394    // The bare forms below are what the web admin calls: no prompt, no reader,
1395    // and a cascade count to report back instead of a bare acknowledgement.
1396
1397    #[tokio::test]
1398    async fn bare_delete_account_reports_not_found_for_an_unknown_id() {
1399        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1400        assert_eq!(
1401            delete_account("nope", db).await.unwrap(),
1402            Deletion::NotFound
1403        );
1404    }
1405
1406    #[tokio::test]
1407    async fn bare_delete_account_deletes_and_counts_the_cascade() {
1408        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1409        let acct = account_id(&db).await;
1410        for _ in 0..2 {
1411            Order::create(
1412                "default",
1413                acct,
1414                vec![Identifier::dns("example.com")],
1415                acme_proxy_store::nonce::now_secs() + 3600,
1416                None,
1417                None,
1418                &db,
1419            )
1420            .await
1421            .unwrap();
1422        }
1423
1424        assert_eq!(
1425            delete_account(acct.to_string().as_str(), db.clone())
1426                .await
1427                .unwrap(),
1428            Deletion::Deleted(Deleted { cascaded: 2 })
1429        );
1430        assert!(
1431            Account::find_by_id("default", acct.to_string().as_str(), &db)
1432                .await
1433                .unwrap()
1434                .is_none()
1435        );
1436    }
1437
1438    #[tokio::test]
1439    async fn bare_delete_order_reports_not_found_for_an_unknown_id() {
1440        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1441        assert_eq!(delete_order("nope", db).await.unwrap(), Deletion::NotFound);
1442    }
1443
1444    #[tokio::test]
1445    async fn bare_delete_order_deletes_and_counts_the_cascade() {
1446        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1447        let acct = account_id(&db).await;
1448        let order = Order::create(
1449            "default",
1450            acct,
1451            vec![Identifier::dns("example.com")],
1452            acme_proxy_store::nonce::now_secs() + 3600,
1453            None,
1454            None,
1455            &db,
1456        )
1457        .await
1458        .unwrap();
1459        Authorization::create(
1460            order.id,
1461            Identifier::dns("example.com"),
1462            acme_proxy_store::nonce::now_secs() + 3600,
1463            &db,
1464        )
1465        .await
1466        .unwrap();
1467
1468        assert_eq!(
1469            delete_order(order.id.to_string().as_str(), db.clone())
1470                .await
1471                .unwrap(),
1472            Deletion::Deleted(Deleted { cascaded: 1 })
1473        );
1474        assert!(
1475            Order::find_by_id(order.id.to_string().as_str(), &db)
1476                .await
1477                .unwrap()
1478                .is_none()
1479        );
1480    }
1481
1482    /// A live certificate refuses each `confirm_*` delete **before** the
1483    /// prompt: the reader is empty and `assume_yes` false, so reaching the
1484    /// question would have answered `Cancelled`. The bare forms refuse too.
1485    #[tokio::test]
1486    async fn a_live_certificate_refuses_account_and_order_deletes_before_asking() {
1487        let db = db().await;
1488        let acct = account_id(&db).await;
1489        let order = acme_proxy_store::testutil::certified_order(&db, acct, None).await;
1490        let (acct, order) = (acct.to_string(), order.id.to_string());
1491
1492        let mut reader: &[u8] = &[];
1493        assert_eq!(
1494            confirm_delete_account(&acct, false, &mut reader, db.clone())
1495                .await
1496                .unwrap(),
1497            DeleteOutcome::LiveCertificates(1)
1498        );
1499        assert_eq!(
1500            confirm_delete_order(&order, false, &mut reader, db.clone())
1501                .await
1502                .unwrap(),
1503            DeleteOutcome::LiveCertificates(1)
1504        );
1505        assert_eq!(
1506            delete_account(&acct, db.clone()).await.unwrap(),
1507            Deletion::LiveCertificates(1)
1508        );
1509        assert_eq!(
1510            delete_order(&order, db.clone()).await.unwrap(),
1511            Deletion::LiveCertificates(1)
1512        );
1513        assert!(Order::find_by_id(&order, &db).await.unwrap().is_some());
1514    }
1515
1516    #[test]
1517    fn the_refusals_name_the_subject_the_count_and_the_way_out() {
1518        let message = live_certificates_refusal("account a-1", 2);
1519        assert!(message.starts_with("account a-1 holds 2 live certificate(s)"));
1520        assert!(message.contains("revoke them first"));
1521
1522        let message = eab_live_certificates_refusal("k-1", 1, 3);
1523        assert!(message.starts_with("1 account(s) bound to EAB credential k-1 hold 3 live"));
1524        assert!(message.contains("deactivate the accounts instead"));
1525    }
1526
1527    /// A credential with one bound account holding a live certificate.
1528    async fn eab_with_live_account(db: &Arc<Database>) -> (Eab, uuid::Uuid) {
1529        let eab = Eab::create(None, None, db).await.unwrap();
1530        let (mut account, _) =
1531            Account::find_or_create("default", &[42u8], vec![], &ClientContext::default(), db)
1532                .await
1533                .unwrap();
1534        account.set_eab_kid(eab.kid, db).await.unwrap();
1535        acme_proxy_store::testutil::certified_order(db, account.id, None).await;
1536        (eab, account.id)
1537    }
1538
1539    #[tokio::test]
1540    async fn confirm_delete_eab_not_found_cancelled_and_refused() {
1541        let db = db().await;
1542        let mut reader: &[u8] = &[];
1543        assert!(matches!(
1544            confirm_delete_eab("nope", BoundAccounts::Keep, true, &mut reader, db.clone())
1545                .await
1546                .unwrap(),
1547            EabDeleteOutcome::NotFound
1548        ));
1549
1550        let (eab, _) = eab_with_live_account(&db).await;
1551        let kid = eab.kid.to_string();
1552        let mut declined: &[u8] = b"n\n";
1553        assert!(matches!(
1554            confirm_delete_eab(&kid, BoundAccounts::Keep, false, &mut declined, db.clone())
1555                .await
1556                .unwrap(),
1557            EabDeleteOutcome::Cancelled
1558        ));
1559        assert!(Eab::find_any_by_kid(&kid, &db).await.unwrap().is_some());
1560
1561        // Refused before the prompt, as the account and order deletes are.
1562        let mut reader: &[u8] = &[];
1563        assert!(matches!(
1564            confirm_delete_eab(&kid, BoundAccounts::Delete, false, &mut reader, db.clone())
1565                .await
1566                .unwrap(),
1567            EabDeleteOutcome::LiveCertificates {
1568                accounts: 1,
1569                certificates: 1
1570            }
1571        ));
1572        assert!(Eab::find_any_by_kid(&kid, &db).await.unwrap().is_some());
1573    }
1574
1575    /// The safe way out of the refusal above: deactivating goes through, and
1576    /// the certificate's order survives it.
1577    #[tokio::test]
1578    async fn confirm_delete_eab_deactivating_keeps_the_live_certificate() {
1579        let db = db().await;
1580        let (eab, account) = eab_with_live_account(&db).await;
1581        let mut reader: &[u8] = &[];
1582
1583        let EabDeleteOutcome::Deleted(deleted) = confirm_delete_eab(
1584            &eab.kid.to_string(),
1585            BoundAccounts::Deactivate,
1586            true,
1587            &mut reader,
1588            db.clone(),
1589        )
1590        .await
1591        .unwrap() else {
1592            panic!("deactivating is never refused");
1593        };
1594        assert_eq!(deleted.deactivated.len(), 1);
1595        assert_eq!(Order::find_by_account(account, &db).await.unwrap().len(), 1);
1596        assert!(
1597            delete_eab(&eab.kid.to_string(), BoundAccounts::Keep, db.clone())
1598                .await
1599                .map(|deletion| matches!(deletion, EabDeletion::NotFound))
1600                .unwrap()
1601        );
1602    }
1603
1604    #[tokio::test]
1605    async fn bare_cleanup_nonces_removes_stale_rows_without_asking() {
1606        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1607        let stale = Nonce {
1608            value: "stale".to_string(),
1609            created_at: acme_proxy_store::nonce::now_secs() - 10_000,
1610        };
1611        stale.save(&db).await.unwrap();
1612        Nonce::new().save(&db).await.unwrap();
1613
1614        assert_eq!(
1615            cleanup_nonces(Duration::from_secs(300), db.clone())
1616                .await
1617                .unwrap(),
1618            1
1619        );
1620        assert!(
1621            !Nonce::verify("stale", &db, Duration::from_secs(300))
1622                .await
1623                .unwrap()
1624        );
1625    }
1626
1627    fn in_memory_ca(database: &Arc<Database>) -> Arc<dyn SignerBackend> {
1628        Arc::new(
1629            acme_proxy_signer::local_ca::LocalCa::generate_in_memory(
1630                "ecdsa-p256",
1631                90,
1632                database.clone(),
1633            )
1634            .expect("in-memory CA"),
1635        )
1636    }
1637
1638    async fn finalized_order(db: Arc<Database>, signer: &Arc<dyn SignerBackend>) -> Order {
1639        let acct = account_id(&db).await;
1640        let mut order = Order::create(
1641            "default",
1642            acct,
1643            vec![Identifier::dns("example.com")],
1644            acme_proxy_store::nonce::now_secs() + 3600,
1645            None,
1646            None,
1647            &db,
1648        )
1649        .await
1650        .unwrap();
1651
1652        let key_pair = rcgen::KeyPair::generate().unwrap();
1653        let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
1654        let csr = params.serialize_request(&key_pair).unwrap();
1655        let chain = match signer
1656            .issue(
1657                order.id.to_string().as_str(),
1658                csr.der(),
1659                &order.identifiers,
1660                acme_proxy_signer::RequestedValidity::default(),
1661            )
1662            .await
1663            .unwrap()
1664        {
1665            acme_proxy_signer::IssueOutcome::Issued(chain) => chain,
1666            acme_proxy_signer::IssueOutcome::Processing => {
1667                panic!("the in-memory local CA issues synchronously")
1668            }
1669        };
1670        let leaf = acme_proxy_core::cert::leaf_der_from_chain(&chain).unwrap();
1671        let (serial, pubkey) = acme_proxy_core::cert::cert_serial_and_spki(&leaf).unwrap();
1672        let not_after = acme_proxy_core::cert::cert_validity(&leaf)
1673            .ok()
1674            .map(|(_, na)| na);
1675        order
1676            .finalize(chain, serial, pubkey, not_after, &db)
1677            .await
1678            .unwrap();
1679        order
1680    }
1681
1682    #[tokio::test]
1683    async fn revoke_order_not_found() {
1684        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1685        let outcome = revoke_order(
1686            "nope",
1687            None,
1688            cli_actor(),
1689            ClientContext::default(),
1690            acme_proxy_protocol::acme::revoke::Revocations {
1691                database: &db,
1692                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1693                notify: None,
1694                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(
1695                    in_memory_ca(&db).as_ref(),
1696                ),
1697            },
1698        )
1699        .await
1700        .unwrap();
1701        assert!(matches!(outcome, RevokeOutcome::NotFound));
1702    }
1703
1704    #[tokio::test]
1705    async fn revoke_order_without_a_certificate_is_refused() {
1706        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1707        let acct = account_id(&db).await;
1708        let order = Order::create(
1709            "default",
1710            acct,
1711            vec![Identifier::dns("example.com")],
1712            acme_proxy_store::nonce::now_secs() + 3600,
1713            None,
1714            None,
1715            &db,
1716        )
1717        .await
1718        .unwrap();
1719
1720        let outcome = revoke_order(
1721            order.id.to_string().as_str(),
1722            None,
1723            cli_actor(),
1724            ClientContext::default(),
1725            acme_proxy_protocol::acme::revoke::Revocations {
1726                database: &db,
1727                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1728                notify: None,
1729                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(
1730                    in_memory_ca(&db).as_ref(),
1731                ),
1732            },
1733        )
1734        .await
1735        .unwrap();
1736        assert!(matches!(outcome, RevokeOutcome::NotIssued));
1737    }
1738
1739    #[tokio::test]
1740    async fn revoke_order_persists() {
1741        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1742        let signer = in_memory_ca(&db);
1743        let order = finalized_order(db.clone(), &signer).await;
1744
1745        let outcome = revoke_order(
1746            order.id.to_string().as_str(),
1747            Some(1),
1748            cli_actor(),
1749            ClientContext::default(),
1750            acme_proxy_protocol::acme::revoke::Revocations {
1751                database: &db,
1752                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1753                notify: None,
1754                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1755            },
1756        )
1757        .await
1758        .unwrap();
1759        let RevokeOutcome::Revoked(revoked) = outcome else {
1760            panic!("expected Revoked, got {outcome:?}");
1761        };
1762        assert!(revoked.revoked_at.is_some());
1763        assert_eq!(revoked.revocation_reason, Some(1));
1764
1765        let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
1766            .await
1767            .unwrap()
1768            .unwrap();
1769        assert!(reloaded.revoked_at.is_some());
1770
1771        use x509_parser::prelude::FromDer;
1772        let der = signer.info().crl_der().await.unwrap().unwrap();
1773        let (_, crl) =
1774            x509_parser::revocation_list::CertificateRevocationList::from_der(&der).unwrap();
1775        assert_eq!(crl.iter_revoked_certificates().count(), 1);
1776    }
1777
1778    #[tokio::test]
1779    async fn revoke_order_already_revoked() {
1780        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1781        let signer = in_memory_ca(&db);
1782        let order = finalized_order(db.clone(), &signer).await;
1783
1784        revoke_order(
1785            order.id.to_string().as_str(),
1786            None,
1787            cli_actor(),
1788            ClientContext::default(),
1789            acme_proxy_protocol::acme::revoke::Revocations {
1790                database: &db,
1791                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1792                notify: None,
1793                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1794            },
1795        )
1796        .await
1797        .unwrap();
1798        let outcome = revoke_order(
1799            order.id.to_string().as_str(),
1800            None,
1801            cli_actor(),
1802            ClientContext::default(),
1803            acme_proxy_protocol::acme::revoke::Revocations {
1804                database: &db,
1805                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1806                notify: None,
1807                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1808            },
1809        )
1810        .await
1811        .unwrap();
1812        assert!(matches!(outcome, RevokeOutcome::AlreadyRevoked));
1813    }
1814
1815    #[tokio::test]
1816    async fn revoke_order_bad_reason_is_refused() {
1817        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1818        let signer = in_memory_ca(&db);
1819        let order = finalized_order(db.clone(), &signer).await;
1820
1821        let error = revoke_order(
1822            order.id.to_string().as_str(),
1823            Some(999),
1824            cli_actor(),
1825            ClientContext::default(),
1826            acme_proxy_protocol::acme::revoke::Revocations {
1827                database: &db,
1828                audit: &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
1829                notify: None,
1830                revoker: acme_proxy_protocol::acme::revoke::Revoker::Backend(signer.as_ref()),
1831            },
1832        )
1833        .await
1834        .unwrap_err();
1835        assert!(matches!(error, RevokeError::BadReason(999)));
1836    }
1837
1838    #[tokio::test]
1839    async fn cleanup_nonces_cancelled_leaves_nonces() {
1840        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1841        Nonce {
1842            value: "stale".to_string(),
1843            created_at: acme_proxy_store::nonce::now_secs() - 600,
1844        }
1845        .save(&db)
1846        .await
1847        .unwrap();
1848
1849        let mut reader = b"n\n".as_slice();
1850        let outcome =
1851            confirm_cleanup_nonces(Duration::from_secs(300), false, &mut reader, db.clone())
1852                .await
1853                .unwrap();
1854        assert_eq!(outcome, None);
1855
1856        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
1857            .fetch_one(db.raw_pool())
1858            .await
1859            .unwrap();
1860        assert_eq!(count, 1);
1861    }
1862
1863    #[tokio::test]
1864    async fn cleanup_nonces_confirmed_removes_stale_and_reports_count() {
1865        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1866        Nonce {
1867            value: "stale".to_string(),
1868            created_at: acme_proxy_store::nonce::now_secs() - 600,
1869        }
1870        .save(&db)
1871        .await
1872        .unwrap();
1873
1874        let mut reader: &[u8] = &[];
1875        let outcome =
1876            confirm_cleanup_nonces(Duration::from_secs(300), true, &mut reader, db.clone())
1877                .await
1878                .unwrap();
1879        assert_eq!(outcome, Some(1));
1880
1881        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
1882            .fetch_one(db.raw_pool())
1883            .await
1884            .unwrap();
1885        assert_eq!(count, 0);
1886    }
1887
1888    #[tokio::test]
1889    async fn update_account_contact_not_found() {
1890        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1891        assert!(
1892            update_account_contact("nope", vec![], db)
1893                .await
1894                .unwrap()
1895                .is_none()
1896        );
1897    }
1898
1899    #[tokio::test]
1900    async fn update_account_contact_persists() {
1901        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1902        let acct = account_id(&db).await;
1903
1904        let contact = vec!["mailto:a@example.com".to_string()];
1905        let updated =
1906            update_account_contact(acct.to_string().as_str(), contact.clone(), db.clone())
1907                .await
1908                .unwrap()
1909                .unwrap();
1910        assert_eq!(updated.contact, contact);
1911
1912        let reloaded = Account::find_by_id("default", acct.to_string().as_str(), &db)
1913            .await
1914            .unwrap()
1915            .unwrap();
1916        assert_eq!(reloaded.contact, contact);
1917    }
1918
1919    #[tokio::test]
1920    async fn deactivate_account_not_found() {
1921        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1922        assert!(
1923            deactivate_account("nope", db, |_| None, None)
1924                .await
1925                .unwrap()
1926                .is_none()
1927        );
1928    }
1929
1930    /// Persists, and notifies the way the account's own request does.
1931    #[tokio::test]
1932    async fn deactivate_account_persists() {
1933        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1934        let acct = account_id(&db).await;
1935        let dispatcher = Arc::new(acme_proxy_jobs::notify::NotifyDispatcher::new(
1936            "default",
1937            vec![acme_proxy_jobs::notify::BackendSlot::new(
1938                "custom:test",
1939                Arc::new(Wanting),
1940                &["account_deactivated".to_string()],
1941            )],
1942            acme_proxy_jobs::testutil::idle_job_queue(db.clone()),
1943        ));
1944
1945        let updated = deactivate_account(
1946            acct.to_string().as_str(),
1947            db.clone(),
1948            |profile| (profile == "default").then(|| dispatcher.clone()),
1949            Some("203.0.113.7".to_string()),
1950        )
1951        .await
1952        .unwrap()
1953        .unwrap();
1954        assert_eq!(updated.status, "deactivated");
1955        assert_eq!(
1956            Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
1957                .await
1958                .unwrap(),
1959            1
1960        );
1961
1962        let reloaded = Account::find_by_id("default", acct.to_string().as_str(), &db)
1963            .await
1964            .unwrap()
1965            .unwrap();
1966        assert_eq!(reloaded.status, "deactivated");
1967    }
1968
1969    #[tokio::test]
1970    async fn load_order_detail_not_found() {
1971        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1972        assert!(load_order_detail("nope", db).await.unwrap().is_none());
1973    }
1974
1975    #[tokio::test]
1976    async fn load_order_detail_nests_authorizations_and_challenges() {
1977        let db = Arc::new(Database::connect_in_memory().await.unwrap());
1978        let acct = account_id(&db).await;
1979        let order = Order::create(
1980            "default",
1981            acct,
1982            vec![Identifier::dns("example.com")],
1983            acme_proxy_store::nonce::now_secs() + 3600,
1984            None,
1985            None,
1986            &db,
1987        )
1988        .await
1989        .unwrap();
1990        let authz = Authorization::create(
1991            order.id,
1992            Identifier::dns("example.com"),
1993            acme_proxy_store::nonce::now_secs() + 3600,
1994            &db,
1995        )
1996        .await
1997        .unwrap();
1998        Challenge::create(authz.id, "http-01", &db).await.unwrap();
1999
2000        let detail = load_order_detail(order.id.to_string().as_str(), db)
2001            .await
2002            .unwrap()
2003            .unwrap();
2004        assert_eq!(detail.order.id, order.id);
2005        assert_eq!(detail.authorizations.len(), 1);
2006        assert_eq!(detail.authorizations[0].0.id, authz.id);
2007        assert_eq!(detail.authorizations[0].1.len(), 1);
2008        assert_eq!(detail.authorizations[0].1[0].typ, "http-01");
2009    }
2010
2011    // --- the job queue operator surface ------------------------------------
2012
2013    use acme_proxy_store::job::Job;
2014    use acme_proxy_store::job::NewJob;
2015
2016    /// An order, an `upstream_orders` row for it, and a `signer_relay_issue`
2017    /// job keyed on the order id — the in-flight-relay shape.
2018    async fn relay_job(db: &Arc<Database>) -> (Order, Job) {
2019        let acct = account_id(db).await;
2020        let order = Order::create(
2021            "default",
2022            acct,
2023            vec![Identifier::dns("example.com")],
2024            now_secs() + 3600,
2025            None,
2026            None,
2027            db,
2028        )
2029        .await
2030        .unwrap();
2031        UpstreamOrder::create(
2032            order.id.to_string().as_str(),
2033            "https://up.example/o/1",
2034            None,
2035            b"csr",
2036            db,
2037        )
2038        .await
2039        .unwrap();
2040        let id = acme_proxy_store::id::mint();
2041        Job::enqueue(
2042            NewJob {
2043                id,
2044                kind: RELAY_JOB_KIND,
2045                dedup_key: &order.id.to_string(),
2046                payload: &serde_json::json!({ "order_id": order.id.to_string(), "profile": "default" }),
2047                run_at: now_secs(),
2048                deadline: Some(order.expires),
2049                max_attempts: 5,
2050            },
2051            db,
2052        )
2053        .await
2054        .unwrap();
2055        let job = Job::find_by_id(id, db).await.unwrap().unwrap();
2056        (order, job)
2057    }
2058
2059    /// A plain `ready` sweep job.
2060    async fn sweep_job(db: &Arc<Database>) -> Job {
2061        let id = acme_proxy_store::id::mint();
2062        Job::enqueue(
2063            NewJob {
2064                id,
2065                kind: "nonce_sweep",
2066                dedup_key: "nonce_sweep",
2067                payload: &serde_json::json!({}),
2068                run_at: now_secs() + 3600,
2069                deadline: None,
2070                max_attempts: 5,
2071            },
2072            db,
2073        )
2074        .await
2075        .unwrap();
2076        Job::find_by_id(id, db).await.unwrap().unwrap()
2077    }
2078
2079    #[tokio::test]
2080    async fn load_job_detail_attaches_the_upstream_order_only_for_a_relay_job() {
2081        let db = db().await;
2082        let (order, job) = relay_job(&db).await;
2083
2084        let detail = load_job_detail(job.id.to_string().as_str(), db.clone())
2085            .await
2086            .unwrap()
2087            .unwrap();
2088        assert_eq!(detail.job.id, job.id);
2089        assert_eq!(detail.upstream_order.as_ref().unwrap().order_id, order.id);
2090
2091        let sweep = sweep_job(&db).await;
2092        let detail = load_job_detail(sweep.id.to_string().as_str(), db.clone())
2093            .await
2094            .unwrap()
2095            .unwrap();
2096        assert!(detail.upstream_order.is_none());
2097
2098        // Junk id and unknown id.
2099        assert!(load_job_detail("nope", db.clone()).await.unwrap().is_none());
2100        assert!(
2101            load_job_detail(acme_proxy_store::id::mint().to_string().as_str(), db)
2102                .await
2103                .unwrap()
2104                .is_none()
2105        );
2106    }
2107
2108    #[tokio::test]
2109    async fn load_job_detail_resolves_the_cross_link_after_the_job_is_done() {
2110        let db = db().await;
2111        let (_order, job) = relay_job(&db).await;
2112        // Drive the job to `done` without touching the upstream row.
2113        sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
2114            .bind(job.id)
2115            .execute(db.raw_pool())
2116            .await
2117            .unwrap();
2118
2119        let detail = load_job_detail(job.id.to_string().as_str(), db)
2120            .await
2121            .unwrap()
2122            .unwrap();
2123        assert!(detail.upstream_order.is_some());
2124    }
2125
2126    #[tokio::test]
2127    async fn load_upstream_order_detail_finds_the_latest_job_even_when_terminal() {
2128        let db = db().await;
2129        let (order, job) = relay_job(&db).await;
2130        sqlx::query("UPDATE jobs SET status = 'failed' WHERE id = ?;")
2131            .bind(job.id)
2132            .execute(db.raw_pool())
2133            .await
2134            .unwrap();
2135
2136        let detail = load_upstream_order_detail(order.id.to_string().as_str(), db.clone())
2137            .await
2138            .unwrap()
2139            .unwrap();
2140        assert_eq!(detail.upstream_order.order_id, order.id);
2141        assert_eq!(detail.job.as_ref().unwrap().id, job.id);
2142
2143        assert!(
2144            load_upstream_order_detail("nope", db)
2145                .await
2146                .unwrap()
2147                .is_none()
2148        );
2149    }
2150
2151    #[tokio::test]
2152    async fn cancel_job_on_a_ready_sweep_writes_a_plain_job_cancelled_row_and_no_order_change() {
2153        let db = db().await;
2154        let sweep = sweep_job(&db).await;
2155
2156        let outcome = cancel_job(
2157            sweep.id.to_string().as_str(),
2158            cli_actor(),
2159            ClientContext::default(),
2160            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2161            db.clone(),
2162        )
2163        .await
2164        .unwrap();
2165        assert!(matches!(outcome, CancelJobOutcome::Cancelled(_)));
2166        assert_eq!(
2167            Job::find_by_id(sweep.id, &db)
2168                .await
2169                .unwrap()
2170                .unwrap()
2171                .status,
2172            "cancelled"
2173        );
2174
2175        // One administrative row — `job_cancelled`, not a `certificate_*` one —
2176        // and it names no order or account.
2177        let rows = audit_rows(&db).await;
2178        assert_eq!(rows.len(), 1);
2179        assert_eq!(rows[0].event, "job_cancelled");
2180        assert_eq!(rows[0].outcome, "success");
2181        assert_eq!(rows[0].actor_kind, "cli");
2182        assert!(rows[0].order_id.is_none());
2183        assert!(rows[0].account_id.is_none());
2184    }
2185
2186    #[tokio::test]
2187    async fn cancel_job_on_a_ready_relay_job_abandons_the_order_and_audits_the_operator() {
2188        let db = db().await;
2189        let (order, job) = relay_job(&db).await;
2190
2191        let outcome = cancel_job(
2192            job.id.to_string().as_str(),
2193            Actor::admin("root"),
2194            ClientContext {
2195                ip: Some("203.0.113.7".to_string()),
2196                ..ClientContext::default()
2197            },
2198            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2199            db.clone(),
2200        )
2201        .await
2202        .unwrap();
2203        let CancelJobOutcome::CancelledAndOrderAbandoned { order_id, .. } = outcome else {
2204            panic!("expected CancelledAndOrderAbandoned, got {outcome:?}");
2205        };
2206        assert_eq!(order_id, order.id.to_string());
2207
2208        assert_eq!(
2209            Job::find_by_id(job.id, &db).await.unwrap().unwrap().status,
2210            "cancelled"
2211        );
2212        let reloaded = Order::find_by_id(order.id.to_string().as_str(), &db)
2213            .await
2214            .unwrap()
2215            .unwrap();
2216        assert_eq!(reloaded.status.as_str(), "invalid");
2217        let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
2218            .await
2219            .unwrap()
2220            .unwrap();
2221        assert_eq!(mapping.status, "invalid");
2222
2223        let rows = audit_rows(&db).await;
2224        assert_eq!(rows.len(), 1);
2225        assert_eq!(rows[0].event, "certificate_issue_failed");
2226        assert_eq!(rows[0].actor_kind, "admin");
2227        assert_eq!(rows[0].actor_id.as_deref(), Some("root"));
2228        assert_eq!(rows[0].client_ip.as_deref(), Some("203.0.113.7"));
2229    }
2230
2231    /// A relay job that already **failed** was abandoned when `runner::retire`
2232    /// retired it: its order is `invalid`, its mapping row is `invalid`, and
2233    /// its `certificate_issue_failed` row is written. Cancelling it — the
2234    /// obvious way to tidy a stuck-looking failure — must not do any of that
2235    /// again.
2236    ///
2237    /// It did, and the damage was not the duplicate row: `UpstreamOrder::
2238    /// mark_invalid` is unguarded, so the second pass overwrote
2239    /// `upstream_orders.error` with "issuance cancelled by operator",
2240    /// destroying the upstream CA's own message — the whole reason
2241    /// `upstream order show` exists.
2242    #[tokio::test]
2243    async fn cancel_job_on_a_failed_relay_job_keeps_the_upstream_error_and_writes_one_row() {
2244        let db = db().await;
2245        let (order, job) = relay_job(&db).await;
2246
2247        // Retire it the way the runner does: the order and mapping already
2248        // invalid, with the upstream's own reason recorded.
2249        acme_proxy_signer::relay::abandon_relayed_order(
2250            &mut Order::find_by_id(order.id.to_string().as_str(), &db)
2251                .await
2252                .unwrap()
2253                .unwrap(),
2254            "urn:ietf:params:acme:error:rejectedIdentifier from the upstream",
2255            Actor::cli(),
2256            ClientContext::default(),
2257            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2258            &db,
2259        )
2260        .await
2261        .unwrap();
2262        // Claim it the way the runner does, then retire it: `abandon` is
2263        // guarded on `status = 'running' AND lease_owner = ?`.
2264        Job::claim_next(
2265            "runner-a",
2266            &[RELAY_JOB_KIND],
2267            now_secs() + 60,
2268            now_secs(),
2269            &db,
2270        )
2271        .await
2272        .unwrap()
2273        .expect("the relay job is claimable");
2274        assert!(
2275            Job::abandon(job.id, "runner-a", "gave up", &db)
2276                .await
2277                .unwrap()
2278        );
2279        let rows_before = audit_rows(&db).await.len();
2280
2281        let outcome = cancel_job(
2282            job.id.to_string().as_str(),
2283            Actor::admin("root"),
2284            ClientContext::default(),
2285            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2286            db.clone(),
2287        )
2288        .await
2289        .unwrap();
2290        assert!(
2291            matches!(outcome, CancelJobOutcome::Cancelled(_)),
2292            "a failed relay job has nothing left to abandon: {outcome:?}"
2293        );
2294
2295        let mapping = UpstreamOrder::find_by_order_id(order.id.to_string().as_str(), &db)
2296            .await
2297            .unwrap()
2298            .unwrap();
2299        assert_eq!(
2300            mapping.error.as_deref(),
2301            Some("urn:ietf:params:acme:error:rejectedIdentifier from the upstream"),
2302            "the upstream's own reason must survive the cancellation"
2303        );
2304
2305        let rows = audit_rows(&db).await;
2306        assert_eq!(
2307            rows.len(),
2308            rows_before + 1,
2309            "one issuance, one certificate_issue_failed row"
2310        );
2311        assert_eq!(rows[0].event, "job_cancelled");
2312    }
2313
2314    /// A relay job whose order has been swept: there is nothing to abandon, so
2315    /// the outcome must not claim one was, and the cancellation still leaves a
2316    /// row — `job_cancelled` is the only record this branch produces.
2317    #[tokio::test]
2318    async fn cancel_job_on_a_relay_job_with_no_order_says_so_and_still_audits() {
2319        let db = db().await;
2320        let (order, job) = relay_job(&db).await;
2321        Order::delete(order.id.to_string().as_str(), &db)
2322            .await
2323            .unwrap();
2324
2325        let outcome = cancel_job(
2326            job.id.to_string().as_str(),
2327            Actor::admin("root"),
2328            ClientContext::default(),
2329            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2330            db.clone(),
2331        )
2332        .await
2333        .unwrap();
2334        assert!(
2335            matches!(outcome, CancelJobOutcome::Cancelled(_)),
2336            "nothing was abandoned, so nothing may say it was: {outcome:?}"
2337        );
2338
2339        let rows = audit_rows(&db).await;
2340        assert_eq!(rows.len(), 1, "the cancellation is still an admin action");
2341        assert_eq!(rows[0].event, "job_cancelled");
2342    }
2343
2344    #[tokio::test]
2345    async fn cancel_job_refuses_a_running_job_and_leaves_the_order_alone() {
2346        let db = db().await;
2347        let (order, job) = relay_job(&db).await;
2348        sqlx::query("UPDATE jobs SET status = 'running' WHERE id = ?;")
2349            .bind(job.id)
2350            .execute(db.raw_pool())
2351            .await
2352            .unwrap();
2353
2354        let outcome = cancel_job(
2355            job.id.to_string().as_str(),
2356            cli_actor(),
2357            ClientContext::default(),
2358            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2359            db.clone(),
2360        )
2361        .await
2362        .unwrap();
2363        assert!(matches!(
2364            outcome,
2365            CancelJobOutcome::NotCancellable(s) if s == "running"
2366        ));
2367        // The order is untouched — cancel of a `running` job abandons nothing.
2368        assert_ne!(
2369            Order::find_by_id(order.id.to_string().as_str(), &db)
2370                .await
2371                .unwrap()
2372                .unwrap()
2373                .status
2374                .as_str(),
2375            "invalid"
2376        );
2377    }
2378
2379    #[tokio::test]
2380    async fn cancel_job_not_found_versus_not_cancellable() {
2381        let db = db().await;
2382        assert!(matches!(
2383            cancel_job(
2384                "nope",
2385                cli_actor(),
2386                ClientContext::default(),
2387                &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2388                db.clone()
2389            )
2390            .await
2391            .unwrap(),
2392            CancelJobOutcome::NotFound
2393        ));
2394        assert!(matches!(
2395            cancel_job(
2396                acme_proxy_store::id::mint().to_string().as_str(),
2397                cli_actor(),
2398                ClientContext::default(),
2399                &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2400                db.clone()
2401            )
2402            .await
2403            .unwrap(),
2404            CancelJobOutcome::NotFound
2405        ));
2406
2407        let sweep = sweep_job(&db).await;
2408        sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
2409            .bind(sweep.id)
2410            .execute(db.raw_pool())
2411            .await
2412            .unwrap();
2413        assert!(matches!(
2414            cancel_job(
2415                sweep.id.to_string().as_str(),
2416                cli_actor(),
2417                ClientContext::default(),
2418                &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2419                db,
2420            )
2421                .await
2422                .unwrap(),
2423            CancelJobOutcome::NotCancellable(s) if s == "done"
2424        ));
2425    }
2426
2427    #[tokio::test]
2428    async fn confirm_cancel_job_declined_leaves_the_job() {
2429        let db = db().await;
2430        let sweep = sweep_job(&db).await;
2431        let mut reader = b"n\n".as_slice();
2432        assert!(
2433            confirm_cancel_job(
2434                sweep.id.to_string().as_str(),
2435                false,
2436                &mut reader,
2437                cli_actor(),
2438                ClientContext::default(),
2439                &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2440                db.clone(),
2441            )
2442            .await
2443            .unwrap()
2444            .is_none()
2445        );
2446        assert_eq!(
2447            Job::find_by_id(sweep.id, &db)
2448                .await
2449                .unwrap()
2450                .unwrap()
2451                .status,
2452            "ready"
2453        );
2454    }
2455
2456    #[tokio::test]
2457    async fn run_job_now_nudges_ready_revives_failed_and_refuses_the_rest() {
2458        let db = db().await;
2459
2460        // ready -> Nudged.
2461        let sweep = sweep_job(&db).await; // run_at far future
2462        let outcome = run_job_now(
2463            sweep.id.to_string().as_str(),
2464            Actor::cli(),
2465            ClientContext::default(),
2466            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2467            db.clone(),
2468        )
2469        .await
2470        .unwrap();
2471        let RunJobNowOutcome::Nudged(job) = outcome else {
2472            panic!("expected Nudged, got {outcome:?}");
2473        };
2474        assert!(job.run_at <= now_secs() + 1);
2475
2476        // failed -> Revived, exactly one more attempt.
2477        let (_order, relay) = relay_job(&db).await;
2478        sqlx::query(
2479            "UPDATE jobs SET status = 'failed', attempts = 5, last_error = 'boom' WHERE id = ?;",
2480        )
2481        .bind(relay.id)
2482        .execute(db.raw_pool())
2483        .await
2484        .unwrap();
2485        let outcome = run_job_now(
2486            relay.id.to_string().as_str(),
2487            Actor::cli(),
2488            ClientContext::default(),
2489            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2490            db.clone(),
2491        )
2492        .await
2493        .unwrap();
2494        let RunJobNowOutcome::Revived(job) = outcome else {
2495            panic!("expected Revived, got {outcome:?}");
2496        };
2497        assert_eq!(job.status, "ready");
2498        assert_eq!(job.attempts, 4, "max_attempts - 1");
2499
2500        // done -> Refused.
2501        sqlx::query("UPDATE jobs SET status = 'done' WHERE id = ?;")
2502            .bind(sweep.id)
2503            .execute(db.raw_pool())
2504            .await
2505            .unwrap();
2506        assert!(matches!(
2507            run_job_now(
2508            sweep.id.to_string().as_str(),
2509            Actor::cli(),
2510            ClientContext::default(),
2511            &acme_proxy_jobs::auditor::Auditor::offline(db.clone()),
2512            db.clone(),
2513        ).await.unwrap(),
2514            RunJobNowOutcome::Refused(s) if s == "done"
2515        ));
2516
2517        assert!(matches!(
2518            run_job_now(
2519                "nope",
2520                Actor::cli(),
2521                ClientContext::default(),
2522                &Auditor::offline(db.clone()),
2523                db
2524            )
2525            .await
2526            .unwrap(),
2527            RunJobNowOutcome::NotFound
2528        ));
2529    }
2530
2531    #[test]
2532    fn revoke_error_display_formatting() {
2533        let db_err: RevokeError = sqlx::Error::RowNotFound.into();
2534        assert!(format!("{db_err}").contains("database error"));
2535
2536        let signer_internal: RevokeError = SignerError::Internal("test".to_string()).into();
2537        assert!(format!("{signer_internal}").contains("signer error: test"));
2538
2539        let signer_bad_csr: RevokeError = SignerError::BadCsr.into();
2540        assert!(format!("{signer_bad_csr}").contains("unexpected badCsr"));
2541
2542        let internal = RevokeError::Internal("detail".to_string());
2543        assert!(format!("{internal}").contains("internal error: detail"));
2544
2545        let bad_reason = RevokeError::BadReason(7);
2546        assert!(format!("{bad_reason}").contains("unsupported revocation reason code 7"));
2547    }
2548}