Files
RustBox/src/auth/session.rs
T
Mateusz Gruszczyński 425ea7449b
RustBox CI / rustbox (push) Canceled after 0s
v0.1.0
2026-10-30 17:22:17 +01:00

1655 lines
52 KiB
Rust

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 crate::session_store::crypto::SessionCrypto;
use crate::session_store::{PersistedSession, SessionPersistence};
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<Duration>,
}
#[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<Duration>,
pub created_at_unix: u64,
pub last_accessed_unix: u64,
pub user_agent: String,
}
#[derive(Clone)]
pub struct SessionStore {
sessions: Arc<DashMap<SessionId, AccountSession>>,
browsers: Arc<DashMap<BrowserId, Vec<AccountId>>>,
account_to_session: Arc<DashMap<AccountId, SessionId>>,
account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
timeout: Duration,
tls_verify_override: Option<bool>,
persistence: Option<Arc<dyn SessionPersistence>>,
crypto: Option<SessionCrypto>,
}
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<bool>) -> 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<bool>,
persistence: Arc<dyn SessionPersistence>,
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<Duration>,
) -> (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<AccountSession> {
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<AccountSession> {
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<AccountSession> {
let now = Instant::now();
let mut result: Vec<AccountSession> = 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<Duration>,
) -> 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<AccountSession> {
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<String> {
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()
}
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
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<PersistedSession, String> {
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<AccountSession> {
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<AccountSession> {
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<AccountSession> {
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<AccountSession> {
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<String> = 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 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");
}
}