1655 lines
52 KiB
Rust
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");
|
|
}
|
|
}
|