Skip to main content

acme_proxy/cli/
order.rs

1//! `acme-proxy order` — list, show, revoke and clean up orders.
2//!
3//! Every listing here is paged and answers the admin API's envelope under
4//! `--json`; the query behind it is `Order::search`, the one listing filter.
5//! Revocation goes through `acme_proxy_admin::admin`, which routes it the way
6//! the server would: a local CA's revocation is a ledger row and a queued
7//! signing, a relay's or a script's is a `signer_revoke` job. **This process
8//! never loads a signing key** — `tests/layering.rs` pins that.
9
10use std::io::BufRead;
11use std::sync::Arc;
12
13use clap::{Args, Subcommand};
14
15use crate::cli::CliError;
16use crate::cli::render;
17use crate::cli::window::{DEFAULT_LIMIT, Window};
18use acme_proxy_admin::admin;
19use acme_proxy_admin::admin::DeleteOutcome;
20use acme_proxy_core::config::Config;
21use acme_proxy_core::palette::Palette;
22use acme_proxy_signer as signer;
23use acme_proxy_store::db::Database;
24use acme_proxy_store::order::Order;
25use acme_proxy_store::order::OrderQuery;
26use acme_proxy_store::status::OrderStatus;
27
28/// `order list`'s flags, a struct of their own so the listing is one function
29/// rather than an arm of [`run_order_command`].
30#[derive(Args)]
31pub struct OrderListArgs {
32    /// Restrict the listing to one ACME endpoint.
33    #[arg(long)]
34    pub profile: Option<String>,
35    /// Only this account's orders.
36    #[arg(long = "account-id")]
37    pub account_id: Option<String>,
38    /// Only orders in this state: `pending`, `ready`, `processing`, `valid` or
39    /// `invalid`.
40    #[arg(long)]
41    pub status: Option<String>,
42    /// Only orders naming this identifier exactly (case-insensitive).
43    #[arg(long)]
44    pub identifier: Option<String>,
45    /// Only orders naming an identifier that contains this substring
46    /// (case-insensitive). Mutually exclusive with `--identifier`.
47    #[arg(long = "identifier-contains", conflicts_with = "identifier")]
48    pub identifier_contains: Option<String>,
49    /// Only the order whose issued certificate has this serial, in hex — the
50    /// value an abuse report hands you. Case and `:` or `-` separators do not
51    /// matter.
52    #[arg(long = "cert-serial")]
53    pub cert_serial: Option<String>,
54    /// Instead: the certificates lapsing within N days, soonest first,
55    /// each annotated with whatever has already replaced it.
56    #[arg(long = "expiring-in")]
57    pub expiring_in: Option<u64>,
58    /// Omit certificates something has already replaced. Needs
59    /// `--expiring-in`, which is where the annotation comes from.
60    #[arg(long = "hide-superseded")]
61    pub hide_superseded: bool,
62    /// Rows per page. A value below 1 is read as 1.
63    #[arg(long, default_value_t = DEFAULT_LIMIT)]
64    pub limit: i64,
65    /// Rows to skip before the page starts.
66    #[arg(long, default_value_t = 0)]
67    pub offset: i64,
68    /// Print the page as JSON: `{items, total, limit, offset}`.
69    #[arg(long)]
70    pub json: bool,
71}
72
73#[derive(Subcommand)]
74pub enum OrderCommand {
75    /// List orders, optionally filtered.
76    List(OrderListArgs),
77    /// Show one order plus its authorizations and challenges.
78    Show {
79        /// The order id.
80        id: String,
81        /// Print it as JSON.
82        #[arg(long)]
83        json: bool,
84    },
85    /// Print the issued certificate chain, as PEM, on stdout.
86    Chain {
87        /// The order id.
88        id: String,
89    },
90    /// Hard-delete the order and everything under it.
91    Delete {
92        /// The order id.
93        id: String,
94    },
95    /// Revoke the order's issued certificate.
96    Revoke {
97        /// The order id.
98        id: String,
99        /// The RFC 5280 reason code: 0-6 or 8-10 (7 is unused). Omitted, the
100        /// revocation carries no reason.
101        #[arg(long)]
102        reason: Option<u32>,
103        /// For a `relay` or `custom` profile, how many seconds to wait for a
104        /// running server to perform the queued revocation before returning.
105        /// `0` queues it and returns at once. A local CA's revocation is
106        /// recorded immediately and does not wait.
107        #[arg(long, default_value_t = DEFAULT_REVOKE_WAIT_SECONDS)]
108        wait: u64,
109    },
110}
111
112pub async fn run_order_command(
113    command: OrderCommand,
114    yes: bool,
115    palette: Palette,
116    reader: &mut impl BufRead,
117    config: &Config,
118    database: Arc<Database>,
119) -> Result<(), CliError> {
120    match command {
121        OrderCommand::List(args) => run_list(args, palette, config, database).await?,
122        OrderCommand::Show { id, json } => match admin::load_order_detail(&id, database).await? {
123            None => return Err(not_found(&id)),
124            Some(detail) if json => {
125                println!(
126                    "{}",
127                    admin::render_order_detail_json(&detail, &config.server.base_url)
128                );
129            }
130            Some(detail) => print!("{}", render::render_order_detail_text(&detail, palette)),
131        },
132        OrderCommand::Chain { id } => {
133            // The whole of stdout, so it pipes: `order chain <id> > cert.pem`.
134            // Deliberately not a flag on `show` -- a flag that discards the rest
135            // of its command's output is a mode wearing a flag's clothes. The
136            // JSON side already had this as `certificatePem` on
137            // `render_order_detail_json`; what was missing was the raw bytes.
138            let Some(order) = Order::find_by_id(&id, &database).await? else {
139                return Err(not_found(&id));
140            };
141            // Refused rather than answered with zero bytes, the rule
142            // `GET /ui/orders/{id}/chain.pem` already keeps: an empty file named
143            // `.pem` reads as a broken certificate rather than an absent one.
144            let Some(pem) = order.certificate else {
145                return Err(CliError::bad_request(format!(
146                    "order {id} has no certificate: it has not been finalized"
147                )));
148            };
149            // `print!`: the stored chain already ends in a newline, and a second
150            // one would make the output differ from the file the panel serves.
151            print!("{pem}");
152        }
153        OrderCommand::Delete { id } => {
154            // Read the order first, for its profile and identifiers: the audit
155            // row names them and the row is gone once the delete returns. Both
156            // web front ends already wrote `order_deleted`; this one hard-
157            // deleted an order and left the trail silent.
158            let doomed = Order::find_by_id(&id, &database).await?;
159            match admin::confirm_delete_order(&id, yes, reader, database.clone()).await? {
160                DeleteOutcome::NotFound => return Err(not_found(&id)),
161                DeleteOutcome::LiveCertificates(live) => {
162                    return Err(CliError::bad_request(admin::live_certificates_refusal(
163                        &format!("order {id}"),
164                        live,
165                    )));
166                }
167                DeleteOutcome::Cancelled => println!("Cancelled."),
168                DeleteOutcome::Deleted(deleted) => {
169                    if let Some(order) = doomed {
170                        acme_proxy_jobs::auditor::admin::record_cli_action(
171                            &database,
172                            |actor, client| {
173                                acme_proxy_jobs::auditor::admin::order_deleted(
174                                    actor,
175                                    client,
176                                    &order,
177                                    deleted.cascaded,
178                                )
179                            },
180                        )
181                        .await;
182                    }
183                    println!(
184                        "Deleted order {id} ({} authorization(s) cascaded).",
185                        deleted.cascaded
186                    );
187                }
188            }
189        }
190        OrderCommand::Revoke { id, reason, wait } => {
191            // Revocation goes through the endpoint that issued the certificate:
192            // another profile's backend holds a different CA, or none at all.
193            let Some(order) = Order::find_by_id(&id, &database).await? else {
194                return Err(not_found(&id));
195            };
196            let profiles = config
197                .resolve_profiles()
198                .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
199            let Some(profile) = profiles.iter().find(|p| p.name == order.profile) else {
200                return Err(CliError::bad_request(format!(
201                    "order {id} was issued by profile `{}`, which this configuration does not \
202                     define — revoking it needs the endpoint that signed it",
203                    order.profile
204                )));
205            };
206            // Before anything reads the signer's configuration: an order with
207            // nothing to revoke is the operator's answer, whatever state the
208            // CA's files are in. `admin::revoke_order` makes the same check
209            // against the row it re-reads.
210            if order.certificate.is_none() {
211                return Err(CliError::bad_request(format!(
212                    "order {id} has no issued certificate"
213                )));
214            }
215            // Queued, not sent: the running server's worker delivers the
216            // `certificate_revoked` notification this revocation owes.
217            let notifiers = super::offline_notifiers(config, database.clone())?;
218            let notify = notifiers
219                .get(&order.profile)
220                .map(|dispatcher| dispatcher.as_ref());
221            let audit = acme_proxy_jobs::auditor::Auditor::offline(database.clone());
222            // A queue this process never drains: what it enqueues, a running
223            // server's job runner works off.
224            let jobs = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
225            let route = signer::revocation_route(&profile.sections.signer)
226                .map_err(|error| CliError::failed(format!("signer error: {error}")))?;
227            // `Actor::cli` and an empty client context: there is no request
228            // here, and the audit row says so rather than inventing an address.
229            let (actor, client) = (
230                acme_proxy_core::audit::Actor::cli(),
231                acme_proxy_core::audit::ClientContext::default(),
232            );
233
234            // A local CA's revocation is recorded here, without its key, and a
235            // running server's worker signs the CRL; a backend only the worker
236            // holds gets the revocation queued, and this waits `--wait` for it.
237            let revoker = acme_proxy_protocol::acme::revoke::Revoker::for_route(
238                &route,
239                &jobs,
240                std::time::Duration::from_secs(wait),
241            );
242            let outcome = admin::revoke_order(
243                &id,
244                reason,
245                actor,
246                client,
247                acme_proxy_protocol::acme::revoke::Revocations {
248                    database: &database,
249                    audit: &audit,
250                    notify,
251                    revoker,
252                },
253            )
254            .await;
255            // A bad `--reason` code is the operator's to fix (exit 3); every
256            // other revoke failure is the host's (a signer or database error).
257            match outcome.map_err(|error| match error {
258                admin::RevokeError::BadReason(_) => CliError::bad_request(error.to_string()),
259                admin::RevokeError::Abandoned { job, reason } => CliError::failed(format!(
260                    "the revocation of order {id} failed (job {job}): {reason}"
261                )),
262                other => CliError::failed(other.to_string()),
263            })? {
264                admin::RevokeOutcome::NotFound => return Err(not_found(&id)),
265                admin::RevokeOutcome::NotIssued => {
266                    return Err(CliError::bad_request(format!(
267                        "order {id} has no issued certificate"
268                    )));
269                }
270                admin::RevokeOutcome::AlreadyRevoked => {
271                    return Err(CliError::bad_request(format!(
272                        "order {id}'s certificate is already revoked"
273                    )));
274                }
275                // Running out of time is not a failure: the revocation is
276                // queued, and the message says where to follow it.
277                admin::RevokeOutcome::Queued(job) => println!(
278                    "Revocation of order {id} queued as job {job}; a running server performs it \
279                     (acme-proxy jobs show {job})."
280                ),
281                admin::RevokeOutcome::Revoked(order) => {
282                    println!("{}", render::render_order_line(&order, palette));
283                    if let signer::RevocationRoute::Ledger { issuer } = &route {
284                        let job = acme_proxy_store::job::Job::find_live(
285                            acme_proxy_signer::local_ca::sweep::CRL_REGENERATE_KIND,
286                            issuer,
287                            &database,
288                        )
289                        .await?;
290                        match job {
291                            Some(job) => println!(
292                                "CRL regeneration queued (job {}); a running server signs it.",
293                                job.id
294                            ),
295                            None => println!("CRL regeneration already done."),
296                        }
297                    }
298                }
299            }
300        }
301    }
302    Ok(())
303}
304
305/// How long `order revoke` waits for a queued revocation by default.
306const DEFAULT_REVOKE_WAIT_SECONDS: u64 = 30;
307
308/// `order list`: the paged listing, or with `--expiring-in` the expiry one.
309async fn run_list(
310    args: OrderListArgs,
311    palette: Palette,
312    config: &Config,
313    database: Arc<Database>,
314) -> Result<(), CliError> {
315    let OrderListArgs {
316        profile,
317        account_id,
318        status,
319        identifier,
320        identifier_contains,
321        cert_serial,
322        expiring_in,
323        hide_superseded,
324        limit,
325        offset,
326        json,
327    } = args;
328    let window = Window::resolve(limit, offset);
329
330    // `--expiring-in` is a different question over a different query,
331    // and the flags that do not compose with it are refused **by name**
332    // rather than ignored -- `--status`'s own rule, and for its reason:
333    // an argument silently dropped answers with rows that look like it
334    // was honoured. The window is not among them: it is the one flag
335    // that means the same thing on both queries, so it is passed
336    // straight through.
337    if let Some(days) = expiring_in {
338        refuse_plain_listing_filters(
339            account_id.as_deref(),
340            status.as_deref(),
341            identifier.as_deref(),
342            identifier_contains.as_deref(),
343            cert_serial.as_deref(),
344        )?;
345        return run_expiring(
346            days,
347            profile,
348            hide_superseded,
349            window,
350            json,
351            palette,
352            database,
353        )
354        .await;
355    }
356    if hide_superseded {
357        return Err(CliError::bad_request(
358            "--hide-superseded needs --expiring-in: it filters on the supersession \
359             annotation, which only the expiry listing carries"
360                .to_string(),
361        ));
362    }
363
364    // Refused by name rather than passed through: an unknown status
365    // would match no rows, which reads exactly like "nothing is in
366    // that state". The same rule `audit list --event` follows.
367    let status = super::parse_flag::<OrderStatus>("--status", status)?;
368
369    // Filtered in SQL, by the same `Order::search` the web admin uses.
370    // It used to load every order in the database and filter the three
371    // fields in Rust, which is one policy written twice — and the two
372    // could drift into disagreeing about what `--status` means.
373    let query = OrderQuery {
374        profile,
375        account_id,
376        status,
377        identifier,
378        identifier_contains,
379        // Folded here rather than bound raw: an operator pastes a
380        // serial out of `openssl` or an abuse report, and the column
381        // only ever holds lowercase unseparated hex.
382        cert_serial: cert_serial
383            .as_deref()
384            .map(acme_proxy_core::cert::normalize_serial),
385        limit: window.limit,
386        offset: window.offset,
387    };
388    let (orders, total) = Order::search(&query, &database).await?;
389    // Not `render::print_page`, and this is the only listing that opts
390    // out: the `--json` rendering needs one batched authorization
391    // lookup for the whole page (`admin::orders_json`, which the web
392    // admin renders through too). Handing that to `print_page` would
393    // make the text path pay for a query it never reads, so the two
394    // halves are spelled out and the shared envelope and footer are
395    // called directly.
396    if json {
397        let rendered = admin::orders_json(&orders, &config.server.base_url, &database).await?;
398        println!("{}", render::json_page(rendered, total, window));
399    } else {
400        for order in &orders {
401            println!("{}", render::render_order_line(order, palette));
402        }
403        render::print_footer(orders.len(), total);
404    }
405    Ok(())
406}
407
408/// `order list --expiring-in <days>`.
409///
410/// A branch rather than a sibling subcommand because it is still "list orders",
411/// asked with a different filter -- but it is a different *query*
412/// (`acme_proxy_store::expiring::list_expiring`, ordered by expiry rather than
413/// by age) with its own fixed status set. The plain listing's filters that
414/// cannot mean anything here are refused by [`run_list`] before it gets here.
415///
416/// Paged like the rest of `order list`, and reporting `hidden` beside the total
417/// exactly as `GET /api/expiring` does -- `total` counts the *window*, not the
418/// answer, because supersession is computed per row and cannot become a SQL
419/// predicate. `acme_proxy_store::expiring::annotate_expiring` still reads each account's orders once
420/// for the whole page rather than once per row, which is what keeps a page over
421/// a single busy account from re-reading its history fifty times.
422async fn run_expiring(
423    days: u64,
424    profile: Option<String>,
425    hide_superseded: bool,
426    window: Window,
427    json: bool,
428    palette: Palette,
429    database: Arc<Database>,
430) -> Result<(), CliError> {
431    let query = acme_proxy_store::expiring::ExpiringQuery {
432        profile,
433        before: acme_proxy_store::expiring::expiring_horizon(days),
434        include_superseded: !hide_superseded,
435        limit: window.limit,
436        offset: window.offset,
437    };
438    let (entries, total, hidden) =
439        acme_proxy_store::expiring::list_expiring(&query, database).await?;
440    if json {
441        let items = entries.iter().map(admin::render_expiring_json).collect();
442        let mut envelope = render::json_page(items, total, window);
443        if let Some(object) = envelope.as_object_mut() {
444            // The same two extra members `GET /api/expiring` adds, spelled the
445            // same way: one answer to "what is expiring" rendered identically
446            // wherever it is asked.
447            object.insert("hidden".to_string(), serde_json::json!(hidden));
448            object.insert("days".to_string(), serde_json::json!(days));
449        }
450        println!("{envelope}");
451    } else {
452        for entry in &entries {
453            println!("{}", render::render_expiring_line(entry, palette));
454        }
455        render::print_expiring_footer(entries.len(), total, hidden);
456    }
457    Ok(())
458}
459
460/// The plain listing's filters that `--expiring-in` cannot honour, refused
461/// **by name** rather than ignored: an argument silently dropped answers with
462/// rows that look like it was honoured.
463fn refuse_plain_listing_filters(
464    account_id: Option<&str>,
465    status: Option<&str>,
466    identifier: Option<&str>,
467    identifier_contains: Option<&str>,
468    cert_serial: Option<&str>,
469) -> Result<(), CliError> {
470    if status.is_some() {
471        return Err(CliError::bad_request(
472            "--status does not apply with --expiring-in: the expiry listing is issued, \
473             unrevoked certificates by definition, so a status filter here would mean \
474             something other than it does everywhere else"
475                .to_string(),
476        ));
477    }
478    if account_id.is_some() {
479        return Err(CliError::bad_request(
480            "--account-id does not apply with --expiring-in: the expiry listing has no \
481             account predicate, and answering as though it did would report one \
482             subscriber's certificates as every subscriber's"
483                .to_string(),
484        ));
485    }
486    if identifier.is_some() || identifier_contains.is_some() || cert_serial.is_some() {
487        return Err(CliError::bad_request(
488            "--identifier, --identifier-contains and --cert-serial do not apply with \
489             --expiring-in: the expiry listing is ordered by expiry over a fixed status \
490             set, so a name or serial filter here would mean something other than it \
491             does on the plain listing"
492                .to_string(),
493        ));
494    }
495    Ok(())
496}
497
498fn not_found(id: &str) -> CliError {
499    CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Order.missing(id))
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::cli::CliErrorKind;
506    use acme_proxy_core::audit::ClientContext;
507    use acme_proxy_signer::IssueOutcome;
508    use acme_proxy_signer::RequestedValidity;
509    use acme_proxy_signer::SignerBackend;
510    use acme_proxy_store::account::Account;
511
512    /// A configuration whose single `default` profile signs with a local CA
513    /// living under `dir` — what `Revoke` needs, since it rebuilds the signer
514    /// from the profile that issued the certificate.
515    fn config_in(dir: impl AsRef<std::path::Path>, profile: &str) -> Config {
516        let dir = dir.as_ref();
517        let _lock = acme_proxy_core::config::ENV_LOCK
518            .lock()
519            .unwrap_or_else(std::sync::PoisonError::into_inner);
520        let ca = dir.join("ca");
521        std::fs::write(
522            dir.join("config.toml"),
523            format!(
524                r#"
525                [profiles.{profile}]
526                signer.local_ca.cert_path = "{ca}.pem"
527                signer.local_ca.key_path = "{ca}.key"
528                signer.local_ca.crl_path = "{ca}.crl"
529                "#,
530                ca = ca.display(),
531            ),
532        )
533        .unwrap();
534        // SAFETY: the lock above makes this the only thread touching the
535        // environment, and the variable is removed before returning.
536        unsafe {
537            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
538        }
539        let config = Config::load().expect("the configuration must load");
540        unsafe {
541            std::env::remove_var("ACME_PROXY_CONFIG");
542        }
543        config
544    }
545
546    fn temp_dir() -> acme_proxy_core::testutil::TempDir {
547        acme_proxy_core::testutil::TempDir::new("cli-order")
548    }
549
550    /// `order delete` records what it removed.
551    ///
552    /// It recorded nothing at all: both web front ends wrote `order_deleted`,
553    /// and the CLI — the only front end that hard-deletes an order from a
554    /// shell — left the trail silent. A declined prompt still writes nothing,
555    /// which is the rule for this whole half of the vocabulary.
556    #[tokio::test]
557    async fn deleting_an_order_writes_a_row_and_a_decline_does_not() {
558        use acme_proxy_store::audit::AuditEntry;
559        use acme_proxy_store::audit::AuditQuery;
560
561        let database = Arc::new(Database::connect_in_memory().await.unwrap());
562        let config = Config::default();
563        let order = seed_order(&database, "default").await;
564        let id = order.id.to_string();
565
566        let mut declined: &[u8] = b"n\n";
567        run_order_command(
568            OrderCommand::Delete { id: id.clone() },
569            false,
570            Palette::plain(),
571            &mut declined,
572            &config,
573            database.clone(),
574        )
575        .await
576        .unwrap();
577        assert_eq!(
578            AuditEntry::search(&AuditQuery::default(), &database)
579                .await
580                .unwrap()
581                .1,
582            0,
583            "a declined delete is not an administrative action"
584        );
585
586        let mut reader: &[u8] = &[];
587        run_order_command(
588            OrderCommand::Delete { id: id.clone() },
589            true,
590            Palette::plain(),
591            &mut reader,
592            &config,
593            database.clone(),
594        )
595        .await
596        .unwrap();
597
598        let (rows, total) = AuditEntry::search(
599            &AuditQuery {
600                limit: 5,
601                ..AuditQuery::default()
602            },
603            &database,
604        )
605        .await
606        .unwrap();
607        assert_eq!(total, 1);
608        assert_eq!(rows[0].event, "order_deleted");
609        assert_eq!(rows[0].actor_kind, "cli");
610        assert_eq!(rows[0].profile, "default");
611        assert_eq!(rows[0].order_id.as_deref(), Some(id.as_str()));
612        // The row outlives the order it names — `audit_log` has no foreign
613        // keys, which is the whole reason it can record a deletion.
614        assert!(
615            Order::find_by_id(&id, &database).await.unwrap().is_none(),
616            "the order really went"
617        );
618    }
619
620    async fn seed_order(database: &Arc<Database>, profile: &str) -> Order {
621        let (account, _) = Account::find_or_create(
622            profile,
623            &[4, 5, 6],
624            vec![],
625            &ClientContext::default(),
626            database,
627        )
628        .await
629        .unwrap();
630        Order::create(
631            profile,
632            account.id,
633            vec![acme_proxy_core::identifier::Identifier::dns("example.com")],
634            acme_proxy_store::nonce::now_secs() + 3600,
635            None,
636            None,
637            database,
638        )
639        .await
640        .unwrap()
641    }
642
643    /// Issues against `config`'s own CA and records the result on `order`, so
644    /// the certificate the CLI later revokes is one that CA actually signed.
645    /// Returns the CA, which the caller initializes (as a server's startup
646    /// does) when it wants a revocation recorded against it.
647    async fn issue_onto(
648        order: &mut Order,
649        config: &Config,
650        database: Arc<Database>,
651    ) -> Arc<dyn SignerBackend> {
652        let profile = &config.resolve_profiles().unwrap()[0];
653        let resolver = acme_proxy_net::dns::resolver_addr(&config.dns)
654            .and_then(acme_proxy_net::challenge::build_resolver)
655            .expect("the default dns configuration must build a resolver");
656        let signer: Arc<dyn SignerBackend> = signer::from_config(
657            &profile.sections.signer,
658            &acme_proxy_signer::testutil::signer_parts(database.clone(), resolver),
659        )
660        .unwrap();
661
662        let key_pair = rcgen::KeyPair::generate().unwrap();
663        let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
664        let csr = params.serialize_request(&key_pair).unwrap();
665        let chain = match signer
666            .issue(
667                order.id.to_string().as_str(),
668                csr.der(),
669                &order.identifiers,
670                RequestedValidity::default(),
671            )
672            .await
673            .unwrap()
674        {
675            IssueOutcome::Issued(chain) => chain,
676            IssueOutcome::Processing => panic!("the local CA issues synchronously"),
677        };
678        let leaf = acme_proxy_core::cert::leaf_der_from_chain(&chain).unwrap();
679        let (serial, pubkey) = acme_proxy_core::cert::cert_serial_and_spki(&leaf).unwrap();
680        let not_after = acme_proxy_core::cert::cert_validity(&leaf)
681            .ok()
682            .map(|(_, na)| na);
683        order
684            .finalize(chain, serial, pubkey, not_after, &database)
685            .await
686            .unwrap();
687        signer
688    }
689
690    #[tokio::test]
691    async fn every_arm_refuses_an_unknown_order() {
692        let database = Arc::new(Database::connect_in_memory().await.unwrap());
693        let config = Config::default();
694        let expected = CliError::bad_request("no such order: ord-nope".to_string());
695
696        let commands = vec![
697            OrderCommand::Show {
698                id: "ord-nope".to_string(),
699                json: false,
700            },
701            OrderCommand::Chain {
702                id: "ord-nope".to_string(),
703            },
704            OrderCommand::Delete {
705                id: "ord-nope".to_string(),
706            },
707            OrderCommand::Revoke {
708                id: "ord-nope".to_string(),
709                reason: None,
710                wait: 0,
711            },
712        ];
713        for command in commands {
714            let mut reader: &[u8] = &[];
715            let error = run_order_command(
716                command,
717                true,
718                Palette::plain(),
719                &mut reader,
720                &config,
721                database.clone(),
722            )
723            .await
724            .expect_err("an unknown order must fail");
725            assert_eq!(error, expected);
726        }
727    }
728
729    /// The PEM on stdout, and the refusal that keeps it honest: an order that
730    /// never reached issuance is an error, not an empty file --
731    /// `GET /ui/orders/{id}/chain.pem`'s own rule, since zero bytes named
732    /// `.pem` read as a broken certificate rather than an absent one.
733    #[tokio::test]
734    async fn chain_prints_the_issued_pem_and_refuses_an_order_with_none() {
735        let dir = temp_dir();
736        let config = config_in(&dir, "default");
737        let database = Arc::new(Database::connect_in_memory().await.unwrap());
738        let mut order = seed_order(&database, "default").await;
739
740        let mut reader: &[u8] = &[];
741        let error = run_order_command(
742            OrderCommand::Chain {
743                id: order.id.to_string(),
744            },
745            true,
746            Palette::plain(),
747            &mut reader,
748            &config,
749            database.clone(),
750        )
751        .await
752        .expect_err("an order with no certificate has no chain to print");
753        assert_eq!(
754            error,
755            CliError::bad_request(format!(
756                "order {} has no certificate: it has not been finalized",
757                order.id
758            ))
759        );
760
761        issue_onto(&mut order, &config, database.clone()).await;
762
763        // What the command prints is the column, verbatim -- the same string
764        // `render_order_detail_json`'s `certificatePem` and the panel's download
765        // both hand over.
766        let stored = Order::find_by_id(order.id.to_string().as_str(), &database)
767            .await
768            .unwrap()
769            .unwrap()
770            .certificate
771            .expect("finalize stored the chain");
772        assert!(stored.starts_with("-----BEGIN CERTIFICATE-----"));
773
774        let mut reader: &[u8] = &[];
775        run_order_command(
776            OrderCommand::Chain {
777                id: order.id.to_string(),
778            },
779            true,
780            Palette::plain(),
781            &mut reader,
782            &config,
783            database,
784        )
785        .await
786        .unwrap();
787    }
788
789    /// `revoke` needs the endpoint that signed the certificate. A profile the
790    /// running configuration no longer defines says so, rather than silently
791    /// revoking against some other profile's CA.
792    #[tokio::test]
793    async fn revoking_an_order_from_an_undefined_profile_is_refused() {
794        let dir = temp_dir();
795        let database = Arc::new(Database::connect_in_memory().await.unwrap());
796        // The order belongs to `default`; the configuration only mounts `other`.
797        let order = seed_order(&database, "default").await;
798        let config = config_in(&dir, "other");
799
800        let mut reader: &[u8] = &[];
801        let error = run_order_command(
802            OrderCommand::Revoke {
803                id: order.id.to_string(),
804                reason: None,
805                wait: 0,
806            },
807            true,
808            Palette::plain(),
809            &mut reader,
810            &config,
811            database,
812        )
813        .await
814        .expect_err("a profile this configuration does not define must be refused");
815        assert!(
816            error.to_string().contains("which this configuration"),
817            "{error}"
818        );
819    }
820
821    /// A configuration that mounts nothing at all cannot name a signer either.
822    #[tokio::test]
823    async fn revoking_without_a_resolvable_configuration_is_refused() {
824        let database = Arc::new(Database::connect_in_memory().await.unwrap());
825        let order = seed_order(&database, "default").await;
826
827        let mut reader: &[u8] = &[];
828        let error = run_order_command(
829            OrderCommand::Revoke {
830                id: order.id.to_string(),
831                reason: None,
832                wait: 0,
833            },
834            true,
835            Palette::plain(),
836            &mut reader,
837            &Config::default(),
838            database,
839        )
840        .await
841        .expect_err("a configuration mounting nothing must be refused");
842        assert!(
843            error.to_string().starts_with("configuration error: "),
844            "{error}"
845        );
846    }
847
848    #[tokio::test]
849    async fn revoking_an_order_with_no_certificate_is_refused() {
850        let dir = temp_dir();
851        let database = Arc::new(Database::connect_in_memory().await.unwrap());
852        let order = seed_order(&database, "default").await;
853        let config = config_in(&dir, "default");
854
855        let mut reader: &[u8] = &[];
856        let error = run_order_command(
857            OrderCommand::Revoke {
858                id: order.id.to_string(),
859                reason: None,
860                wait: 0,
861            },
862            true,
863            Palette::plain(),
864            &mut reader,
865            &config,
866            database,
867        )
868        .await
869        .expect_err("there is nothing to revoke");
870        assert_eq!(
871            error,
872            CliError::bad_request(format!("order {} has no issued certificate", order.id))
873        );
874    }
875
876    /// The whole arm end to end: issue, revoke through the CLI — which
877    /// records the revocation without the CA key and queues the CRL — let the
878    /// server's handler sign it, then find the second attempt refused because
879    /// the first one stuck.
880    #[tokio::test]
881    async fn an_issued_order_revokes_once() {
882        use acme_proxy_jobs::jobs::JobHandler;
883        use acme_proxy_signer::local_ca::sweep::CRL_REGENERATE_KIND;
884        use acme_proxy_signer::local_ca::sweep::CrlRegenerateJob;
885        use acme_proxy_store::job::Job;
886
887        let dir = temp_dir();
888        let database = Arc::new(Database::connect_in_memory().await.unwrap());
889        let config = config_in(&dir, "default");
890        let mut order = seed_order(&database, "default").await;
891        let ca = issue_onto(&mut order, &config, database.clone()).await;
892        // What a server's first pass does: meet the database, store a CRL.
893        ca.crl_refresher().unwrap().refresh().await.unwrap();
894
895        let mut reader: &[u8] = &[];
896        run_order_command(
897            OrderCommand::Revoke {
898                id: order.id.to_string(),
899                reason: Some(1),
900                wait: 0,
901            },
902            true,
903            Palette::plain(),
904            &mut reader,
905            &config,
906            database.clone(),
907        )
908        .await
909        .expect("a certificate issued by this profile's CA must revoke");
910
911        let revoked = Order::find_by_id(order.id.to_string().as_str(), &database)
912            .await
913            .unwrap()
914            .unwrap();
915        assert!(revoked.revoked_at.is_some());
916
917        // Recorded, not yet signed: the CRL waits for the job.
918        let serial = revoked.cert_serial.clone().unwrap();
919        let lists = |der: &[u8]| {
920            use x509_parser::prelude::FromDer;
921            let (_, crl) =
922                x509_parser::revocation_list::CertificateRevocationList::from_der(der).unwrap();
923            crl.iter_revoked_certificates()
924                .any(|entry| hex::encode(entry.raw_serial()).eq_ignore_ascii_case(&serial))
925        };
926        assert!(!lists(&ca.info().crl_der().await.unwrap().unwrap()));
927        let refresher = ca.crl_refresher().unwrap();
928        let job = Job::find_live(CRL_REGENERATE_KIND, refresher.issuer(), &database)
929            .await
930            .unwrap()
931            .expect("the revocation queued its CRL");
932        let handler = CrlRegenerateJob::new(vec![refresher]);
933        assert!(matches!(
934            handler.run(&job).await,
935            acme_proxy_jobs::jobs::JobOutcome::Done
936        ));
937        assert!(lists(&ca.info().crl_der().await.unwrap().unwrap()));
938
939        let error = run_order_command(
940            OrderCommand::Revoke {
941                id: order.id.to_string(),
942                reason: None,
943                wait: 0,
944            },
945            true,
946            Palette::plain(),
947            &mut reader,
948            &config,
949            database.clone(),
950        )
951        .await
952        .expect_err("a second revocation has nothing left to do");
953        assert_eq!(
954            error,
955            CliError::bad_request(format!(
956                "order {}'s certificate is already revoked",
957                order.id
958            ))
959        );
960    }
961
962    /// A CA that no server has met yet has no stored CRL, and its old
963    /// `ca.json` ledger may still be waiting to be imported: the CLI refuses
964    /// rather than writing a revocation under a row that import owns.
965    #[tokio::test]
966    async fn revoking_against_a_ca_no_server_has_initialised_is_refused() {
967        let dir = temp_dir();
968        let database = Arc::new(Database::connect_in_memory().await.unwrap());
969        let config = config_in(&dir, "default");
970        let mut order = seed_order(&database, "default").await;
971        issue_onto(&mut order, &config, database.clone()).await;
972
973        let mut reader: &[u8] = &[];
974        let error = run_order_command(
975            OrderCommand::Revoke {
976                id: order.id.to_string(),
977                reason: None,
978                wait: 0,
979            },
980            true,
981            Palette::plain(),
982            &mut reader,
983            &config,
984            database.clone(),
985        )
986        .await
987        .expect_err("an uninitialised CA must not take a revocation");
988        assert_eq!(error.kind(), crate::cli::CliErrorKind::Failed);
989        assert!(error.to_string().contains("acme-proxy serve"), "{error}");
990        assert!(
991            Order::find_by_id(order.id.to_string().as_str(), &database)
992                .await
993                .unwrap()
994                .unwrap()
995                .revoked_at
996                .is_none()
997        );
998    }
999
1000    /// A `custom` profile's revocation is the script's to perform, so the CLI
1001    /// queues it for a running server instead of running the script itself:
1002    /// `--wait 0` returns with the order untouched and one `signer_revoke` row
1003    /// queued, a second ask waits on that row rather than queueing another, and
1004    /// the server's handler runs the script once and records the revocation.
1005    #[tokio::test]
1006    async fn a_delegated_revocation_is_queued_for_the_server() {
1007        use acme_proxy_jobs::jobs::JobHandler;
1008        use acme_proxy_jobs::jobs::JobOutcome;
1009        use acme_proxy_protocol::acme::revoke::SIGNER_REVOKE_KIND;
1010        use acme_proxy_protocol::acme::revoke::SignerRevokeJob;
1011        use acme_proxy_store::job::Job;
1012
1013        let dir = temp_dir();
1014        let marker = dir.join("revoked");
1015        let script = acme_proxy_core::testutil::write_script(
1016            &dir,
1017            "signer.sh",
1018            &format!(
1019                "#!/bin/sh\n[ \"$ACME_SIGNER_HOOK\" = revoke ] && echo once >> {}\nexit 0\n",
1020                marker.display()
1021            ),
1022        );
1023        let config = {
1024            let _lock = acme_proxy_core::config::ENV_LOCK
1025                .lock()
1026                .unwrap_or_else(std::sync::PoisonError::into_inner);
1027            std::fs::write(
1028                dir.join("config.toml"),
1029                format!(
1030                    "[profiles.default]\nsigner.backend = \"custom\"\nsigner.custom.script_path = \"{}\"\n",
1031                    script.display()
1032                ),
1033            )
1034            .unwrap();
1035            // SAFETY: the lock above makes this the only thread touching the
1036            // environment, and the variable is removed before it is released.
1037            unsafe {
1038                std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
1039            }
1040            let config = Config::load().expect("the configuration must load");
1041            unsafe {
1042                std::env::remove_var("ACME_PROXY_CONFIG");
1043            }
1044            config
1045        };
1046        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1047        let account = seed_order(&database, "default").await.account_id;
1048        let order = acme_proxy_store::testutil::issued_order(
1049            &database,
1050            "default",
1051            account,
1052            &["example.com"],
1053            30,
1054        )
1055        .await;
1056        let id = order.id.to_string();
1057
1058        for _ in 0..2 {
1059            let mut reader: &[u8] = &[];
1060            run_order_command(
1061                OrderCommand::Revoke {
1062                    id: id.clone(),
1063                    reason: Some(4),
1064                    wait: 0,
1065                },
1066                true,
1067                Palette::plain(),
1068                &mut reader,
1069                &config,
1070                database.clone(),
1071            )
1072            .await
1073            .expect("a queued revocation is not a failure");
1074        }
1075        assert_eq!(
1076            Job::count_live(SIGNER_REVOKE_KIND, &database)
1077                .await
1078                .unwrap(),
1079            1
1080        );
1081        assert!(
1082            Order::find_by_id(&id, &database)
1083                .await
1084                .unwrap()
1085                .unwrap()
1086                .revoked_at
1087                .is_none()
1088        );
1089        assert!(!marker.exists(), "the CLI must not run the script itself");
1090
1091        let profile = &config.resolve_profiles().unwrap()[0];
1092        let resolver = acme_proxy_net::dns::resolver_addr(&config.dns)
1093            .and_then(acme_proxy_net::challenge::build_resolver)
1094            .unwrap();
1095        let signer = signer::from_config(
1096            &profile.sections.signer,
1097            &acme_proxy_signer::testutil::signer_parts(database.clone(), resolver),
1098        )
1099        .unwrap();
1100        let handler = SignerRevokeJob::new(
1101            database.clone(),
1102            Arc::new(acme_proxy_jobs::auditor::Auditor::offline(database.clone())),
1103            vec![("default".to_string(), signer)],
1104            std::collections::HashMap::new().into(),
1105        );
1106        let job = Job::find_live(SIGNER_REVOKE_KIND, &id, &database)
1107            .await
1108            .unwrap()
1109            .unwrap();
1110        assert!(matches!(handler.run(&job).await, JobOutcome::Done));
1111
1112        let revoked = Order::find_by_id(&id, &database).await.unwrap().unwrap();
1113        assert_eq!(revoked.revocation_reason, Some(4));
1114        assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1);
1115        // The row names the operator who asked, not the server that acted.
1116        let (rows, _) = acme_proxy_store::audit::AuditEntry::search(
1117            &acme_proxy_store::audit::AuditQuery {
1118                limit: 5,
1119                ..acme_proxy_store::audit::AuditQuery::default()
1120            },
1121            &database,
1122        )
1123        .await
1124        .unwrap();
1125        assert_eq!(rows[0].event, "certificate_revoked");
1126        assert_eq!(rows[0].actor_kind, "cli");
1127    }
1128
1129    /// An out-of-range reason code comes back from `admin::revoke_order` as a
1130    /// typed error, not a database one.
1131    #[tokio::test]
1132    async fn an_invalid_revocation_reason_is_refused() {
1133        let dir = temp_dir();
1134        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1135        let config = config_in(&dir, "default");
1136        let mut order = seed_order(&database, "default").await;
1137        issue_onto(&mut order, &config, database.clone()).await;
1138
1139        let mut reader: &[u8] = &[];
1140        let error = run_order_command(
1141            OrderCommand::Revoke {
1142                id: order.id.to_string(),
1143                reason: Some(7),
1144                wait: 0,
1145            },
1146            true,
1147            Palette::plain(),
1148            &mut reader,
1149            &config,
1150            database,
1151        )
1152        .await
1153        .expect_err("7 is not a defined CRLReason");
1154        assert!(error.to_string().contains('7'), "{error}");
1155        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1156    }
1157
1158    /// A declined delete leaves the order in place and is not a failure.
1159    #[tokio::test]
1160    async fn a_declined_delete_is_not_a_failure() {
1161        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1162        let order = seed_order(&database, "default").await;
1163
1164        let mut reader: &[u8] = b"n\n";
1165        run_order_command(
1166            OrderCommand::Delete {
1167                id: order.id.to_string(),
1168            },
1169            false,
1170            Palette::plain(),
1171            &mut reader,
1172            &Config::default(),
1173            database.clone(),
1174        )
1175        .await
1176        .unwrap();
1177
1178        assert!(
1179            Order::find_by_id(order.id.to_string().as_str(), &database)
1180                .await
1181                .unwrap()
1182                .is_some()
1183        );
1184    }
1185
1186    /// `show --json` renders through a different branch than the text form,
1187    /// and `list --json` additionally walks each order's authorizations.
1188    #[tokio::test]
1189    async fn the_json_arms_render() {
1190        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1191        let order = seed_order(&database, "default").await;
1192
1193        let mut reader: &[u8] = &[];
1194        for command in [
1195            OrderCommand::List(OrderListArgs {
1196                profile: Some("default".to_string()),
1197                account_id: None,
1198                status: None,
1199                identifier: None,
1200                identifier_contains: None,
1201                cert_serial: None,
1202                expiring_in: None,
1203                hide_superseded: false,
1204                limit: DEFAULT_LIMIT,
1205                offset: 0,
1206                json: true,
1207            }),
1208            // The identifier and serial filters walk the same render paths.
1209            OrderCommand::List(OrderListArgs {
1210                profile: None,
1211                account_id: None,
1212                status: None,
1213                identifier: Some("seeded.example.com".to_string()),
1214                identifier_contains: None,
1215                cert_serial: None,
1216                expiring_in: None,
1217                hide_superseded: false,
1218                limit: DEFAULT_LIMIT,
1219                offset: 0,
1220                json: true,
1221            }),
1222            OrderCommand::List(OrderListArgs {
1223                profile: None,
1224                account_id: None,
1225                status: None,
1226                identifier: None,
1227                identifier_contains: Some("example".to_string()),
1228                cert_serial: Some("deadbeef".to_string()),
1229                expiring_in: None,
1230                hide_superseded: false,
1231                limit: DEFAULT_LIMIT,
1232                offset: 0,
1233                json: false,
1234            }),
1235            OrderCommand::Show {
1236                id: order.id.to_string(),
1237                json: true,
1238            },
1239            OrderCommand::Show {
1240                id: order.id.to_string(),
1241                json: false,
1242            },
1243        ] {
1244            run_order_command(
1245                command,
1246                true,
1247                Palette::plain(),
1248                &mut reader,
1249                &Config::default(),
1250                database.clone(),
1251            )
1252            .await
1253            .unwrap();
1254        }
1255    }
1256
1257    /// An unknown `--status` is refused **by name**, not passed to SQL.
1258    ///
1259    /// The distinction is the whole point: a typo handed through to the query
1260    /// answers "no rows", which an operator cannot tell from "nothing is in
1261    /// that state". The same rule `audit list --event` follows.
1262    #[tokio::test]
1263    async fn an_unknown_status_is_refused_by_name_rather_than_matching_nothing() {
1264        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1265        seed_order(&database, "default").await;
1266
1267        let mut reader: &[u8] = &[];
1268        let error = run_order_command(
1269            OrderCommand::List(OrderListArgs {
1270                profile: None,
1271                account_id: None,
1272                status: Some("readyy".to_string()),
1273                identifier: None,
1274                identifier_contains: None,
1275                cert_serial: None,
1276                expiring_in: None,
1277                hide_superseded: false,
1278                limit: DEFAULT_LIMIT,
1279                offset: 0,
1280                json: false,
1281            }),
1282            true,
1283            Palette::plain(),
1284            &mut reader,
1285            &Config::default(),
1286            database.clone(),
1287        )
1288        .await
1289        .unwrap_err();
1290
1291        assert!(error.message.contains("--status"), "{error}");
1292        assert!(error.message.contains("`readyy`"), "{error}");
1293        // The operator typed it, so re-running it unchanged cannot help: exit 3.
1294        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1295        // ...and it names the alternatives, so the operator does not guess.
1296        assert!(
1297            error
1298                .message
1299                .contains("pending, ready, processing, valid, invalid"),
1300            "{error}"
1301        );
1302    }
1303
1304    /// Every status the CLI *does* accept reaches `Order::search`.
1305    ///
1306    /// Guards the other half: a refusal that also rejected valid input would
1307    /// pass the test above and break the command.
1308    #[tokio::test]
1309    async fn every_order_status_is_accepted_as_a_filter() {
1310        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1311        seed_order(&database, "default").await;
1312
1313        let mut reader: &[u8] = &[];
1314        for status in OrderStatus::ALL {
1315            run_order_command(
1316                OrderCommand::List(OrderListArgs {
1317                    profile: None,
1318                    account_id: None,
1319                    status: Some(status.as_str().to_string()),
1320                    identifier: None,
1321                    identifier_contains: None,
1322                    cert_serial: None,
1323                    expiring_in: None,
1324                    hide_superseded: false,
1325                    limit: DEFAULT_LIMIT,
1326                    offset: 0,
1327                    json: false,
1328                }),
1329                true,
1330                Palette::plain(),
1331                &mut reader,
1332                &Config::default(),
1333                database.clone(),
1334            )
1335            .await
1336            .unwrap_or_else(|error| panic!("--status {status} was refused: {error}"));
1337        }
1338    }
1339
1340    /// A helper for the expiry arm: `order list` with only the flags under
1341    /// test, run to completion.
1342    #[allow(clippy::too_many_arguments)]
1343    async fn list_with(
1344        expiring_in: Option<u64>,
1345        account_id: Option<&str>,
1346        status: Option<&str>,
1347        identifier: Option<&str>,
1348        identifier_contains: Option<&str>,
1349        cert_serial: Option<&str>,
1350        hide_superseded: bool,
1351        json: bool,
1352        database: Arc<Database>,
1353    ) -> Result<(), CliError> {
1354        let mut reader: &[u8] = &[];
1355        run_order_command(
1356            OrderCommand::List(OrderListArgs {
1357                profile: None,
1358                account_id: account_id.map(str::to_string),
1359                status: status.map(str::to_string),
1360                identifier: identifier.map(str::to_string),
1361                identifier_contains: identifier_contains.map(str::to_string),
1362                cert_serial: cert_serial.map(str::to_string),
1363                expiring_in,
1364                hide_superseded,
1365                limit: DEFAULT_LIMIT,
1366                offset: 0,
1367                json,
1368            }),
1369            true,
1370            Palette::plain(),
1371            &mut reader,
1372            &Config::default(),
1373            database,
1374        )
1375        .await
1376    }
1377
1378    /// The expiry listing, both output branches, with a row something has
1379    /// replaced and a row nothing has.
1380    #[tokio::test]
1381    async fn the_expiring_arm_lists_and_renders_both_ways() {
1382        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1383        let acct = acme_proxy_store::testutil::account_id(&database).await;
1384        acme_proxy_store::testutil::issued_order(&database, "default", acct, &["a.example.com"], 3)
1385            .await;
1386        acme_proxy_store::testutil::issued_order(&database, "default", acct, &["b.example.com"], 5)
1387            .await;
1388        // Renews the first, so one row carries the annotation and one does not.
1389        acme_proxy_store::testutil::issued_order(
1390            &database,
1391            "default",
1392            acct,
1393            &["a.example.com"],
1394            90,
1395        )
1396        .await;
1397
1398        for json in [false, true] {
1399            list_with(
1400                Some(30),
1401                None,
1402                None,
1403                None,
1404                None,
1405                None,
1406                false,
1407                json,
1408                database.clone(),
1409            )
1410            .await
1411            .unwrap();
1412            // ...and with the replaced row filtered out.
1413            list_with(
1414                Some(30),
1415                None,
1416                None,
1417                None,
1418                None,
1419                None,
1420                true,
1421                json,
1422                database.clone(),
1423            )
1424            .await
1425            .unwrap();
1426        }
1427    }
1428
1429    /// Both queries take the same window, and a nonsense one is corrected
1430    /// rather than handed to SQL — where `LIMIT -1` means *no limit* in SQLite.
1431    /// `--expiring-in` is included on purpose: the window is the one flag that
1432    /// means the same thing on both, so unlike `--status` it is not refused
1433    /// beside it.
1434    #[tokio::test]
1435    async fn both_listings_take_a_window_and_clamp_a_nonsense_one() {
1436        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1437        seed_order(&database, "default").await;
1438
1439        let mut reader: &[u8] = &[];
1440        for expiring_in in [None, Some(30)] {
1441            for (limit, offset, json) in
1442                [(1, 0, false), (1, 1, false), (1, 0, true), (0, -5, false)]
1443            {
1444                run_order_command(
1445                    OrderCommand::List(OrderListArgs {
1446                        profile: None,
1447                        account_id: None,
1448                        status: None,
1449                        identifier: None,
1450                        identifier_contains: None,
1451                        cert_serial: None,
1452                        expiring_in,
1453                        hide_superseded: false,
1454                        limit,
1455                        offset,
1456                        json,
1457                    }),
1458                    true,
1459                    Palette::plain(),
1460                    &mut reader,
1461                    &Config::default(),
1462                    database.clone(),
1463                )
1464                .await
1465                .unwrap_or_else(|error| {
1466                    panic!(
1467                        "--expiring-in {expiring_in:?} --limit {limit} --offset {offset}: {error}"
1468                    )
1469                });
1470            }
1471        }
1472    }
1473
1474    /// The flag combinations refused **by name** beside `--expiring-in`.
1475    ///
1476    /// `--status`, `--account-id`, `--identifier`, `--identifier-contains` and
1477    /// `--cert-serial` do not apply to the expiry query, and `--hide-superseded`
1478    /// has no annotation to filter on without it. Each is refused rather than
1479    /// ignored for `--status`'s own reason: an argument silently dropped answers
1480    /// with rows that look like it was honoured.
1481    #[tokio::test]
1482    async fn the_flags_that_do_not_compose_with_expiring_in_are_refused_by_name() {
1483        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1484        seed_order(&database, "default").await;
1485
1486        let error = list_with(
1487            Some(30),
1488            None,
1489            Some("valid"),
1490            None,
1491            None,
1492            None,
1493            false,
1494            false,
1495            database.clone(),
1496        )
1497        .await
1498        .unwrap_err();
1499        assert!(error.message.contains("--status"), "{error}");
1500        assert!(error.message.contains("--expiring-in"), "{error}");
1501        // Contradictory flags are the operator's to fix: exit 3.
1502        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1503
1504        let error = list_with(
1505            Some(30),
1506            Some("acct-1"),
1507            None,
1508            None,
1509            None,
1510            None,
1511            false,
1512            false,
1513            database.clone(),
1514        )
1515        .await
1516        .unwrap_err();
1517        assert!(error.message.contains("--account-id"), "{error}");
1518        assert!(error.message.contains("--expiring-in"), "{error}");
1519
1520        let error = list_with(
1521            None,
1522            None,
1523            None,
1524            None,
1525            None,
1526            None,
1527            true,
1528            false,
1529            database.clone(),
1530        )
1531        .await
1532        .unwrap_err();
1533        assert!(error.message.contains("--hide-superseded"), "{error}");
1534        assert!(error.message.contains("--expiring-in"), "{error}");
1535
1536        // The name and serial filters are refused beside it too, each named.
1537        for (identifier, contains, serial, needle) in [
1538            (Some("a.example.com"), None, None, "--identifier"),
1539            (None, Some("example"), None, "--identifier-contains"),
1540            (None, None, Some("deadbeef"), "--cert-serial"),
1541        ] {
1542            let error = list_with(
1543                Some(30),
1544                None,
1545                None,
1546                identifier,
1547                contains,
1548                serial,
1549                false,
1550                false,
1551                database.clone(),
1552            )
1553            .await
1554            .unwrap_err();
1555            assert!(error.message.contains(needle), "{error}");
1556            assert!(error.message.contains("--expiring-in"), "{error}");
1557        }
1558
1559        // And the ordinary listing is untouched by any of it.
1560        list_with(
1561            None,
1562            None,
1563            Some("valid"),
1564            Some("a.example.com"),
1565            None,
1566            None,
1567            false,
1568            false,
1569            database,
1570        )
1571        .await
1572        .unwrap();
1573    }
1574
1575    /// `order delete` over a live certificate fails with the shared wording.
1576    #[tokio::test]
1577    async fn delete_refuses_an_order_holding_a_live_certificate() {
1578        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1579        let account = acme_proxy_store::testutil::account_id(&database).await;
1580        let order = acme_proxy_store::testutil::certified_order(&database, account, None).await;
1581
1582        let error = run_order_command(
1583            OrderCommand::Delete {
1584                id: order.id.to_string(),
1585            },
1586            true,
1587            Palette::plain(),
1588            &mut &b""[..],
1589            &Config::default(),
1590            database.clone(),
1591        )
1592        .await
1593        .expect_err("a live certificate must refuse the delete");
1594        assert_eq!(
1595            error,
1596            CliError::bad_request(admin::live_certificates_refusal(
1597                &format!("order {}", order.id),
1598                1
1599            ))
1600        );
1601    }
1602}