Skip to main content

acme_proxy/cli/
order.rs

1use std::io::BufRead;
2use std::sync::Arc;
3
4use clap::Subcommand;
5
6use crate::admin::{self, DeleteOutcome};
7use crate::cli::CliError;
8use crate::cli::render;
9use crate::cli::style::Palette;
10use crate::cli::window::{DEFAULT_LIMIT, Window};
11use crate::config::Config;
12use crate::signer;
13use crate::sqlite::authz::Authorization;
14use crate::sqlite::db::Database;
15use crate::sqlite::order::{Order, OrderQuery};
16use crate::sqlite::status::OrderStatus;
17
18#[derive(Subcommand)]
19pub enum OrderCommand {
20    /// List orders, optionally filtered.
21    List {
22        /// Restrict the listing to one ACME endpoint.
23        #[arg(long)]
24        profile: Option<String>,
25        #[arg(long = "account-id")]
26        account_id: Option<String>,
27        #[arg(long)]
28        status: Option<String>,
29        /// Instead: the certificates lapsing within N days, soonest first,
30        /// each annotated with whatever has already replaced it.
31        #[arg(long = "expiring-in")]
32        expiring_in: Option<u64>,
33        /// Omit certificates something has already replaced. Needs
34        /// `--expiring-in`, which is where the annotation comes from.
35        #[arg(long = "hide-superseded")]
36        hide_superseded: bool,
37        #[arg(long, default_value_t = DEFAULT_LIMIT)]
38        limit: i64,
39        #[arg(long, default_value_t = 0)]
40        offset: i64,
41        #[arg(long)]
42        json: bool,
43    },
44    /// Show one order plus its authorizations and challenges.
45    Show {
46        id: String,
47        #[arg(long)]
48        json: bool,
49    },
50    /// Hard-delete the order and everything under it.
51    Delete { id: String },
52    /// Revoke the order's issued certificate.
53    Revoke {
54        id: String,
55        #[arg(long)]
56        reason: Option<u32>,
57    },
58}
59
60pub async fn run_order_command(
61    command: OrderCommand,
62    yes: bool,
63    palette: Palette,
64    reader: &mut impl BufRead,
65    config: &Config,
66    database: Arc<Database>,
67) -> Result<(), CliError> {
68    match command {
69        OrderCommand::List {
70            profile,
71            account_id,
72            status,
73            expiring_in,
74            hide_superseded,
75            limit,
76            offset,
77            json,
78        } => {
79            let window = Window::resolve(limit, offset);
80
81            // `--expiring-in` is a different question over a different query,
82            // and the three flags that do not compose with it are refused **by
83            // name** rather than ignored -- `--status`'s own rule, and for its
84            // reason: an argument silently dropped answers with rows that look
85            // like it was honoured. The window is not among them: it is the one
86            // flag that means the same thing on both queries, so it is passed
87            // straight through.
88            if let Some(days) = expiring_in {
89                return run_expiring(
90                    days,
91                    profile,
92                    account_id.as_deref(),
93                    status.as_deref(),
94                    hide_superseded,
95                    window,
96                    json,
97                    palette,
98                    database,
99                )
100                .await;
101            }
102            if hide_superseded {
103                return Err(CliError(
104                    "--hide-superseded needs --expiring-in: it filters on the supersession \
105                     annotation, which only the expiry listing carries"
106                        .to_string(),
107                ));
108            }
109
110            // Refused by name rather than passed through: an unknown status
111            // would match no rows, which reads exactly like "nothing is in
112            // that state". The same rule `audit list --event` follows.
113            let status = status
114                .map(|value| value.parse::<OrderStatus>())
115                .transpose()
116                .map_err(|error| CliError(format!("--status: {error}")))?;
117
118            // Filtered in SQL, by the same `Order::search` the web admin uses.
119            // It used to load every order in the database and filter the three
120            // fields in Rust, which is one policy written twice — and the two
121            // could drift into disagreeing about what `--status` means.
122            let query = OrderQuery {
123                profile,
124                account_id,
125                status,
126                limit: window.limit,
127                offset: window.offset,
128            };
129            let (orders, total) = Order::search(&query, &database).await?;
130            // Not `render::print_page`, and this is the only listing that opts
131            // out: the `--json` rendering needs one batched authorization
132            // lookup for the whole page, not one query per row — the N+1 the
133            // web admin's `render_orders` already avoids, and what
134            // `find_ids_by_orders` exists for. Handing that closure to
135            // `print_page` would make the text path pay for a query it never
136            // reads, so the two halves are spelled out and the shared envelope
137            // and footer are called directly.
138            if json {
139                let ids: Vec<&str> = orders.iter().map(|o| o.id.as_str()).collect();
140                let mut authz_ids = Authorization::find_ids_by_orders(&ids, &database).await?;
141                let rendered: Vec<_> = orders
142                    .iter()
143                    .map(|order| {
144                        admin::render_order_json(
145                            order,
146                            &config.server.base_url,
147                            &authz_ids.remove(&order.id).unwrap_or_default(),
148                        )
149                    })
150                    .collect();
151                println!("{}", render::json_page(rendered, total, window));
152            } else {
153                for order in &orders {
154                    println!("{}", render::render_order_line(order, palette));
155                }
156                render::print_footer(orders.len(), total);
157            }
158        }
159        OrderCommand::Show { id, json } => match admin::load_order_detail(&id, database).await? {
160            None => return Err(not_found(&id)),
161            Some(detail) if json => {
162                println!(
163                    "{}",
164                    admin::render_order_detail_json(&detail, &config.server.base_url)
165                );
166            }
167            Some(detail) => print!("{}", render::render_order_detail_text(&detail, palette)),
168        },
169        OrderCommand::Delete { id } => {
170            match admin::confirm_delete_order(&id, yes, reader, database).await? {
171                DeleteOutcome::NotFound => return Err(not_found(&id)),
172                DeleteOutcome::Cancelled => println!("Cancelled."),
173                DeleteOutcome::Deleted => println!("Deleted order {id}."),
174            }
175        }
176        OrderCommand::Revoke { id, reason } => {
177            // Revocation goes through the endpoint that issued the certificate:
178            // another profile's backend holds a different CA, or none at all.
179            let Some(order) = Order::find_by_id(&id, &database).await? else {
180                return Err(not_found(&id));
181            };
182            let profiles = config
183                .resolve_profiles()
184                .map_err(|error| CliError(format!("configuration error: {error}")))?;
185            let Some(profile) = profiles.iter().find(|p| p.name == order.profile) else {
186                return Err(CliError(format!(
187                    "order {id} was issued by profile `{}`, which this configuration does not \
188                     define — revoking it needs the endpoint that signed it",
189                    order.profile
190                )));
191            };
192            // No notifiers: this is a one-off admin invocation, not the long-
193            // running server — there is no background completion task here
194            // for a notifier to ever be reached from.
195            // A throwaway egress: this is a one-shot admin command, not the
196            // long-running server, so there is no shared resolver or proxy
197            // policy to reuse — both come from the same `[dns]`/`[proxy]`
198            // sections `serve` reads.
199            let egress = Arc::new(
200                crate::Egress::from_config(config)
201                    .map_err(|error| CliError(format!("configuration error: {error}")))?,
202            );
203            // A queue nothing drains: this command revokes, which every backend
204            // answers inline, so no job is ever enqueued. Handing over a live
205            // queue would be worse than useless — it would let a one-shot CLI
206            // invocation write rows that only the running server can work off.
207            let jobs = crate::jobs::JobQueue::new(database.clone(), &config.jobs);
208            // A registry nothing scrapes, for the same reason as the queue
209            // above: this process exits when the command does, and the counters
210            // that matter belong to the server that is serving `/metrics`.
211            let metrics = Arc::new(crate::metrics::Metrics::new(database.clone()));
212            let signer = signer::from_config(
213                &profile.sections.signer,
214                vec![profile.name.clone()],
215                &signer::SignerParts {
216                    database: database.clone(),
217                    notifiers: std::collections::HashMap::new().into(),
218                    metrics,
219                    egress,
220                    jobs,
221                },
222                // Nothing to adopt: there is no previous generation in a process
223                // that exits when this command does.
224                &signer::CarriedState::new(),
225            )
226            .map_err(|error| CliError(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            match admin::revoke_order(
230                &id,
231                reason,
232                crate::audit::Actor::cli(),
233                crate::audit::ClientContext::default(),
234                database,
235                signer,
236            )
237            .await
238            .map_err(|error| CliError(error.to_string()))?
239            {
240                admin::RevokeOutcome::NotFound => return Err(not_found(&id)),
241                admin::RevokeOutcome::NotIssued => {
242                    return Err(CliError(format!("order {id} has no issued certificate")));
243                }
244                admin::RevokeOutcome::AlreadyRevoked => {
245                    return Err(CliError(format!(
246                        "order {id}'s certificate is already revoked"
247                    )));
248                }
249                admin::RevokeOutcome::Revoked(order) => {
250                    println!("{}", render::render_order_line(&order, palette));
251                }
252            }
253        }
254    }
255    Ok(())
256}
257
258/// `order list --expiring-in <days>`.
259///
260/// A branch rather than a sibling subcommand because it is still "list orders",
261/// asked with a different filter -- but it is a different *query*
262/// (`Order::find_expiring`, ordered by expiry rather than by age) with its own
263/// fixed status set, so the two filters that cannot mean anything here are
264/// refused instead of ignored.
265///
266/// Paged like the rest of `order list`, and reporting `hidden` beside the total
267/// exactly as `GET /api/expiring` does -- `total` counts the *window*, not the
268/// answer, because supersession is computed per row and cannot become a SQL
269/// predicate. `admin::annotate_expiring` still reads each account's orders once
270/// for the whole page rather than once per row, which is what keeps a page over
271/// a single busy account from re-reading its history fifty times.
272#[allow(clippy::too_many_arguments)]
273async fn run_expiring(
274    days: u64,
275    profile: Option<String>,
276    account_id: Option<&str>,
277    status: Option<&str>,
278    hide_superseded: bool,
279    window: Window,
280    json: bool,
281    palette: Palette,
282    database: Arc<Database>,
283) -> Result<(), CliError> {
284    if status.is_some() {
285        return Err(CliError(
286            "--status does not apply with --expiring-in: the expiry listing is issued, \
287             unrevoked certificates by definition, so a status filter here would mean \
288             something other than it does everywhere else"
289                .to_string(),
290        ));
291    }
292    if account_id.is_some() {
293        return Err(CliError(
294            "--account-id does not apply with --expiring-in: the expiry listing has no \
295             account predicate, and answering as though it did would report one \
296             subscriber's certificates as every subscriber's"
297                .to_string(),
298        ));
299    }
300
301    let query = admin::ExpiringQuery {
302        profile,
303        before: admin::expiring_horizon(days),
304        include_superseded: !hide_superseded,
305        limit: window.limit,
306        offset: window.offset,
307    };
308    let (entries, total, hidden) = admin::list_expiring(&query, database).await?;
309    if json {
310        let items = entries.iter().map(admin::render_expiring_json).collect();
311        let mut envelope = render::json_page(items, total, window);
312        if let Some(object) = envelope.as_object_mut() {
313            // The same two extra members `GET /api/expiring` adds, spelled the
314            // same way: one answer to "what is expiring" rendered identically
315            // wherever it is asked.
316            object.insert("hidden".to_string(), serde_json::json!(hidden));
317            object.insert("days".to_string(), serde_json::json!(days));
318        }
319        println!("{envelope}");
320    } else {
321        for entry in &entries {
322            println!("{}", render::render_expiring_line(entry, palette));
323        }
324        render::print_expiring_footer(entries.len(), total, hidden);
325    }
326    Ok(())
327}
328
329fn not_found(id: &str) -> CliError {
330    CliError(format!("no such order: {id}"))
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::audit::ClientContext;
337    use crate::signer::{IssueOutcome, RequestedValidity, SignerBackend};
338    use crate::sqlite::account::Account;
339
340    /// A configuration whose single `default` profile signs with a local CA
341    /// living under `dir` — what `Revoke` needs, since it rebuilds the signer
342    /// from the profile that issued the certificate.
343    fn config_in(dir: impl AsRef<std::path::Path>, profile: &str) -> Config {
344        let dir = dir.as_ref();
345        let _lock = crate::config::ENV_LOCK
346            .lock()
347            .unwrap_or_else(std::sync::PoisonError::into_inner);
348        let ca = dir.join("ca");
349        std::fs::write(
350            dir.join("config.toml"),
351            format!(
352                r#"
353                [profiles.{profile}]
354                signer.local_ca.cert_path = "{ca}.pem"
355                signer.local_ca.key_path = "{ca}.key"
356                signer.local_ca.crl_path = "{ca}.crl"
357                "#,
358                ca = ca.display(),
359            ),
360        )
361        .unwrap();
362        // SAFETY: the lock above makes this the only thread touching the
363        // environment, and the variable is removed before returning.
364        unsafe {
365            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
366        }
367        let config = Config::load().expect("the configuration must load");
368        unsafe {
369            std::env::remove_var("ACME_PROXY_CONFIG");
370        }
371        config
372    }
373
374    fn temp_dir() -> crate::testutil::TempDir {
375        crate::testutil::TempDir::new("cli-order")
376    }
377
378    async fn seed_order(database: &Arc<Database>, profile: &str) -> Order {
379        let (account, _) = Account::find_or_create(
380            profile,
381            &[4, 5, 6],
382            vec![],
383            &ClientContext::default(),
384            database,
385        )
386        .await
387        .unwrap();
388        Order::create(
389            profile,
390            &account.id,
391            vec![crate::sqlite::order::Identifier::dns("example.com")],
392            crate::sqlite::nonce::now_secs() + 3600,
393            None,
394            None,
395            database,
396        )
397        .await
398        .unwrap()
399    }
400
401    /// Issues against `config`'s own CA and records the result on `order`, so
402    /// the certificate the CLI later revokes is one that CA actually signed.
403    async fn issue_onto(order: &mut Order, config: &Config, database: Arc<Database>) {
404        let profile = &config.resolve_profiles().unwrap()[0];
405        let resolver = crate::dns::resolver_addr(&config.dns)
406            .and_then(crate::challenge::build_resolver)
407            .expect("the default dns configuration must build a resolver");
408        let signer: Arc<dyn SignerBackend> = signer::from_config(
409            &profile.sections.signer,
410            vec![profile.name.clone()],
411            &crate::testutil::signer_parts(database.clone(), resolver),
412            &signer::CarriedState::new(),
413        )
414        .unwrap();
415
416        let key_pair = rcgen::KeyPair::generate().unwrap();
417        let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
418        let csr = params.serialize_request(&key_pair).unwrap();
419        let chain = match signer
420            .issue(
421                &order.id,
422                csr.der(),
423                &order.identifiers,
424                RequestedValidity::default(),
425            )
426            .await
427            .unwrap()
428        {
429            IssueOutcome::Issued(chain) => chain,
430            IssueOutcome::Processing => panic!("the local CA issues synchronously"),
431        };
432        let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
433        let (serial, pubkey) = crate::cert::cert_serial_and_spki(&leaf).unwrap();
434        let not_after = crate::cert::cert_validity(&leaf).ok().map(|(_, na)| na);
435        order
436            .finalize(chain, serial, pubkey, not_after, &database)
437            .await
438            .unwrap();
439    }
440
441    #[tokio::test]
442    async fn every_arm_refuses_an_unknown_order() {
443        let database = Arc::new(Database::connect_in_memory().await.unwrap());
444        let config = Config::default();
445        let expected = CliError("no such order: ord-nope".to_string());
446
447        let commands = vec![
448            OrderCommand::Show {
449                id: "ord-nope".to_string(),
450                json: false,
451            },
452            OrderCommand::Delete {
453                id: "ord-nope".to_string(),
454            },
455            OrderCommand::Revoke {
456                id: "ord-nope".to_string(),
457                reason: None,
458            },
459        ];
460        for command in commands {
461            let mut reader: &[u8] = &[];
462            let error = run_order_command(
463                command,
464                true,
465                Palette::plain(),
466                &mut reader,
467                &config,
468                database.clone(),
469            )
470            .await
471            .expect_err("an unknown order must fail");
472            assert_eq!(error, expected);
473        }
474    }
475
476    /// `revoke` needs the endpoint that signed the certificate. A profile the
477    /// running configuration no longer defines says so, rather than silently
478    /// revoking against some other profile's CA.
479    #[tokio::test]
480    async fn revoking_an_order_from_an_undefined_profile_is_refused() {
481        let dir = temp_dir();
482        let database = Arc::new(Database::connect_in_memory().await.unwrap());
483        // The order belongs to `default`; the configuration only mounts `other`.
484        let order = seed_order(&database, "default").await;
485        let config = config_in(&dir, "other");
486
487        let mut reader: &[u8] = &[];
488        let error = run_order_command(
489            OrderCommand::Revoke {
490                id: order.id.clone(),
491                reason: None,
492            },
493            true,
494            Palette::plain(),
495            &mut reader,
496            &config,
497            database,
498        )
499        .await
500        .expect_err("a profile this configuration does not define must be refused");
501        assert!(
502            error.to_string().contains("which this configuration"),
503            "{error}"
504        );
505    }
506
507    /// A configuration that mounts nothing at all cannot name a signer either.
508    #[tokio::test]
509    async fn revoking_without_a_resolvable_configuration_is_refused() {
510        let database = Arc::new(Database::connect_in_memory().await.unwrap());
511        let order = seed_order(&database, "default").await;
512
513        let mut reader: &[u8] = &[];
514        let error = run_order_command(
515            OrderCommand::Revoke {
516                id: order.id,
517                reason: None,
518            },
519            true,
520            Palette::plain(),
521            &mut reader,
522            &Config::default(),
523            database,
524        )
525        .await
526        .expect_err("a configuration mounting nothing must be refused");
527        assert!(
528            error.to_string().starts_with("configuration error: "),
529            "{error}"
530        );
531    }
532
533    #[tokio::test]
534    async fn revoking_an_order_with_no_certificate_is_refused() {
535        let dir = temp_dir();
536        let database = Arc::new(Database::connect_in_memory().await.unwrap());
537        let order = seed_order(&database, "default").await;
538        let config = config_in(&dir, "default");
539
540        let mut reader: &[u8] = &[];
541        let error = run_order_command(
542            OrderCommand::Revoke {
543                id: order.id.clone(),
544                reason: None,
545            },
546            true,
547            Palette::plain(),
548            &mut reader,
549            &config,
550            database,
551        )
552        .await
553        .expect_err("there is nothing to revoke");
554        assert_eq!(
555            error,
556            CliError(format!("order {} has no issued certificate", order.id))
557        );
558    }
559
560    /// The whole arm end to end: issue, revoke through the CLI, then find the
561    /// second attempt refused because the first one stuck.
562    #[tokio::test]
563    async fn an_issued_order_revokes_once() {
564        let dir = temp_dir();
565        let database = Arc::new(Database::connect_in_memory().await.unwrap());
566        let config = config_in(&dir, "default");
567        let mut order = seed_order(&database, "default").await;
568        issue_onto(&mut order, &config, database.clone()).await;
569
570        let mut reader: &[u8] = &[];
571        run_order_command(
572            OrderCommand::Revoke {
573                id: order.id.clone(),
574                reason: Some(1),
575            },
576            true,
577            Palette::plain(),
578            &mut reader,
579            &config,
580            database.clone(),
581        )
582        .await
583        .expect("a certificate issued by this profile's CA must revoke");
584
585        assert!(
586            Order::find_by_id(&order.id, &database)
587                .await
588                .unwrap()
589                .unwrap()
590                .revoked_at
591                .is_some()
592        );
593
594        let error = run_order_command(
595            OrderCommand::Revoke {
596                id: order.id.clone(),
597                reason: None,
598            },
599            true,
600            Palette::plain(),
601            &mut reader,
602            &config,
603            database.clone(),
604        )
605        .await
606        .expect_err("a second revocation has nothing left to do");
607        assert_eq!(
608            error,
609            CliError(format!(
610                "order {}'s certificate is already revoked",
611                order.id
612            ))
613        );
614    }
615
616    /// An out-of-range reason code comes back from `admin::revoke_order` as a
617    /// typed error, not a database one.
618    #[tokio::test]
619    async fn an_invalid_revocation_reason_is_refused() {
620        let dir = temp_dir();
621        let database = Arc::new(Database::connect_in_memory().await.unwrap());
622        let config = config_in(&dir, "default");
623        let mut order = seed_order(&database, "default").await;
624        issue_onto(&mut order, &config, database.clone()).await;
625
626        let mut reader: &[u8] = &[];
627        let error = run_order_command(
628            OrderCommand::Revoke {
629                id: order.id.clone(),
630                reason: Some(7),
631            },
632            true,
633            Palette::plain(),
634            &mut reader,
635            &config,
636            database,
637        )
638        .await
639        .expect_err("7 is not a defined CRLReason");
640        assert!(error.to_string().contains('7'), "{error}");
641    }
642
643    /// A declined delete leaves the order in place and is not a failure.
644    #[tokio::test]
645    async fn a_declined_delete_is_not_a_failure() {
646        let database = Arc::new(Database::connect_in_memory().await.unwrap());
647        let order = seed_order(&database, "default").await;
648
649        let mut reader: &[u8] = b"n\n";
650        run_order_command(
651            OrderCommand::Delete {
652                id: order.id.clone(),
653            },
654            false,
655            Palette::plain(),
656            &mut reader,
657            &Config::default(),
658            database.clone(),
659        )
660        .await
661        .unwrap();
662
663        assert!(
664            Order::find_by_id(&order.id, &database)
665                .await
666                .unwrap()
667                .is_some()
668        );
669    }
670
671    /// `show --json` renders through a different branch than the text form,
672    /// and `list --json` additionally walks each order's authorizations.
673    #[tokio::test]
674    async fn the_json_arms_render() {
675        let database = Arc::new(Database::connect_in_memory().await.unwrap());
676        let order = seed_order(&database, "default").await;
677
678        let mut reader: &[u8] = &[];
679        for command in [
680            OrderCommand::List {
681                profile: Some("default".to_string()),
682                account_id: None,
683                status: None,
684                expiring_in: None,
685                hide_superseded: false,
686                limit: DEFAULT_LIMIT,
687                offset: 0,
688                json: true,
689            },
690            OrderCommand::Show {
691                id: order.id.clone(),
692                json: true,
693            },
694            OrderCommand::Show {
695                id: order.id.clone(),
696                json: false,
697            },
698        ] {
699            run_order_command(
700                command,
701                true,
702                Palette::plain(),
703                &mut reader,
704                &Config::default(),
705                database.clone(),
706            )
707            .await
708            .unwrap();
709        }
710    }
711
712    /// An unknown `--status` is refused **by name**, not passed to SQL.
713    ///
714    /// The distinction is the whole point: a typo handed through to the query
715    /// answers "no rows", which an operator cannot tell from "nothing is in
716    /// that state". The same rule `audit list --event` follows.
717    #[tokio::test]
718    async fn an_unknown_status_is_refused_by_name_rather_than_matching_nothing() {
719        let database = Arc::new(Database::connect_in_memory().await.unwrap());
720        seed_order(&database, "default").await;
721
722        let mut reader: &[u8] = &[];
723        let error = run_order_command(
724            OrderCommand::List {
725                profile: None,
726                account_id: None,
727                status: Some("readyy".to_string()),
728                expiring_in: None,
729                hide_superseded: false,
730                limit: DEFAULT_LIMIT,
731                offset: 0,
732                json: false,
733            },
734            true,
735            Palette::plain(),
736            &mut reader,
737            &Config::default(),
738            database.clone(),
739        )
740        .await
741        .unwrap_err();
742
743        assert!(error.0.contains("--status"), "{error}");
744        assert!(error.0.contains("`readyy`"), "{error}");
745        // ...and it names the alternatives, so the operator does not guess.
746        assert!(
747            error
748                .0
749                .contains("pending, ready, processing, valid, invalid"),
750            "{error}"
751        );
752    }
753
754    /// Every status the CLI *does* accept reaches `Order::search`.
755    ///
756    /// Guards the other half: a refusal that also rejected valid input would
757    /// pass the test above and break the command.
758    #[tokio::test]
759    async fn every_order_status_is_accepted_as_a_filter() {
760        let database = Arc::new(Database::connect_in_memory().await.unwrap());
761        seed_order(&database, "default").await;
762
763        let mut reader: &[u8] = &[];
764        for status in OrderStatus::ALL {
765            run_order_command(
766                OrderCommand::List {
767                    profile: None,
768                    account_id: None,
769                    status: Some(status.as_str().to_string()),
770                    expiring_in: None,
771                    hide_superseded: false,
772                    limit: DEFAULT_LIMIT,
773                    offset: 0,
774                    json: false,
775                },
776                true,
777                Palette::plain(),
778                &mut reader,
779                &Config::default(),
780                database.clone(),
781            )
782            .await
783            .unwrap_or_else(|error| panic!("--status {status} was refused: {error}"));
784        }
785    }
786
787    /// A helper for the expiry arm: `order list` with only the flags under
788    /// test, run to completion.
789    async fn list_with(
790        expiring_in: Option<u64>,
791        account_id: Option<&str>,
792        status: Option<&str>,
793        hide_superseded: bool,
794        json: bool,
795        database: Arc<Database>,
796    ) -> Result<(), CliError> {
797        let mut reader: &[u8] = &[];
798        run_order_command(
799            OrderCommand::List {
800                profile: None,
801                account_id: account_id.map(str::to_string),
802                status: status.map(str::to_string),
803                expiring_in,
804                hide_superseded,
805                limit: DEFAULT_LIMIT,
806                offset: 0,
807                json,
808            },
809            true,
810            Palette::plain(),
811            &mut reader,
812            &Config::default(),
813            database,
814        )
815        .await
816    }
817
818    /// The expiry listing, both output branches, with a row something has
819    /// replaced and a row nothing has.
820    #[tokio::test]
821    async fn the_expiring_arm_lists_and_renders_both_ways() {
822        let database = Arc::new(Database::connect_in_memory().await.unwrap());
823        let acct = crate::testutil::account_id(&database).await;
824        crate::testutil::issued_order(&database, "default", &acct, &["a.example.com"], 3).await;
825        crate::testutil::issued_order(&database, "default", &acct, &["b.example.com"], 5).await;
826        // Renews the first, so one row carries the annotation and one does not.
827        crate::testutil::issued_order(&database, "default", &acct, &["a.example.com"], 90).await;
828
829        for json in [false, true] {
830            list_with(Some(30), None, None, false, json, database.clone())
831                .await
832                .unwrap();
833            // ...and with the replaced row filtered out.
834            list_with(Some(30), None, None, true, json, database.clone())
835                .await
836                .unwrap();
837        }
838    }
839
840    /// Both queries take the same window, and a nonsense one is corrected
841    /// rather than handed to SQL — where `LIMIT -1` means *no limit* in SQLite.
842    /// `--expiring-in` is included on purpose: the window is the one flag that
843    /// means the same thing on both, so unlike `--status` it is not refused
844    /// beside it.
845    #[tokio::test]
846    async fn both_listings_take_a_window_and_clamp_a_nonsense_one() {
847        let database = Arc::new(Database::connect_in_memory().await.unwrap());
848        seed_order(&database, "default").await;
849
850        let mut reader: &[u8] = &[];
851        for expiring_in in [None, Some(30)] {
852            for (limit, offset, json) in
853                [(1, 0, false), (1, 1, false), (1, 0, true), (0, -5, false)]
854            {
855                run_order_command(
856                    OrderCommand::List {
857                        profile: None,
858                        account_id: None,
859                        status: None,
860                        expiring_in,
861                        hide_superseded: false,
862                        limit,
863                        offset,
864                        json,
865                    },
866                    true,
867                    Palette::plain(),
868                    &mut reader,
869                    &Config::default(),
870                    database.clone(),
871                )
872                .await
873                .unwrap_or_else(|error| {
874                    panic!(
875                        "--expiring-in {expiring_in:?} --limit {limit} --offset {offset}: {error}"
876                    )
877                });
878            }
879        }
880    }
881
882    /// The three combinations refused **by name**.
883    ///
884    /// `--status` and `--account-id` do not apply to the expiry query, and
885    /// `--hide-superseded` has no annotation to filter on without it. Each is
886    /// refused rather than ignored for `--status`'s own reason: an argument
887    /// silently dropped answers with rows that look like it was honoured.
888    #[tokio::test]
889    async fn the_flags_that_do_not_compose_with_expiring_in_are_refused_by_name() {
890        let database = Arc::new(Database::connect_in_memory().await.unwrap());
891        seed_order(&database, "default").await;
892
893        let error = list_with(
894            Some(30),
895            None,
896            Some("valid"),
897            false,
898            false,
899            database.clone(),
900        )
901        .await
902        .unwrap_err();
903        assert!(error.0.contains("--status"), "{error}");
904        assert!(error.0.contains("--expiring-in"), "{error}");
905
906        let error = list_with(
907            Some(30),
908            Some("acct-1"),
909            None,
910            false,
911            false,
912            database.clone(),
913        )
914        .await
915        .unwrap_err();
916        assert!(error.0.contains("--account-id"), "{error}");
917        assert!(error.0.contains("--expiring-in"), "{error}");
918
919        let error = list_with(None, None, None, true, false, database.clone())
920            .await
921            .unwrap_err();
922        assert!(error.0.contains("--hide-superseded"), "{error}");
923        assert!(error.0.contains("--expiring-in"), "{error}");
924
925        // And the ordinary listing is untouched by any of it.
926        list_with(None, None, Some("valid"), false, false, database)
927            .await
928            .unwrap();
929    }
930}