Skip to main content

acme_proxy/cli/
order.rs

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