Skip to main content

acme_proxy/cli/
webadmin.rs

1//! `acme-proxy admin …` — the web admin's operators and their sessions.
2//!
3//! This is how the panel is bootstrapped: it has no sign-up page and never
4//! will, so the first operator is created here, from a shell on the host.
5//!
6//! ## The password never goes in argv
7//!
8//! There is deliberately no `--password` flag. argv is visible to every
9//! process on the host via `ps` and is routinely written to shell history —
10//! the same reasoning `upstream register` already applies to the EAB secret,
11//! and pinned by the same kind of negative test. A password arrives either
12//! through `--password-file` or on stdin.
13
14use std::io::{BufRead, IsTerminal};
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use clap::Subcommand;
19
20use crate::admin;
21use crate::admin::mfa;
22use crate::admin::ops::DeleteOutcome;
23use crate::admin::prompt::confirm;
24use crate::admin::users::{self, UserError};
25use crate::cli::CliError;
26use crate::cli::render;
27use crate::cli::style::Palette;
28use crate::sqlite::admin_session::AdminSession;
29use crate::sqlite::admin_user::AdminUser;
30use crate::sqlite::db::Database;
31
32#[derive(Subcommand)]
33pub enum AdminCommand {
34    /// Manage the operators who can sign in to the web admin.
35    User {
36        #[command(subcommand)]
37        command: AdminUserCommand,
38    },
39    /// Inspect and revoke logged-in browser sessions.
40    Session {
41        #[command(subcommand)]
42        command: AdminSessionCommand,
43    },
44}
45
46#[derive(Subcommand)]
47pub enum AdminUserCommand {
48    /// Create an operator. The password is read from `--password-file`, or
49    /// from stdin.
50    Create {
51        username: String,
52        /// Read the password from this file instead of stdin. A single
53        /// trailing newline is stripped.
54        #[arg(long = "password-file")]
55        password_file: Option<PathBuf>,
56    },
57    /// List every operator. Never shows a password hash.
58    List {
59        #[arg(long)]
60        json: bool,
61    },
62    /// Replace an operator's password, revoking every session they hold.
63    Passwd {
64        username: String,
65        #[arg(long = "password-file")]
66        password_file: Option<PathBuf>,
67    },
68    /// Delete an operator and every session of theirs.
69    Delete { username: String },
70    /// Bar an operator from signing in, dropping their current sessions.
71    Disable { username: String },
72    /// Undo `disable`.
73    Enable { username: String },
74    /// Inspect or remove an operator's second factor.
75    Totp {
76        #[command(subcommand)]
77        command: AdminUserTotpCommand,
78    },
79}
80
81/// The operator-side half of the second factor.
82///
83/// There is deliberately **no `enrol`** here, and the omission is the same one
84/// that keeps a password out of argv: there is no way to enrol from a terminal
85/// that does not put the base32 secret into scrollback and the shell's own
86/// history. The panel shows it once, behind `Cache-Control: no-store`, on a
87/// loopback listener. What a shell is for is the case the panel cannot serve --
88/// an operator who has lost the factor and so cannot sign in to fix it.
89#[derive(Subcommand)]
90pub enum AdminUserTotpCommand {
91    /// Whether an operator has a second factor, and how many recovery codes
92    /// are left.
93    Status {
94        username: String,
95        #[arg(long)]
96        json: bool,
97    },
98    /// Remove an operator's second factor and every recovery code, and revoke
99    /// their sessions.
100    ///
101    /// The lockout lever: a lost phone is a shell command on the host, not a
102    /// database edit. Asks first, because it takes a security control away.
103    Reset { username: String },
104    /// Mint a fresh set of recovery codes, printed once. The previous set stops
105    /// working immediately.
106    RecoveryCodes { username: String },
107}
108
109#[derive(Subcommand)]
110pub enum AdminSessionCommand {
111    /// List live sessions, newest first.
112    List {
113        /// Only this operator's sessions.
114        #[arg(long)]
115        username: Option<String>,
116        #[arg(long)]
117        json: bool,
118    },
119    /// Revoke sessions: one operator's, or everyone's.
120    Revoke {
121        #[arg(long, conflicts_with = "all")]
122        user: Option<String>,
123        /// Revoke every session on the server.
124        #[arg(long, conflicts_with = "user")]
125        all: bool,
126    },
127}
128
129pub async fn run_admin_command(
130    command: AdminCommand,
131    yes: bool,
132    palette: Palette,
133    reader: &mut impl BufRead,
134    database: Arc<Database>,
135) -> Result<(), CliError> {
136    match command {
137        AdminCommand::User { command } => {
138            run_user_command(command, yes, palette, reader, database).await
139        }
140        AdminCommand::Session { command } => run_session_command(command, palette, database).await,
141    }
142}
143
144async fn run_user_command(
145    command: AdminUserCommand,
146    yes: bool,
147    palette: Palette,
148    reader: &mut impl BufRead,
149    database: Arc<Database>,
150) -> Result<(), CliError> {
151    match command {
152        AdminUserCommand::Create {
153            username,
154            password_file,
155        } => {
156            let password = read_password(password_file.as_deref(), reader)?;
157            let user = users::create_user(&username, &password, database)
158                .await
159                .map_err(user_error)?;
160            // The id, not the password: nothing echoes a credential back.
161            println!("Created admin user {} ({}).", user.username, user.id);
162        }
163        AdminUserCommand::List { json } => {
164            let users = users::list_users(database).await?;
165            render::print_rows(&users, json, admin::render_admin_user_json, |user| {
166                render::render_admin_user_line(user, palette)
167            });
168        }
169        AdminUserCommand::Passwd {
170            username,
171            password_file,
172        } => {
173            let password = read_password(password_file.as_deref(), reader)?;
174            match users::set_password(&username, &password, database)
175                .await
176                .map_err(user_error)?
177            {
178                None => return Err(not_found(&username)),
179                Some(user) => println!(
180                    "Password changed for {}. Every session they held was revoked.",
181                    user.username
182                ),
183            }
184        }
185        AdminUserCommand::Delete { username } => {
186            match users::confirm_delete_user(&username, yes, reader, database).await? {
187                DeleteOutcome::NotFound => return Err(not_found(&username)),
188                DeleteOutcome::Cancelled => println!("Cancelled."),
189                DeleteOutcome::Deleted => println!("Deleted admin user {username}."),
190            }
191        }
192        AdminUserCommand::Disable { username } => {
193            set_status_or_not_found(&username, "disabled", database).await?;
194            println!("Disabled {username}. Their sessions were revoked.");
195        }
196        AdminUserCommand::Enable { username } => {
197            set_status_or_not_found(&username, "active", database).await?;
198            println!("Enabled {username}.");
199        }
200        AdminUserCommand::Totp { command } => {
201            run_totp_command(command, yes, palette, reader, database).await?;
202        }
203    }
204    Ok(())
205}
206
207async fn run_totp_command(
208    command: AdminUserTotpCommand,
209    yes: bool,
210    palette: Palette,
211    reader: &mut impl BufRead,
212    database: Arc<Database>,
213) -> Result<(), CliError> {
214    match command {
215        AdminUserTotpCommand::Status { username, json } => {
216            let user = find_user(&username, database.clone()).await?;
217            let remaining = mfa::recovery_codes_remaining(&user.id, database).await?;
218
219            if json {
220                println!(
221                    "{}",
222                    serde_json::json!({
223                        "username": user.username,
224                        "totpEnabled": user.has_totp(),
225                        "enrolmentPending": user.has_pending_totp(),
226                        "recoveryCodesRemaining": remaining,
227                    })
228                );
229            } else {
230                println!(
231                    "{}",
232                    render::render_admin_totp_line(&user, remaining, palette)
233                );
234            }
235        }
236        AdminUserTotpCommand::Reset { username } => {
237            let mut user = find_user(&username, database.clone()).await?;
238            if !user.has_totp() && !user.has_pending_totp() {
239                println!("{} has no second factor; nothing to reset.", user.username);
240                return Ok(());
241            }
242
243            let prompt = format!(
244                "Remove the second factor and every recovery code for {}, \
245                 and revoke their sessions?",
246                user.username
247            );
248            if !confirm(&prompt, yes, reader) {
249                println!("Cancelled.");
250                return Ok(());
251            }
252
253            // `None`: this is a change made on the operator's behalf, from a
254            // shell they are not signed in from, so there is no session to keep.
255            mfa::disable_totp(&mut user, None, database).await?;
256            println!(
257                "Removed the second factor for {}. Their sessions were revoked; \
258                 they can sign in with a password alone until they enrol again.",
259                user.username
260            );
261        }
262        AdminUserTotpCommand::RecoveryCodes { username } => {
263            let user = find_user(&username, database.clone()).await?;
264            if !user.has_totp() {
265                return Err(CliError(format!(
266                    "{} has no second factor, so recovery codes would recover nothing: \
267                     enrol from the panel first",
268                    user.username
269                )));
270            }
271
272            let codes = mfa::regenerate_recovery_codes(&user, database).await?;
273            // The `eab create` treatment: printed once, stored one-way, and the
274            // previous set is already dead by the time this prints.
275            println!(
276                "New recovery codes for {} — the previous set no longer works.\n\
277                 Store these now; they are not recoverable.\n",
278                user.username
279            );
280            for code in &codes {
281                println!("  {code}");
282            }
283        }
284    }
285    Ok(())
286}
287
288/// Resolves a username, reporting an unknown one in words rather than as a
289/// silent no-op.
290async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
291    AdminUser::find_by_username(username, &database)
292        .await?
293        .ok_or_else(|| not_found(username))
294}
295
296async fn run_session_command(
297    command: AdminSessionCommand,
298    palette: Palette,
299    database: Arc<Database>,
300) -> Result<(), CliError> {
301    match command {
302        AdminSessionCommand::List { username, json } => {
303            // Resolved to an id first: `admin_sessions` carries the user id,
304            // and an unknown name must say so rather than quietly listing
305            // every session on the server.
306            let user_id = match username.as_deref() {
307                None => None,
308                Some(name) => match AdminUser::find_by_username(name, &database).await? {
309                    None => return Err(not_found(name)),
310                    Some(user) => Some(user.id),
311                },
312            };
313
314            let sessions = AdminSession::list_all(user_id.as_deref(), &database).await?;
315            render::print_rows(
316                &sessions,
317                json,
318                admin::render_admin_session_json,
319                |session| render::render_admin_session_line(session, palette),
320            );
321        }
322        AdminSessionCommand::Revoke { user, all } => match (user, all) {
323            (Some(username), _) => match users::revoke_sessions(&username, database).await? {
324                None => return Err(not_found(&username)),
325                Some(count) => println!("Revoked {count} session(s) for {username}."),
326            },
327            (None, true) => {
328                let count = AdminSession::delete_all(&database).await?;
329                println!("Revoked {count} session(s).");
330            }
331            (None, false) => {
332                return Err(CliError(
333                    "say whose sessions to revoke: --user <username>, or --all".to_string(),
334                ));
335            }
336        },
337    }
338    Ok(())
339}
340
341async fn set_status_or_not_found(
342    username: &str,
343    status: &str,
344    database: Arc<Database>,
345) -> Result<(), CliError> {
346    if users::set_status(username, status, database)
347        .await?
348        .is_none()
349    {
350        return Err(not_found(username));
351    }
352    Ok(())
353}
354
355/// Reads a password from a file, or one line of `reader`.
356///
357/// The file form strips a single trailing newline, so
358/// `printf '%s\n' "$pw" > file` and `printf '%s' "$pw" > file` mean the same
359/// thing — an operator should not have to know which their editor wrote.
360fn read_password(
361    path: Option<&std::path::Path>,
362    reader: &mut impl BufRead,
363) -> Result<String, CliError> {
364    match path {
365        Some(path) => {
366            let raw = std::fs::read_to_string(path)
367                .map_err(|error| CliError(format!("cannot read {}: {error}", path.display())))?;
368            Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
369        }
370        None => {
371            // No `rpassword`: echo suppression needs a real TTY, which would
372            // break the injectable-reader testability this whole layer is
373            // built on. Warn instead, and point at the flag that avoids it.
374            if std::io::stdin().is_terminal() {
375                eprintln!(
376                    "Note: the password will be echoed. Use --password-file, or pipe it in:\n  \
377                     printf '%s' \"$password\" | acme-proxy admin user create <username>"
378                );
379            }
380            eprintln!("Enter the password, then press Enter:");
381            let mut line = String::new();
382            if reader.read_line(&mut line).unwrap_or(0) == 0 {
383                return Err(CliError("no password supplied".to_string()));
384            }
385            // Only the line terminator, never surrounding whitespace: a
386            // password may legitimately begin or end with a space.
387            let password = line.strip_suffix('\n').unwrap_or(&line);
388            let password = password.strip_suffix('\r').unwrap_or(password);
389            Ok(password.to_string())
390        }
391    }
392}
393
394fn user_error(error: UserError) -> CliError {
395    match error {
396        UserError::Database(error) => CliError::from(error),
397        other => CliError(other.to_string()),
398    }
399}
400
401fn not_found(username: &str) -> CliError {
402    CliError(format!("no such admin user: {username}"))
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::sqlite::admin_session::NewSession;
409    use crate::testutil::TempDir;
410
411    const GOOD: &str = "a-long-enough-password";
412
413    async fn db() -> Arc<Database> {
414        Arc::new(Database::connect_in_memory().await.unwrap())
415    }
416
417    /// Runs a command with a stdin that supplies `input`.
418    async fn run(
419        command: AdminCommand,
420        input: &str,
421        database: Arc<Database>,
422    ) -> Result<(), CliError> {
423        let mut reader = input.as_bytes();
424        run_admin_command(command, true, Palette::plain(), &mut reader, database).await
425    }
426
427    fn create(username: &str) -> AdminCommand {
428        AdminCommand::User {
429            command: AdminUserCommand::Create {
430                username: username.to_string(),
431                password_file: None,
432            },
433        }
434    }
435
436    #[tokio::test]
437    async fn create_reads_the_password_from_stdin() {
438        let db = db().await;
439        run(create("alice"), &format!("{GOOD}\n"), db.clone())
440            .await
441            .unwrap();
442
443        let user = AdminUser::find_by_username("alice", &db)
444            .await
445            .unwrap()
446            .unwrap();
447        assert!(user.is_active());
448        assert_eq!(
449            admin::password::verify_password(&user.password_hash, GOOD),
450            Ok(true)
451        );
452    }
453
454    #[tokio::test]
455    async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
456        let dir = TempDir::new("admin-passwd");
457        let path = dir.join("pw");
458        std::fs::write(&path, format!("{GOOD}\n")).unwrap();
459
460        let db = db().await;
461        run(
462            AdminCommand::User {
463                command: AdminUserCommand::Create {
464                    username: "alice".to_string(),
465                    password_file: Some(path),
466                },
467            },
468            "",
469            db.clone(),
470        )
471        .await
472        .unwrap();
473
474        let user = AdminUser::find_by_username("alice", &db)
475            .await
476            .unwrap()
477            .unwrap();
478        assert_eq!(
479            admin::password::verify_password(&user.password_hash, GOOD),
480            Ok(true),
481            "the trailing newline must not be part of the password"
482        );
483    }
484
485    #[tokio::test]
486    async fn create_refuses_a_missing_password_file() {
487        let db = db().await;
488        let error = run(
489            AdminCommand::User {
490                command: AdminUserCommand::Create {
491                    username: "alice".to_string(),
492                    password_file: Some(PathBuf::from("/nonexistent/pw")),
493                },
494            },
495            "",
496            db,
497        )
498        .await
499        .unwrap_err();
500        assert!(error.0.starts_with("cannot read /nonexistent/pw"));
501    }
502
503    #[tokio::test]
504    async fn create_refuses_empty_stdin() {
505        let db = db().await;
506        let error = run(create("alice"), "", db).await.unwrap_err();
507        assert_eq!(error, CliError("no password supplied".to_string()));
508    }
509
510    #[tokio::test]
511    async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
512        let db = db().await;
513        let error = run(create("alice"), "short\n", db.clone())
514            .await
515            .unwrap_err();
516        assert!(error.0.contains("at least 12"), "got: {}", error.0);
517
518        run(create("alice"), &format!("{GOOD}\n"), db.clone())
519            .await
520            .unwrap();
521        let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
522            .await
523            .unwrap_err();
524        assert_eq!(
525            error,
526            CliError("an admin user named `alice` already exists".to_string())
527        );
528    }
529
530    #[tokio::test]
531    async fn passwd_changes_the_password_and_reports_an_unknown_user() {
532        let db = db().await;
533        run(create("alice"), &format!("{GOOD}\n"), db.clone())
534            .await
535            .unwrap();
536
537        let passwd = |username: &str| AdminCommand::User {
538            command: AdminUserCommand::Passwd {
539                username: username.to_string(),
540                password_file: None,
541            },
542        };
543
544        run(passwd("alice"), "another-long-password\n", db.clone())
545            .await
546            .unwrap();
547        let user = AdminUser::find_by_username("alice", &db)
548            .await
549            .unwrap()
550            .unwrap();
551        assert_eq!(
552            admin::password::verify_password(&user.password_hash, "another-long-password"),
553            Ok(true)
554        );
555
556        let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
557            .await
558            .unwrap_err();
559        assert_eq!(error, CliError("no such admin user: nobody".to_string()));
560    }
561
562    #[tokio::test]
563    async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
564        let db = db().await;
565        for json in [true, false] {
566            run(
567                AdminCommand::User {
568                    command: AdminUserCommand::List { json },
569                },
570                "",
571                db.clone(),
572            )
573            .await
574            .unwrap();
575        }
576
577        run(create("alice"), &format!("{GOOD}\n"), db.clone())
578            .await
579            .unwrap();
580        for json in [true, false] {
581            run(
582                AdminCommand::User {
583                    command: AdminUserCommand::List { json },
584                },
585                "",
586                db.clone(),
587            )
588            .await
589            .unwrap();
590        }
591    }
592
593    #[tokio::test]
594    async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
595        let db = db().await;
596        run(create("alice"), &format!("{GOOD}\n"), db.clone())
597            .await
598            .unwrap();
599
600        let disable = |username: &str| AdminCommand::User {
601            command: AdminUserCommand::Disable {
602                username: username.to_string(),
603            },
604        };
605        let enable = |username: &str| AdminCommand::User {
606            command: AdminUserCommand::Enable {
607                username: username.to_string(),
608            },
609        };
610
611        run(disable("alice"), "", db.clone()).await.unwrap();
612        assert!(
613            !AdminUser::find_by_username("alice", &db)
614                .await
615                .unwrap()
616                .unwrap()
617                .is_active()
618        );
619
620        run(enable("alice"), "", db.clone()).await.unwrap();
621        assert!(
622            AdminUser::find_by_username("alice", &db)
623                .await
624                .unwrap()
625                .unwrap()
626                .is_active()
627        );
628
629        for command in [disable("nobody"), enable("nobody")] {
630            assert_eq!(
631                run(command, "", db.clone()).await.unwrap_err(),
632                CliError("no such admin user: nobody".to_string())
633            );
634        }
635    }
636
637    #[tokio::test]
638    async fn delete_covers_not_found_cancelled_and_deleted() {
639        let db = db().await;
640        let delete = AdminCommand::User {
641            command: AdminUserCommand::Delete {
642                username: "alice".to_string(),
643            },
644        };
645
646        assert_eq!(
647            run(
648                AdminCommand::User {
649                    command: AdminUserCommand::Delete {
650                        username: "nobody".to_string()
651                    }
652                },
653                "",
654                db.clone()
655            )
656            .await
657            .unwrap_err(),
658            CliError("no such admin user: nobody".to_string())
659        );
660
661        run(create("alice"), &format!("{GOOD}\n"), db.clone())
662            .await
663            .unwrap();
664
665        // Declined: `yes` is false here, so the reader's "n" decides.
666        let mut no = b"n\n".as_slice();
667        run_admin_command(
668            AdminCommand::User {
669                command: AdminUserCommand::Delete {
670                    username: "alice".to_string(),
671                },
672            },
673            false,
674            Palette::plain(),
675            &mut no,
676            db.clone(),
677        )
678        .await
679        .unwrap();
680        assert!(
681            AdminUser::find_by_username("alice", &db)
682                .await
683                .unwrap()
684                .is_some()
685        );
686
687        run(delete, "", db.clone()).await.unwrap();
688        assert!(
689            AdminUser::find_by_username("alice", &db)
690                .await
691                .unwrap()
692                .is_none()
693        );
694    }
695
696    #[tokio::test]
697    async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
698        let db = db().await;
699        run(create("alice"), &format!("{GOOD}\n"), db.clone())
700            .await
701            .unwrap();
702        let alice = AdminUser::find_by_username("alice", &db)
703            .await
704            .unwrap()
705            .unwrap();
706        AdminSession::create(
707            NewSession {
708                user_id: &alice.id,
709                token_hash: "hash-a",
710                csrf_token: "csrf",
711                created_ip: None,
712                user_agent: None,
713            },
714            std::time::Duration::from_secs(60),
715            &db,
716        )
717        .await
718        .unwrap();
719
720        for (username, json) in [
721            (None, true),
722            (None, false),
723            (Some("alice".to_string()), true),
724            (Some("alice".to_string()), false),
725        ] {
726            run(
727                AdminCommand::Session {
728                    command: AdminSessionCommand::List { username, json },
729                },
730                "",
731                db.clone(),
732            )
733            .await
734            .unwrap();
735        }
736
737        assert_eq!(
738            run(
739                AdminCommand::Session {
740                    command: AdminSessionCommand::List {
741                        username: Some("nobody".to_string()),
742                        json: false,
743                    },
744                },
745                "",
746                db,
747            )
748            .await
749            .unwrap_err(),
750            CliError("no such admin user: nobody".to_string())
751        );
752    }
753
754    #[tokio::test]
755    async fn session_revoke_handles_user_all_and_neither() {
756        let db = db().await;
757        run(create("alice"), &format!("{GOOD}\n"), db.clone())
758            .await
759            .unwrap();
760        let alice = AdminUser::find_by_username("alice", &db)
761            .await
762            .unwrap()
763            .unwrap();
764        for hash in ["a", "b"] {
765            AdminSession::create(
766                NewSession {
767                    user_id: &alice.id,
768                    token_hash: hash,
769                    csrf_token: "csrf",
770                    created_ip: None,
771                    user_agent: None,
772                },
773                std::time::Duration::from_secs(60),
774                &db,
775            )
776            .await
777            .unwrap();
778        }
779
780        // Neither flag: refuse rather than guess which was meant.
781        assert_eq!(
782            run(
783                AdminCommand::Session {
784                    command: AdminSessionCommand::Revoke {
785                        user: None,
786                        all: false
787                    },
788                },
789                "",
790                db.clone(),
791            )
792            .await
793            .unwrap_err(),
794            CliError("say whose sessions to revoke: --user <username>, or --all".to_string())
795        );
796
797        assert_eq!(
798            run(
799                AdminCommand::Session {
800                    command: AdminSessionCommand::Revoke {
801                        user: Some("nobody".to_string()),
802                        all: false
803                    },
804                },
805                "",
806                db.clone(),
807            )
808            .await
809            .unwrap_err(),
810            CliError("no such admin user: nobody".to_string())
811        );
812
813        run(
814            AdminCommand::Session {
815                command: AdminSessionCommand::Revoke {
816                    user: Some("alice".to_string()),
817                    all: false,
818                },
819            },
820            "",
821            db.clone(),
822        )
823        .await
824        .unwrap();
825        assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
826
827        // `--all` on an empty table is a no-op, not a failure.
828        run(
829            AdminCommand::Session {
830                command: AdminSessionCommand::Revoke {
831                    user: None,
832                    all: true,
833                },
834            },
835            "",
836            db,
837        )
838        .await
839        .unwrap();
840    }
841
842    // --- Second factor ----------------------------------------------------
843
844    fn totp(command: AdminUserTotpCommand) -> AdminCommand {
845        AdminCommand::User {
846            command: AdminUserCommand::Totp { command },
847        }
848    }
849
850    /// Enrols `username` through the operation layer, the way the panel would,
851    /// so the CLI arms have a real factor to act on.
852    async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
853        let mut user = AdminUser::find_by_username(username, &database)
854            .await
855            .unwrap()
856            .unwrap();
857        let enrolment =
858            mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
859                .await
860                .unwrap();
861        let code = admin::totp::totp_at(
862            &enrolment.secret,
863            admin::totp::step_at(crate::sqlite::nonce::now_secs()),
864            admin::totp::DIGITS,
865        );
866        mfa::confirm_totp_enrolment(&mut user, &code, None, database)
867            .await
868            .unwrap()
869            .expect("a freshly generated code must confirm its own enrolment");
870        user
871    }
872
873    #[tokio::test]
874    async fn totp_status_reports_all_three_states() {
875        let db = db().await;
876        run(create("alice"), &format!("{GOOD}\n"), db.clone())
877            .await
878            .unwrap();
879
880        let status = |json| {
881            totp(AdminUserTotpCommand::Status {
882                username: "alice".to_string(),
883                json,
884            })
885        };
886
887        // No factor.
888        run(status(false), "", db.clone()).await.unwrap();
889        run(status(true), "", db.clone()).await.unwrap();
890
891        // Enrolment started and never confirmed: still not a factor, but it
892        // must not read the same as "off" -- an operator who thinks they
893        // enrolled has no other way to find out.
894        let mut user = AdminUser::find_by_username("alice", &db)
895            .await
896            .unwrap()
897            .unwrap();
898        mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
899            .await
900            .unwrap();
901        let line = render::render_admin_totp_line(&user, 0, Palette::plain());
902        assert!(line.contains("pending"), "{line}");
903        run(status(false), "", db.clone()).await.unwrap();
904
905        // Confirmed.
906        let user = enrol("alice", db.clone()).await;
907        let line = render::render_admin_totp_line(&user, 10, Palette::plain());
908        assert!(line.contains("totp=enabled"), "{line}");
909        assert!(line.contains("recovery-codes=10"), "{line}");
910        run(status(true), "", db).await.unwrap();
911    }
912
913    #[tokio::test]
914    async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
915        let db = db().await;
916        run(create("alice"), &format!("{GOOD}\n"), db.clone())
917            .await
918            .unwrap();
919        let user = enrol("alice", db.clone()).await;
920
921        AdminSession::create(
922            NewSession {
923                user_id: &user.id,
924                token_hash: "live-session",
925                csrf_token: "csrf",
926                created_ip: None,
927                user_agent: None,
928            },
929            std::time::Duration::from_secs(3600),
930            &db,
931        )
932        .await
933        .unwrap();
934
935        let reset = || {
936            totp(AdminUserTotpCommand::Reset {
937                username: "alice".to_string(),
938            })
939        };
940
941        // Declined: `yes` is false, so the reader's "n" decides and nothing
942        // moves. Removing a security control is confirm-gated, unlike
943        // `order revoke`, which only ever tightens trust.
944        let mut no = b"n\n".as_slice();
945        run_admin_command(reset(), false, Palette::plain(), &mut no, db.clone())
946            .await
947            .unwrap();
948        let unchanged = AdminUser::find_by_username("alice", &db)
949            .await
950            .unwrap()
951            .unwrap();
952        assert!(unchanged.has_totp());
953
954        run(reset(), "", db.clone()).await.unwrap();
955
956        let after = AdminUser::find_by_username("alice", &db)
957            .await
958            .unwrap()
959            .unwrap();
960        assert!(!after.has_totp());
961        assert!(!after.has_pending_totp());
962        assert_eq!(
963            mfa::recovery_codes_remaining(&after.id, db.clone())
964                .await
965                .unwrap(),
966            0
967        );
968        assert!(
969            AdminSession::list_all(Some(&after.id), &db)
970                .await
971                .unwrap()
972                .is_empty(),
973            "a factor removed that left a live session behind is a change in name only"
974        );
975
976        // Idempotent, and says so rather than asking again.
977        run(reset(), "", db).await.unwrap();
978    }
979
980    #[tokio::test]
981    async fn totp_recovery_codes_supersede_the_previous_set() {
982        let db = db().await;
983        run(create("alice"), &format!("{GOOD}\n"), db.clone())
984            .await
985            .unwrap();
986        let user = enrol("alice", db.clone()).await;
987
988        let before =
989            crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(&user.id, &db)
990                .await
991                .unwrap();
992        assert_eq!(before.len(), 10);
993
994        run(
995            totp(AdminUserTotpCommand::RecoveryCodes {
996                username: "alice".to_string(),
997            }),
998            "",
999            db.clone(),
1000        )
1001        .await
1002        .unwrap();
1003
1004        let after =
1005            crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(&user.id, &db)
1006                .await
1007                .unwrap();
1008        assert_eq!(after.len(), 10);
1009        assert!(
1010            after
1011                .iter()
1012                .all(|code| before.iter().all(|old| old.id != code.id)),
1013            "the previous set must stop working"
1014        );
1015    }
1016
1017    /// Recovery codes for an operator with no factor would recover nothing, so
1018    /// the command says so rather than minting ten useless strings.
1019    #[tokio::test]
1020    async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
1021        let db = db().await;
1022        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1023            .await
1024            .unwrap();
1025
1026        let error = run(
1027            totp(AdminUserTotpCommand::RecoveryCodes {
1028                username: "alice".to_string(),
1029            }),
1030            "",
1031            db,
1032        )
1033        .await
1034        .unwrap_err();
1035        assert!(error.0.contains("no second factor"), "{}", error.0);
1036    }
1037
1038    #[tokio::test]
1039    async fn every_totp_arm_refuses_an_unknown_operator() {
1040        let db = db().await;
1041        let expected = CliError("no such admin user: nobody".to_string());
1042
1043        for command in [
1044            AdminUserTotpCommand::Status {
1045                username: "nobody".to_string(),
1046                json: false,
1047            },
1048            AdminUserTotpCommand::Reset {
1049                username: "nobody".to_string(),
1050            },
1051            AdminUserTotpCommand::RecoveryCodes {
1052                username: "nobody".to_string(),
1053            },
1054        ] {
1055            assert_eq!(
1056                run(totp(command), "", db.clone()).await.unwrap_err(),
1057                expected
1058            );
1059        }
1060    }
1061}