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