use std::io::{BufRead, IsTerminal};
use std::sync::Arc;
use clap::{Parser, Subcommand};
use clap_complete::aot::Shell;
pub mod account;
pub mod audit;
pub mod eab;
pub mod filter;
pub mod generate;
pub mod jobs;
pub(crate) mod logging;
pub use logging::{LogLevel, LoggingPlan, plan_logging};
pub mod nonce;
pub mod order;
pub mod profile;
pub mod render;
pub mod schema;
pub mod style;
pub mod transfer;
pub mod upstream;
pub mod webadmin;
pub mod window;
pub use account::AccountCommand;
pub use audit::AuditCommand;
pub use eab::EabCommand;
pub use jobs::JobsCommand;
pub use nonce::NonceCommand;
pub use order::OrderCommand;
pub use profile::ProfileCommand;
pub use upstream::UpstreamCommand;
pub use webadmin::AdminCommand;
use crate::cli::filter::FilterCommand;
pub use crate::cli::style::ColorChoice;
use acme_proxy_core::config::Config;
use acme_proxy_core::palette::Palette;
use acme_proxy_store::db::Database;
#[derive(Parser)]
#[command(
name = "acme-proxy",
version = env!("CARGO_PKG_VERSION"),
about = "ACME server, plus admin commands for its database"
)]
pub struct Cli {
#[arg(short = 'y', long, global = true)]
pub yes: bool,
#[arg(long, value_enum, default_value_t = ColorChoice::Auto, global = true)]
pub color: ColorChoice,
#[arg(long, value_enum, global = true)]
pub log_level: Option<LogLevel>,
#[command(subcommand)]
pub command: Option<Command>,
}
fn parse_roles(value: &str) -> Result<acme_proxy_server::RoleSet, String> {
acme_proxy_server::RoleSet::parse(Some(value))
}
#[derive(Subcommand)]
pub enum Command {
Serve {
#[arg(long, value_name = "ROLES", value_parser = parse_roles)]
role: Option<acme_proxy_server::RoleSet>,
},
Migrate,
Init,
Transfer {
#[arg(long = "to", value_name = "URL")]
to: String,
#[arg(long)]
json: bool,
},
Account {
#[command(subcommand)]
command: AccountCommand,
},
Order {
#[command(subcommand)]
command: OrderCommand,
},
Audit {
#[command(subcommand)]
command: AuditCommand,
},
Jobs {
#[command(subcommand)]
command: JobsCommand,
},
Nonce {
#[command(subcommand)]
command: NonceCommand,
},
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
Eab {
#[command(subcommand)]
command: EabCommand,
},
Filter {
#[command(subcommand)]
command: FilterCommand,
},
Upstream {
#[command(subcommand)]
command: UpstreamCommand,
},
Admin {
#[command(subcommand)]
command: AdminCommand,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
Man,
}
pub(crate) fn offline_notifiers(
config: &Config,
database: Arc<Database>,
) -> Result<acme_proxy_jobs::notify::DispatcherMap, CliError> {
let failed = |error: anyhow::Error| CliError::failed(format!("configuration error: {error}"));
let profiles = config
.resolve_profiles()
.map_err(|error| failed(anyhow::anyhow!(error)))?;
let egress = acme_proxy_net::egress::Egress::from_config(config).map_err(failed)?;
let jobs = acme_proxy_jobs::jobs::JobQueue::new(database, &config.jobs);
let mut dispatchers =
acme_proxy_jobs::notify::build_registry(&profiles, egress.outbound(), &jobs)
.map_err(failed)?;
if config.admin.enabled {
dispatchers.insert(
acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
acme_proxy_jobs::notify::from_config(
acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
&config.admin.notify,
egress.outbound(),
&jobs,
)
.map_err(failed)?,
);
}
Ok(dispatchers)
}
pub(crate) fn resolve_profile(
config: &Config,
wanted: Option<&str>,
) -> Result<acme_proxy_core::config::ProfileConfig, CliError> {
let profiles = config
.resolve_profiles()
.map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
match wanted {
Some(name) => profiles
.into_iter()
.find(|profile| profile.name == name)
.ok_or_else(|| {
CliError::bad_request(format!("no profile named `{name}` in this configuration"))
}),
None if profiles.len() == 1 => Ok(profiles.into_iter().next().expect("length checked")),
None => {
let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
Err(CliError::bad_request(format!(
"this configuration defines several profiles ({}); say which one with --profile",
names.join(", ")
)))
}
}
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct CliError {
pub message: String,
pub kind: CliErrorKind,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CliErrorKind {
#[default]
Failed,
BadRequest,
}
pub(crate) fn parse_value<T>(flag: &str, value: &str) -> Result<T, CliError>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value
.parse::<T>()
.map_err(|error| CliError::bad_request(format!("{flag}: {error}")))
}
pub(crate) fn parse_flag<T>(flag: &str, value: Option<String>) -> Result<Option<T>, CliError>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value.map(|value| parse_value(flag, &value)).transpose()
}
impl CliError {
pub fn failed(message: impl Into<String>) -> Self {
Self {
message: message.into(),
kind: CliErrorKind::Failed,
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self {
message: message.into(),
kind: CliErrorKind::BadRequest,
}
}
pub fn kind(&self) -> CliErrorKind {
self.kind
}
pub fn exit_code(&self) -> u8 {
match self.kind {
CliErrorKind::Failed => 1,
CliErrorKind::BadRequest => 3,
}
}
}
impl From<String> for CliError {
fn from(message: String) -> Self {
Self::failed(message)
}
}
impl From<&str> for CliError {
fn from(message: &str) -> Self {
Self::failed(message)
}
}
impl From<sqlx::Error> for CliError {
fn from(error: sqlx::Error) -> Self {
Self::failed(format!("database error: {error}"))
}
}
pub async fn dispatch(
command: Option<Command>,
yes: bool,
color: ColorChoice,
reader: &mut impl BufRead,
config: &Arc<Config>,
database: Arc<Database>,
) -> Result<(), CliError> {
let palette = crate::cli::style::resolve(
color,
std::io::stdout().is_terminal(),
std::env::var("NO_COLOR").ok().as_deref(),
);
match command.unwrap_or(Command::Serve { role: None }) {
Command::Serve { role } => serve(role, config.clone(), database).await,
Command::Migrate => migrate(palette, database).await,
Command::Init => init(palette, config, database).await,
Command::Transfer { to, json } => {
transfer::run_transfer_command(&to, json, yes, reader, &config.database.url, database)
.await
}
Command::Account { command } => {
account::run_account_command(command, yes, palette, reader, config, database).await
}
Command::Order { command } => {
order::run_order_command(command, yes, palette, reader, config, database).await
}
Command::Audit { command } => {
audit::run_audit_command(command, yes, palette, reader, database).await
}
Command::Jobs { command } => {
jobs::run_jobs_command(command, yes, palette, reader, database).await
}
Command::Nonce { command } => {
nonce::run_nonce_command(command, yes, reader, config, database).await
}
Command::Profile { command } => {
profile::run_profile_command(command, palette, config).await
}
Command::Eab { command } => {
eab::run_eab_command(command, yes, palette, reader, config, database).await
}
Command::Filter { command } => filter::run_filter_command(command, palette, config).await,
Command::Upstream { command } => {
upstream::run_upstream_command(command, reader, palette, config, database).await
}
Command::Admin { command } => {
webadmin::run_admin_command(command, yes, palette, reader, config, database).await
}
command @ (Command::Completions { .. } | Command::Man) => {
generate::write(&command, &mut std::io::stdout().lock())
}
}
}
pub async fn serve(
roles: Option<acme_proxy_server::RoleSet>,
config: Arc<Config>,
database: Arc<Database>,
) -> Result<(), CliError> {
acme_proxy_server::run(roles.unwrap_or_default(), config, database)
.await
.map_err(|error| CliError::failed(error.to_string()))
}
pub async fn migrate(palette: Palette, database: Arc<Database>) -> Result<(), CliError> {
let pending = database.pending_migrations().await?;
if pending.is_empty() {
println!("The schema is already up to date.");
return Ok(());
}
println!("Applying {} migration(s)…", pending.len());
database
.migrate()
.await
.map_err(|error| CliError::failed(format!("migration failed: {error}")))?;
println!("{}", palette.ok("The schema is up to date."));
Ok(())
}
pub async fn init(
palette: Palette,
config: &Arc<Config>,
database: Arc<Database>,
) -> Result<(), CliError> {
migrate(palette, database.clone()).await?;
let queue = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
let profiles = acme_proxy_server::profile::build_all(config, database, &queue)
.map_err(|error| CliError::failed(error.to_string()))?;
for profile in &profiles {
println!("Profile `{}` is ready.", profile.name);
}
println!("{}", palette.ok("Initialisation complete."));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_flag_reports_the_crate_version() {
let Err(error) = Cli::try_parse_from(["acme-proxy", "--version"]) else {
panic!("--version parsed as a command rather than printing a version");
};
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn eab_delete_refuses_both_account_modes_at_once() {
let Err(error) = Cli::try_parse_from([
"acme-proxy",
"eab",
"delete",
"kid",
"--deactivate-accounts",
"--delete-accounts",
]) else {
panic!("both modes at once must be refused");
};
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
for flag in ["--deactivate-accounts", "--delete-accounts"] {
Cli::try_parse_from(["acme-proxy", "eab", "delete", "kid", flag]).unwrap();
}
}
#[test]
fn log_level_is_a_global_flag_with_a_closed_set_of_values() {
let cli = Cli::try_parse_from(["acme-proxy", "account", "list"]).unwrap();
assert_eq!(
cli.log_level, None,
"absent by default: an admin command says nothing unless asked",
);
for argv in [
["acme-proxy", "--log-level", "debug", "account", "list"],
["acme-proxy", "account", "list", "--log-level", "debug"],
] {
let cli = Cli::try_parse_from(argv).unwrap();
assert_eq!(cli.log_level, Some(LogLevel::Debug), "{argv:?}");
}
let cli = Cli::try_parse_from(["acme-proxy", "serve", "--log-level", "off"]).unwrap();
assert_eq!(cli.log_level, Some(LogLevel::Off));
let Err(error) =
Cli::try_parse_from(["acme-proxy", "account", "list", "--log-level", "loud"])
else {
panic!("`--log-level loud` must be refused");
};
assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue);
}
#[test]
fn parse_cli_subcommands() {
let cli = Cli::try_parse_from(["acme-proxy"]).unwrap();
assert!(cli.command.is_none());
let cli = Cli::try_parse_from(["acme-proxy", "serve"]).unwrap();
assert!(matches!(cli.command, Some(Command::Serve { role: None })));
let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::List {
json: true,
profile: None,
eab_kid: None,
limit: window::DEFAULT_LIMIT,
offset: 0
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "account", "show", "acct-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::Show { id, json: false }
}) if id == "acct-1"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"account",
"update-contact",
"acct-1",
"--contact",
"mailto:test@example.com",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::UpdateContact { id, contact }
}) if id == "acct-1" && contact == vec!["mailto:test@example.com"]
));
let cli = Cli::try_parse_from(["acme-proxy", "account", "deactivate", "acct-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::Deactivate { id }
}) if id == "acct-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "-y", "account", "delete", "acct-1"]).unwrap();
assert!(cli.yes);
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::Delete { id }
}) if id == "acct-1"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"order",
"list",
"--account-id",
"acct-1",
"--status",
"pending",
"--json",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::List(crate::cli::order::OrderListArgs {
profile: None,
account_id: Some(a),
status: Some(s),
identifier: None,
identifier_contains: None,
cert_serial: None,
expiring_in: None,
hide_superseded: false,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: true
})
}) if a == "acct-1" && s == "pending"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"order",
"list",
"--expiring-in",
"30",
"--hide-superseded",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::List(crate::cli::order::OrderListArgs {
expiring_in: Some(30),
hide_superseded: true,
status: None,
account_id: None,
profile: None,
identifier: None,
identifier_contains: None,
cert_serial: None,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false
})
})
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "show", "ord-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Show { id, json: false }
}) if id == "ord-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "delete", "ord-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Delete { id }
}) if id == "ord-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "revoke", "ord-1", "--reason", "1"])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Revoke { id, reason: Some(1), wait: 30 }
}) if id == "ord-1"
));
let cli =
Cli::try_parse_from(["acme-proxy", "nonce", "cleanup", "--ttl-seconds", "60"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Nonce {
command: NonceCommand::Cleanup {
ttl_seconds: Some(60)
}
})
));
let cli = Cli::try_parse_from([
"acme-proxy",
"eab",
"create",
"--label",
"test-key",
"--json",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::Create { label: Some(l), profile: None, json: true }
}) if l == "test-key"
));
let cli = Cli::try_parse_from(["acme-proxy", "eab", "list"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::List {
limit: 50,
offset: 0,
json: false
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "chain", "ord-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Chain { id }
}) if id == "ord-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "nonce", "count", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Nonce {
command: NonceCommand::Count { json: true }
})
));
let cli = Cli::try_parse_from(["acme-proxy", "profile", "list"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Profile {
command: ProfileCommand::List { json: false }
})
));
let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "show", "alice"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Show { username, json: false }
}
}) if username == "alice"
));
let cli =
Cli::try_parse_from(["acme-proxy", "admin", "user", "list", "--limit", "2"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::List {
limit: 2,
offset: 0,
json: false
}
}
})
));
let cli =
Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--offset=5"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::Session {
command: crate::cli::webadmin::AdminSessionCommand::List {
user: None,
limit: window::DEFAULT_LIMIT,
offset: 5,
json: false
}
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "eab", "show", "kid-1", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::Show { kid, json: true }
}) if kid == "kid-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-kid", "kid-1"])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Upstream {
command: UpstreamCommand::Register { eab_kid: Some(kid), eab_hmac_key_file: None, profile: None }
}) if kid == "kid-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Upstream {
command: UpstreamCommand::Register {
eab_kid: None,
eab_hmac_key_file: None,
profile: None,
}
})
));
assert!(
Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-hmac-key", "s"])
.is_err(),
"an EAB secret must not be accepted on the command line"
);
let cli = Cli::try_parse_from(["acme-proxy", "upstream", "show", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Upstream {
command: UpstreamCommand::Show {
json: true,
profile: None
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "eab", "revoke", "kid-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::Revoke { kid }
}) if kid == "kid-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "create", "alice"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Create {
username,
password_file: None,
role: _,
contact: None,
}
}
}) if username == "alice"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"admin",
"user",
"passwd",
"alice",
"--password-file",
"/run/secrets/pw",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Passwd {
username,
password_file: Some(path)
}
}
}) if username == "alice" && path == std::path::Path::new("/run/secrets/pw")
));
for command in ["create", "passwd"] {
assert!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"user",
command,
"alice",
"--password",
"hunter2",
])
.is_err(),
"`admin user {command}` must not accept a password on the command line"
);
}
let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "never"])
.expect("--color is global and accepts `never`");
assert_eq!(cli.color, ColorChoice::Never);
let cli = Cli::try_parse_from(["acme-proxy", "--color", "always", "account", "list"])
.expect("--color is global, so it may precede the subcommand");
assert_eq!(cli.color, ColorChoice::Always);
assert_eq!(
Cli::try_parse_from(["acme-proxy", "account", "list"])
.unwrap()
.color,
ColorChoice::Auto,
"unset means auto"
);
assert!(
Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "sometimes"]).is_err(),
"an unknown --color value must be refused, not ignored"
);
let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "status", "alice"])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Totp {
command: crate::cli::webadmin::AdminUserTotpCommand::Status {
username,
json: false
}
}
}
}) if username == "alice"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"admin",
"user",
"totp",
"recovery-codes",
"alice",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Totp {
command: crate::cli::webadmin::AdminUserTotpCommand::RecoveryCodes {
username
}
}
}
}) if username == "alice"
));
assert!(
Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "enrol", "alice"]).is_err()
);
let cli =
Cli::try_parse_from(["acme-proxy", "-y", "admin", "user", "delete", "alice"]).unwrap();
assert!(cli.yes);
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Delete { username }
}
}) if username == "alice"
));
let cli =
Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::Session {
command: crate::cli::webadmin::AdminSessionCommand::List {
user: None,
limit: 50,
offset: 0,
json: true
}
}
})
));
assert!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"session",
"revoke",
"--user",
"alice",
"--all",
])
.is_err(),
"--user and --all are mutually exclusive"
);
assert!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"session",
"revoke",
"--session",
"abc"
])
.is_err(),
"--session requires --user"
);
assert!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"session",
"revoke",
"--all",
"--session",
"abc",
])
.is_err(),
"--session and --all are mutually exclusive"
);
assert!(matches!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"session",
"revoke",
"--user",
"alice",
"--session",
"abc",
])
.unwrap()
.command,
Some(Command::Admin {
command: AdminCommand::Session {
command: crate::cli::webadmin::AdminSessionCommand::Revoke {
user: Some(user),
all: false,
session: Some(session),
}
}
}) if user == "alice" && session == "abc"
));
}
#[test]
fn a_database_error_renders_as_a_cli_error() {
let error = CliError::from(sqlx::Error::PoolClosed);
assert!(error.to_string().starts_with("database error: "), "{error}");
assert_eq!(error.kind(), CliErrorKind::Failed);
assert_eq!(error.exit_code(), 1);
}
#[test]
fn the_kind_decides_the_exit_code() {
assert_eq!(CliError::failed("x").kind(), CliErrorKind::Failed);
assert_eq!(CliError::bad_request("x").kind(), CliErrorKind::BadRequest);
assert_eq!(CliError::failed("x").exit_code(), 1);
assert_eq!(CliError::bad_request("x").exit_code(), 3);
assert_eq!(CliError::from("x").kind(), CliErrorKind::Failed);
assert_eq!(CliError::from("x".to_string()).kind(), CliErrorKind::Failed);
}
#[test]
fn resolve_profile_reports_a_missing_profile_set_as_failed() {
let config = Config::default();
assert_eq!(
resolve_profile(&config, None).unwrap_err().kind(),
CliErrorKind::Failed
);
}
#[tokio::test]
async fn dispatch_routes_each_command() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Arc::new(Config::default());
let mut reader: &[u8] = &[];
let commands = vec![
Command::Account {
command: AccountCommand::List {
profile: None,
eab_kid: None,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false,
},
},
Command::Order {
command: OrderCommand::List(crate::cli::order::OrderListArgs {
profile: None,
account_id: None,
status: None,
identifier: None,
identifier_contains: None,
cert_serial: None,
expiring_in: None,
hide_superseded: false,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false,
}),
},
Command::Nonce {
command: NonceCommand::Cleanup {
ttl_seconds: Some(1),
},
},
Command::Nonce {
command: NonceCommand::Count { json: false },
},
Command::Eab {
command: EabCommand::List {
limit: 50,
offset: 0,
json: false,
},
},
Command::Jobs {
command: JobsCommand::List {
kind: None,
status: None,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false,
},
},
Command::Man,
Command::Completions {
shell: clap_complete::aot::Shell::Bash,
},
];
for command in commands {
dispatch(
Some(command),
true,
ColorChoice::Never,
&mut reader,
&config,
database.clone(),
)
.await
.expect("every command must succeed against an empty database");
}
}
#[tokio::test]
async fn dispatch_routes_transfer() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Arc::new(Config::default());
let mut reader: &[u8] = &[];
let error = dispatch(
Some(Command::Transfer {
to: config.database.url.clone(),
json: false,
}),
true,
ColorChoice::Never,
&mut reader,
&config,
database,
)
.await
.expect_err("the source and the target are one database");
assert_eq!(error.kind(), CliErrorKind::BadRequest);
}
#[tokio::test]
async fn dispatch_propagates_a_command_failure() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Arc::new(Config::default());
let mut reader: &[u8] = &[];
let error = dispatch(
Some(Command::Account {
command: AccountCommand::Show {
id: "acct-nope".to_string(),
json: false,
},
}),
true,
ColorChoice::Never,
&mut reader,
&config,
database,
)
.await
.expect_err("an unknown account must fail");
assert_eq!(
error,
CliError::bad_request("no such account: acct-nope".to_string())
);
assert_eq!(error.exit_code(), 3);
}
#[tokio::test]
async fn dispatch_serve_reports_a_startup_failure() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let mut config = Config::default();
config.server.bind_address = "127.0.0.1:0".to_string();
let mut reader: &[u8] = &[];
let error = dispatch(
Some(Command::Serve { role: None }),
true,
ColorChoice::Never,
&mut reader,
&Arc::new(config),
database,
)
.await
.expect_err("a server with no endpoint must not start");
assert!(error.to_string().contains("profile"), "{error}");
}
}