Skip to main content

acme_proxy/cli/
account.rs

1use std::io::BufRead;
2use std::sync::Arc;
3
4use clap::Subcommand;
5
6use crate::admin::{self, DeleteOutcome};
7use crate::cli::CliError;
8use crate::cli::render;
9use crate::cli::style::Palette;
10use crate::config::Config;
11use crate::sqlite::account::Account;
12use crate::sqlite::db::Database;
13
14#[derive(Subcommand)]
15pub enum AccountCommand {
16    /// List accounts, of every profile unless one is named.
17    List {
18        /// Restrict the listing to one ACME endpoint.
19        #[arg(long)]
20        profile: Option<String>,
21        #[arg(long)]
22        json: bool,
23    },
24    /// Show one account.
25    Show {
26        id: String,
27        #[arg(long)]
28        json: bool,
29    },
30    /// Replace an account's contact list.
31    UpdateContact {
32        id: String,
33        #[arg(long = "contact")]
34        contact: Vec<String>,
35    },
36    /// Set status = deactivated (RFC 8555 §7.3.6, terminal).
37    Deactivate { id: String },
38    /// Hard-delete the account and everything under it.
39    Delete { id: String },
40}
41
42pub async fn run_account_command(
43    command: AccountCommand,
44    yes: bool,
45    palette: Palette,
46    reader: &mut impl BufRead,
47    config: &Config,
48    database: Arc<Database>,
49) -> Result<(), CliError> {
50    match command {
51        AccountCommand::List { profile, json } => {
52            let accounts = Account::list_all(profile.as_deref(), &database).await?;
53            render::print_rows(
54                &accounts,
55                json,
56                |a| admin::render_account_json(a, &config.server.base_url),
57                |a| render::render_account_line(a, palette),
58            );
59        }
60        AccountCommand::Show { id, json } => match Account::find_any_by_id(&id, &database).await? {
61            None => return Err(not_found(&id)),
62            Some(account) if json => {
63                println!(
64                    "{}",
65                    admin::render_account_json(&account, &config.server.base_url)
66                );
67            }
68            Some(account) => print!("{}", render::render_account_detail_text(&account, palette)),
69        },
70        AccountCommand::UpdateContact { id, contact } => {
71            match admin::update_account_contact(&id, contact, database).await? {
72                None => return Err(not_found(&id)),
73                Some(account) => println!("{}", render::render_account_line(&account, palette)),
74            }
75        }
76        AccountCommand::Deactivate { id } => {
77            match admin::deactivate_account(&id, database).await? {
78                None => return Err(not_found(&id)),
79                Some(account) => println!("{}", render::render_account_line(&account, palette)),
80            }
81        }
82        AccountCommand::Delete { id } => {
83            match admin::confirm_delete_account(&id, yes, reader, database).await? {
84                DeleteOutcome::NotFound => return Err(not_found(&id)),
85                DeleteOutcome::Cancelled => println!("Cancelled."),
86                DeleteOutcome::Deleted => println!("Deleted account {id}."),
87            }
88        }
89    }
90    Ok(())
91}
92
93fn not_found(id: &str) -> CliError {
94    CliError(format!("no such account: {id}"))
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::audit::ClientContext;
101
102    /// Every arm taking an id reports the same thing for one that does not
103    /// exist — and reports it as a value, so the caller decides the exit code.
104    #[tokio::test]
105    async fn every_arm_refuses_an_unknown_account() {
106        let database = Arc::new(Database::connect_in_memory().await.unwrap());
107        let config = Config::default();
108        let expected = CliError("no such account: acct-nope".to_string());
109
110        let commands = vec![
111            AccountCommand::Show {
112                id: "acct-nope".to_string(),
113                json: false,
114            },
115            AccountCommand::UpdateContact {
116                id: "acct-nope".to_string(),
117                contact: vec!["mailto:someone@example.com".to_string()],
118            },
119            AccountCommand::Deactivate {
120                id: "acct-nope".to_string(),
121            },
122            AccountCommand::Delete {
123                id: "acct-nope".to_string(),
124            },
125        ];
126        for command in commands {
127            let mut reader: &[u8] = &[];
128            let error = run_account_command(
129                command,
130                true,
131                Palette::plain(),
132                &mut reader,
133                &config,
134                database.clone(),
135            )
136            .await
137            .expect_err("an unknown account must fail");
138            assert_eq!(error, expected);
139        }
140    }
141
142    /// `delete` without `--yes` asks first, and a refusal is a success: the
143    /// operator answered, nothing was destroyed.
144    #[tokio::test]
145    async fn a_declined_delete_is_not_a_failure() {
146        let database = Arc::new(Database::connect_in_memory().await.unwrap());
147        let config = Config::default();
148        let (account, _) = Account::find_or_create(
149            "default",
150            &[7, 7, 7],
151            vec![],
152            &ClientContext::default(),
153            &database,
154        )
155        .await
156        .unwrap();
157
158        let mut reader: &[u8] = b"n\n";
159        run_account_command(
160            AccountCommand::Delete {
161                id: account.id.clone(),
162            },
163            false,
164            Palette::plain(),
165            &mut reader,
166            &config,
167            database.clone(),
168        )
169        .await
170        .unwrap();
171
172        assert!(
173            Account::find_any_by_id(&account.id, &database)
174                .await
175                .unwrap()
176                .is_some(),
177            "a declined delete must leave the account in place"
178        );
179    }
180
181    /// The JSON arms render through `admin::render_account_json`, which needs
182    /// the configured `base_url` — a separate branch from the line renderer.
183    #[tokio::test]
184    async fn the_json_arms_render() {
185        let database = Arc::new(Database::connect_in_memory().await.unwrap());
186        let config = Config::default();
187        let (account, _) = Account::find_or_create(
188            "default",
189            &[9, 9, 9],
190            vec![],
191            &ClientContext::default(),
192            &database,
193        )
194        .await
195        .unwrap();
196
197        let mut reader: &[u8] = &[];
198        run_account_command(
199            AccountCommand::List {
200                profile: Some("default".to_string()),
201                json: true,
202            },
203            true,
204            Palette::plain(),
205            &mut reader,
206            &config,
207            database.clone(),
208        )
209        .await
210        .unwrap();
211
212        run_account_command(
213            AccountCommand::Show {
214                id: account.id,
215                json: true,
216            },
217            true,
218            Palette::plain(),
219            &mut reader,
220            &config,
221            database,
222        )
223        .await
224        .unwrap();
225    }
226}