1use std::time::Duration;
2use uuid::Uuid;
3
4use serde_json::Value;
5use sqlx::Row;
6use sqlx::sqlite::SqliteRow;
7use tracing::{debug, info};
8
9use crate::sqlite::db::Database;
10use crate::sqlite::nonce::{fingerprint, now_secs};
11use crate::sqlite::order::rfc3339;
12
13#[derive(Debug, Clone)]
33pub struct AdminSession {
34 pub token_hash: String,
36 pub user_id: Uuid,
37 pub csrf_token: String,
39 pub state: String,
41 pub mfa_attempts: i64,
44 pub created_at: i64,
45 pub expires_at: i64,
47 pub last_seen_at: i64,
49 pub created_ip: Option<String>,
51 pub user_agent: Option<String>,
52}
53
54#[derive(Debug, Clone)]
65pub struct NewSession<'a> {
66 pub user_id: Uuid,
67 pub token_hash: &'a str,
70 pub csrf_token: &'a str,
71 pub created_ip: Option<String>,
73 pub user_agent: Option<String>,
74}
75
76macro_rules! columns {
82 () => {
83 "token_hash, user_id, csrf_token, state, mfa_attempts, created_at, \
84 expires_at, last_seen_at, created_ip, user_agent"
85 };
86}
87
88impl AdminSession {
89 fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
90 Ok(AdminSession {
91 token_hash: row.try_get("token_hash")?,
92 user_id: row.try_get("user_id")?,
93 csrf_token: row.try_get("csrf_token")?,
94 state: row.try_get("state")?,
95 mfa_attempts: row.try_get("mfa_attempts")?,
96 created_at: row.try_get("created_at")?,
97 expires_at: row.try_get("expires_at")?,
98 last_seen_at: row.try_get("last_seen_at")?,
99 created_ip: row.try_get("created_ip")?,
100 user_agent: row.try_get("user_agent")?,
101 })
102 }
103
104 pub async fn create(
106 new: NewSession<'_>,
107 ttl: Duration,
108 database: &Database,
109 ) -> Result<AdminSession, sqlx::Error> {
110 Self::create_with_state("active", new, ttl, database).await
111 }
112
113 pub async fn create_pending(
125 new: NewSession<'_>,
126 ttl: Duration,
127 database: &Database,
128 ) -> Result<AdminSession, sqlx::Error> {
129 Self::create_with_state("pending_mfa", new, ttl, database).await
130 }
131
132 async fn create_with_state(
136 state: &str,
137 new: NewSession<'_>,
138 ttl: Duration,
139 database: &Database,
140 ) -> Result<AdminSession, sqlx::Error> {
141 let now = now_secs();
142 let session = AdminSession {
143 token_hash: new.token_hash.to_string(),
144 user_id: new.user_id,
145 csrf_token: new.csrf_token.to_string(),
146 state: state.to_string(),
147 mfa_attempts: 0,
148 created_at: now,
149 expires_at: now.saturating_add(ttl.as_secs() as i64),
152 last_seen_at: now,
153 created_ip: new.created_ip,
154 user_agent: new.user_agent,
155 };
156
157 sqlx::query(
158 "INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, \
159 expires_at, last_seen_at, created_ip, user_agent) \
160 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
161 )
162 .bind(&session.token_hash)
163 .bind(session.user_id)
164 .bind(&session.csrf_token)
165 .bind(&session.state)
166 .bind(session.created_at)
167 .bind(session.expires_at)
168 .bind(session.last_seen_at)
169 .bind(&session.created_ip)
170 .bind(&session.user_agent)
171 .execute(&database.pool)
172 .await?;
173
174 info!(event = "db_admin_session_created",
178 outcome = "success",
179 session_fp = %fingerprint(&session.token_hash),
180 user_id = %session.user_id,
181 state = %session.state);
182 Ok(session)
183 }
184
185 pub async fn promote(
204 pending_token_hash: &str,
205 new_token_hash: &str,
206 new_csrf_token: &str,
207 ttl: Duration,
208 database: &Database,
209 ) -> Result<Option<AdminSession>, sqlx::Error> {
210 let mut tx = database.pool.begin().await?;
211
212 let row = sqlx::query(concat!(
213 "SELECT ",
214 columns!(),
215 " FROM admin_sessions WHERE token_hash = ? AND state = 'pending_mfa';"
216 ))
217 .bind(pending_token_hash)
218 .fetch_optional(&mut *tx)
219 .await?;
220
221 let Some(pending) = row.map(AdminSession::from_row).transpose()? else {
222 return Ok(None);
223 };
224
225 let removed = sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?;")
228 .bind(pending_token_hash)
229 .execute(&mut *tx)
230 .await?;
231 if removed.rows_affected() != 1 {
232 return Ok(None);
233 }
234
235 let now = now_secs();
236 let session = AdminSession {
237 token_hash: new_token_hash.to_string(),
238 user_id: pending.user_id,
239 csrf_token: new_csrf_token.to_string(),
240 state: "active".to_string(),
241 mfa_attempts: 0,
245 created_at: now,
246 expires_at: now.saturating_add(ttl.as_secs() as i64),
247 last_seen_at: now,
248 created_ip: pending.created_ip,
249 user_agent: pending.user_agent,
250 };
251
252 sqlx::query(
253 "INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, \
254 expires_at, last_seen_at, created_ip, user_agent) \
255 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
256 )
257 .bind(&session.token_hash)
258 .bind(session.user_id)
259 .bind(&session.csrf_token)
260 .bind(&session.state)
261 .bind(session.created_at)
262 .bind(session.expires_at)
263 .bind(session.last_seen_at)
264 .bind(&session.created_ip)
265 .bind(&session.user_agent)
266 .execute(&mut *tx)
267 .await?;
268
269 tx.commit().await?;
270
271 info!(event = "db_admin_session_promoted",
272 outcome = "success",
273 session_fp = %fingerprint(&session.token_hash),
274 replaced = %fingerprint(pending_token_hash),
275 user_id = %session.user_id);
276 Ok(Some(session))
277 }
278
279 pub async fn record_mfa_failure(
296 token_hash: &str,
297 database: &Database,
298 ) -> Result<Option<i64>, sqlx::Error> {
299 let row = sqlx::query(
300 "UPDATE admin_sessions SET mfa_attempts = mfa_attempts + 1 \
301 WHERE token_hash = ? RETURNING mfa_attempts;",
302 )
303 .bind(token_hash)
304 .fetch_optional(&database.pool)
305 .await?;
306
307 let attempts = row
308 .map(|row| row.try_get::<i64, _>("mfa_attempts"))
309 .transpose()?;
310 if let Some(attempts) = attempts {
311 debug!(event = "db_admin_session_mfa_failure_recorded",
312 outcome = "success",
313 session_fp = %fingerprint(token_hash),
314 attempts);
315 }
316 Ok(attempts)
317 }
318
319 pub async fn find_by_token_hash(
323 token_hash: &str,
324 database: &Database,
325 ) -> Result<Option<AdminSession>, sqlx::Error> {
326 let row = sqlx::query(concat!(
327 "SELECT ",
328 columns!(),
329 " FROM admin_sessions WHERE token_hash = ?;"
330 ))
331 .bind(token_hash)
332 .fetch_optional(&database.pool)
333 .await?;
334
335 row.map(AdminSession::from_row).transpose()
336 }
337
338 pub async fn touch(&mut self, database: &Database) -> Result<(), sqlx::Error> {
342 let now = now_secs();
343 sqlx::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = ?;")
344 .bind(now)
345 .bind(&self.token_hash)
346 .execute(&database.pool)
347 .await?;
348
349 self.last_seen_at = now;
350 Ok(())
351 }
352
353 pub async fn delete(token_hash: &str, database: &Database) -> Result<bool, sqlx::Error> {
355 let result = sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?;")
356 .bind(token_hash)
357 .execute(&database.pool)
358 .await?;
359
360 let deleted = result.rows_affected() > 0;
361 if deleted {
362 info!(event = "db_admin_session_deleted", outcome = "success", session_fp = %fingerprint(token_hash));
363 }
364 Ok(deleted)
365 }
366
367 pub async fn delete_for_user(user_id: Uuid, database: &Database) -> Result<u64, sqlx::Error> {
370 let result = sqlx::query("DELETE FROM admin_sessions WHERE user_id = ?;")
371 .bind(user_id)
372 .execute(&database.pool)
373 .await?;
374
375 info!(event = "db_admin_sessions_revoked",
376 outcome = "success",
377 scope = "user",
378 user_id = %user_id,
379 rows_removed = result.rows_affected());
380 Ok(result.rows_affected())
381 }
382
383 pub async fn delete_for_user_except(
387 user_id: Uuid,
388 keep_token_hash: &str,
389 database: &Database,
390 ) -> Result<u64, sqlx::Error> {
391 let result =
392 sqlx::query("DELETE FROM admin_sessions WHERE user_id = ? AND token_hash != ?;")
393 .bind(user_id)
394 .bind(keep_token_hash)
395 .execute(&database.pool)
396 .await?;
397
398 info!(event = "db_admin_sessions_revoked",
399 outcome = "success",
400 scope = "user_except_current",
401 user_id = %user_id,
402 rows_removed = result.rows_affected());
403 Ok(result.rows_affected())
404 }
405
406 pub async fn delete_all(database: &Database) -> Result<u64, sqlx::Error> {
409 let result = sqlx::query("DELETE FROM admin_sessions;")
410 .execute(&database.pool)
411 .await?;
412
413 info!(
417 event = "db_admin_sessions_revoked",
418 outcome = "success",
419 scope = "all",
420 rows_removed = result.rows_affected()
421 );
422 Ok(result.rows_affected())
423 }
424
425 pub async fn list_all(
433 user_id: Option<Uuid>,
434 database: &Database,
435 ) -> Result<Vec<AdminSession>, sqlx::Error> {
436 let rows = match user_id {
440 Some(id) => sqlx::query(concat!(
441 "SELECT ",
442 columns!(),
443 " FROM admin_sessions WHERE user_id = ? ORDER BY created_at DESC, token_hash ASC;"
444 ))
445 .bind(id)
446 .fetch_all(&database.pool)
447 .await?,
448 None => {
449 sqlx::query(concat!(
450 "SELECT ",
451 columns!(),
452 " FROM admin_sessions ORDER BY created_at DESC, token_hash ASC;"
453 ))
454 .fetch_all(&database.pool)
455 .await?
456 }
457 };
458
459 rows.into_iter().map(AdminSession::from_row).collect()
460 }
461
462 pub async fn search(
470 user_id: Option<Uuid>,
471 limit: i64,
472 offset: i64,
473 database: &Database,
474 ) -> Result<(Vec<AdminSession>, i64), sqlx::Error> {
475 let (rows, total) = match user_id {
479 Some(id) => (
480 sqlx::query(concat!(
481 "SELECT ",
482 columns!(),
483 " FROM admin_sessions WHERE user_id = ? \
484 ORDER BY created_at DESC, token_hash ASC LIMIT ? OFFSET ?;"
485 ))
486 .bind(id)
487 .bind(limit)
488 .bind(offset)
489 .fetch_all(&database.pool)
490 .await?,
491 sqlx::query("SELECT COUNT(*) FROM admin_sessions WHERE user_id = ?;")
492 .bind(id)
493 .fetch_one(&database.pool)
494 .await?
495 .try_get::<i64, _>(0)?,
496 ),
497 None => (
498 sqlx::query(concat!(
499 "SELECT ",
500 columns!(),
501 " FROM admin_sessions ORDER BY created_at DESC, token_hash ASC \
502 LIMIT ? OFFSET ?;"
503 ))
504 .bind(limit)
505 .bind(offset)
506 .fetch_all(&database.pool)
507 .await?,
508 sqlx::query("SELECT COUNT(*) FROM admin_sessions;")
509 .fetch_one(&database.pool)
510 .await?
511 .try_get::<i64, _>(0)?,
512 ),
513 };
514
515 let sessions = rows
516 .into_iter()
517 .map(AdminSession::from_row)
518 .collect::<Result<_, _>>()?;
519 Ok((sessions, total))
520 }
521
522 pub async fn cleanup(idle_timeout: Duration, database: &Database) -> Result<u64, sqlx::Error> {
526 let now = now_secs();
527 let idle_cutoff = now.saturating_sub(idle_timeout.as_secs() as i64);
528
529 let result =
530 sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= ? OR last_seen_at <= ?;")
531 .bind(now)
532 .bind(idle_cutoff)
533 .execute(&database.pool)
534 .await?;
535
536 debug!(
537 event = "db_admin_session_cleanup_completed",
538 outcome = "success",
539 rows_removed = result.rows_affected(),
540 idle_cutoff = idle_cutoff
541 );
542 Ok(result.rows_affected())
543 }
544
545 #[must_use]
547 pub fn is_expired(&self, now: i64) -> bool {
548 now >= self.expires_at
549 }
550
551 #[must_use]
553 pub fn is_idle(&self, now: i64, idle_timeout: Duration) -> bool {
554 now.saturating_sub(self.last_seen_at) >= idle_timeout.as_secs() as i64
555 }
556
557 #[must_use]
560 pub fn is_active(&self) -> bool {
561 self.state == "active"
562 }
563
564 #[must_use]
571 pub fn to_json(&self) -> Value {
572 serde_json::json!({
573 "id": fingerprint(&self.token_hash),
574 "userId": self.user_id,
575 "state": self.state,
576 "createdAt": rfc3339(self.created_at),
577 "expiresAt": rfc3339(self.expires_at),
578 "lastSeenAt": rfc3339(self.last_seen_at),
579 "createdIp": self.created_ip,
580 "userAgent": self.user_agent,
581 })
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::sqlite::admin_user::AdminUser;
589 use std::sync::Arc;
590
591 const TTL: Duration = Duration::from_secs(43_200);
592 const IDLE: Duration = Duration::from_secs(3_600);
593
594 async fn db_with_user() -> (Arc<Database>, AdminUser) {
595 let db = Arc::new(Database::connect_in_memory().await.unwrap());
596 let user = AdminUser::create("alice", "hash", &db).await.unwrap();
597 (db, user)
598 }
599
600 async fn session(db: Arc<Database>, user: &AdminUser, token_hash: &str) -> AdminSession {
601 AdminSession::create(
602 NewSession {
603 user_id: user.id,
604 token_hash,
605 csrf_token: "csrf",
606 created_ip: Some("192.0.2.1".to_string()),
607 user_agent: Some("curl/8".to_string()),
608 },
609 TTL,
610 &db,
611 )
612 .await
613 .unwrap()
614 }
615
616 #[tokio::test]
617 async fn create_persists_an_active_session_and_round_trips() {
618 let (db, user) = db_with_user().await;
619 let created = session(db.clone(), &user, "aaaa").await;
620 assert!(created.is_active());
621 assert_eq!(
622 created.expires_at,
623 created.created_at + TTL.as_secs() as i64
624 );
625 assert_eq!(created.last_seen_at, created.created_at);
626
627 let found = AdminSession::find_by_token_hash("aaaa", &db)
628 .await
629 .unwrap()
630 .unwrap();
631 assert_eq!(found.user_id, user.id);
632 assert_eq!(found.csrf_token, "csrf");
633 assert_eq!(found.created_ip.as_deref(), Some("192.0.2.1"));
634 assert_eq!(found.user_agent.as_deref(), Some("curl/8"));
635 }
636
637 #[tokio::test]
638 async fn find_by_unknown_token_hash_returns_none() {
639 let (db, _user) = db_with_user().await;
640 assert!(
641 AdminSession::find_by_token_hash("nope", &db)
642 .await
643 .unwrap()
644 .is_none()
645 );
646 }
647
648 #[tokio::test]
649 async fn a_session_for_an_unknown_user_is_refused_by_the_foreign_key() {
650 let db = Arc::new(Database::connect_in_memory().await.unwrap());
651 let error = AdminSession::create(
652 NewSession {
653 user_id: crate::sqlite::id::mint(),
654 token_hash: "aaaa",
655 csrf_token: "csrf",
656 created_ip: None,
657 user_agent: None,
658 },
659 TTL,
660 &db,
661 )
662 .await
663 .unwrap_err();
664 assert!(
665 error.to_string().to_lowercase().contains("foreign key"),
666 "expected a FOREIGN KEY violation, got: {error}"
667 );
668 }
669
670 #[tokio::test]
671 async fn the_state_check_refuses_a_value_outside_the_schema() {
672 let (db, user) = db_with_user().await;
673 session(db.clone(), &user, "aaaa").await;
674 let error =
675 sqlx::query("UPDATE admin_sessions SET state = 'whatever' WHERE token_hash = ?;")
676 .bind("aaaa")
677 .execute(&db.pool)
678 .await
679 .unwrap_err();
680 assert!(error.to_string().to_lowercase().contains("check"));
681 }
682
683 #[tokio::test]
684 async fn touch_advances_the_idle_deadline_and_persists() {
685 let (db, user) = db_with_user().await;
686 let mut created = session(db.clone(), &user, "aaaa").await;
687 sqlx::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = ?;")
689 .bind(created.created_at - 500)
690 .bind("aaaa")
691 .execute(&db.pool)
692 .await
693 .unwrap();
694
695 created.touch(&db).await.unwrap();
696 let reloaded = AdminSession::find_by_token_hash("aaaa", &db)
697 .await
698 .unwrap()
699 .unwrap();
700 assert_eq!(reloaded.last_seen_at, created.last_seen_at);
701 assert!(reloaded.last_seen_at > created.created_at - 500);
702 }
703
704 #[tokio::test]
705 async fn delete_reports_whether_a_row_existed() {
706 let (db, user) = db_with_user().await;
707 session(db.clone(), &user, "aaaa").await;
708 assert!(AdminSession::delete("aaaa", &db).await.unwrap());
709 assert!(!AdminSession::delete("aaaa", &db).await.unwrap());
710 }
711
712 #[tokio::test]
713 async fn delete_for_user_removes_every_session_of_that_user_only() {
714 let (db, alice) = db_with_user().await;
715 let bob = AdminUser::create("bob", "hash", &db).await.unwrap();
716 session(db.clone(), &alice, "a1").await;
717 session(db.clone(), &alice, "a2").await;
718 session(db.clone(), &bob, "b1").await;
719
720 assert_eq!(
721 AdminSession::delete_for_user(alice.id, &db).await.unwrap(),
722 2
723 );
724 assert!(
725 AdminSession::find_by_token_hash("b1", &db)
726 .await
727 .unwrap()
728 .is_some()
729 );
730 }
731
732 #[tokio::test]
733 async fn delete_for_user_except_keeps_the_named_session() {
734 let (db, user) = db_with_user().await;
735 session(db.clone(), &user, "keep").await;
736 session(db.clone(), &user, "drop1").await;
737 session(db.clone(), &user, "drop2").await;
738
739 assert_eq!(
740 AdminSession::delete_for_user_except(user.id, "keep", &db)
741 .await
742 .unwrap(),
743 2
744 );
745 assert!(
746 AdminSession::find_by_token_hash("keep", &db)
747 .await
748 .unwrap()
749 .is_some()
750 );
751 assert!(
752 AdminSession::find_by_token_hash("drop1", &db)
753 .await
754 .unwrap()
755 .is_none()
756 );
757 }
758
759 #[tokio::test]
760 async fn deleting_a_user_cascades_to_their_sessions() {
761 let (db, user) = db_with_user().await;
762 session(db.clone(), &user, "aaaa").await;
763 assert!(AdminUser::delete(user.id, &db).await.unwrap());
764 assert!(
765 AdminSession::find_by_token_hash("aaaa", &db)
766 .await
767 .unwrap()
768 .is_none(),
769 "ON DELETE CASCADE needs `foreign_keys` on, which connect_in_memory pins"
770 );
771 }
772
773 #[tokio::test]
774 async fn list_all_filters_by_user_and_is_empty_when_there_are_none() {
775 let (db, alice) = db_with_user().await;
776 assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
777
778 let bob = AdminUser::create("bob", "hash", &db).await.unwrap();
779 session(db.clone(), &alice, "a1").await;
780 session(db.clone(), &bob, "b1").await;
781
782 assert_eq!(AdminSession::list_all(None, &db).await.unwrap().len(), 2);
783 let alices = AdminSession::list_all(Some(alice.id), &db).await.unwrap();
784 assert_eq!(alices.len(), 1);
785 assert_eq!(alices[0].token_hash, "a1");
786 }
787
788 #[tokio::test]
793 async fn search_pages_each_filter_and_counts_what_that_filter_matches() {
794 let (db, alice) = db_with_user().await;
795 assert_eq!(AdminSession::search(None, 50, 0, &db).await.unwrap().1, 0);
796
797 let bob = AdminUser::create("bob", "hash", &db).await.unwrap();
798 for token in ["a1", "a2", "a3"] {
799 session(db.clone(), &alice, token).await;
800 }
801 session(db.clone(), &bob, "b1").await;
802
803 let (_, all) = AdminSession::search(None, 50, 0, &db).await.unwrap();
804 assert_eq!(all, 4);
805
806 let (first, total) = AdminSession::search(Some(alice.id), 2, 0, &db)
807 .await
808 .unwrap();
809 let (second, also_total) = AdminSession::search(Some(alice.id), 2, 2, &db)
810 .await
811 .unwrap();
812 assert_eq!((total, also_total), (3, 3), "alice's rows, not the table's");
813 assert_eq!((first.len(), second.len()), (2, 1));
814
815 let walked: Vec<&str> = first
819 .iter()
820 .chain(second.iter())
821 .map(|s| s.token_hash.as_str())
822 .collect();
823 assert_eq!(walked.len(), 3);
824 for token in ["a1", "a2", "a3"] {
825 assert_eq!(
826 walked.iter().filter(|seen| **seen == token).count(),
827 1,
828 "{token} was not on exactly one page"
829 );
830 }
831 assert!(!walked.contains(&"b1"), "bob's session is not alice's page");
832 }
833
834 #[tokio::test]
835 async fn cleanup_removes_expired_and_idle_rows_and_leaves_live_ones() {
836 let (db, user) = db_with_user().await;
837 session(db.clone(), &user, "live").await;
838 session(db.clone(), &user, "expired").await;
839 session(db.clone(), &user, "idle").await;
840
841 let now = now_secs();
842 sqlx::query("UPDATE admin_sessions SET expires_at = ? WHERE token_hash = 'expired';")
843 .bind(now - 1)
844 .execute(&db.pool)
845 .await
846 .unwrap();
847 sqlx::query("UPDATE admin_sessions SET last_seen_at = ? WHERE token_hash = 'idle';")
848 .bind(now - IDLE.as_secs() as i64 - 1)
849 .execute(&db.pool)
850 .await
851 .unwrap();
852
853 assert_eq!(AdminSession::cleanup(IDLE, &db).await.unwrap(), 2);
854 let left = AdminSession::list_all(None, &db).await.unwrap();
855 assert_eq!(left.len(), 1);
856 assert_eq!(left[0].token_hash, "live");
857 }
858
859 const PENDING_TTL: Duration = Duration::from_secs(300);
860
861 #[tokio::test]
862 async fn create_pending_writes_the_half_authenticated_state() {
863 let (db, user) = db_with_user().await;
864 let pending = AdminSession::create_pending(
865 NewSession {
866 user_id: user.id,
867 token_hash: "pending-hash",
868 csrf_token: "csrf",
869 created_ip: Some("192.0.2.1".to_string()),
870 user_agent: Some("curl".to_string()),
871 },
872 PENDING_TTL,
873 &db,
874 )
875 .await
876 .unwrap();
877
878 assert_eq!(pending.state, "pending_mfa");
879 assert!(!pending.is_active());
880 assert!(
881 pending.expires_at - pending.created_at <= PENDING_TTL.as_secs() as i64,
882 "a half-authenticated row must not get the full session lifetime"
883 );
884
885 let reloaded = AdminSession::find_by_token_hash("pending-hash", &db)
886 .await
887 .unwrap()
888 .unwrap();
889 assert_eq!(reloaded.state, "pending_mfa");
890 assert_eq!(reloaded.created_ip.as_deref(), Some("192.0.2.1"));
891 assert_eq!(reloaded.mfa_attempts, 0);
892 }
893
894 #[tokio::test]
897 async fn mfa_failures_accumulate_on_the_session_row() {
898 let (db, user) = db_with_user().await;
899 AdminSession::create_pending(
900 NewSession {
901 user_id: user.id,
902 token_hash: "pending-hash",
903 csrf_token: "csrf",
904 created_ip: None,
905 user_agent: None,
906 },
907 PENDING_TTL,
908 &db,
909 )
910 .await
911 .unwrap();
912
913 for expected in 1..=3 {
914 assert_eq!(
915 AdminSession::record_mfa_failure("pending-hash", &db)
916 .await
917 .unwrap(),
918 Some(expected),
919 "the new total comes back, so the caller needs no second read"
920 );
921 }
922
923 let reloaded = AdminSession::find_by_token_hash("pending-hash", &db)
924 .await
925 .unwrap()
926 .unwrap();
927 assert_eq!(reloaded.mfa_attempts, 3);
928
929 AdminSession::delete("pending-hash", &db).await.unwrap();
932 assert_eq!(
933 AdminSession::record_mfa_failure("pending-hash", &db)
934 .await
935 .unwrap(),
936 None
937 );
938 }
939
940 #[tokio::test]
943 async fn promotion_does_not_carry_the_attempt_counter_across() {
944 let (db, user) = db_with_user().await;
945 AdminSession::create_pending(
946 NewSession {
947 user_id: user.id,
948 token_hash: "pending",
949 csrf_token: "csrf",
950 created_ip: None,
951 user_agent: None,
952 },
953 PENDING_TTL,
954 &db,
955 )
956 .await
957 .unwrap();
958 AdminSession::record_mfa_failure("pending", &db)
959 .await
960 .unwrap();
961
962 let promoted = AdminSession::promote("pending", "active", "csrf2", TTL, &db)
963 .await
964 .unwrap()
965 .unwrap();
966 assert_eq!(promoted.mfa_attempts, 0);
967 }
968
969 #[tokio::test]
970 async fn promote_rotates_the_token_and_can_only_happen_once() {
971 let (db, user) = db_with_user().await;
972 AdminSession::create_pending(
973 NewSession {
974 user_id: user.id,
975 token_hash: "pending-hash",
976 csrf_token: "pending-csrf",
977 created_ip: Some("192.0.2.1".to_string()),
978 user_agent: Some("curl".to_string()),
979 },
980 PENDING_TTL,
981 &db,
982 )
983 .await
984 .unwrap();
985
986 let promoted =
987 AdminSession::promote("pending-hash", "active-hash", "active-csrf", TTL, &db)
988 .await
989 .unwrap()
990 .expect("a pending row must promote");
991
992 assert_eq!(promoted.token_hash, "active-hash");
994 assert_ne!(promoted.csrf_token, "pending-csrf");
995 assert_eq!(promoted.state, "active");
996 assert_eq!(promoted.user_id, user.id);
997 assert_eq!(promoted.created_ip.as_deref(), Some("192.0.2.1"));
999 assert_eq!(promoted.user_agent.as_deref(), Some("curl"));
1000 assert!(promoted.expires_at - promoted.created_at > PENDING_TTL.as_secs() as i64);
1001
1002 assert!(
1004 AdminSession::find_by_token_hash("pending-hash", &db)
1005 .await
1006 .unwrap()
1007 .is_none()
1008 );
1009
1010 assert!(
1013 AdminSession::promote("pending-hash", "second-hash", "c", TTL, &db)
1014 .await
1015 .unwrap()
1016 .is_none()
1017 );
1018 assert_eq!(
1019 AdminSession::list_all(Some(user.id), &db)
1020 .await
1021 .unwrap()
1022 .len(),
1023 1
1024 );
1025 }
1026
1027 #[tokio::test]
1030 async fn promote_refuses_a_session_that_is_already_active() {
1031 let (db, user) = db_with_user().await;
1032 session(db.clone(), &user, "active-hash").await;
1033
1034 assert!(
1035 AdminSession::promote("active-hash", "new-hash", "c", TTL, &db)
1036 .await
1037 .unwrap()
1038 .is_none()
1039 );
1040 assert!(
1041 AdminSession::find_by_token_hash("active-hash", &db)
1042 .await
1043 .unwrap()
1044 .is_some(),
1045 "the existing session must survive a refused promotion"
1046 );
1047 }
1048
1049 #[tokio::test]
1052 async fn cleanup_sweeps_an_abandoned_pending_session_and_leaves_a_fresh_one() {
1053 let (db, user) = db_with_user().await;
1054 AdminSession::create_pending(
1055 NewSession {
1056 user_id: user.id,
1057 token_hash: "fresh",
1058 csrf_token: "c",
1059 created_ip: None,
1060 user_agent: None,
1061 },
1062 PENDING_TTL,
1063 &db,
1064 )
1065 .await
1066 .unwrap();
1067 AdminSession::create_pending(
1068 NewSession {
1069 user_id: user.id,
1070 token_hash: "abandoned",
1071 csrf_token: "c",
1072 created_ip: None,
1073 user_agent: None,
1074 },
1075 PENDING_TTL,
1076 &db,
1077 )
1078 .await
1079 .unwrap();
1080 sqlx::query("UPDATE admin_sessions SET expires_at = ? WHERE token_hash = 'abandoned';")
1081 .bind(now_secs() - 1)
1082 .execute(&db.pool)
1083 .await
1084 .unwrap();
1085
1086 assert_eq!(AdminSession::cleanup(IDLE, &db).await.unwrap(), 1);
1087 let left = AdminSession::list_all(None, &db).await.unwrap();
1088 assert_eq!(left.len(), 1);
1089 assert_eq!(left[0].token_hash, "fresh");
1090 }
1091
1092 #[test]
1093 fn expiry_and_idleness_are_judged_at_the_boundary_second() {
1094 let base = AdminSession {
1095 token_hash: "aaaa".to_string(),
1096 user_id: crate::sqlite::id::mint(),
1097 csrf_token: "c".to_string(),
1098 state: "active".to_string(),
1099 mfa_attempts: 0,
1100 created_at: 1_000,
1101 expires_at: 2_000,
1102 last_seen_at: 1_000,
1103 created_ip: None,
1104 user_agent: None,
1105 };
1106
1107 assert!(!base.is_expired(1_999));
1108 assert!(
1109 base.is_expired(2_000),
1110 "the deadline second is already past"
1111 );
1112
1113 assert!(!base.is_idle(1_000 + 3_599, IDLE));
1114 assert!(base.is_idle(1_000 + 3_600, IDLE));
1115 }
1116
1117 #[tokio::test]
1118 async fn to_json_never_leaks_the_token_hash_or_the_csrf_token() {
1119 let (db, user) = db_with_user().await;
1120 let created = AdminSession::create(
1121 NewSession {
1122 user_id: user.id,
1123 token_hash: "0123456789abcdef0123456789abcdef",
1124 csrf_token: "the-csrf-token",
1125 created_ip: None,
1126 user_agent: None,
1127 },
1128 TTL,
1129 &db,
1130 )
1131 .await
1132 .unwrap();
1133
1134 let json = created.to_json();
1135 let rendered = json.to_string();
1136 assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
1137 assert!(!rendered.contains("the-csrf-token"));
1138 assert_eq!(json["id"], "01234567");
1139 assert_eq!(json["userId"], user.id.to_string());
1140 assert_eq!(json["state"], "active");
1141 assert_eq!(json["createdIp"], Value::Null);
1142 }
1143}