use std::io::BufRead;
use std::sync::Arc;
use clap::Subcommand;
use crate::cli::CliError;
use crate::cli::render;
use crate::cli::window::{DEFAULT_LIMIT, Window};
use acme_proxy_admin::admin;
use acme_proxy_admin::admin::DeleteOutcome;
use acme_proxy_core::config::Config;
use acme_proxy_core::palette::Palette;
use acme_proxy_jobs::auditor::admin as audit_admin;
use acme_proxy_store::account::Account;
use acme_proxy_store::db::Database;
#[derive(Subcommand)]
pub enum AccountCommand {
List {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
eab_kid: Option<String>,
#[arg(long, default_value_t = DEFAULT_LIMIT)]
limit: i64,
#[arg(long, default_value_t = 0)]
offset: i64,
#[arg(long)]
json: bool,
},
Show {
id: String,
#[arg(long)]
json: bool,
},
UpdateContact {
id: String,
#[arg(long = "contact")]
contact: Vec<String>,
},
Deactivate {
id: String,
},
Delete {
id: String,
},
}
pub async fn run_account_command(
command: AccountCommand,
yes: bool,
palette: Palette,
reader: &mut impl BufRead,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
match command {
AccountCommand::List {
profile,
eab_kid,
limit,
offset,
json,
} => {
let window = Window::resolve(limit, offset);
let (accounts, total) = Account::search(
profile.as_deref(),
eab_kid.as_deref(),
window.limit,
window.offset,
&database,
)
.await?;
render::print_page(
&accounts,
total,
window,
json,
|a| admin::render_account_json(a, &config.server.base_url),
|a| render::render_account_line(a, palette),
);
}
AccountCommand::Show { id, json } => match Account::find_any_by_id(&id, &database).await? {
None => return Err(not_found(&id)),
Some(account) if json => {
println!(
"{}",
admin::render_account_json(&account, &config.server.base_url)
);
}
Some(account) => print!("{}", render::render_account_detail_text(&account, palette)),
},
AccountCommand::UpdateContact { id, contact } => {
match admin::update_account_contact(&id, contact, database.clone())
.await
.map_err(|error| match error {
admin::ContactError::Invalid(detail) => CliError::bad_request(detail),
admin::ContactError::Database(error) => CliError::from(error),
})? {
None => return Err(not_found(&id)),
Some(account) => {
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::account_contact_updated(
actor,
client,
&account,
&account.contact,
)
})
.await;
println!("{}", render::render_account_line(&account, palette));
}
}
}
AccountCommand::Deactivate { id } => {
let notifiers = super::offline_notifiers(config, database.clone()).unwrap_or_default();
match admin::deactivate_account(
&id,
database.clone(),
|profile| notifiers.get(profile).cloned(),
None,
)
.await?
{
None => return Err(not_found(&id)),
Some(account) => {
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::account_deactivated(actor, client, &account)
})
.await;
println!("{}", render::render_account_line(&account, palette));
}
}
}
AccountCommand::Delete { id } => {
let doomed = Account::find_any_by_id(&id, &database).await?;
match admin::confirm_delete_account(&id, yes, reader, database.clone()).await? {
DeleteOutcome::NotFound => return Err(not_found(&id)),
DeleteOutcome::LiveCertificates(live) => {
return Err(CliError::bad_request(admin::live_certificates_refusal(
&format!("account {id}"),
live,
)));
}
DeleteOutcome::Cancelled => println!("Cancelled."),
DeleteOutcome::Deleted(deleted) => {
if let Some(account) = doomed {
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::account_deleted(actor, client, &account, deleted.cascaded)
})
.await;
}
println!(
"Deleted account {id} ({} order(s) cascaded).",
deleted.cascaded
);
}
}
}
}
Ok(())
}
fn not_found(id: &str) -> CliError {
CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Account.missing(id))
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_core::audit::ClientContext;
#[tokio::test]
async fn every_arm_refuses_an_unknown_account() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
let expected = CliError::bad_request("no such account: acct-nope".to_string());
let commands = vec![
AccountCommand::Show {
id: "acct-nope".to_string(),
json: false,
},
AccountCommand::UpdateContact {
id: "acct-nope".to_string(),
contact: vec!["mailto:someone@example.com".to_string()],
},
AccountCommand::Deactivate {
id: "acct-nope".to_string(),
},
AccountCommand::Delete {
id: "acct-nope".to_string(),
},
];
for command in commands {
let mut reader: &[u8] = &[];
let error = run_account_command(
command,
true,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.expect_err("an unknown account must fail");
assert_eq!(error, expected);
}
}
#[tokio::test]
async fn update_contact_refuses_what_new_account_refuses() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
let (account, _) = Account::find_or_create(
"default",
&[2, 7, 1],
vec!["mailto:ops@example.com".to_string()],
&ClientContext::default(),
&database,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
let error = run_account_command(
AccountCommand::UpdateContact {
id: account.id.to_string(),
contact: vec!["tel:+15555550100".to_string()],
},
true,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.expect_err("an unsupported scheme must be refused");
assert_eq!(error.kind(), crate::cli::CliErrorKind::BadRequest);
let reloaded = Account::find_any_by_id(&account.id.to_string(), &database)
.await
.unwrap()
.unwrap();
assert_eq!(reloaded.contact, vec!["mailto:ops@example.com".to_string()]);
}
#[tokio::test]
async fn a_mutation_writes_an_audit_row_and_a_decline_does_not() {
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
let (account, _) = Account::find_or_create(
"default",
&[3, 1, 4],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
run_account_command(
AccountCommand::Deactivate {
id: account.id.to_string(),
},
true,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.unwrap();
let mut declined: &[u8] = b"n\n";
run_account_command(
AccountCommand::Delete {
id: account.id.to_string(),
},
false,
Palette::plain(),
&mut declined,
&config,
database.clone(),
)
.await
.unwrap();
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 50,
..AuditQuery::default()
},
&database,
)
.await
.unwrap();
assert_eq!(rows.len(), 1, "only the deactivation is a mutation");
assert_eq!(rows[0].event, "account_deactivated");
assert_eq!(rows[0].actor_kind, "cli");
assert_eq!(rows[0].profile, "default");
assert_eq!(
rows[0].account_id.as_deref(),
Some(account.id.to_string().as_str())
);
}
#[tokio::test]
async fn deleting_an_account_records_what_actually_cascaded() {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
use acme_proxy_store::order::Order;
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
let (account, _) = Account::find_or_create(
"default",
&[2, 7, 1],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
for name in ["a.example.com", "b.example.com"] {
Order::create(
"default",
account.id,
vec![Identifier::dns(name)],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&database,
)
.await
.unwrap();
}
let mut reader: &[u8] = &[];
run_account_command(
AccountCommand::Delete {
id: account.id.to_string(),
},
true,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.unwrap();
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 5,
..AuditQuery::default()
},
&database,
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].event, "account_deleted");
assert_eq!(
rows[0].detail.as_deref(),
Some("2 order(s) cascaded"),
"the count must be what the cascade actually took"
);
}
#[tokio::test]
async fn a_declined_delete_is_not_a_failure() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
let (account, _) = Account::find_or_create(
"default",
&[7, 7, 7],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
let mut reader: &[u8] = b"n\n";
run_account_command(
AccountCommand::Delete {
id: account.id.to_string(),
},
false,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.unwrap();
assert!(
Account::find_any_by_id(account.id.to_string().as_str(), &database)
.await
.unwrap()
.is_some(),
"a declined delete must leave the account in place"
);
}
#[tokio::test]
async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
.await
.unwrap();
}
let mut reader: &[u8] = &[];
for (limit, offset, json) in [(2, 0, false), (2, 2, false), (2, 0, true), (0, -5, false)] {
run_account_command(
AccountCommand::List {
profile: None,
eab_kid: None,
limit,
offset,
json,
},
true,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.unwrap_or_else(|error| panic!("--limit {limit} --offset {offset}: {error}"));
}
}
#[tokio::test]
async fn consecutive_pages_do_not_overlap_and_the_total_stays_unpaged() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
.await
.unwrap();
}
let (first, total) = Account::search(None, None, 2, 0, &database).await.unwrap();
let (second, also_total) = Account::search(None, None, 2, 2, &database).await.unwrap();
assert_eq!(total, 3);
assert_eq!(also_total, 3, "the total is the table, not the page");
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 1);
for account in &second {
assert!(
!first.iter().any(|earlier| earlier.id == account.id),
"a row appeared on two pages"
);
}
}
#[tokio::test]
async fn the_json_arms_render() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Config::default();
let (account, _) = Account::find_or_create(
"default",
&[9, 9, 9],
vec![],
&ClientContext::default(),
&database,
)
.await
.unwrap();
let mut reader: &[u8] = &[];
run_account_command(
AccountCommand::List {
profile: Some("default".to_string()),
eab_kid: None,
limit: DEFAULT_LIMIT,
offset: 0,
json: true,
},
true,
Palette::plain(),
&mut reader,
&config,
database.clone(),
)
.await
.unwrap();
run_account_command(
AccountCommand::Show {
id: account.id.to_string(),
json: true,
},
true,
Palette::plain(),
&mut reader,
&config,
database,
)
.await
.unwrap();
}
#[tokio::test]
async fn delete_refuses_an_account_holding_a_live_certificate() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let account = acme_proxy_store::testutil::account_id(&database).await;
acme_proxy_store::testutil::certified_order(&database, account, None).await;
let error = run_account_command(
AccountCommand::Delete {
id: account.to_string(),
},
true,
Palette::plain(),
&mut &b""[..],
&Config::default(),
database.clone(),
)
.await
.expect_err("a live certificate must refuse the delete");
assert_eq!(
error,
CliError::bad_request(admin::live_certificates_refusal(
&format!("account {account}"),
1
))
);
let (rows, _) = acme_proxy_store::audit::AuditEntry::search(
&acme_proxy_store::audit::AuditQuery::default(),
&database,
)
.await
.unwrap();
assert!(rows.is_empty());
}
}