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::cli::window::{DEFAULT_LIMIT, Window};
11use crate::config::Config;
12use crate::sqlite::account::Account;
13use crate::sqlite::db::Database;
14
15#[derive(Subcommand)]
16pub enum AccountCommand {
17    /// List accounts, newest first, of every profile unless one is named.
18    List {
19        /// Restrict the listing to one ACME endpoint.
20        #[arg(long)]
21        profile: Option<String>,
22        #[arg(long, default_value_t = DEFAULT_LIMIT)]
23        limit: i64,
24        #[arg(long, default_value_t = 0)]
25        offset: i64,
26        #[arg(long)]
27        json: bool,
28    },
29    /// Show one account.
30    Show {
31        id: String,
32        #[arg(long)]
33        json: bool,
34    },
35    /// Replace an account's contact list.
36    UpdateContact {
37        id: String,
38        #[arg(long = "contact")]
39        contact: Vec<String>,
40    },
41    /// Set status = deactivated (RFC 8555 ยง7.3.6, terminal).
42    Deactivate { id: String },
43    /// Hard-delete the account and everything under it.
44    Delete { id: String },
45}
46
47pub async fn run_account_command(
48    command: AccountCommand,
49    yes: bool,
50    palette: Palette,
51    reader: &mut impl BufRead,
52    config: &Config,
53    database: Arc<Database>,
54) -> Result<(), CliError> {
55    match command {
56        AccountCommand::List {
57            profile,
58            limit,
59            offset,
60            json,
61        } => {
62            let window = Window::resolve(limit, offset);
63            let (accounts, total) =
64                Account::search(profile.as_deref(), window.limit, window.offset, &database).await?;
65            render::print_page(
66                &accounts,
67                total,
68                window,
69                json,
70                |a| admin::render_account_json(a, &config.server.base_url),
71                |a| render::render_account_line(a, palette),
72            );
73        }
74        AccountCommand::Show { id, json } => match Account::find_any_by_id(&id, &database).await? {
75            None => return Err(not_found(&id)),
76            Some(account) if json => {
77                println!(
78                    "{}",
79                    admin::render_account_json(&account, &config.server.base_url)
80                );
81            }
82            Some(account) => print!("{}", render::render_account_detail_text(&account, palette)),
83        },
84        AccountCommand::UpdateContact { id, contact } => {
85            match admin::update_account_contact(&id, contact, database).await? {
86                None => return Err(not_found(&id)),
87                Some(account) => println!("{}", render::render_account_line(&account, palette)),
88            }
89        }
90        AccountCommand::Deactivate { id } => {
91            match admin::deactivate_account(&id, database).await? {
92                None => return Err(not_found(&id)),
93                Some(account) => println!("{}", render::render_account_line(&account, palette)),
94            }
95        }
96        AccountCommand::Delete { id } => {
97            match admin::confirm_delete_account(&id, yes, reader, database).await? {
98                DeleteOutcome::NotFound => return Err(not_found(&id)),
99                DeleteOutcome::Cancelled => println!("Cancelled."),
100                DeleteOutcome::Deleted => println!("Deleted account {id}."),
101            }
102        }
103    }
104    Ok(())
105}
106
107fn not_found(id: &str) -> CliError {
108    CliError(format!("no such account: {id}"))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::audit::ClientContext;
115
116    /// Every arm taking an id reports the same thing for one that does not
117    /// exist โ€” and reports it as a value, so the caller decides the exit code.
118    #[tokio::test]
119    async fn every_arm_refuses_an_unknown_account() {
120        let database = Arc::new(Database::connect_in_memory().await.unwrap());
121        let config = Config::default();
122        let expected = CliError("no such account: acct-nope".to_string());
123
124        let commands = vec![
125            AccountCommand::Show {
126                id: "acct-nope".to_string(),
127                json: false,
128            },
129            AccountCommand::UpdateContact {
130                id: "acct-nope".to_string(),
131                contact: vec!["mailto:someone@example.com".to_string()],
132            },
133            AccountCommand::Deactivate {
134                id: "acct-nope".to_string(),
135            },
136            AccountCommand::Delete {
137                id: "acct-nope".to_string(),
138            },
139        ];
140        for command in commands {
141            let mut reader: &[u8] = &[];
142            let error = run_account_command(
143                command,
144                true,
145                Palette::plain(),
146                &mut reader,
147                &config,
148                database.clone(),
149            )
150            .await
151            .expect_err("an unknown account must fail");
152            assert_eq!(error, expected);
153        }
154    }
155
156    /// `delete` without `--yes` asks first, and a refusal is a success: the
157    /// operator answered, nothing was destroyed.
158    #[tokio::test]
159    async fn a_declined_delete_is_not_a_failure() {
160        let database = Arc::new(Database::connect_in_memory().await.unwrap());
161        let config = Config::default();
162        let (account, _) = Account::find_or_create(
163            "default",
164            &[7, 7, 7],
165            vec![],
166            &ClientContext::default(),
167            &database,
168        )
169        .await
170        .unwrap();
171
172        let mut reader: &[u8] = b"n\n";
173        run_account_command(
174            AccountCommand::Delete {
175                id: account.id.clone(),
176            },
177            false,
178            Palette::plain(),
179            &mut reader,
180            &config,
181            database.clone(),
182        )
183        .await
184        .unwrap();
185
186        assert!(
187            Account::find_any_by_id(&account.id, &database)
188                .await
189                .unwrap()
190                .is_some(),
191            "a declined delete must leave the account in place"
192        );
193    }
194
195    /// The listing takes a window, and a nonsense one is corrected rather than
196    /// handed to SQL โ€” where `LIMIT -1` means *no limit* in SQLite, the one
197    /// answer a page must never accidentally give. `audit list`'s rule, now
198    /// this one's.
199    #[tokio::test]
200    async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
201        let database = Arc::new(Database::connect_in_memory().await.unwrap());
202        let config = Config::default();
203        for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
204            Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
205                .await
206                .unwrap();
207        }
208
209        let mut reader: &[u8] = &[];
210        for (limit, offset, json) in [(2, 0, false), (2, 2, false), (2, 0, true), (0, -5, false)] {
211            run_account_command(
212                AccountCommand::List {
213                    profile: None,
214                    limit,
215                    offset,
216                    json,
217                },
218                true,
219                Palette::plain(),
220                &mut reader,
221                &config,
222                database.clone(),
223            )
224            .await
225            .unwrap_or_else(|error| panic!("--limit {limit} --offset {offset}: {error}"));
226        }
227    }
228
229    /// The window reaches the query rather than being clamped and dropped: two
230    /// pages of two over three rows do not overlap, and the total stays the
231    /// unpaged count on both. Asserted against the model the command calls,
232    /// since a command body prints rather than returns.
233    #[tokio::test]
234    async fn consecutive_pages_do_not_overlap_and_the_total_stays_unpaged() {
235        let database = Arc::new(Database::connect_in_memory().await.unwrap());
236        for key in [&[1u8][..], &[2u8][..], &[3u8][..]] {
237            Account::find_or_create("default", key, vec![], &ClientContext::default(), &database)
238                .await
239                .unwrap();
240        }
241
242        let (first, total) = Account::search(None, 2, 0, &database).await.unwrap();
243        let (second, also_total) = Account::search(None, 2, 2, &database).await.unwrap();
244
245        assert_eq!(total, 3);
246        assert_eq!(also_total, 3, "the total is the table, not the page");
247        assert_eq!(first.len(), 2);
248        assert_eq!(second.len(), 1);
249        for account in &second {
250            assert!(
251                !first.iter().any(|earlier| earlier.id == account.id),
252                "a row appeared on two pages"
253            );
254        }
255    }
256
257    /// The JSON arms render through `admin::render_account_json`, which needs
258    /// the configured `base_url` โ€” a separate branch from the line renderer.
259    #[tokio::test]
260    async fn the_json_arms_render() {
261        let database = Arc::new(Database::connect_in_memory().await.unwrap());
262        let config = Config::default();
263        let (account, _) = Account::find_or_create(
264            "default",
265            &[9, 9, 9],
266            vec![],
267            &ClientContext::default(),
268            &database,
269        )
270        .await
271        .unwrap();
272
273        let mut reader: &[u8] = &[];
274        run_account_command(
275            AccountCommand::List {
276                profile: Some("default".to_string()),
277                limit: DEFAULT_LIMIT,
278                offset: 0,
279                json: true,
280            },
281            true,
282            Palette::plain(),
283            &mut reader,
284            &config,
285            database.clone(),
286        )
287        .await
288        .unwrap();
289
290        run_account_command(
291            AccountCommand::Show {
292                id: account.id,
293                json: true,
294            },
295            true,
296            Palette::plain(),
297            &mut reader,
298            &config,
299            database,
300        )
301        .await
302        .unwrap();
303    }
304}