1use std::str::FromStr;
12
13use crate::sql::Row;
14use serde_json::Value;
15use tracing::{debug, info};
16use uuid::Uuid;
17
18use crate::db::Database;
19use crate::nonce::now_secs;
20use acme_proxy_core::datetime::rfc3339;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub enum AdminRole {
36 Viewer,
40 Operator,
46 Admin,
52}
53
54impl AdminRole {
55 pub const ALL: &'static [Self] = &[Self::Viewer, Self::Operator, Self::Admin];
58
59 #[must_use]
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Self::Viewer => "viewer",
64 Self::Operator => "operator",
65 Self::Admin => "admin",
66 }
67 }
68
69 #[must_use]
79 pub fn from_storage(raw: Option<&str>) -> Self {
80 match raw {
81 None | Some("admin") => Self::Admin,
82 Some("operator") => Self::Operator,
83 _ => Self::Viewer,
84 }
85 }
86}
87
88impl std::fmt::Display for AdminRole {
89 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 formatter.write_str(self.as_str())
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108pub enum AdminStatus {
109 Active,
111 Disabled,
114}
115
116impl AdminStatus {
117 #[must_use]
119 pub fn as_str(self) -> &'static str {
120 match self {
121 Self::Active => "active",
122 Self::Disabled => "disabled",
123 }
124 }
125}
126
127impl std::fmt::Display for AdminStatus {
128 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 formatter.write_str(self.as_str())
130 }
131}
132
133impl FromStr for AdminRole {
134 type Err = String;
135
136 fn from_str(value: &str) -> Result<Self, Self::Err> {
141 match value {
142 "viewer" => Ok(Self::Viewer),
143 "operator" => Ok(Self::Operator),
144 "admin" => Ok(Self::Admin),
145 other => Err(format!(
146 "unknown role `{other}` (expected one of: viewer, operator, admin)"
147 )),
148 }
149 }
150}
151
152#[derive(Debug, Clone)]
171pub struct AdminUser {
172 pub id: Uuid,
173 pub username: String,
176 pub password_hash: String,
178 pub status: String,
179 pub role: Option<String>,
183 pub totp_secret: Option<Vec<u8>>,
186 pub totp_pending_secret: Option<Vec<u8>>,
188 pub totp_last_step: Option<i64>,
191 pub created_at: i64,
192 pub updated_at: i64,
193 pub last_login_at: Option<i64>,
194 pub contact_email: Option<String>,
198 pub known_login_ips: Vec<String>,
203}
204
205macro_rules! columns {
211 () => {
212 "id, username, password_hash, status, role, totp_secret, totp_pending_secret, \
213 totp_last_step, created_at, updated_at, last_login_at, contact_email, known_login_ips"
214 };
215}
216
217pub const KNOWN_LOGIN_IPS: usize = 5;
225
226impl AdminUser {
227 fn from_row(row: Row) -> Result<Self, sqlx::Error> {
228 Ok(AdminUser {
229 id: row.try_get("id")?,
230 username: row.try_get("username")?,
231 password_hash: row.try_get("password_hash")?,
232 status: row.try_get("status")?,
233 role: row.try_get("role")?,
234 totp_secret: row.try_get("totp_secret")?,
235 totp_pending_secret: row.try_get("totp_pending_secret")?,
236 totp_last_step: row.try_get("totp_last_step")?,
237 created_at: row.try_get("created_at")?,
238 updated_at: row.try_get("updated_at")?,
239 last_login_at: row.try_get("last_login_at")?,
240 contact_email: row.try_get("contact_email")?,
241 known_login_ips: {
242 let raw: String = row.try_get("known_login_ips")?;
243 serde_json::from_str(&raw).map_err(|e| sqlx::Error::Decode(Box::new(e)))?
244 },
245 })
246 }
247
248 pub async fn create(
266 username: &str,
267 password_hash: &str,
268 role: Option<AdminRole>,
269 database: &Database,
270 ) -> Result<AdminUser, sqlx::Error> {
271 let now = now_secs();
272 let user = AdminUser {
273 id: crate::id::mint(),
274 username: username.trim().to_lowercase(),
275 password_hash: password_hash.to_string(),
276 status: "active".to_string(),
277 role: role.map(|role| role.as_str().to_string()),
278 totp_secret: None,
279 totp_pending_secret: None,
280 totp_last_step: None,
281 created_at: now,
282 updated_at: now,
283 last_login_at: None,
284 contact_email: None,
285 known_login_ips: Vec::new(),
286 };
287
288 debug!(event = "db_admin_user_create_started", outcome = "progress", username = %user.username);
289 crate::sql::query(
290 "INSERT INTO admin_users \
291 (id, username, password_hash, status, role, created_at, updated_at) \
292 VALUES (?, ?, ?, ?, ?, ?, ?);",
293 )
294 .bind(user.id)
295 .bind(&user.username)
296 .bind(&user.password_hash)
297 .bind(&user.status)
298 .bind(user.role.clone())
299 .bind(user.created_at)
300 .bind(user.updated_at)
301 .execute(database)
302 .await?;
303
304 info!(event = "db_admin_user_created", outcome = "success", user_id = %user.id, username = %user.username);
305 Ok(user)
306 }
307
308 pub async fn find_by_id(
310 id: Uuid,
311 database: &Database,
312 ) -> Result<Option<AdminUser>, sqlx::Error> {
313 debug!(event = "db_admin_user_find_by_id_started", outcome = "progress", id = ?id);
314 let row = crate::sql::query(concat!(
315 "SELECT ",
316 columns!(),
317 " FROM admin_users WHERE id = ?;"
318 ))
319 .bind(id)
320 .fetch_optional(database)
321 .await?;
322
323 row.map(AdminUser::from_row).transpose()
324 }
325
326 pub async fn find_by_username(
329 username: &str,
330 database: &Database,
331 ) -> Result<Option<AdminUser>, sqlx::Error> {
332 debug!(
333 event = "db_admin_user_find_by_username_started",
334 outcome = "progress"
335 );
336 let row = crate::sql::query(concat!(
337 "SELECT ",
338 columns!(),
339 " FROM admin_users WHERE username = ?;"
340 ))
341 .bind(username.trim().to_lowercase())
342 .fetch_optional(database)
343 .await?;
344
345 row.map(AdminUser::from_row).transpose()
346 }
347
348 pub async fn list_all(database: &Database) -> Result<Vec<AdminUser>, sqlx::Error> {
358 debug!(
359 event = "db_admin_user_list_all_started",
360 outcome = "progress"
361 );
362 let rows = crate::sql::query(concat!(
363 "SELECT ",
364 columns!(),
365 " FROM admin_users ORDER BY created_at ASC, id ASC;"
366 ))
367 .fetch_all(database)
368 .await?;
369
370 rows.into_iter().map(AdminUser::from_row).collect()
371 }
372
373 pub async fn search(
382 limit: i64,
383 offset: i64,
384 database: &Database,
385 ) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
386 debug!(
387 event = "db_admin_user_search_started",
388 outcome = "progress",
389 limit = limit,
390 offset = offset
391 );
392 let rows = crate::sql::query(concat!(
393 "SELECT ",
394 columns!(),
395 " FROM admin_users ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?;"
396 ))
397 .bind(limit)
398 .bind(offset)
399 .fetch_all(database)
400 .await?;
401 let total: i64 = crate::sql::query("SELECT COUNT(*) FROM admin_users;")
402 .fetch_one(database)
403 .await?
404 .try_get(0)?;
405
406 let users = rows
407 .into_iter()
408 .map(AdminUser::from_row)
409 .collect::<Result<_, _>>()?;
410 Ok((users, total))
411 }
412
413 pub async fn set_password_hash(
417 &mut self,
418 password_hash: &str,
419 database: &Database,
420 ) -> Result<(), sqlx::Error> {
421 let now = now_secs();
422 crate::sql::query("UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?;")
423 .bind(password_hash)
424 .bind(now)
425 .bind(self.id)
426 .execute(database)
427 .await?;
428
429 self.password_hash = password_hash.to_string();
430 self.updated_at = now;
431 info!(event = "db_admin_user_password_changed", outcome = "success", user_id = %self.id, username = %self.username);
432 Ok(())
433 }
434
435 pub async fn set_status(
440 &mut self,
441 status: &str,
442 database: &Database,
443 ) -> Result<(), sqlx::Error> {
444 let now = now_secs();
445 crate::sql::query("UPDATE admin_users SET status = ?, updated_at = ? WHERE id = ?;")
446 .bind(status)
447 .bind(now)
448 .bind(self.id)
449 .execute(database)
450 .await?;
451
452 self.status = status.to_string();
453 self.updated_at = now;
454 info!(event = "db_admin_user_status_changed", outcome = "success", user_id = %self.id, username = %self.username, status = %status);
455 Ok(())
456 }
457
458 pub async fn set_role(
463 &mut self,
464 role: AdminRole,
465 database: &Database,
466 ) -> Result<(), sqlx::Error> {
467 let now = now_secs();
468 crate::sql::query("UPDATE admin_users SET role = ?, updated_at = ? WHERE id = ?;")
469 .bind(role.as_str())
470 .bind(now)
471 .bind(self.id)
472 .execute(database)
473 .await?;
474
475 self.role = Some(role.as_str().to_string());
476 self.updated_at = now;
477 info!(event = "db_admin_user_role_changed", outcome = "success", user_id = %self.id, username = %self.username, role = %role);
478 Ok(())
479 }
480
481 pub async fn set_contact_email(
486 &mut self,
487 email: Option<&str>,
488 database: &Database,
489 ) -> Result<(), sqlx::Error> {
490 let now = now_secs();
491 crate::sql::query("UPDATE admin_users SET contact_email = ?, updated_at = ? WHERE id = ?;")
492 .bind(email)
493 .bind(now)
494 .bind(self.id)
495 .execute(database)
496 .await?;
497
498 self.contact_email = email.map(str::to_string);
499 self.updated_at = now;
500 info!(event = "db_admin_user_contact_changed", outcome = "success", user_id = %self.id, username = %self.username, cleared = email.is_none());
501 Ok(())
502 }
503
504 pub async fn set_totp_pending(
510 &mut self,
511 secret: &[u8],
512 database: &Database,
513 ) -> Result<(), sqlx::Error> {
514 let now = now_secs();
515 crate::sql::query(
516 "UPDATE admin_users SET totp_pending_secret = ?, updated_at = ? WHERE id = ?;",
517 )
518 .bind(secret)
519 .bind(now)
520 .bind(self.id)
521 .execute(database)
522 .await?;
523
524 self.totp_pending_secret = Some(secret.to_vec());
525 self.updated_at = now;
526 info!(event = "db_admin_totp_enrolment_started", outcome = "progress", user_id = %self.id, username = %self.username);
527 Ok(())
528 }
529
530 pub async fn confirm_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
540 let Some(pending) = self.totp_pending_secret.clone() else {
541 return Ok(());
542 };
543
544 let now = now_secs();
545 crate::sql::query(
546 "UPDATE admin_users SET totp_secret = totp_pending_secret, \
547 totp_pending_secret = NULL, totp_last_step = NULL, updated_at = ? \
548 WHERE id = ? AND totp_pending_secret IS NOT NULL;",
549 )
550 .bind(now)
551 .bind(self.id)
552 .execute(database)
553 .await?;
554
555 self.totp_secret = Some(pending);
556 self.totp_pending_secret = None;
557 self.totp_last_step = None;
558 self.updated_at = now;
559 info!(event = "db_admin_totp_enabled", outcome = "success", user_id = %self.id, username = %self.username);
560 Ok(())
561 }
562
563 pub async fn clear_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
567 let now = now_secs();
568 crate::sql::query(
569 "UPDATE admin_users SET totp_secret = NULL, totp_pending_secret = NULL, \
570 totp_last_step = NULL, updated_at = ? WHERE id = ?;",
571 )
572 .bind(now)
573 .bind(self.id)
574 .execute(database)
575 .await?;
576
577 self.totp_secret = None;
578 self.totp_pending_secret = None;
579 self.totp_last_step = None;
580 self.updated_at = now;
581 info!(event = "db_admin_totp_disabled", outcome = "success", user_id = %self.id, username = %self.username);
582 Ok(())
583 }
584
585 pub async fn claim_totp_step(
594 &mut self,
595 step: i64,
596 database: &Database,
597 ) -> Result<bool, sqlx::Error> {
598 let now = now_secs();
599 let result = crate::sql::query(
600 "UPDATE admin_users SET totp_last_step = ?, updated_at = ? \
601 WHERE id = ? AND (totp_last_step IS NULL OR totp_last_step < ?);",
602 )
603 .bind(step)
604 .bind(now)
605 .bind(self.id)
606 .bind(step)
607 .execute(database)
608 .await?;
609
610 let claimed = result.rows_affected() == 1;
611 if claimed {
612 self.totp_last_step = Some(step);
613 self.updated_at = now;
614 }
615 Ok(claimed)
616 }
617
618 pub async fn mark_logged_in(
628 &mut self,
629 client_ip: Option<&str>,
630 database: &Database,
631 ) -> Result<(), sqlx::Error> {
632 let now = now_secs();
633
634 if let Some(ip) = client_ip.filter(|ip| !ip.is_empty()) {
635 let mut known = Vec::with_capacity(KNOWN_LOGIN_IPS);
636 known.push(ip.to_string());
637 known.extend(
638 self.known_login_ips
639 .iter()
640 .filter(|seen| seen.as_str() != ip)
641 .take(KNOWN_LOGIN_IPS - 1)
642 .cloned(),
643 );
644 let known_json = Value::from(known.clone()).to_string();
646 crate::sql::query(
647 "UPDATE admin_users SET last_login_at = ?, known_login_ips = ? WHERE id = ?;",
648 )
649 .bind(now)
650 .bind(known_json)
651 .bind(self.id)
652 .execute(database)
653 .await?;
654 self.known_login_ips = known;
655 } else {
656 crate::sql::query("UPDATE admin_users SET last_login_at = ? WHERE id = ?;")
657 .bind(now)
658 .bind(self.id)
659 .execute(database)
660 .await?;
661 }
662
663 self.last_login_at = Some(now);
664 Ok(())
665 }
666
667 pub async fn delete(id: Uuid, database: &Database) -> Result<bool, sqlx::Error> {
671 debug!(event = "db_admin_user_delete_started", outcome = "progress", id = ?id);
672 let result = crate::sql::query("DELETE FROM admin_users WHERE id = ?;")
673 .bind(id)
674 .execute(database)
675 .await?;
676
677 let deleted = result.rows_affected() > 0;
678 if deleted {
679 info!(event = "db_admin_user_deleted", outcome = "success", user_id = %id);
680 }
681 Ok(deleted)
682 }
683
684 #[must_use]
686 pub fn is_active(&self) -> bool {
687 self.status == "active"
688 }
689
690 #[must_use]
693 pub fn role(&self) -> AdminRole {
694 AdminRole::from_storage(self.role.as_deref())
695 }
696
697 #[must_use]
700 pub fn has_totp(&self) -> bool {
701 self.totp_secret.is_some()
702 }
703
704 #[must_use]
712 pub fn has_pending_totp(&self) -> bool {
713 self.totp_pending_secret.is_some()
714 }
715
716 #[must_use]
719 pub fn to_json(&self) -> Value {
720 serde_json::json!({
721 "id": self.id,
722 "username": self.username,
723 "status": self.status,
724 "role": self.role().as_str(),
725 "totpEnabled": self.has_totp(),
726 "createdAt": rfc3339(self.created_at),
727 "updatedAt": rfc3339(self.updated_at),
728 "lastLoginAt": self.last_login_at.map(rfc3339),
729 "contactEmail": self.contact_email,
730 "knownLoginIps": self.known_login_ips,
731 })
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use std::sync::Arc;
739
740 async fn db() -> Arc<Database> {
741 Arc::new(Database::connect_for_test().await.unwrap())
742 }
743
744 #[tokio::test]
745 async fn create_persists_an_active_user_with_a_lowercased_username() {
746 let db = db().await;
747 let user = AdminUser::create(" Alice ", "hash", None, &db)
748 .await
749 .unwrap();
750 assert_eq!(user.username, "alice");
751 assert_eq!(user.status, "active");
752 assert!(user.is_active());
753 assert!(user.last_login_at.is_none());
754 assert!(!user.has_totp());
755 }
756
757 #[tokio::test]
758 async fn find_by_username_is_case_insensitive_and_round_trips() {
759 let db = db().await;
760 let created = AdminUser::create("alice", "hash", None, &db).await.unwrap();
761 let found = AdminUser::find_by_username("ALICE", &db)
762 .await
763 .unwrap()
764 .unwrap();
765 assert_eq!(found.id, created.id);
766 assert_eq!(found.password_hash, "hash");
767
768 let by_id = AdminUser::find_by_id(created.id, &db)
769 .await
770 .unwrap()
771 .unwrap();
772 assert_eq!(by_id.username, "alice");
773 }
774
775 #[tokio::test]
776 async fn lookups_of_unknown_users_return_none() {
777 let db = db().await;
778 assert!(
779 AdminUser::find_by_username("nobody", &db)
780 .await
781 .unwrap()
782 .is_none()
783 );
784 assert!(
785 AdminUser::find_by_id(crate::id::mint(), &db)
786 .await
787 .unwrap()
788 .is_none()
789 );
790 }
791
792 #[tokio::test]
793 async fn a_duplicate_username_is_refused_by_the_unique_constraint() {
794 let db = db().await;
795 AdminUser::create("alice", "hash", None, &db).await.unwrap();
796 let error = AdminUser::create("Alice", "other", None, &db)
799 .await
800 .unwrap_err();
801 assert!(
802 crate::sql::is_unique_violation(&error),
803 "expected a UNIQUE violation, got: {error}"
804 );
805 }
806
807 #[tokio::test]
808 async fn list_all_returns_every_user_and_empty_is_empty() {
809 let db = db().await;
810 assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
811
812 AdminUser::create("a", "h", None, &db).await.unwrap();
813 AdminUser::create("b", "h", None, &db).await.unwrap();
814 let all = AdminUser::list_all(&db).await.unwrap();
815 assert_eq!(all.len(), 2);
816 let names: Vec<&str> = all.iter().map(|u| u.username.as_str()).collect();
825 assert_eq!(names, ["a", "b"], "the v7 tiebreak is insertion order");
826 }
827
828 #[tokio::test]
831 async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
832 let db = db().await;
833 assert_eq!(AdminUser::search(50, 0, &db).await.unwrap().1, 0);
834
835 for name in ["a", "b", "c", "d", "e"] {
836 AdminUser::create(name, "h", None, &db).await.unwrap();
837 }
838
839 let (first, total) = AdminUser::search(2, 0, &db).await.unwrap();
840 let (second, also_total) = AdminUser::search(2, 2, &db).await.unwrap();
841 let (third, _) = AdminUser::search(2, 4, &db).await.unwrap();
842
843 assert_eq!((total, also_total), (5, 5), "the total is the table");
844 assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
845
846 let walked: Vec<&str> = first
851 .iter()
852 .chain(second.iter())
853 .chain(third.iter())
854 .map(|user| user.username.as_str())
855 .collect();
856 assert_eq!(walked, ["a", "b", "c", "d", "e"]);
857 }
858
859 #[tokio::test]
863 async fn search_reads_the_table_in_the_same_order_as_the_scan() {
864 let db = db().await;
865 for name in ["a", "b", "c"] {
866 AdminUser::create(name, "h", None, &db).await.unwrap();
867 }
868
869 let scanned: Vec<String> = AdminUser::list_all(&db)
870 .await
871 .unwrap()
872 .into_iter()
873 .map(|user| user.username)
874 .collect();
875 let paged: Vec<String> = AdminUser::search(50, 0, &db)
876 .await
877 .unwrap()
878 .0
879 .into_iter()
880 .map(|user| user.username)
881 .collect();
882 assert_eq!(paged, scanned);
883 }
884
885 #[tokio::test]
886 async fn list_all_orders_oldest_first() {
887 let db = db().await;
888 let older = AdminUser::create("older", "h", None, &db).await.unwrap();
889 let newer = AdminUser::create("newer", "h", None, &db).await.unwrap();
890 crate::sql::query("UPDATE admin_users SET created_at = ? WHERE id = ?;")
893 .bind(older.created_at - 60)
894 .bind(older.id)
895 .execute(&db)
896 .await
897 .unwrap();
898
899 let all = AdminUser::list_all(&db).await.unwrap();
900 assert_eq!(all[0].id, older.id);
901 assert_eq!(all[1].id, newer.id);
902 }
903
904 #[tokio::test]
905 async fn set_password_hash_persists_and_syncs_in_memory() {
906 let db = db().await;
907 let mut user = AdminUser::create("alice", "old", None, &db).await.unwrap();
908 user.set_password_hash("new", &db).await.unwrap();
909 assert_eq!(user.password_hash, "new");
910
911 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
912 assert_eq!(reloaded.password_hash, "new");
913 }
914
915 #[tokio::test]
916 async fn set_status_persists_and_disables() {
917 let db = db().await;
918 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
919 user.set_status("disabled", &db).await.unwrap();
920 assert!(!user.is_active());
921
922 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
923 assert!(!reloaded.is_active());
924 }
925
926 #[test]
927 fn admin_role_maps_storage_both_ways() {
928 assert_eq!(AdminRole::from_storage(None), AdminRole::Admin);
931 assert_eq!(AdminRole::from_storage(Some("admin")), AdminRole::Admin);
932 assert_eq!(
933 AdminRole::from_storage(Some("operator")),
934 AdminRole::Operator
935 );
936 assert_eq!(AdminRole::from_storage(Some("viewer")), AdminRole::Viewer);
937 assert_eq!(AdminRole::from_storage(Some("root")), AdminRole::Viewer);
938 assert_eq!(AdminRole::from_storage(Some("")), AdminRole::Viewer);
939
940 for role in AdminRole::ALL {
941 assert_eq!(
942 AdminRole::from_storage(Some(role.as_str())),
943 *role,
944 "as_str and from_storage must round-trip"
945 );
946 }
947
948 assert!(AdminRole::Viewer < AdminRole::Operator);
950 assert!(AdminRole::Operator < AdminRole::Admin);
951 }
952
953 #[test]
954 fn admin_role_from_str_refuses_an_unknown_value_by_name() {
955 assert_eq!("operator".parse::<AdminRole>(), Ok(AdminRole::Operator));
956 let error = "supervisor".parse::<AdminRole>().unwrap_err();
957 assert!(error.contains("supervisor"), "{error}");
958 assert!(error.contains("viewer, operator, admin"), "{error}");
959 }
960
961 #[tokio::test]
962 async fn a_row_with_no_role_reads_as_admin() {
963 let db = db().await;
964 let user = AdminUser::create("alice", "h", None, &db).await.unwrap();
965 assert_eq!(user.role, None);
966 assert_eq!(user.role(), AdminRole::Admin);
967
968 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
969 assert_eq!(reloaded.role, None);
970 assert_eq!(reloaded.role(), AdminRole::Admin);
971 }
972
973 #[tokio::test]
974 async fn set_role_persists_and_syncs_in_memory() {
975 let db = db().await;
976 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
977
978 user.set_role(AdminRole::Viewer, &db).await.unwrap();
979 assert_eq!(user.role(), AdminRole::Viewer);
980
981 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
982 assert_eq!(reloaded.role.as_deref(), Some("viewer"));
983 assert_eq!(reloaded.role(), AdminRole::Viewer);
984
985 user.set_role(AdminRole::Operator, &db).await.unwrap();
986 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
987 assert_eq!(reloaded.role(), AdminRole::Operator);
988 }
989
990 #[tokio::test]
991 async fn the_totp_setters_persist_and_sync_in_memory() {
992 let db = db().await;
993 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
994
995 user.set_totp_pending(b"secret-bytes", &db).await.unwrap();
996 assert!(user.has_pending_totp());
997 assert!(
998 !user.has_totp(),
999 "a pending enrolment must not read as a second factor"
1000 );
1001 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1002 assert_eq!(
1003 reloaded.totp_pending_secret.as_deref(),
1004 Some(&b"secret-bytes"[..])
1005 );
1006 assert!(!reloaded.has_totp());
1007
1008 user.confirm_totp(&db).await.unwrap();
1009 assert!(user.has_totp());
1010 assert!(!user.has_pending_totp());
1011 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1012 assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"secret-bytes"[..]));
1013 assert_eq!(reloaded.totp_pending_secret, None);
1014
1015 user.clear_totp(&db).await.unwrap();
1016 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1017 assert_eq!(reloaded.totp_secret, None);
1018 assert_eq!(reloaded.totp_pending_secret, None);
1019 assert_eq!(reloaded.totp_last_step, None);
1020 }
1021
1022 #[tokio::test]
1025 async fn confirming_with_nothing_pending_leaves_a_live_factor_alone() {
1026 let db = db().await;
1027 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1028 user.set_totp_pending(b"live", &db).await.unwrap();
1029 user.confirm_totp(&db).await.unwrap();
1030
1031 user.confirm_totp(&db).await.unwrap();
1032
1033 assert!(user.has_totp());
1034 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1035 assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"live"[..]));
1036 }
1037
1038 #[tokio::test]
1041 async fn claim_totp_step_refuses_a_step_it_has_already_seen() {
1042 let db = db().await;
1043 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1044
1045 assert!(user.claim_totp_step(100, &db).await.unwrap());
1046 assert_eq!(user.totp_last_step, Some(100));
1047
1048 assert!(!user.claim_totp_step(100, &db).await.unwrap());
1050 assert!(!user.claim_totp_step(99, &db).await.unwrap());
1051 assert_eq!(
1052 user.totp_last_step,
1053 Some(100),
1054 "a refused claim must not move the guard"
1055 );
1056
1057 assert!(user.claim_totp_step(101, &db).await.unwrap());
1059 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1060 assert_eq!(reloaded.totp_last_step, Some(101));
1061 }
1062
1063 #[tokio::test]
1064 async fn the_status_check_refuses_a_value_outside_the_schema() {
1065 let db = db().await;
1066 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1067 assert!(user.set_status("banished", &db).await.is_err());
1068 }
1069
1070 #[tokio::test]
1071 async fn mark_logged_in_stamps_last_login_at() {
1072 let db = db().await;
1073 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1074 user.mark_logged_in(None, &db).await.unwrap();
1075 assert!(user.last_login_at.is_some());
1076
1077 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1078 assert_eq!(reloaded.last_login_at, user.last_login_at);
1079 assert!(
1080 reloaded.known_login_ips.is_empty(),
1081 "no address was supplied"
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn mark_logged_in_keeps_a_capped_move_to_front_address_set() {
1087 let db = db().await;
1088 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1089
1090 for n in 0..KNOWN_LOGIN_IPS + 2 {
1092 user.mark_logged_in(Some(&format!("10.0.0.{n}")), &db)
1093 .await
1094 .unwrap();
1095 }
1096 assert_eq!(user.known_login_ips.len(), KNOWN_LOGIN_IPS);
1097 assert_eq!(
1098 user.known_login_ips[0],
1099 format!("10.0.0.{}", KNOWN_LOGIN_IPS + 1)
1100 );
1101
1102 let known = user.known_login_ips[3].clone();
1104 user.mark_logged_in(Some(&known), &db).await.unwrap();
1105 assert_eq!(user.known_login_ips.len(), KNOWN_LOGIN_IPS);
1106 assert_eq!(user.known_login_ips[0], known);
1107 assert_eq!(
1108 user.known_login_ips
1109 .iter()
1110 .filter(|ip| **ip == known)
1111 .count(),
1112 1,
1113 "deduplicated"
1114 );
1115
1116 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1117 assert_eq!(reloaded.known_login_ips, user.known_login_ips);
1118 }
1119
1120 #[tokio::test]
1121 async fn set_contact_email_persists_and_syncs_and_clears() {
1122 let db = db().await;
1123 let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1124 assert_eq!(user.contact_email, None);
1125
1126 user.set_contact_email(Some("alice@example.com"), &db)
1127 .await
1128 .unwrap();
1129 assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
1130 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1131 assert_eq!(reloaded.contact_email.as_deref(), Some("alice@example.com"));
1132
1133 user.set_contact_email(None, &db).await.unwrap();
1134 assert_eq!(user.contact_email, None);
1135 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1136 assert_eq!(reloaded.contact_email, None);
1137 }
1138
1139 #[tokio::test]
1140 async fn delete_reports_whether_a_row_existed() {
1141 let db = db().await;
1142 let user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1143 assert!(AdminUser::delete(user.id, &db).await.unwrap());
1144 assert!(!AdminUser::delete(user.id, &db).await.unwrap());
1145 }
1146
1147 #[tokio::test]
1148 async fn to_json_never_leaks_the_hash_or_the_totp_secret() {
1149 let db = db().await;
1150 let user = AdminUser::create("alice", "super-secret-hash", None, &db)
1151 .await
1152 .unwrap();
1153 let json = user.to_json();
1154 assert!(json.get("password_hash").is_none());
1155 assert!(json.get("passwordHash").is_none());
1156 assert!(json.get("totpSecret").is_none());
1157 assert!(!json.to_string().contains("super-secret-hash"));
1158 assert_eq!(json["username"], "alice");
1159 assert_eq!(json["status"], "active");
1160 assert_eq!(json["totpEnabled"], false);
1161 assert_eq!(json["lastLoginAt"], Value::Null);
1162 assert_eq!(json["contactEmail"], Value::Null);
1163 assert_eq!(json["knownLoginIps"], serde_json::json!([]));
1164 }
1165}