use std::collections::HashSet; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use dashmap::DashMap; use rand::RngCore; use serde::Serialize; use crate::session_store::crypto::SessionCrypto; use crate::session_store::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics}; pub type SessionId = String; pub type BrowserId = String; pub type AccountId = String; pub type SessionState = AccountSession; #[derive(Debug, Clone)] pub struct NewAccountSession { pub email: String, pub password: String, pub user_hash: String, pub imap_host: String, pub imap_port: u16, pub imap_tls: bool, pub smtp_host: String, pub smtp_port: u16, pub smtp_tls: bool, pub timeout_override: Option, } #[derive(Debug, Clone)] pub struct AccountSession { pub account_id: AccountId, pub email: String, #[allow(dead_code)] pub password: String, #[allow(dead_code)] pub user_hash: String, pub imap_host: String, pub imap_port: u16, pub imap_tls: bool, pub imap_tls_verify: bool, pub smtp_host: String, #[allow(dead_code)] pub smtp_port: u16, #[allow(dead_code)] pub smtp_tls: bool, pub smtp_tls_verify: bool, pub last_accessed: Instant, pub timeout_override: Option, pub created_at_unix: u64, pub last_accessed_unix: u64, pub user_agent: String, } #[derive(Debug, Clone, Serialize)] pub struct DiagnosticSession { #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub user_hash: Option, pub imap_host: String, pub imap_port: u16, pub smtp_host: String, pub smtp_port: u16, pub created_at_unix: u64, pub last_accessed_unix: u64, pub user_agent: String, } #[derive(Debug, Clone, Serialize)] pub struct SessionStoreDiagnostics { pub local_cached_sessions: usize, pub local_cached_browsers: usize, pub local_unique_users: usize, pub sessions: Vec, } #[derive(Clone)] pub struct SessionStore { sessions: Arc>, browsers: Arc>>, account_to_session: Arc>, account_to_browser: Arc>, timeout: Duration, tls_verify_override: Option, persistence: Option>, crypto: Option, } impl SessionStore { fn unix_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } #[cfg(test)] pub fn new(timeout: Duration) -> Self { Self::new_with_tls_verify(timeout, None) } #[cfg(test)] pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option) -> Self { Self { sessions: Arc::new(DashMap::new()), browsers: Arc::new(DashMap::new()), account_to_session: Arc::new(DashMap::new()), account_to_browser: Arc::new(DashMap::new()), timeout, tls_verify_override, persistence: None, crypto: None, } } pub fn new_persistent( timeout: Duration, tls_verify_override: Option, persistence: Arc, crypto: SessionCrypto, ) -> Self { Self { sessions: Arc::new(DashMap::new()), browsers: Arc::new(DashMap::new()), account_to_session: Arc::new(DashMap::new()), account_to_browser: Arc::new(DashMap::new()), timeout, tls_verify_override, persistence: Some(persistence), crypto: Some(crypto), } } fn tls_verify_for_host(&self, host: &str) -> bool { if let Some(value) = self.tls_verify_override { return value; } let normalized = host .trim() .trim_matches(|ch| ch == '[' || ch == ']') .to_ascii_lowercase(); !matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1") } pub fn generate_token() -> SessionId { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } fn generate_id() -> String { let mut bytes = [0u8; 16]; rand::rng().fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } pub fn create_browser(&self) -> BrowserId { let browser_id = Self::generate_id(); self.browsers.insert(browser_id.clone(), Vec::new()); browser_id } #[cfg(test)] #[allow(clippy::too_many_arguments)] pub fn add_account_to_browser( &self, browser_id: &str, email: String, password: String, user_hash: String, imap_host: String, imap_port: u16, imap_tls: bool, smtp_host: String, smtp_port: u16, smtp_tls: bool, ) -> (SessionId, AccountId) { self.add_account_to_browser_with_timeout( browser_id, email, password, user_hash, imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls, None, ) } #[allow(clippy::too_many_arguments)] pub fn add_account_to_browser_with_timeout( &self, browser_id: &str, email: String, password: String, user_hash: String, imap_host: String, imap_port: u16, imap_tls: bool, smtp_host: String, smtp_port: u16, smtp_tls: bool, timeout_override: Option, ) -> (SessionId, AccountId) { let token = Self::generate_token(); let account_id = Self::generate_id(); let now_unix = Self::unix_now(); let imap_tls_verify = self.tls_verify_for_host(&imap_host); let smtp_tls_verify = self.tls_verify_for_host(&smtp_host); let session = AccountSession { account_id: account_id.clone(), email, password, user_hash, imap_host, imap_port, imap_tls, imap_tls_verify, smtp_host, smtp_port, smtp_tls, smtp_tls_verify, last_accessed: Instant::now(), timeout_override, created_at_unix: now_unix, last_accessed_unix: now_unix, user_agent: String::new(), }; self.sessions.insert(token.clone(), session); self.account_to_session .insert(account_id.clone(), token.clone()); self.account_to_browser .insert(account_id.clone(), browser_id.to_string()); self.browsers .entry(browser_id.to_string()) .or_default() .push(account_id.clone()); (token, account_id) } pub fn get_browser_accounts(&self, browser_id: &str) -> Vec { let account_ids = self .browsers .get(browser_id) .map(|entry| entry.clone()) .unwrap_or_default(); account_ids .into_iter() .filter_map(|account_id| { let session_id = self.account_to_session.get(&account_id)?; self.sessions.get(session_id.value()).map(|s| s.clone()) }) .collect() } pub fn browser_exists(&self, browser_id: &str) -> bool { self.browsers.contains_key(browser_id) } pub fn get_account_session( &self, browser_id: &str, account_id: &str, token: &str, ) -> Option { let accounts = self.browsers.get(browser_id)?; if !accounts.contains(&account_id.to_string()) { return None; } drop(accounts); let session_id = self.account_to_session.get(account_id)?; if session_id.value() != token { return None; } drop(session_id); self.get(token) } pub fn remove_account(&self, account_id: &str) -> bool { if let Some((removed_account_id, session_id)) = self.account_to_session.remove(account_id) { if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { self.sessions.remove(&session_id); })) { self.account_to_session .insert(removed_account_id, session_id); std::panic::resume_unwind(payload); } if let Some((_, browser_id)) = self.account_to_browser.remove(account_id) && let Some(mut entry) = self.browsers.get_mut(&browser_id) && let Some(pos) = entry.value().iter().position(|id| id == account_id) { entry.value_mut().remove(pos); } true } else { false } } pub fn set_user_agent(&self, account_id: &str, user_agent: String) { let Some(session_id) = self .account_to_session .get(account_id) .map(|entry| entry.clone()) else { return; }; if let Some(mut session) = self.sessions.get_mut(&session_id) { session.user_agent = user_agent.chars().take(512).collect(); } } pub fn get_user_sessions(&self, user_hash: &str) -> Vec { let now = Instant::now(); let mut result: Vec = self .sessions .iter() .filter_map(|entry| { let session = entry.value(); let effective_timeout = session.timeout_override.unwrap_or(self.timeout); if session.user_hash == user_hash && now.duration_since(session.last_accessed) <= effective_timeout { Some(session.clone()) } else { None } }) .collect(); result.sort_by_key(|session| std::cmp::Reverse(session.last_accessed_unix)); result } pub fn remove_browser(&self, browser_id: &str) { if let Some((_, account_ids)) = self.browsers.remove(browser_id) { for account_id in account_ids { self.account_to_browser.remove(&account_id); if let Some((_, session_id)) = self.account_to_session.remove(&account_id) { self.sessions.remove(&session_id); } } } } #[allow(dead_code)] pub fn insert( &self, email: String, password: String, user_hash: String, timeout_override: Option, ) -> SessionId { let token = Self::generate_token(); let account_id = Self::generate_id(); let now_unix = Self::unix_now(); let state = AccountSession { account_id, email, password, user_hash, imap_host: String::new(), imap_port: 993, imap_tls: true, imap_tls_verify: true, smtp_host: String::new(), smtp_port: 587, smtp_tls: true, smtp_tls_verify: true, last_accessed: Instant::now(), timeout_override, created_at_unix: now_unix, last_accessed_unix: now_unix, user_agent: String::new(), }; self.sessions.insert(token.clone(), state); token } pub fn get(&self, token: &str) -> Option { let mut entry = self.sessions.get_mut(token)?; let now = Instant::now(); let effective_timeout = entry.timeout_override.unwrap_or(self.timeout); if now.duration_since(entry.last_accessed) > effective_timeout { let account_id = entry.account_id.clone(); drop(entry); self.sessions.remove(token); self.evict_account(&account_id); return None; } entry.last_accessed = now; entry.last_accessed_unix = Self::unix_now(); Some(entry.clone()) } #[allow(dead_code)] pub fn remove(&self, token: &str) -> bool { self.sessions.remove(token).is_some() } fn evict_account(&self, account_id: &str) { self.account_to_session.remove(account_id); self.account_to_browser.remove(account_id); self.browsers.retain(|_, accounts| { accounts.retain(|id| id != account_id); !accounts.is_empty() }); } #[allow(dead_code)] pub fn purge_expired(&self) { let now = Instant::now(); let mut expired_accounts = Vec::new(); self.sessions.retain(|_, state| { let effective_timeout = state.timeout_override.unwrap_or(self.timeout); let alive = now.duration_since(state.last_accessed) <= effective_timeout; if !alive { expired_accounts.push(state.account_id.clone()); } alive }); for account_id in &expired_accounts { self.evict_account(account_id); } } #[allow(dead_code)] pub fn check_consistency(&self) -> Vec { let mut issues = Vec::new(); for entry in self.sessions.iter() { let token = entry.key(); let account_id = &entry.value().account_id; match self.account_to_session.get(account_id) { Some(mapped_token) if mapped_token.value() == token => {} Some(mapped_token) => { issues.push(format!( "account_to_session[{account_id}] points to {} but session token is {token}", mapped_token.value() )); } None => { issues.push(format!( "session {token} references account {account_id} with no account_to_session entry" )); } } } for entry in self.account_to_session.iter() { let account_id = entry.key(); let session_id = entry.value(); if !self.sessions.contains_key(session_id) { issues.push(format!( "account_to_session[{account_id}] references missing session {session_id}" )); } } for entry in self.browsers.iter() { let browser_id = entry.key(); for account_id in entry.value().iter() { if !self.account_to_session.contains_key(account_id) { issues.push(format!( "browser {browser_id} references account {account_id} missing from account_to_session" )); } } } for entry in self.account_to_browser.iter() { let account_id = entry.key(); let browser_id = entry.value(); if !self.account_to_session.contains_key(account_id) { issues.push(format!( "account_to_browser[{account_id}] references account missing from account_to_session" )); } if !self.browsers.contains_key(browser_id) { issues.push(format!( "account_to_browser[{account_id}] references missing browser {browser_id}" )); } } issues } #[allow(dead_code)] pub fn len(&self) -> usize { self.sessions.len() } #[allow(dead_code)] pub fn is_empty(&self) -> bool { self.sessions.is_empty() } pub fn diagnostics_snapshot(&self, include_user_identifiers: bool) -> SessionStoreDiagnostics { let mut users = HashSet::new(); let mut sessions = Vec::new(); for entry in self.sessions.iter() { let session = entry.value(); users.insert(session.user_hash.clone()); sessions.push(DiagnosticSession { email: include_user_identifiers.then(|| session.email.clone()), user_hash: include_user_identifiers.then(|| session.user_hash.clone()), imap_host: session.imap_host.clone(), imap_port: session.imap_port, smtp_host: session.smtp_host.clone(), smtp_port: session.smtp_port, created_at_unix: session.created_at_unix, last_accessed_unix: session.last_accessed_unix, user_agent: session.user_agent.chars().take(256).collect(), }); } sessions.sort_by_key(|session| std::cmp::Reverse(session.last_accessed_unix)); sessions.truncate(100); SessionStoreDiagnostics { local_cached_sessions: self.sessions.len(), local_cached_browsers: self.browsers.len(), local_unique_users: users.len(), sessions, } } fn cache_persisted(&self, record: &PersistedSession) -> Result { let crypto = self .crypto .as_ref() .ok_or("session crypto is not configured")?; let password = crypto.decrypt(&record.password_ciphertext)?; let age = Self::unix_now().saturating_sub(record.last_accessed_unix); let now = Instant::now(); let last_accessed = now.checked_sub(Duration::from_secs(age)).unwrap_or(now); let timeout_override = if record.timeout_secs == self.timeout.as_secs() { None } else { Some(Duration::from_secs(record.timeout_secs)) }; let session = AccountSession { account_id: record.account_id.clone(), email: record.email.clone(), password, user_hash: record.user_hash.clone(), imap_host: record.imap_host.clone(), imap_port: record.imap_port, imap_tls: record.imap_tls, imap_tls_verify: record.imap_tls_verify, smtp_host: record.smtp_host.clone(), smtp_port: record.smtp_port, smtp_tls: record.smtp_tls, smtp_tls_verify: record.smtp_tls_verify, last_accessed, timeout_override, created_at_unix: record.created_at_unix, last_accessed_unix: record.last_accessed_unix, user_agent: record.user_agent.clone(), }; self.sessions.insert(record.token.clone(), session.clone()); self.account_to_session .insert(record.account_id.clone(), record.token.clone()); self.account_to_browser .insert(record.account_id.clone(), record.browser_id.clone()); let mut entry = self.browsers.entry(record.browser_id.clone()).or_default(); if !entry.contains(&record.account_id) { entry.push(record.account_id.clone()); } Ok(session) } fn persisted_from_memory( &self, token: &str, browser_id: &str, session: &AccountSession, ) -> Result { let crypto = self .crypto .as_ref() .ok_or("session crypto is not configured")?; Ok(PersistedSession { token: token.to_string(), account_id: session.account_id.clone(), browser_id: browser_id.to_string(), email: session.email.clone(), password_ciphertext: crypto.encrypt(&session.password)?, user_hash: session.user_hash.clone(), imap_host: session.imap_host.clone(), imap_port: session.imap_port, imap_tls: session.imap_tls, imap_tls_verify: session.imap_tls_verify, smtp_host: session.smtp_host.clone(), smtp_port: session.smtp_port, smtp_tls: session.smtp_tls, smtp_tls_verify: session.smtp_tls_verify, timeout_secs: session.timeout_override.unwrap_or(self.timeout).as_secs(), created_at_unix: session.created_at_unix, last_accessed_unix: session.last_accessed_unix, user_agent: session.user_agent.clone(), }) } async fn persist_account(&self, browser_id: &str, account_id: &str) { let (Some(persistence), Some(token)) = ( self.persistence.as_ref(), self.account_to_session.get(account_id).map(|v| v.clone()), ) else { return; }; let Some(session) = self.sessions.get(&token).map(|v| v.clone()) else { return; }; match self.persisted_from_memory(&token, browser_id, &session) { Ok(record) => { if let Err(error) = persistence.save(&record).await { tracing::error!(%error, "failed to persist session"); } } Err(error) => tracing::error!(%error, "failed to encrypt session"), } } pub async fn add_account_to_browser_shared( &self, browser_id: &str, input: NewAccountSession, ) -> (SessionId, AccountId) { let result = self.add_account_to_browser_with_timeout( browser_id, input.email, input.password, input.user_hash, input.imap_host, input.imap_port, input.imap_tls, input.smtp_host, input.smtp_port, input.smtp_tls, input.timeout_override, ); self.persist_account(browser_id, &result.1).await; result } pub async fn get_browser_accounts_shared(&self, browser_id: &str) -> Vec { let Some(persistence) = self.persistence.as_ref() else { return self.get_browser_accounts(browser_id); }; match persistence.load_browser(browser_id).await { Ok(records) => records .into_iter() .filter(|r| !r.expired_at(Self::unix_now())) .filter_map(|r| { self.cache_persisted(&r) .map_err(|e| tracing::error!(error=%e, "failed to restore session")) .ok() }) .collect(), Err(error) => { tracing::error!(%error, "failed to load browser sessions"); self.get_browser_accounts(browser_id) } } } pub async fn browser_exists_shared(&self, browser_id: &str) -> bool { let Some(persistence) = self.persistence.as_ref() else { return self.browser_exists(browser_id); }; persistence .browser_exists(browser_id) .await .unwrap_or_else(|error| { tracing::error!(%error, "failed to query browser session"); false }) } pub async fn get_shared(&self, token: &str) -> Option { let Some(persistence) = self.persistence.as_ref() else { return self.get(token); }; let record = persistence.load_token(token).await.ok().flatten()?; if record.expired_at(Self::unix_now()) { let _ = persistence.delete_account(&record.account_id).await; return None; } self.cache_persisted(&record).ok()?; let refreshed = self.get(token)?; self.persist_account(&record.browser_id, &record.account_id) .await; Some(refreshed) } pub async fn get_account_session_shared( &self, browser_id: &str, account_id: &str, token: &str, ) -> Option { let Some(persistence) = self.persistence.as_ref() else { return self.get_account_session(browser_id, account_id, token); }; let record = persistence.load_token(token).await.ok().flatten()?; if record.browser_id != browser_id || record.account_id != account_id || record.expired_at(Self::unix_now()) { return None; } self.cache_persisted(&record).ok()?; let session = self.get(token)?; self.persist_account(browser_id, account_id).await; Some(session) } pub async fn set_user_agent_shared(&self, account_id: &str, user_agent: String) { self.set_user_agent(account_id, user_agent); if let Some(browser_id) = self.account_to_browser.get(account_id).map(|v| v.clone()) { self.persist_account(&browser_id, account_id).await; } } pub async fn remove_account_shared(&self, account_id: &str) -> bool { let local = self.remove_account(account_id); let Some(persistence) = self.persistence.as_ref() else { return local; }; match persistence.delete_account(account_id).await { Ok(remote) => local || remote, Err(error) => { tracing::error!(%error, "failed to delete persisted session"); local } } } pub async fn remove_browser_shared(&self, browser_id: &str) { self.remove_browser(browser_id); if let Some(persistence) = self.persistence.as_ref() && let Err(error) = persistence.delete_browser(browser_id).await { tracing::error!(%error, "failed to delete persisted browser sessions"); } } pub async fn get_user_sessions_shared(&self, user_hash: &str) -> Vec { let Some(persistence) = self.persistence.as_ref() else { return self.get_user_sessions(user_hash); }; match persistence.load_user(user_hash).await { Ok(records) => records .into_iter() .filter(|r| !r.expired_at(Self::unix_now())) .filter_map(|r| self.cache_persisted(&r).ok()) .collect(), Err(error) => { tracing::error!(%error, "failed to load user sessions"); self.get_user_sessions(user_hash) } } } pub async fn remove_user_session_shared(&self, user_hash: &str, account_id: &str) -> bool { let sessions = self.get_user_sessions_shared(user_hash).await; if !sessions.iter().any(|s| s.account_id == account_id) { return false; } self.remove_account_shared(account_id).await } pub async fn remove_other_user_sessions_shared( &self, user_hash: &str, current_account_id: &str, ) -> usize { let ids: Vec = self .get_user_sessions_shared(user_hash) .await .into_iter() .filter(|s| s.account_id != current_account_id) .map(|s| s.account_id) .collect(); let mut removed = 0; for id in ids { if self.remove_account_shared(&id).await { removed += 1; } } removed } pub async fn persistence_diagnostics(&self) -> Result { let persistence = self .persistence .as_ref() .ok_or_else(|| "session persistence is not configured".to_string())?; persistence.diagnostics(Self::unix_now()).await } pub async fn purge_expired_shared(&self) { self.purge_expired(); if let Some(persistence) = self.persistence.as_ref() && let Err(error) = persistence.purge_expired(Self::unix_now()).await { tracing::error!(%error, "failed to purge persisted sessions"); } } } #[cfg(test)] mod tests { use super::*; use std::collections::HashSet; use std::thread; fn long_lived_store() -> SessionStore { SessionStore::new(Duration::from_secs(3600)) } fn short_lived_store() -> SessionStore { SessionStore::new(Duration::from_millis(50)) } fn create_test_session(email: &str, password: &str, user_hash: &str) -> AccountSession { AccountSession { account_id: SessionStore::generate_id(), email: email.to_string(), password: password.to_string(), user_hash: user_hash.to_string(), imap_host: "imap.test.com".to_string(), imap_port: 993, imap_tls: true, imap_tls_verify: true, smtp_host: "smtp.test.com".to_string(), smtp_port: 587, smtp_tls: true, smtp_tls_verify: true, last_accessed: Instant::now(), timeout_override: None, created_at_unix: SessionStore::unix_now(), last_accessed_unix: SessionStore::unix_now(), user_agent: String::new(), } } #[test] fn generate_token_produces_unique_values() { let mut tokens = HashSet::new(); for _ in 0..1000 { let t = SessionStore::generate_token(); assert!(tokens.insert(t), "duplicate token generated"); } } #[test] fn generate_token_length_is_correct() { let token = SessionStore::generate_token(); assert_eq!(token.len(), 43); } #[test] fn generate_token_is_valid_base64url() { let token = SessionStore::generate_token(); let decoded = URL_SAFE_NO_PAD.decode(&token); assert!(decoded.is_ok(), "token is not valid base64url"); assert_eq!(decoded.unwrap().len(), 32); } #[test] fn generate_id_produces_22_chars() { let id = SessionStore::generate_id(); assert_eq!(id.len(), 22); } #[test] fn create_browser_returns_valid_id() { let store = long_lived_store(); let browser_id = store.create_browser(); assert_eq!(browser_id.len(), 22); assert!(store.browsers.contains_key(&browser_id)); } #[test] fn add_account_to_browser() { let store = long_lived_store(); let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, "alice@example.com".into(), "password".into(), "hash123".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert_eq!(token.len(), 43); assert_eq!(account_id.len(), 22); let accounts = store.get_browser_accounts(&browser_id); assert_eq!(accounts.len(), 1); assert_eq!(accounts[0].email, "alice@example.com"); assert_eq!(accounts[0].imap_host, "imap.example.com"); let session = store.get(&token).expect("session should exist"); assert_eq!(session.email, "alice@example.com"); } #[test] fn get_account_session_validates_browser_binding() { let store = long_lived_store(); let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); let session = store .get_account_session(&browser_id, &account_id, &token) .expect("should return session"); assert_eq!(session.email, "user@example.com"); } #[test] fn get_account_session_rejects_wrong_browser() { let store = long_lived_store(); let browser1 = store.create_browser(); let browser2 = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser1, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert!( store .get_account_session(&browser2, &account_id, &token) .is_none(), "should reject wrong browser" ); } #[test] fn get_account_session_rejects_wrong_token() { let store = long_lived_store(); let browser_id = store.create_browser(); let (_, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); let wrong_token = SessionStore::generate_token(); assert!( store .get_account_session(&browser_id, &account_id, &wrong_token) .is_none(), "should reject wrong token" ); } #[test] fn remove_account() { let store = long_lived_store(); let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert!(store.remove_account(&account_id)); assert!(store.get(&token).is_none()); assert!(store.get_browser_accounts(&browser_id).is_empty()); assert!(!store.remove_account(&account_id)); } #[test] fn remove_browser_clears_all_accounts() { let store = long_lived_store(); let browser_id = store.create_browser(); let (token1, account1) = store.add_account_to_browser( &browser_id, "user1@example.com".into(), "pass".into(), "hash1".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); let (token2, account2) = store.add_account_to_browser( &browser_id, "user2@example.com".into(), "pass".into(), "hash2".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert_eq!(store.get_browser_accounts(&browser_id).len(), 2); store.remove_browser(&browser_id); assert!(store.get(&token1).is_none()); assert!(store.get(&token2).is_none()); assert!(store.get_browser_accounts(&browser_id).is_empty()); assert!(!store.account_to_session.contains_key(&account1)); assert!(!store.account_to_session.contains_key(&account2)); } #[test] fn expired_session_returns_none() { let store = short_lived_store(); let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(100)); assert!( store.get(&token).is_none(), "expired session should not be returned" ); assert!( store .get_account_session(&browser_id, &account_id, &token) .is_none(), "expired session via get_account_session should return none" ); } #[test] fn multiple_accounts_per_browser() { let store = long_lived_store(); let browser_id = store.create_browser(); store.add_account_to_browser( &browser_id, "user1@example.com".into(), "pass1".into(), "hash1".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); store.add_account_to_browser( &browser_id, "user2@example.com".into(), "pass2".into(), "hash2".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); store.add_account_to_browser( &browser_id, "user3@example.com".into(), "pass3".into(), "hash3".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); let accounts = store.get_browser_accounts(&browser_id); assert_eq!(accounts.len(), 3); let emails: HashSet<_> = accounts.iter().map(|a| a.email.as_str()).collect(); assert!(emails.contains("user1@example.com")); assert!(emails.contains("user2@example.com")); assert!(emails.contains("user3@example.com")); } #[test] fn browsers_are_isolated() { let store = long_lived_store(); let browser1 = store.create_browser(); let browser2 = store.create_browser(); store.add_account_to_browser( &browser1, "user1@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); store.add_account_to_browser( &browser2, "user2@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); let accounts1 = store.get_browser_accounts(&browser1); let accounts2 = store.get_browser_accounts(&browser2); assert_eq!(accounts1.len(), 1); assert_eq!(accounts2.len(), 1); assert_eq!(accounts1[0].email, "user1@example.com"); assert_eq!(accounts2[0].email, "user2@example.com"); store.remove_browser(&browser1); assert_eq!(store.get_browser_accounts(&browser2).len(), 1); } #[test] fn sliding_window_refreshes_expiry() { let store = SessionStore::new(Duration::from_millis(800)); let browser_id = store.create_browser(); let (token, _account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(400)); assert!(store.get(&token).is_some()); thread::sleep(Duration::from_millis(400)); assert!(store.get(&token).is_some()); } #[test] fn helper_creates_valid_session() { let session = create_test_session("test@example.com", "pass", "hash"); assert_eq!(session.email, "test@example.com"); assert_eq!(session.imap_host, "imap.test.com"); assert_eq!(session.imap_port, 993); } fn add_test_account( store: &SessionStore, browser_id: &str, email: &str, ) -> (SessionId, AccountId) { store.add_account_to_browser( browser_id, email.into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ) } #[test] fn consistency_after_add_account() { let store = long_lived_store(); let browser = store.create_browser(); add_test_account(&store, &browser, "a@example.com"); add_test_account(&store, &browser, "b@example.com"); assert!( store.check_consistency().is_empty(), "store should be consistent after adding accounts" ); } #[test] fn consistency_after_remove_account() { let store = long_lived_store(); let browser = store.create_browser(); let (_, aid1) = add_test_account(&store, &browser, "a@example.com"); let (_, _aid2) = add_test_account(&store, &browser, "b@example.com"); store.remove_account(&aid1); let issues = store.check_consistency(); assert!( issues.is_empty(), "store should be consistent after remove_account, got: {issues:?}" ); assert_eq!(store.get_browser_accounts(&browser).len(), 1); } #[test] fn consistency_after_remove_browser() { let store = long_lived_store(); let browser = store.create_browser(); add_test_account(&store, &browser, "a@example.com"); add_test_account(&store, &browser, "b@example.com"); store.remove_browser(&browser); let issues = store.check_consistency(); assert!( issues.is_empty(), "store should be consistent after remove_browser, got: {issues:?}" ); assert_eq!(store.sessions.len(), 0); assert_eq!(store.account_to_session.len(), 0); } #[test] fn consistency_after_purge_expired() { let store = short_lived_store(); let browser = store.create_browser(); add_test_account(&store, &browser, "a@example.com"); add_test_account(&store, &browser, "b@example.com"); thread::sleep(Duration::from_millis(100)); store.purge_expired(); let issues = store.check_consistency(); assert!( issues.is_empty(), "store should be consistent after purge_expired, got: {issues:?}" ); assert_eq!(store.sessions.len(), 0); assert_eq!(store.account_to_session.len(), 0); assert!(!store.browser_exists(&browser)); } #[test] fn purge_expired_respects_timeout_override() { let store = short_lived_store(); let browser = store.create_browser(); let (token, account_id) = add_test_account(&store, &browser, "long@example.com"); store.sessions.get_mut(&token).unwrap().timeout_override = Some(Duration::from_secs(3600)); add_test_account(&store, &browser, "short@example.com"); thread::sleep(Duration::from_millis(100)); store.purge_expired(); assert!(store.get(&token).is_some()); assert!(store.account_to_session.contains_key(&account_id)); assert_eq!(store.sessions.len(), 1); let issues = store.check_consistency(); assert!( issues.is_empty(), "store should be consistent after selective purge, got: {issues:?}" ); } #[test] fn consistency_empty_store() { let store = long_lived_store(); assert!(store.check_consistency().is_empty()); } #[test] fn consistency_mixed_operations() { let store = long_lived_store(); let b1 = store.create_browser(); let b2 = store.create_browser(); let (_, a1) = add_test_account(&store, &b1, "a@example.com"); add_test_account(&store, &b1, "b@example.com"); add_test_account(&store, &b2, "c@example.com"); store.remove_account(&a1); store.remove_browser(&b2); let issues = store.check_consistency(); assert!( issues.is_empty(), "store should be consistent after mixed operations, got: {issues:?}" ); assert_eq!(store.sessions.len(), 1); assert_eq!(store.account_to_session.len(), 1); } #[test] fn remove_account_uses_reverse_index() { let store = long_lived_store(); let mut other_browsers = Vec::new(); for _ in 0..100 { let bid = store.create_browser(); store.add_account_to_browser( &bid, "other@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); other_browsers.push(bid); } let target_browser = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &target_browser, "target@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert!(store.account_to_browser.contains_key(&account_id)); assert_eq!( store.account_to_browser.get(&account_id).unwrap().value(), &target_browser ); assert!(store.remove_account(&account_id)); assert!(store.get(&token).is_none()); assert!(!store.account_to_session.contains_key(&account_id)); assert!(!store.account_to_browser.contains_key(&account_id)); assert!(store.get_browser_accounts(&target_browser).is_empty()); for bid in &other_browsers { assert_eq!(store.get_browser_accounts(bid).len(), 1); } } #[test] fn remove_browser_cleans_up_reverse_index() { let store = long_lived_store(); let browser_id = store.create_browser(); let (_, account1) = store.add_account_to_browser( &browser_id, "user1@example.com".into(), "pass".into(), "hash1".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); let (_, account2) = store.add_account_to_browser( &browser_id, "user2@example.com".into(), "pass".into(), "hash2".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert!(store.account_to_browser.contains_key(&account1)); assert!(store.account_to_browser.contains_key(&account2)); store.remove_browser(&browser_id); assert!(!store.account_to_browser.contains_key(&account1)); assert!(!store.account_to_browser.contains_key(&account2)); } #[test] fn purge_expired_cleans_up_reverse_index() { let store = short_lived_store(); let browser_id = store.create_browser(); let (_, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); assert!(store.account_to_browser.contains_key(&account_id)); thread::sleep(Duration::from_millis(100)); store.purge_expired(); assert!(!store.account_to_browser.contains_key(&account_id)); assert!(!store.account_to_session.contains_key(&account_id)); assert!(store.get_browser_accounts(&browser_id).is_empty()); } #[test] fn purge_expired_cascades_to_browsers_and_account_map() { let store = short_lived_store(); let browser_id = store.create_browser(); let (_token, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(100)); store.purge_expired(); assert!(store.sessions.is_empty(), "session should be purged"); assert!( !store.account_to_session.contains_key(&account_id), "account_to_session entry should be removed" ); assert!( !store.browsers.contains_key(&browser_id), "empty browser entry should be removed" ); } #[test] fn purge_expired_keeps_live_accounts_in_browser() { let store = SessionStore::new(Duration::from_millis(150)); let browser_id = store.create_browser(); let (_token1, account1) = store.add_account_to_browser( &browser_id, "old@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(100)); let (_token2, account2) = store.add_account_to_browser( &browser_id, "new@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(60)); store.purge_expired(); assert_eq!( store.sessions.len(), 1, "only the live session should remain" ); assert!( !store.account_to_session.contains_key(&account1), "expired account mapping should be gone" ); assert!( store.account_to_session.contains_key(&account2), "live account mapping should remain" ); let browser_accounts = store.browsers.get(&browser_id).unwrap(); assert_eq!(browser_accounts.len(), 1); assert_eq!(browser_accounts[0], account2); } #[test] fn lazy_eviction_on_get_cascades() { let store = short_lived_store(); let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(100)); assert!(store.get(&token).is_none()); assert!( !store.account_to_session.contains_key(&account_id), "account_to_session should be cleaned up by lazy eviction" ); assert!( !store.browsers.contains_key(&browser_id), "empty browser should be cleaned up by lazy eviction" ); } #[test] fn lazy_eviction_on_get_account_session_cascades() { let store = short_lived_store(); let browser_id = store.create_browser(); let (token, account_id) = store.add_account_to_browser( &browser_id, "user@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, ); thread::sleep(Duration::from_millis(100)); assert!( store .get_account_session(&browser_id, &account_id, &token) .is_none() ); assert!( !store.account_to_session.contains_key(&account_id), "account_to_session should be cleaned up via get_account_session" ); } #[test] fn remembered_account_uses_timeout_override() { let store = SessionStore::new(Duration::from_millis(50)); let browser_id = store.create_browser(); let (token, _) = store.add_account_to_browser_with_timeout( &browser_id, "remember@example.com".into(), "pass".into(), "hash".into(), "imap.example.com".into(), 993, true, "smtp.example.com".into(), 587, true, Some(Duration::from_secs(1)), ); thread::sleep(Duration::from_millis(100)); assert!( store.get(&token).is_some(), "remembered session should outlive default timeout" ); } #[tokio::test] async fn persistent_sqlite_session_survives_store_restart() { let temp = tempfile::tempdir().expect("tempdir"); let config = crate::config::AppConfig { data_dir: temp.path().to_string_lossy().into_owned(), database_backend: "sqlite".to_string(), session_backend: "database".to_string(), ..Default::default() }; let persistence = crate::session_store::from_config(&config) .await .expect("persistence"); let crypto = crate::session_store::crypto::SessionCrypto::load_or_create(&config).expect("crypto"); let store = SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto); let browser_id = store.create_browser(); let (token, account_id) = store .add_account_to_browser_shared( &browser_id, NewAccountSession { email: "restart@example.com".to_string(), password: "secret-password".to_string(), user_hash: "user-hash".to_string(), imap_host: "imap.example.com".to_string(), imap_port: 993, imap_tls: true, smtp_host: "smtp.example.com".to_string(), smtp_port: 587, smtp_tls: true, timeout_override: None, }, ) .await; drop(store); let persistence = crate::session_store::from_config(&config) .await .expect("persistence after restart"); let crypto = crate::session_store::crypto::SessionCrypto::load_or_create(&config) .expect("crypto after restart"); let restarted = SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto); let restored = restarted .get_account_session_shared(&browser_id, &account_id, &token) .await .expect("session should survive restart"); assert_eq!(restored.email, "restart@example.com"); assert_eq!(restored.password, "secret-password"); } }