1use std::io::BufRead;
10use std::sync::Arc;
11
12use clap::Subcommand;
13
14use crate::cli::CliError;
15use crate::cli::render;
16use crate::cli::window::{DEFAULT_LIMIT, Window};
17use acme_proxy_admin::admin;
18use acme_proxy_admin::admin::DeleteOutcome;
19use acme_proxy_core::config::Config;
20use acme_proxy_core::palette::Palette;
21use acme_proxy_jobs::auditor::admin as audit_admin;
22use acme_proxy_store::account::Account;
23use acme_proxy_store::db::Database;
24
25#[derive(Subcommand)]
26pub enum AccountCommand {
27 List {
29 #[arg(long)]
31 profile: Option<String>,
32 #[arg(long)]
34 eab_kid: Option<String>,
35 #[arg(long, default_value_t = DEFAULT_LIMIT)]
37 limit: i64,
38 #[arg(long, default_value_t = 0)]
40 offset: i64,
41 #[arg(long)]
43 json: bool,
44 },
45 Show {
47 id: String,
49 #[arg(long)]
51 json: bool,
52 },
53 UpdateContact {
55 id: String,
57 #[arg(long = "contact")]
60 contact: Vec<String>,
61 },
62 Deactivate {
64 id: String,
66 },
67 Delete {
69 id: String,
71 },
72}
73
74pub async fn run_account_command(
75 command: AccountCommand,
76 yes: bool,
77 palette: Palette,
78 reader: &mut impl BufRead,
79 config: &Config,
80 database: Arc<Database>,
81) -> Result<(), CliError> {
82 match command {
83 AccountCommand::List {
84 profile,
85 eab_kid,
86 limit,
87 offset,
88 json,
89 } => {
90 let window = Window::resolve(limit, offset);
91 let (accounts, total) = Account::search(
92 profile.as_deref(),
93 eab_kid.as_deref(),
94 window.limit,
95 window.offset,
96 &database,
97 )
98 .await?;
99 render::print_page(
100 &accounts,
101 total,
102 window,
103 json,
104 |a| admin::render_account_json(a, &config.server.base_url),
105 |a| render::render_account_line(a, palette),
106 );
107 }
108 AccountCommand::Show { id, json } => match Account::find_any_by_id(&id, &database).await? {
109 None => return Err(not_found(&id)),
110 Some(account) if json => {
111 println!(
112 "{}",
113 admin::render_account_json(&account, &config.server.base_url)
114 );
115 }
116 Some(account) => print!("{}", render::render_account_detail_text(&account, palette)),
117 },
118 AccountCommand::UpdateContact { id, contact } => {
119 match admin::update_account_contact(&id, contact, database.clone())
120 .await
121 .map_err(|error| match error {
122 admin::ContactError::Invalid(detail) => CliError::bad_request(detail),
123 admin::ContactError::Database(error) => CliError::from(error),
124 })? {
125 None => return Err(not_found(&id)),
126 Some(account) => {
127 audit_admin::record_cli_action(&database, |actor, client| {
128 audit_admin::account_contact_updated(
129 actor,
130 client,
131 &account,
132 &account.contact,
133 )
134 })
135 .await;
136 println!("{}", render::render_account_line(&account, palette));
137 }
138 }
139 }
140 AccountCommand::Deactivate { id } => {
141 let notifiers = super::offline_notifiers(config, database.clone()).unwrap_or_default();
145 match admin::deactivate_account(
146 &id,
147 database.clone(),
148 |profile| notifiers.get(profile).cloned(),
149 None,
150 )
151 .await?
152 {
153 None => return Err(not_found(&id)),
154 Some(account) => {
155 audit_admin::record_cli_action(&database, |actor, client| {
156 audit_admin::account_deactivated(actor, client, &account)
157 })
158 .await;
159 println!("{}", render::render_account_line(&account, palette));
160 }
161 }
162 }
163 AccountCommand::Delete { id } => {
164 let doomed = Account::find_any_by_id(&id, &database).await?;
171 match admin::confirm_delete_account(&id, yes, reader, database.clone()).await? {
172 DeleteOutcome::NotFound => return Err(not_found(&id)),
173 DeleteOutcome::LiveCertificates(live) => {
174 return Err(CliError::bad_request(admin::live_certificates_refusal(
175 &format!("account {id}"),
176 live,
177 )));
178 }
179 DeleteOutcome::Cancelled => println!("Cancelled."),
180 DeleteOutcome::Deleted(deleted) => {
181 if let Some(account) = doomed {
182 audit_admin::record_cli_action(&database, |actor, client| {
183 audit_admin::account_deleted(actor, client, &account, deleted.cascaded)
184 })
185 .await;
186 }
187 println!(
188 "Deleted account {id} ({} order(s) cascaded).",
189 deleted.cascaded
190 );
191 }
192 }
193 }
194 }
195 Ok(())
196}
197
198fn not_found(id: &str) -> CliError {
199 CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Account.missing(id))
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use acme_proxy_core::audit::ClientContext;
206
207 #[tokio::test]
210 async fn every_arm_refuses_an_unknown_account() {
211 let database = Arc::new(Database::connect_in_memory().await.unwrap());
212 let config = Config::default();
213 let expected = CliError::bad_request("no such account: acct-nope".to_string());
214
215 let commands = vec![
216 AccountCommand::Show {
217 id: "acct-nope".to_string(),
218 json: false,
219 },
220 AccountCommand::UpdateContact {
221 id: "acct-nope".to_string(),
222 contact: vec!["mailto:someone@example.com".to_string()],
223 },
224 AccountCommand::Deactivate {
225 id: "acct-nope".to_string(),
226 },
227 AccountCommand::Delete {
228 id: "acct-nope".to_string(),
229 },
230 ];
231 for command in commands {
232 let mut reader: &[u8] = &[];
233 let error = run_account_command(
234 command,
235 true,
236 Palette::plain(),
237 &mut reader,
238 &config,
239 database.clone(),
240 )
241 .await
242 .expect_err("an unknown account must fail");
243 assert_eq!(error, expected);
244 }
245 }
246
247 #[tokio::test]
250 async fn update_contact_refuses_what_new_account_refuses() {
251 let database = Arc::new(Database::connect_in_memory().await.unwrap());
252 let config = Config::default();
253 let (account, _) = Account::find_or_create(
254 "default",
255 &[2, 7, 1],
256 vec!["mailto:ops@example.com".to_string()],
257 &ClientContext::default(),
258 &database,
259 )
260 .await
261 .unwrap();
262
263 let mut reader: &[u8] = &[];
264 let error = run_account_command(
265 AccountCommand::UpdateContact {
266 id: account.id.to_string(),
267 contact: vec!["tel:+15555550100".to_string()],
268 },
269 true,
270 Palette::plain(),
271 &mut reader,
272 &config,
273 database.clone(),
274 )
275 .await
276 .expect_err("an unsupported scheme must be refused");
277 assert_eq!(error.kind(), crate::cli::CliErrorKind::BadRequest);
278
279 let reloaded = Account::find_any_by_id(&account.id.to_string(), &database)
280 .await
281 .unwrap()
282 .unwrap();
283 assert_eq!(reloaded.contact, vec!["mailto:ops@example.com".to_string()]);
284 }
285
286 #[tokio::test]
289 async fn a_mutation_writes_an_audit_row_and_a_decline_does_not() {
290 use acme_proxy_store::audit::AuditEntry;
291 use acme_proxy_store::audit::AuditQuery;
292
293 let database = Arc::new(Database::connect_in_memory().await.unwrap());
294 let config = Config::default();
295 let (account, _) = Account::find_or_create(
296 "default",
297 &[3, 1, 4],
298 vec![],
299 &ClientContext::default(),
300 &database,
301 )
302 .await
303 .unwrap();
304
305 let mut reader: &[u8] = &[];
306 run_account_command(
307 AccountCommand::Deactivate {
308 id: account.id.to_string(),
309 },
310 true,
311 Palette::plain(),
312 &mut reader,
313 &config,
314 database.clone(),
315 )
316 .await
317 .unwrap();
318
319 let mut declined: &[u8] = b"n\n";
320 run_account_command(
321 AccountCommand::Delete {
322 id: account.id.to_string(),
323 },
324 false,
325 Palette::plain(),
326 &mut declined,
327 &config,
328 database.clone(),
329 )
330 .await
331 .unwrap();
332
333 let (rows, _) = AuditEntry::search(
334 &AuditQuery {
335 limit: 50,
336 ..AuditQuery::default()
337 },
338 &database,
339 )
340 .await
341 .unwrap();
342 assert_eq!(rows.len(), 1, "only the deactivation is a mutation");
343 assert_eq!(rows[0].event, "account_deactivated");
344 assert_eq!(rows[0].actor_kind, "cli");
345 assert_eq!(rows[0].profile, "default");
346 assert_eq!(
347 rows[0].account_id.as_deref(),
348 Some(account.id.to_string().as_str())
349 );
350 }
351
352 #[tokio::test]
360 async fn deleting_an_account_records_what_actually_cascaded() {
361 use acme_proxy_core::identifier::Identifier;
362 use acme_proxy_store::audit::AuditEntry;
363 use acme_proxy_store::audit::AuditQuery;
364 use acme_proxy_store::order::Order;
365
366 let database = Arc::new(Database::connect_in_memory().await.unwrap());
367 let config = Config::default();
368 let (account, _) = Account::find_or_create(
369 "default",
370 &[2, 7, 1],
371 vec![],
372 &ClientContext::default(),
373 &database,
374 )
375 .await
376 .unwrap();
377 for name in ["a.example.com", "b.example.com"] {
378 Order::create(
379 "default",
380 account.id,
381 vec![Identifier::dns(name)],
382 acme_proxy_store::nonce::now_secs() + 3600,
383 None,
384 None,
385 &database,
386 )
387 .await
388 .unwrap();
389 }
390
391 let mut reader: &[u8] = &[];
392 run_account_command(
393 AccountCommand::Delete {
394 id: account.id.to_string(),
395 },
396 true,
397 Palette::plain(),
398 &mut reader,
399 &config,
400 database.clone(),
401 )
402 .await
403 .unwrap();
404
405 let (rows, _) = AuditEntry::search(
406 &AuditQuery {
407 limit: 5,
408 ..AuditQuery::default()
409 },
410 &database,
411 )
412 .await
413 .unwrap();
414 assert_eq!(rows.len(), 1);
415 assert_eq!(rows[0].event, "account_deleted");
416 assert_eq!(
417 rows[0].detail.as_deref(),
418 Some("2 order(s) cascaded"),
419 "the count must be what the cascade actually took"
420 );
421 }
422
423 #[tokio::test]
426 async fn a_declined_delete_is_not_a_failure() {
427 let database = Arc::new(Database::connect_in_memory().await.unwrap());
428 let config = Config::default();
429 let (account, _) = Account::find_or_create(
430 "default",
431 &[7, 7, 7],
432 vec![],
433 &ClientContext::default(),
434 &database,
435 )
436 .await
437 .unwrap();
438
439 let mut reader: &[u8] = b"n\n";
440 run_account_command(
441 AccountCommand::Delete {
442 id: account.id.to_string(),
443 },
444 false,
445 Palette::plain(),
446 &mut reader,
447 &config,
448 database.clone(),
449 )
450 .await
451 .unwrap();
452
453 assert!(
454 Account::find_any_by_id(account.id.to_string().as_str(), &database)
455 .await
456 .unwrap()
457 .is_some(),
458 "a declined delete must leave the account in place"
459 );
460 }
461
462 #[tokio::test]
467 async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
468 let database = Arc::new(Database::connect_in_memory().await.unwrap());
469 let config = Config::default();
470 for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
471 Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
472 .await
473 .unwrap();
474 }
475
476 let mut reader: &[u8] = &[];
477 for (limit, offset, json) in [(2, 0, false), (2, 2, false), (2, 0, true), (0, -5, false)] {
478 run_account_command(
479 AccountCommand::List {
480 profile: None,
481 eab_kid: None,
482 limit,
483 offset,
484 json,
485 },
486 true,
487 Palette::plain(),
488 &mut reader,
489 &config,
490 database.clone(),
491 )
492 .await
493 .unwrap_or_else(|error| panic!("--limit {limit} --offset {offset}: {error}"));
494 }
495 }
496
497 #[tokio::test]
502 async fn consecutive_pages_do_not_overlap_and_the_total_stays_unpaged() {
503 let database = Arc::new(Database::connect_in_memory().await.unwrap());
504 for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
505 Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
506 .await
507 .unwrap();
508 }
509
510 let (first, total) = Account::search(None, None, 2, 0, &database).await.unwrap();
511 let (second, also_total) = Account::search(None, None, 2, 2, &database).await.unwrap();
512
513 assert_eq!(total, 3);
514 assert_eq!(also_total, 3, "the total is the table, not the page");
515 assert_eq!(first.len(), 2);
516 assert_eq!(second.len(), 1);
517 for account in &second {
518 assert!(
519 !first.iter().any(|earlier| earlier.id == account.id),
520 "a row appeared on two pages"
521 );
522 }
523 }
524
525 #[tokio::test]
528 async fn the_json_arms_render() {
529 let database = Arc::new(Database::connect_in_memory().await.unwrap());
530 let config = Config::default();
531 let (account, _) = Account::find_or_create(
532 "default",
533 &[9, 9, 9],
534 vec![],
535 &ClientContext::default(),
536 &database,
537 )
538 .await
539 .unwrap();
540
541 let mut reader: &[u8] = &[];
542 run_account_command(
543 AccountCommand::List {
544 profile: Some("default".to_string()),
545 eab_kid: None,
546 limit: DEFAULT_LIMIT,
547 offset: 0,
548 json: true,
549 },
550 true,
551 Palette::plain(),
552 &mut reader,
553 &config,
554 database.clone(),
555 )
556 .await
557 .unwrap();
558
559 run_account_command(
560 AccountCommand::Show {
561 id: account.id.to_string(),
562 json: true,
563 },
564 true,
565 Palette::plain(),
566 &mut reader,
567 &config,
568 database,
569 )
570 .await
571 .unwrap();
572 }
573
574 #[tokio::test]
577 async fn delete_refuses_an_account_holding_a_live_certificate() {
578 let database = Arc::new(Database::connect_in_memory().await.unwrap());
579 let account = acme_proxy_store::testutil::account_id(&database).await;
580 acme_proxy_store::testutil::certified_order(&database, account, None).await;
581
582 let error = run_account_command(
583 AccountCommand::Delete {
584 id: account.to_string(),
585 },
586 true,
587 Palette::plain(),
588 &mut &b""[..],
589 &Config::default(),
590 database.clone(),
591 )
592 .await
593 .expect_err("a live certificate must refuse the delete");
594 assert_eq!(
595 error,
596 CliError::bad_request(admin::live_certificates_refusal(
597 &format!("account {account}"),
598 1
599 ))
600 );
601 let (rows, _) = acme_proxy_store::audit::AuditEntry::search(
602 &acme_proxy_store::audit::AuditQuery::default(),
603 &database,
604 )
605 .await
606 .unwrap();
607 assert!(rows.is_empty());
608 }
609}