1use std::io::{BufRead, IsTerminal};
30use std::sync::Arc;
31
32use clap::{Parser, Subcommand};
33use clap_complete::aot::Shell;
34
35pub mod account;
36pub mod audit;
37pub mod eab;
38pub mod filter;
39pub mod generate;
40pub mod jobs;
41pub(crate) mod logging;
42
43pub use logging::{LogLevel, LoggingPlan, plan_logging};
46pub mod nonce;
47pub mod order;
48pub mod profile;
49pub mod render;
50pub mod schema;
51pub mod style;
52pub mod transfer;
53pub mod upstream;
54pub mod webadmin;
55pub mod window;
56
57pub use account::AccountCommand;
58pub use audit::AuditCommand;
59pub use eab::EabCommand;
60pub use jobs::JobsCommand;
61pub use nonce::NonceCommand;
62pub use order::OrderCommand;
63pub use profile::ProfileCommand;
64pub use upstream::UpstreamCommand;
65pub use webadmin::AdminCommand;
66
67use crate::cli::filter::FilterCommand;
68pub use crate::cli::style::ColorChoice;
69use acme_proxy_core::config::Config;
70use acme_proxy_core::palette::Palette;
71use acme_proxy_store::db::Database;
72
73#[derive(Parser)]
74#[command(
75 name = "acme-proxy",
76 version = env!("CARGO_PKG_VERSION"),
77 about = "ACME server, plus admin commands for its database"
78)]
79pub struct Cli {
80 #[arg(short = 'y', long, global = true)]
82 pub yes: bool,
83
84 #[arg(long, value_enum, default_value_t = ColorChoice::Auto, global = true)]
86 pub color: ColorChoice,
87
88 #[arg(long, value_enum, global = true)]
91 pub log_level: Option<LogLevel>,
92
93 #[command(subcommand)]
94 pub command: Option<Command>,
95}
96
97fn parse_roles(value: &str) -> Result<acme_proxy_server::RoleSet, String> {
104 acme_proxy_server::RoleSet::parse(Some(value))
105}
106
107#[derive(Subcommand)]
108pub enum Command {
109 Serve {
111 #[arg(long, value_name = "ROLES", value_parser = parse_roles)]
120 role: Option<acme_proxy_server::RoleSet>,
121 },
122 Migrate,
127 Init,
135 Transfer {
144 #[arg(long = "to", value_name = "URL")]
146 to: String,
147 #[arg(long)]
149 json: bool,
150 },
151 Account {
153 #[command(subcommand)]
154 command: AccountCommand,
155 },
156 Order {
158 #[command(subcommand)]
159 command: OrderCommand,
160 },
161 Audit {
163 #[command(subcommand)]
164 command: AuditCommand,
165 },
166 Jobs {
168 #[command(subcommand)]
169 command: JobsCommand,
170 },
171 Nonce {
173 #[command(subcommand)]
174 command: NonceCommand,
175 },
176 Profile {
178 #[command(subcommand)]
179 command: ProfileCommand,
180 },
181 Eab {
183 #[command(subcommand)]
184 command: EabCommand,
185 },
186 Filter {
188 #[command(subcommand)]
189 command: FilterCommand,
190 },
191 Upstream {
194 #[command(subcommand)]
195 command: UpstreamCommand,
196 },
197 Admin {
200 #[command(subcommand)]
201 command: AdminCommand,
202 },
203 Completions {
205 #[arg(value_enum)]
207 shell: Shell,
208 },
209 Man,
211}
212
213pub(crate) fn offline_notifiers(
224 config: &Config,
225 database: Arc<Database>,
226) -> Result<acme_proxy_jobs::notify::DispatcherMap, CliError> {
227 let failed = |error: anyhow::Error| CliError::failed(format!("configuration error: {error}"));
228 let profiles = config
229 .resolve_profiles()
230 .map_err(|error| failed(anyhow::anyhow!(error)))?;
231 let egress = acme_proxy_net::egress::Egress::from_config(config).map_err(failed)?;
232 let jobs = acme_proxy_jobs::jobs::JobQueue::new(database, &config.jobs);
233 let mut dispatchers =
234 acme_proxy_jobs::notify::build_registry(&profiles, egress.outbound(), &jobs)
235 .map_err(failed)?;
236 if config.admin.enabled {
237 dispatchers.insert(
238 acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
239 acme_proxy_jobs::notify::from_config(
240 acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
241 &config.admin.notify,
242 egress.outbound(),
243 &jobs,
244 )
245 .map_err(failed)?,
246 );
247 }
248 Ok(dispatchers)
249}
250
251pub(crate) fn resolve_profile(
258 config: &Config,
259 wanted: Option<&str>,
260) -> Result<acme_proxy_core::config::ProfileConfig, CliError> {
261 let profiles = config
262 .resolve_profiles()
263 .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
264
265 match wanted {
266 Some(name) => profiles
267 .into_iter()
268 .find(|profile| profile.name == name)
269 .ok_or_else(|| {
270 CliError::bad_request(format!("no profile named `{name}` in this configuration"))
271 }),
272 None if profiles.len() == 1 => Ok(profiles.into_iter().next().expect("length checked")),
273 None => {
274 let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
275 Err(CliError::bad_request(format!(
276 "this configuration defines several profiles ({}); say which one with --profile",
277 names.join(", ")
278 )))
279 }
280 }
281}
282
283#[derive(Debug, PartialEq, Eq, thiserror::Error)]
291#[error("{message}")]
292pub struct CliError {
293 pub message: String,
295 pub kind: CliErrorKind,
297}
298
299#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
303pub enum CliErrorKind {
304 #[default]
308 Failed,
309 BadRequest,
315}
316
317pub(crate) fn parse_value<T>(flag: &str, value: &str) -> Result<T, CliError>
323where
324 T: std::str::FromStr,
325 T::Err: std::fmt::Display,
326{
327 value
328 .parse::<T>()
329 .map_err(|error| CliError::bad_request(format!("{flag}: {error}")))
330}
331
332pub(crate) fn parse_flag<T>(flag: &str, value: Option<String>) -> Result<Option<T>, CliError>
334where
335 T: std::str::FromStr,
336 T::Err: std::fmt::Display,
337{
338 value.map(|value| parse_value(flag, &value)).transpose()
339}
340
341impl CliError {
342 pub fn failed(message: impl Into<String>) -> Self {
344 Self {
345 message: message.into(),
346 kind: CliErrorKind::Failed,
347 }
348 }
349
350 pub fn bad_request(message: impl Into<String>) -> Self {
352 Self {
353 message: message.into(),
354 kind: CliErrorKind::BadRequest,
355 }
356 }
357
358 pub fn kind(&self) -> CliErrorKind {
360 self.kind
361 }
362
363 pub fn exit_code(&self) -> u8 {
366 match self.kind {
367 CliErrorKind::Failed => 1,
368 CliErrorKind::BadRequest => 3,
369 }
370 }
371}
372
373impl From<String> for CliError {
374 fn from(message: String) -> Self {
375 Self::failed(message)
376 }
377}
378
379impl From<&str> for CliError {
380 fn from(message: &str) -> Self {
381 Self::failed(message)
382 }
383}
384
385impl From<sqlx::Error> for CliError {
386 fn from(error: sqlx::Error) -> Self {
387 Self::failed(format!("database error: {error}"))
388 }
389}
390
391pub async fn dispatch(
405 command: Option<Command>,
406 yes: bool,
407 color: ColorChoice,
408 reader: &mut impl BufRead,
409 config: &Arc<Config>,
410 database: Arc<Database>,
411) -> Result<(), CliError> {
412 let palette = crate::cli::style::resolve(
413 color,
414 std::io::stdout().is_terminal(),
415 std::env::var("NO_COLOR").ok().as_deref(),
416 );
417 match command.unwrap_or(Command::Serve { role: None }) {
418 Command::Serve { role } => serve(role, config.clone(), database).await,
419 Command::Migrate => migrate(palette, database).await,
420 Command::Init => init(palette, config, database).await,
421 Command::Transfer { to, json } => {
422 transfer::run_transfer_command(&to, json, yes, reader, &config.database.url, database)
423 .await
424 }
425 Command::Account { command } => {
426 account::run_account_command(command, yes, palette, reader, config, database).await
427 }
428 Command::Order { command } => {
429 order::run_order_command(command, yes, palette, reader, config, database).await
430 }
431 Command::Audit { command } => {
432 audit::run_audit_command(command, yes, palette, reader, database).await
433 }
434 Command::Jobs { command } => {
435 jobs::run_jobs_command(command, yes, palette, reader, database).await
436 }
437 Command::Nonce { command } => {
438 nonce::run_nonce_command(command, yes, reader, config, database).await
439 }
440 Command::Profile { command } => {
441 profile::run_profile_command(command, palette, config).await
442 }
443 Command::Eab { command } => {
444 eab::run_eab_command(command, yes, palette, reader, config, database).await
445 }
446 Command::Filter { command } => filter::run_filter_command(command, palette, config).await,
447 Command::Upstream { command } => {
448 upstream::run_upstream_command(command, reader, palette, config, database).await
449 }
450 Command::Admin { command } => {
451 webadmin::run_admin_command(command, yes, palette, reader, config, database).await
452 }
453 command @ (Command::Completions { .. } | Command::Man) => {
457 generate::write(&command, &mut std::io::stdout().lock())
458 }
459 }
460}
461
462pub async fn serve(
467 roles: Option<acme_proxy_server::RoleSet>,
468 config: Arc<Config>,
469 database: Arc<Database>,
470) -> Result<(), CliError> {
471 acme_proxy_server::run(roles.unwrap_or_default(), config, database)
472 .await
473 .map_err(|error| CliError::failed(error.to_string()))
474}
475
476pub async fn migrate(palette: Palette, database: Arc<Database>) -> Result<(), CliError> {
482 let pending = database.pending_migrations().await?;
483 if pending.is_empty() {
484 println!("The schema is already up to date.");
485 return Ok(());
486 }
487
488 println!("Applying {} migration(s)…", pending.len());
489 database
490 .migrate()
491 .await
492 .map_err(|error| CliError::failed(format!("migration failed: {error}")))?;
493 println!("{}", palette.ok("The schema is up to date."));
494 Ok(())
495}
496
497pub async fn init(
510 palette: Palette,
511 config: &Arc<Config>,
512 database: Arc<Database>,
513) -> Result<(), CliError> {
514 migrate(palette, database.clone()).await?;
515
516 let queue = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
517 let profiles = acme_proxy_server::profile::build_all(config, database, &queue)
518 .map_err(|error| CliError::failed(error.to_string()))?;
519
520 for profile in &profiles {
521 println!("Profile `{}` is ready.", profile.name);
522 }
523 println!("{}", palette.ok("Initialisation complete."));
524 Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
536 fn version_flag_reports_the_crate_version() {
537 let Err(error) = Cli::try_parse_from(["acme-proxy", "--version"]) else {
538 panic!("--version parsed as a command rather than printing a version");
539 };
540 assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
541 assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
542 }
543
544 #[test]
547 fn eab_delete_refuses_both_account_modes_at_once() {
548 let Err(error) = Cli::try_parse_from([
549 "acme-proxy",
550 "eab",
551 "delete",
552 "kid",
553 "--deactivate-accounts",
554 "--delete-accounts",
555 ]) else {
556 panic!("both modes at once must be refused");
557 };
558 assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
559
560 for flag in ["--deactivate-accounts", "--delete-accounts"] {
561 Cli::try_parse_from(["acme-proxy", "eab", "delete", "kid", flag]).unwrap();
562 }
563 }
564
565 #[test]
569 fn log_level_is_a_global_flag_with_a_closed_set_of_values() {
570 let cli = Cli::try_parse_from(["acme-proxy", "account", "list"]).unwrap();
571 assert_eq!(
572 cli.log_level, None,
573 "absent by default: an admin command says nothing unless asked",
574 );
575
576 for argv in [
577 ["acme-proxy", "--log-level", "debug", "account", "list"],
578 ["acme-proxy", "account", "list", "--log-level", "debug"],
579 ] {
580 let cli = Cli::try_parse_from(argv).unwrap();
581 assert_eq!(cli.log_level, Some(LogLevel::Debug), "{argv:?}");
582 }
583
584 let cli = Cli::try_parse_from(["acme-proxy", "serve", "--log-level", "off"]).unwrap();
585 assert_eq!(cli.log_level, Some(LogLevel::Off));
586
587 let Err(error) =
590 Cli::try_parse_from(["acme-proxy", "account", "list", "--log-level", "loud"])
591 else {
592 panic!("`--log-level loud` must be refused");
593 };
594 assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue);
595 }
596
597 #[test]
598 fn parse_cli_subcommands() {
599 let cli = Cli::try_parse_from(["acme-proxy"]).unwrap();
600 assert!(cli.command.is_none());
601
602 let cli = Cli::try_parse_from(["acme-proxy", "serve"]).unwrap();
603 assert!(matches!(cli.command, Some(Command::Serve { role: None })));
604
605 let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--json"]).unwrap();
606 assert!(matches!(
607 cli.command,
608 Some(Command::Account {
609 command: AccountCommand::List {
610 json: true,
611 profile: None,
612 eab_kid: None,
613 limit: window::DEFAULT_LIMIT,
614 offset: 0
615 }
616 })
617 ));
618
619 let cli = Cli::try_parse_from(["acme-proxy", "account", "show", "acct-1"]).unwrap();
620 assert!(matches!(
621 cli.command,
622 Some(Command::Account {
623 command: AccountCommand::Show { id, json: false }
624 }) if id == "acct-1"
625 ));
626
627 let cli = Cli::try_parse_from([
628 "acme-proxy",
629 "account",
630 "update-contact",
631 "acct-1",
632 "--contact",
633 "mailto:test@example.com",
634 ])
635 .unwrap();
636 assert!(matches!(
637 cli.command,
638 Some(Command::Account {
639 command: AccountCommand::UpdateContact { id, contact }
640 }) if id == "acct-1" && contact == vec!["mailto:test@example.com"]
641 ));
642
643 let cli = Cli::try_parse_from(["acme-proxy", "account", "deactivate", "acct-1"]).unwrap();
644 assert!(matches!(
645 cli.command,
646 Some(Command::Account {
647 command: AccountCommand::Deactivate { id }
648 }) if id == "acct-1"
649 ));
650
651 let cli = Cli::try_parse_from(["acme-proxy", "-y", "account", "delete", "acct-1"]).unwrap();
652 assert!(cli.yes);
653 assert!(matches!(
654 cli.command,
655 Some(Command::Account {
656 command: AccountCommand::Delete { id }
657 }) if id == "acct-1"
658 ));
659
660 let cli = Cli::try_parse_from([
661 "acme-proxy",
662 "order",
663 "list",
664 "--account-id",
665 "acct-1",
666 "--status",
667 "pending",
668 "--json",
669 ])
670 .unwrap();
671 assert!(matches!(
672 cli.command,
673 Some(Command::Order {
674 command: OrderCommand::List(crate::cli::order::OrderListArgs {
675 profile: None,
676 account_id: Some(a),
677 status: Some(s),
678 identifier: None,
679 identifier_contains: None,
680 cert_serial: None,
681 expiring_in: None,
682 hide_superseded: false,
683 limit: window::DEFAULT_LIMIT,
684 offset: 0,
685 json: true
686 })
687 }) if a == "acct-1" && s == "pending"
688 ));
689
690 let cli = Cli::try_parse_from([
691 "acme-proxy",
692 "order",
693 "list",
694 "--expiring-in",
695 "30",
696 "--hide-superseded",
697 ])
698 .unwrap();
699 assert!(matches!(
700 cli.command,
701 Some(Command::Order {
702 command: OrderCommand::List(crate::cli::order::OrderListArgs {
703 expiring_in: Some(30),
704 hide_superseded: true,
705 status: None,
706 account_id: None,
707 profile: None,
708 identifier: None,
709 identifier_contains: None,
710 cert_serial: None,
711 limit: window::DEFAULT_LIMIT,
712 offset: 0,
713 json: false
714 })
715 })
716 ));
717
718 let cli = Cli::try_parse_from(["acme-proxy", "order", "show", "ord-1"]).unwrap();
719 assert!(matches!(
720 cli.command,
721 Some(Command::Order {
722 command: OrderCommand::Show { id, json: false }
723 }) if id == "ord-1"
724 ));
725
726 let cli = Cli::try_parse_from(["acme-proxy", "order", "delete", "ord-1"]).unwrap();
727 assert!(matches!(
728 cli.command,
729 Some(Command::Order {
730 command: OrderCommand::Delete { id }
731 }) if id == "ord-1"
732 ));
733
734 let cli = Cli::try_parse_from(["acme-proxy", "order", "revoke", "ord-1", "--reason", "1"])
735 .unwrap();
736 assert!(matches!(
737 cli.command,
738 Some(Command::Order {
739 command: OrderCommand::Revoke { id, reason: Some(1), wait: 30 }
740 }) if id == "ord-1"
741 ));
742
743 let cli =
744 Cli::try_parse_from(["acme-proxy", "nonce", "cleanup", "--ttl-seconds", "60"]).unwrap();
745 assert!(matches!(
746 cli.command,
747 Some(Command::Nonce {
748 command: NonceCommand::Cleanup {
749 ttl_seconds: Some(60)
750 }
751 })
752 ));
753
754 let cli = Cli::try_parse_from([
755 "acme-proxy",
756 "eab",
757 "create",
758 "--label",
759 "test-key",
760 "--json",
761 ])
762 .unwrap();
763 assert!(matches!(
764 cli.command,
765 Some(Command::Eab {
766 command: EabCommand::Create { label: Some(l), profile: None, json: true }
767 }) if l == "test-key"
768 ));
769
770 let cli = Cli::try_parse_from(["acme-proxy", "eab", "list"]).unwrap();
771 assert!(matches!(
772 cli.command,
773 Some(Command::Eab {
774 command: EabCommand::List {
775 limit: 50,
776 offset: 0,
777 json: false
778 }
779 })
780 ));
781
782 let cli = Cli::try_parse_from(["acme-proxy", "order", "chain", "ord-1"]).unwrap();
786 assert!(matches!(
787 cli.command,
788 Some(Command::Order {
789 command: OrderCommand::Chain { id }
790 }) if id == "ord-1"
791 ));
792
793 let cli = Cli::try_parse_from(["acme-proxy", "nonce", "count", "--json"]).unwrap();
794 assert!(matches!(
795 cli.command,
796 Some(Command::Nonce {
797 command: NonceCommand::Count { json: true }
798 })
799 ));
800
801 let cli = Cli::try_parse_from(["acme-proxy", "profile", "list"]).unwrap();
802 assert!(matches!(
803 cli.command,
804 Some(Command::Profile {
805 command: ProfileCommand::List { json: false }
806 })
807 ));
808
809 let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "show", "alice"]).unwrap();
810 assert!(matches!(
811 cli.command,
812 Some(Command::Admin {
813 command: AdminCommand::User {
814 command: crate::cli::webadmin::AdminUserCommand::Show { username, json: false }
815 }
816 }) if username == "alice"
817 ));
818
819 let cli =
822 Cli::try_parse_from(["acme-proxy", "admin", "user", "list", "--limit", "2"]).unwrap();
823 assert!(matches!(
824 cli.command,
825 Some(Command::Admin {
826 command: AdminCommand::User {
827 command: crate::cli::webadmin::AdminUserCommand::List {
828 limit: 2,
829 offset: 0,
830 json: false
831 }
832 }
833 })
834 ));
835
836 let cli =
837 Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--offset=5"]).unwrap();
838 assert!(matches!(
839 cli.command,
840 Some(Command::Admin {
841 command: AdminCommand::Session {
842 command: crate::cli::webadmin::AdminSessionCommand::List {
843 user: None,
844 limit: window::DEFAULT_LIMIT,
845 offset: 5,
846 json: false
847 }
848 }
849 })
850 ));
851
852 let cli = Cli::try_parse_from(["acme-proxy", "eab", "show", "kid-1", "--json"]).unwrap();
853 assert!(matches!(
854 cli.command,
855 Some(Command::Eab {
856 command: EabCommand::Show { kid, json: true }
857 }) if kid == "kid-1"
858 ));
859
860 let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-kid", "kid-1"])
861 .unwrap();
862 assert!(matches!(
863 cli.command,
864 Some(Command::Upstream {
865 command: UpstreamCommand::Register { eab_kid: Some(kid), eab_hmac_key_file: None, profile: None }
866 }) if kid == "kid-1"
867 ));
868
869 let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register"]).unwrap();
871 assert!(matches!(
872 cli.command,
873 Some(Command::Upstream {
874 command: UpstreamCommand::Register {
875 eab_kid: None,
876 eab_hmac_key_file: None,
877 profile: None,
878 }
879 })
880 ));
881
882 assert!(
884 Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-hmac-key", "s"])
885 .is_err(),
886 "an EAB secret must not be accepted on the command line"
887 );
888
889 let cli = Cli::try_parse_from(["acme-proxy", "upstream", "show", "--json"]).unwrap();
890 assert!(matches!(
891 cli.command,
892 Some(Command::Upstream {
893 command: UpstreamCommand::Show {
894 json: true,
895 profile: None
896 }
897 })
898 ));
899
900 let cli = Cli::try_parse_from(["acme-proxy", "eab", "revoke", "kid-1"]).unwrap();
901 assert!(matches!(
902 cli.command,
903 Some(Command::Eab {
904 command: EabCommand::Revoke { kid }
905 }) if kid == "kid-1"
906 ));
907
908 let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "create", "alice"]).unwrap();
909 assert!(matches!(
910 cli.command,
911 Some(Command::Admin {
912 command: AdminCommand::User {
913 command: crate::cli::webadmin::AdminUserCommand::Create {
914 username,
915 password_file: None,
916 role: _,
917 contact: None,
918 }
919 }
920 }) if username == "alice"
921 ));
922
923 let cli = Cli::try_parse_from([
924 "acme-proxy",
925 "admin",
926 "user",
927 "passwd",
928 "alice",
929 "--password-file",
930 "/run/secrets/pw",
931 ])
932 .unwrap();
933 assert!(matches!(
934 cli.command,
935 Some(Command::Admin {
936 command: AdminCommand::User {
937 command: crate::cli::webadmin::AdminUserCommand::Passwd {
938 username,
939 password_file: Some(path)
940 }
941 }
942 }) if username == "alice" && path == std::path::Path::new("/run/secrets/pw")
943 ));
944
945 for command in ["create", "passwd"] {
948 assert!(
949 Cli::try_parse_from([
950 "acme-proxy",
951 "admin",
952 "user",
953 command,
954 "alice",
955 "--password",
956 "hunter2",
957 ])
958 .is_err(),
959 "`admin user {command}` must not accept a password on the command line"
960 );
961 }
962
963 let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "never"])
968 .expect("--color is global and accepts `never`");
969 assert_eq!(cli.color, ColorChoice::Never);
970
971 let cli = Cli::try_parse_from(["acme-proxy", "--color", "always", "account", "list"])
972 .expect("--color is global, so it may precede the subcommand");
973 assert_eq!(cli.color, ColorChoice::Always);
974
975 assert_eq!(
976 Cli::try_parse_from(["acme-proxy", "account", "list"])
977 .unwrap()
978 .color,
979 ColorChoice::Auto,
980 "unset means auto"
981 );
982
983 assert!(
984 Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "sometimes"]).is_err(),
985 "an unknown --color value must be refused, not ignored"
986 );
987
988 let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "status", "alice"])
989 .unwrap();
990 assert!(matches!(
991 cli.command,
992 Some(Command::Admin {
993 command: AdminCommand::User {
994 command: crate::cli::webadmin::AdminUserCommand::Totp {
995 command: crate::cli::webadmin::AdminUserTotpCommand::Status {
996 username,
997 json: false
998 }
999 }
1000 }
1001 }) if username == "alice"
1002 ));
1003
1004 let cli = Cli::try_parse_from([
1005 "acme-proxy",
1006 "admin",
1007 "user",
1008 "totp",
1009 "recovery-codes",
1010 "alice",
1011 ])
1012 .unwrap();
1013 assert!(matches!(
1014 cli.command,
1015 Some(Command::Admin {
1016 command: AdminCommand::User {
1017 command: crate::cli::webadmin::AdminUserCommand::Totp {
1018 command: crate::cli::webadmin::AdminUserTotpCommand::RecoveryCodes {
1019 username
1020 }
1021 }
1022 }
1023 }) if username == "alice"
1024 ));
1025
1026 assert!(
1030 Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "enrol", "alice"]).is_err()
1031 );
1032
1033 let cli =
1034 Cli::try_parse_from(["acme-proxy", "-y", "admin", "user", "delete", "alice"]).unwrap();
1035 assert!(cli.yes);
1036 assert!(matches!(
1037 cli.command,
1038 Some(Command::Admin {
1039 command: AdminCommand::User {
1040 command: crate::cli::webadmin::AdminUserCommand::Delete { username }
1041 }
1042 }) if username == "alice"
1043 ));
1044
1045 let cli =
1046 Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--json"]).unwrap();
1047 assert!(matches!(
1048 cli.command,
1049 Some(Command::Admin {
1050 command: AdminCommand::Session {
1051 command: crate::cli::webadmin::AdminSessionCommand::List {
1052 user: None,
1053 limit: 50,
1054 offset: 0,
1055 json: true
1056 }
1057 }
1058 })
1059 ));
1060
1061 assert!(
1064 Cli::try_parse_from([
1065 "acme-proxy",
1066 "admin",
1067 "session",
1068 "revoke",
1069 "--user",
1070 "alice",
1071 "--all",
1072 ])
1073 .is_err(),
1074 "--user and --all are mutually exclusive"
1075 );
1076
1077 assert!(
1080 Cli::try_parse_from([
1081 "acme-proxy",
1082 "admin",
1083 "session",
1084 "revoke",
1085 "--session",
1086 "abc"
1087 ])
1088 .is_err(),
1089 "--session requires --user"
1090 );
1091 assert!(
1092 Cli::try_parse_from([
1093 "acme-proxy",
1094 "admin",
1095 "session",
1096 "revoke",
1097 "--all",
1098 "--session",
1099 "abc",
1100 ])
1101 .is_err(),
1102 "--session and --all are mutually exclusive"
1103 );
1104 assert!(matches!(
1105 Cli::try_parse_from([
1106 "acme-proxy",
1107 "admin",
1108 "session",
1109 "revoke",
1110 "--user",
1111 "alice",
1112 "--session",
1113 "abc",
1114 ])
1115 .unwrap()
1116 .command,
1117 Some(Command::Admin {
1118 command: AdminCommand::Session {
1119 command: crate::cli::webadmin::AdminSessionCommand::Revoke {
1120 user: Some(user),
1121 all: false,
1122 session: Some(session),
1123 }
1124 }
1125 }) if user == "alice" && session == "abc"
1126 ));
1127 }
1128
1129 #[test]
1130 fn a_database_error_renders_as_a_cli_error() {
1131 let error = CliError::from(sqlx::Error::PoolClosed);
1132 assert!(error.to_string().starts_with("database error: "), "{error}");
1133 assert_eq!(error.kind(), CliErrorKind::Failed);
1135 assert_eq!(error.exit_code(), 1);
1136 }
1137
1138 #[test]
1142 fn the_kind_decides_the_exit_code() {
1143 assert_eq!(CliError::failed("x").kind(), CliErrorKind::Failed);
1144 assert_eq!(CliError::bad_request("x").kind(), CliErrorKind::BadRequest);
1145 assert_eq!(CliError::failed("x").exit_code(), 1);
1146 assert_eq!(CliError::bad_request("x").exit_code(), 3);
1147 assert_eq!(CliError::from("x").kind(), CliErrorKind::Failed);
1150 assert_eq!(CliError::from("x".to_string()).kind(), CliErrorKind::Failed);
1151 }
1152
1153 #[test]
1159 fn resolve_profile_reports_a_missing_profile_set_as_failed() {
1160 let config = Config::default();
1161 assert_eq!(
1162 resolve_profile(&config, None).unwrap_err().kind(),
1163 CliErrorKind::Failed
1164 );
1165 }
1166
1167 #[tokio::test]
1170 async fn dispatch_routes_each_command() {
1171 let database = Arc::new(Database::connect_in_memory().await.unwrap());
1172 let config = Arc::new(Config::default());
1173 let mut reader: &[u8] = &[];
1174
1175 let commands = vec![
1176 Command::Account {
1177 command: AccountCommand::List {
1178 profile: None,
1179 eab_kid: None,
1180 limit: window::DEFAULT_LIMIT,
1181 offset: 0,
1182 json: false,
1183 },
1184 },
1185 Command::Order {
1186 command: OrderCommand::List(crate::cli::order::OrderListArgs {
1187 profile: None,
1188 account_id: None,
1189 status: None,
1190 identifier: None,
1191 identifier_contains: None,
1192 cert_serial: None,
1193 expiring_in: None,
1194 hide_superseded: false,
1195 limit: window::DEFAULT_LIMIT,
1196 offset: 0,
1197 json: false,
1198 }),
1199 },
1200 Command::Nonce {
1201 command: NonceCommand::Cleanup {
1202 ttl_seconds: Some(1),
1203 },
1204 },
1205 Command::Nonce {
1206 command: NonceCommand::Count { json: false },
1207 },
1208 Command::Eab {
1209 command: EabCommand::List {
1210 limit: 50,
1211 offset: 0,
1212 json: false,
1213 },
1214 },
1215 Command::Jobs {
1216 command: JobsCommand::List {
1217 kind: None,
1218 status: None,
1219 limit: window::DEFAULT_LIMIT,
1220 offset: 0,
1221 json: false,
1222 },
1223 },
1224 Command::Man,
1225 Command::Completions {
1226 shell: clap_complete::aot::Shell::Bash,
1227 },
1228 ];
1239 for command in commands {
1240 dispatch(
1241 Some(command),
1242 true,
1243 ColorChoice::Never,
1244 &mut reader,
1245 &config,
1246 database.clone(),
1247 )
1248 .await
1249 .expect("every command must succeed against an empty database");
1250 }
1251 }
1252
1253 #[tokio::test]
1261 async fn dispatch_routes_transfer() {
1262 let database = Arc::new(Database::connect_in_memory().await.unwrap());
1263 let config = Arc::new(Config::default());
1264 let mut reader: &[u8] = &[];
1265
1266 let error = dispatch(
1267 Some(Command::Transfer {
1268 to: config.database.url.clone(),
1269 json: false,
1270 }),
1271 true,
1272 ColorChoice::Never,
1273 &mut reader,
1274 &config,
1275 database,
1276 )
1277 .await
1278 .expect_err("the source and the target are one database");
1279
1280 assert_eq!(error.kind(), CliErrorKind::BadRequest);
1281 }
1282
1283 #[tokio::test]
1286 async fn dispatch_propagates_a_command_failure() {
1287 let database = Arc::new(Database::connect_in_memory().await.unwrap());
1288 let config = Arc::new(Config::default());
1289 let mut reader: &[u8] = &[];
1290
1291 let error = dispatch(
1292 Some(Command::Account {
1293 command: AccountCommand::Show {
1294 id: "acct-nope".to_string(),
1295 json: false,
1296 },
1297 }),
1298 true,
1299 ColorChoice::Never,
1300 &mut reader,
1301 &config,
1302 database,
1303 )
1304 .await
1305 .expect_err("an unknown account must fail");
1306 assert_eq!(
1307 error,
1308 CliError::bad_request("no such account: acct-nope".to_string())
1309 );
1310 assert_eq!(error.exit_code(), 3);
1311 }
1312
1313 #[tokio::test]
1316 async fn dispatch_serve_reports_a_startup_failure() {
1317 let database = Arc::new(Database::connect_in_memory().await.unwrap());
1318 let mut config = Config::default();
1321 config.server.bind_address = "127.0.0.1:0".to_string();
1322 let mut reader: &[u8] = &[];
1323
1324 let error = dispatch(
1325 Some(Command::Serve { role: None }),
1326 true,
1327 ColorChoice::Never,
1328 &mut reader,
1329 &Arc::new(config),
1330 database,
1331 )
1332 .await
1333 .expect_err("a server with no endpoint must not start");
1334 assert!(error.to_string().contains("profile"), "{error}");
1335 }
1336}