1use 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 User {
36 #[command(subcommand)]
37 command: AdminUserCommand,
38 },
39 Session {
41 #[command(subcommand)]
42 command: AdminSessionCommand,
43 },
44}
45
46#[derive(Subcommand)]
47pub enum AdminUserCommand {
48 Create {
51 username: String,
52 #[arg(long = "password-file")]
55 password_file: Option<PathBuf>,
56 },
57 List {
59 #[arg(long)]
60 json: bool,
61 },
62 Passwd {
64 username: String,
65 #[arg(long = "password-file")]
66 password_file: Option<PathBuf>,
67 },
68 Delete { username: String },
70 Disable { username: String },
72 Enable { username: String },
74 Totp {
76 #[command(subcommand)]
77 command: AdminUserTotpCommand,
78 },
79}
80
81#[derive(Subcommand)]
90pub enum AdminUserTotpCommand {
91 Status {
94 username: String,
95 #[arg(long)]
96 json: bool,
97 },
98 Reset { username: String },
104 RecoveryCodes { username: String },
107}
108
109#[derive(Subcommand)]
110pub enum AdminSessionCommand {
111 List {
113 #[arg(long)]
115 username: Option<String>,
116 #[arg(long)]
117 json: bool,
118 },
119 Revoke {
121 #[arg(long, conflicts_with = "all")]
122 user: Option<String>,
123 #[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 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 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 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
288async 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 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
355fn 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 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 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 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 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 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 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 fn totp(command: AdminUserTotpCommand) -> AdminCommand {
845 AdminCommand::User {
846 command: AdminUserCommand::Totp { command },
847 }
848 }
849
850 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 run(status(false), "", db.clone()).await.unwrap();
889 run(status(true), "", db.clone()).await.unwrap();
890
891 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 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 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 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 #[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}