use crate::error::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
const MAX_IP_LENGTH: usize = 45;
const MAX_USER_AGENT_LENGTH: usize = 512;
const MAX_LOCATION_LENGTH: usize = 256;
const DEFAULT_MAX_SESSIONS: usize = 5;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SessionOverflowBehavior {
#[default]
RevokeOldest,
RejectNew,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionLimitConfig {
pub max_sessions: usize,
pub overflow_behavior: SessionOverflowBehavior,
}
impl Default for SessionLimitConfig {
fn default() -> Self {
Self {
max_sessions: DEFAULT_MAX_SESSIONS,
overflow_behavior: SessionOverflowBehavior::default(),
}
}
}
impl SessionLimitConfig {
#[must_use]
pub fn new(max_sessions: usize) -> Self {
Self {
max_sessions,
overflow_behavior: SessionOverflowBehavior::default(),
}
}
#[must_use]
pub fn overflow_behavior(mut self, behavior: SessionOverflowBehavior) -> Self {
self.overflow_behavior = behavior;
self
}
#[must_use]
pub fn reject_new(mut self) -> Self {
self.overflow_behavior = SessionOverflowBehavior::RejectNew;
self
}
#[must_use]
pub fn revoke_oldest(mut self) -> Self {
self.overflow_behavior = SessionOverflowBehavior::RevokeOldest;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionCreateResult {
pub created: bool,
pub evicted_sessions: Vec<String>,
}
impl SessionCreateResult {
#[must_use]
fn success() -> Self {
Self {
created: true,
evicted_sessions: Vec::new(),
}
}
#[must_use]
fn success_with_evictions(evicted: Vec<String>) -> Self {
Self {
created: true,
evicted_sessions: evicted,
}
}
#[must_use]
fn rejected() -> Self {
Self {
created: false,
evicted_sessions: Vec::new(),
}
}
#[must_use]
pub fn is_created(&self) -> bool {
self.created
}
#[must_use]
pub fn has_evictions(&self) -> bool {
!self.evicted_sessions.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
pub created_at: SystemTime,
pub last_used_at: SystemTime,
#[serde(default)]
pub is_current: bool,
}
impl SessionInfo {
#[must_use]
pub fn new(id: impl Into<String>, user_id: impl Into<String>) -> Self {
let now = SystemTime::now();
Self {
id: id.into(),
user_id: user_id.into(),
ip_address: None,
user_agent: None,
device_info: None,
location: None,
created_at: now,
last_used_at: now,
is_current: false,
}
}
#[must_use]
pub fn with_ip(mut self, ip: impl Into<String>) -> Self {
let ip = truncate_string(ip.into(), MAX_IP_LENGTH);
self.ip_address = Some(ip);
self
}
#[must_use]
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
let ua_string = truncate_string(ua.into(), MAX_USER_AGENT_LENGTH);
self.device_info = Some(parse_user_agent(&ua_string));
self.user_agent = Some(ua_string);
self
}
#[must_use]
pub fn with_location(mut self, location: impl Into<String>) -> Self {
let location = truncate_string(location.into(), MAX_LOCATION_LENGTH);
self.location = Some(location);
self
}
#[must_use]
pub fn mark_current(mut self) -> Self {
self.is_current = true;
self
}
}
fn truncate_string(s: String, max_len: usize) -> String {
if s.len() <= max_len {
s
} else {
s.chars().take(max_len).collect()
}
}
fn parse_user_agent(ua: &str) -> String {
let browser = if ua.contains("Firefox") {
"Firefox"
} else if ua.contains("Edg/") {
"Edge"
} else if ua.contains("Chrome") {
"Chrome"
} else if ua.contains("Safari") {
"Safari"
} else if ua.contains("curl") {
"curl"
} else {
"Unknown Browser"
};
let os = if ua.contains("Windows") {
"Windows"
} else if ua.contains("iPhone") || ua.contains("iPad") {
"iOS"
} else if ua.contains("Android") {
"Android"
} else if ua.contains("Mac OS X") || ua.contains("macOS") {
"macOS"
} else if ua.contains("Linux") {
"Linux"
} else {
"Unknown OS"
};
format!("{} on {}", browser, os)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SessionMetadata {
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub location: Option<String>,
}
impl SessionMetadata {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_ip(mut self, ip: impl Into<String>) -> Self {
self.ip_address = Some(truncate_string(ip.into(), MAX_IP_LENGTH));
self
}
#[must_use]
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(truncate_string(ua.into(), MAX_USER_AGENT_LENGTH));
self
}
#[must_use]
pub fn with_location(mut self, location: impl Into<String>) -> Self {
self.location = Some(truncate_string(location.into(), MAX_LOCATION_LENGTH));
self
}
}
#[async_trait]
pub trait SessionStore: Send + Sync {
async fn create_session(
&self,
session_id: &str,
user_id: &str,
metadata: SessionMetadata,
) -> Result<()>;
async fn touch_session(&self, session_id: &str) -> Result<()>;
async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>>;
async fn list_sessions(&self, user_id: &str) -> Result<Vec<SessionInfo>>;
async fn revoke_session(&self, session_id: &str) -> Result<bool>;
async fn revoke_all_sessions(&self, user_id: &str) -> Result<usize>;
async fn revoke_other_sessions(&self, user_id: &str, except_session_id: &str) -> Result<usize>;
async fn session_count(&self, user_id: &str) -> Result<usize> {
Ok(self.list_sessions(user_id).await?.len())
}
async fn list_sessions_paginated(
&self,
user_id: &str,
offset: usize,
limit: usize,
) -> Result<Vec<SessionInfo>> {
let all = self.list_sessions(user_id).await?;
Ok(all.into_iter().skip(offset).take(limit).collect())
}
}
pub struct SessionManager<S: SessionStore> {
store: S,
session_limit: Option<SessionLimitConfig>,
}
impl<S: SessionStore> SessionManager<S> {
#[must_use]
pub fn new(store: S) -> Self {
Self {
store,
session_limit: None,
}
}
#[must_use]
pub fn with_session_limit(mut self, config: SessionLimitConfig) -> Self {
self.session_limit = Some(config);
self
}
pub async fn create_session(
&self,
session_id: &str,
user_id: &str,
metadata: SessionMetadata,
) -> Result<SessionCreateResult> {
let ip_for_logging = metadata.ip_address.clone();
let mut evicted = Vec::new();
if let Some(ref limit_config) = self.session_limit {
let current_count = self.store.session_count(user_id).await?;
if current_count >= limit_config.max_sessions {
match limit_config.overflow_behavior {
SessionOverflowBehavior::RejectNew => {
tracing::warn!(
target: "auth.session.limit_exceeded",
user_id = %user_id,
current_count = current_count,
max_sessions = limit_config.max_sessions,
"Session limit exceeded, rejecting new session"
);
return Ok(SessionCreateResult::rejected());
}
SessionOverflowBehavior::RevokeOldest => {
let to_evict = current_count - limit_config.max_sessions + 1;
evicted = self.evict_oldest_sessions(user_id, to_evict).await?;
}
}
}
}
self.store
.create_session(session_id, user_id, metadata)
.await?;
tracing::info!(
target: "auth.session.created",
session_id = %session_id,
user_id = %user_id,
ip_address = ip_for_logging.as_deref().unwrap_or("unknown"),
evicted_count = evicted.len(),
"New session created"
);
if evicted.is_empty() {
Ok(SessionCreateResult::success())
} else {
Ok(SessionCreateResult::success_with_evictions(evicted))
}
}
async fn evict_oldest_sessions(&self, user_id: &str, count: usize) -> Result<Vec<String>> {
let sessions = self.store.list_sessions(user_id).await?;
let to_evict: Vec<_> = sessions
.iter()
.rev()
.take(count)
.map(|s| s.id.clone())
.collect();
for session_id in &to_evict {
self.store.revoke_session(session_id).await?;
tracing::info!(
target: "auth.session.evicted",
session_id = %session_id,
user_id = %user_id,
"Session evicted due to session limit"
);
}
Ok(to_evict)
}
pub async fn touch_session(&self, session_id: &str) -> Result<()> {
self.store.touch_session(session_id).await
}
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>> {
self.store.get_session(session_id).await
}
pub async fn list_sessions(
&self,
user_id: &str,
current_session_id: Option<&str>,
) -> Result<Vec<SessionInfo>> {
let mut sessions = self.store.list_sessions(user_id).await?;
if let Some(current_id) = current_session_id {
for session in &mut sessions {
if session.id == current_id {
session.is_current = true;
}
}
}
Ok(sessions)
}
pub async fn revoke_session(&self, user_id: &str, session_id: &str) -> Result<bool> {
if let Some(session) = self.store.get_session(session_id).await? {
if session.user_id != user_id {
return Ok(false);
}
}
let revoked = self.store.revoke_session(session_id).await?;
if revoked {
tracing::info!(
target: "auth.session.revoked",
session_id = %session_id,
user_id = %user_id,
"Session revoked"
);
}
Ok(revoked)
}
pub async fn revoke_all_sessions(&self, user_id: &str) -> Result<usize> {
let count = self.store.revoke_all_sessions(user_id).await?;
tracing::warn!(
target: "auth.session.revoke_all",
user_id = %user_id,
count = count,
"All sessions revoked"
);
Ok(count)
}
pub async fn revoke_other_sessions(
&self,
user_id: &str,
current_session_id: &str,
) -> Result<usize> {
let count = self
.store
.revoke_other_sessions(user_id, current_session_id)
.await?;
tracing::info!(
target: "auth.session.revoke_others",
user_id = %user_id,
current_session_id = %current_session_id,
count = count,
"Other sessions revoked"
);
Ok(count)
}
pub async fn session_count(&self, user_id: &str) -> Result<usize> {
self.store.session_count(user_id).await
}
pub async fn list_sessions_paginated(
&self,
user_id: &str,
current_session_id: Option<&str>,
offset: usize,
limit: usize,
) -> Result<Vec<SessionInfo>> {
let mut sessions = self
.store
.list_sessions_paginated(user_id, offset, limit)
.await?;
if let Some(current_id) = current_session_id {
for session in &mut sessions {
if session.id == current_id {
session.is_current = true;
}
}
}
Ok(sessions)
}
pub fn store(&self) -> &S {
&self.store
}
}
#[cfg(any(test, feature = "test-auth-bypass"))]
pub mod test {
use super::*;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
#[derive(Default)]
struct InMemoryState {
sessions: HashMap<String, SessionInfo>,
user_sessions: HashMap<String, Vec<String>>,
revoked: HashSet<String>,
}
#[derive(Default)]
pub struct InMemorySessionStore {
state: RwLock<InMemoryState>,
}
impl InMemorySessionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl SessionStore for InMemorySessionStore {
async fn create_session(
&self,
session_id: &str,
user_id: &str,
metadata: SessionMetadata,
) -> Result<()> {
let mut session = SessionInfo::new(session_id, user_id);
if let Some(ip) = metadata.ip_address {
session = session.with_ip(ip);
}
if let Some(ua) = metadata.user_agent {
session = session.with_user_agent(ua);
}
if let Some(loc) = metadata.location {
session = session.with_location(loc);
}
let mut state = self.state.write().unwrap();
state.sessions.insert(session_id.to_string(), session);
state
.user_sessions
.entry(user_id.to_string())
.or_default()
.push(session_id.to_string());
Ok(())
}
async fn touch_session(&self, session_id: &str) -> Result<()> {
let mut state = self.state.write().unwrap();
if let Some(session) = state.sessions.get_mut(session_id) {
session.last_used_at = SystemTime::now();
}
Ok(())
}
async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>> {
let state = self.state.read().unwrap();
if state.revoked.contains(session_id) {
return Ok(None);
}
Ok(state.sessions.get(session_id).cloned())
}
async fn list_sessions(&self, user_id: &str) -> Result<Vec<SessionInfo>> {
let state = self.state.read().unwrap();
let mut result = Vec::new();
if let Some(session_ids) = state.user_sessions.get(user_id) {
for id in session_ids {
if !state.revoked.contains(id) {
if let Some(session) = state.sessions.get(id) {
result.push(session.clone());
}
}
}
}
result.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(result)
}
async fn revoke_session(&self, session_id: &str) -> Result<bool> {
let mut state = self.state.write().unwrap();
if state.sessions.contains_key(session_id) && !state.revoked.contains(session_id) {
state.revoked.insert(session_id.to_string());
Ok(true)
} else {
Ok(false)
}
}
async fn revoke_all_sessions(&self, user_id: &str) -> Result<usize> {
let mut state = self.state.write().unwrap();
let ids_to_revoke: Vec<String> = state
.user_sessions
.get(user_id)
.map(|ids| {
ids.iter()
.filter(|id| !state.revoked.contains(*id))
.cloned()
.collect()
})
.unwrap_or_default();
let count = ids_to_revoke.len();
for id in ids_to_revoke {
state.revoked.insert(id);
}
Ok(count)
}
async fn revoke_other_sessions(
&self,
user_id: &str,
except_session_id: &str,
) -> Result<usize> {
let mut state = self.state.write().unwrap();
let ids_to_revoke: Vec<String> = state
.user_sessions
.get(user_id)
.map(|ids| {
ids.iter()
.filter(|id| *id != except_session_id && !state.revoked.contains(*id))
.cloned()
.collect()
})
.unwrap_or_default();
let count = ids_to_revoke.len();
for id in ids_to_revoke {
state.revoked.insert(id);
}
Ok(count)
}
}
}
#[cfg(test)]
mod tests {
use super::test::InMemorySessionStore;
use super::*;
#[tokio::test]
async fn test_create_and_get_session() {
let store = InMemorySessionStore::new();
let metadata = SessionMetadata::new()
.with_ip("192.168.1.1")
.with_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0");
store
.create_session("session-1", "user-1", metadata)
.await
.unwrap();
let session = store.get_session("session-1").await.unwrap().unwrap();
assert_eq!(session.id, "session-1");
assert_eq!(session.user_id, "user-1");
assert_eq!(session.ip_address, Some("192.168.1.1".to_string()));
assert_eq!(session.device_info, Some("Chrome on macOS".to_string()));
}
#[tokio::test]
async fn test_list_sessions() {
let store = InMemorySessionStore::new();
store
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
store
.create_session("session-2", "user-1", SessionMetadata::new())
.await
.unwrap();
store
.create_session("session-3", "user-2", SessionMetadata::new())
.await
.unwrap();
let sessions = store.list_sessions("user-1").await.unwrap();
assert_eq!(sessions.len(), 2);
let sessions = store.list_sessions("user-2").await.unwrap();
assert_eq!(sessions.len(), 1);
}
#[tokio::test]
async fn test_revoke_session() {
let store = InMemorySessionStore::new();
store
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(store.get_session("session-1").await.unwrap().is_some());
let revoked = store.revoke_session("session-1").await.unwrap();
assert!(revoked);
assert!(store.get_session("session-1").await.unwrap().is_none());
}
#[tokio::test]
async fn test_revoke_all_sessions() {
let store = InMemorySessionStore::new();
store
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
store
.create_session("session-2", "user-1", SessionMetadata::new())
.await
.unwrap();
store
.create_session("session-3", "user-2", SessionMetadata::new())
.await
.unwrap();
let count = store.revoke_all_sessions("user-1").await.unwrap();
assert_eq!(count, 2);
let sessions = store.list_sessions("user-1").await.unwrap();
assert_eq!(sessions.len(), 0);
let sessions = store.list_sessions("user-2").await.unwrap();
assert_eq!(sessions.len(), 1);
}
#[tokio::test]
async fn test_revoke_other_sessions() {
let store = InMemorySessionStore::new();
store
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
store
.create_session("session-2", "user-1", SessionMetadata::new())
.await
.unwrap();
store
.create_session("session-3", "user-1", SessionMetadata::new())
.await
.unwrap();
let count = store
.revoke_other_sessions("user-1", "session-2")
.await
.unwrap();
assert_eq!(count, 2);
let sessions = store.list_sessions("user-1").await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "session-2");
}
#[tokio::test]
async fn test_touch_session() {
let store = InMemorySessionStore::new();
store
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
let session1 = store.get_session("session-1").await.unwrap().unwrap();
let last_used1 = session1.last_used_at;
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
store.touch_session("session-1").await.unwrap();
let session2 = store.get_session("session-1").await.unwrap().unwrap();
assert!(session2.last_used_at > last_used1);
}
#[tokio::test]
async fn test_session_manager() {
let store = InMemorySessionStore::new();
let manager = SessionManager::new(store);
let metadata = SessionMetadata::new().with_ip("10.0.0.1");
manager
.create_session("session-1", "user-1", metadata)
.await
.unwrap();
let sessions = manager
.list_sessions("user-1", Some("session-1"))
.await
.unwrap();
assert_eq!(sessions.len(), 1);
assert!(sessions[0].is_current);
let revoked = manager.revoke_session("user-1", "session-1").await.unwrap();
assert!(revoked);
let sessions = manager.list_sessions("user-1", None).await.unwrap();
assert_eq!(sessions.len(), 0);
}
#[tokio::test]
async fn test_session_manager_security() {
let store = InMemorySessionStore::new();
let manager = SessionManager::new(store);
manager
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
let revoked = manager.revoke_session("user-2", "session-1").await.unwrap();
assert!(!revoked);
let sessions = manager.list_sessions("user-1", None).await.unwrap();
assert_eq!(sessions.len(), 1);
}
#[test]
fn test_parse_user_agent() {
assert_eq!(
parse_user_agent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0"
),
"Chrome on macOS"
);
assert_eq!(
parse_user_agent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
),
"Firefox on Windows"
);
assert_eq!(
parse_user_agent(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari/605.1.15"
),
"Safari on iOS"
);
assert_eq!(parse_user_agent("curl/7.88.1"), "curl on Unknown OS");
}
#[test]
fn test_input_truncation() {
let long_ip = "a".repeat(100);
let metadata = SessionMetadata::new().with_ip(&long_ip);
assert_eq!(metadata.ip_address.as_ref().unwrap().len(), 45);
let long_ua = "Mozilla/".to_string() + &"x".repeat(1000);
let metadata = SessionMetadata::new().with_user_agent(&long_ua);
assert_eq!(metadata.user_agent.as_ref().unwrap().len(), 512);
let long_location = "a".repeat(500);
let metadata = SessionMetadata::new().with_location(&long_location);
assert_eq!(metadata.location.as_ref().unwrap().len(), 256);
let session = SessionInfo::new("id", "user")
.with_ip(&long_ip)
.with_user_agent(&long_ua)
.with_location(&long_location);
assert_eq!(session.ip_address.as_ref().unwrap().len(), 45);
assert_eq!(session.user_agent.as_ref().unwrap().len(), 512);
assert_eq!(session.location.as_ref().unwrap().len(), 256);
}
#[test]
fn test_truncate_string_utf8_safe() {
let unicode = "🔐🔐🔐🔐🔐"; let truncated = truncate_string(unicode.to_string(), 10);
assert_eq!(truncated, unicode);
let truncated = truncate_string(unicode.to_string(), 3);
assert_eq!(truncated, "🔐🔐🔐");
}
#[tokio::test]
async fn test_session_limit_revoke_oldest() {
let store = InMemorySessionStore::new();
let manager = SessionManager::new(store).with_session_limit(SessionLimitConfig::new(3));
for i in 1..=3 {
let result = manager
.create_session(&format!("session-{}", i), "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(result.created);
assert!(result.evicted_sessions.is_empty());
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let result = manager
.create_session("session-4", "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(result.created);
assert_eq!(result.evicted_sessions.len(), 1);
assert_eq!(result.evicted_sessions[0], "session-1");
let sessions = manager.list_sessions("user-1", None).await.unwrap();
assert_eq!(sessions.len(), 3);
assert!(!sessions.iter().any(|s| s.id == "session-1"));
}
#[tokio::test]
async fn test_session_limit_reject_new() {
let store = InMemorySessionStore::new();
let manager =
SessionManager::new(store).with_session_limit(SessionLimitConfig::new(2).reject_new());
for i in 1..=2 {
let result = manager
.create_session(&format!("session-{}", i), "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(result.created);
}
let result = manager
.create_session("session-3", "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(!result.created);
assert!(result.evicted_sessions.is_empty());
let sessions = manager.list_sessions("user-1", None).await.unwrap();
assert_eq!(sessions.len(), 2);
}
#[tokio::test]
async fn test_session_limit_per_user() {
let store = InMemorySessionStore::new();
let manager = SessionManager::new(store).with_session_limit(SessionLimitConfig::new(2));
manager
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
manager
.create_session("session-2", "user-1", SessionMetadata::new())
.await
.unwrap();
manager
.create_session("session-3", "user-2", SessionMetadata::new())
.await
.unwrap();
manager
.create_session("session-4", "user-2", SessionMetadata::new())
.await
.unwrap();
let sessions_1 = manager.list_sessions("user-1", None).await.unwrap();
let sessions_2 = manager.list_sessions("user-2", None).await.unwrap();
assert_eq!(sessions_1.len(), 2);
assert_eq!(sessions_2.len(), 2);
}
#[tokio::test]
async fn test_session_limit_evict_multiple() {
let store = InMemorySessionStore::new();
let manager = SessionManager::new(store).with_session_limit(SessionLimitConfig::new(2));
manager
.store()
.create_session("session-1", "user-1", SessionMetadata::new())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
manager
.store()
.create_session("session-2", "user-1", SessionMetadata::new())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
manager
.store()
.create_session("session-3", "user-1", SessionMetadata::new())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
manager
.store()
.create_session("session-4", "user-1", SessionMetadata::new())
.await
.unwrap();
let result = manager
.create_session("session-5", "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(result.created);
assert_eq!(result.evicted_sessions.len(), 3);
let sessions = manager.list_sessions("user-1", None).await.unwrap();
assert_eq!(sessions.len(), 2);
}
#[tokio::test]
async fn test_session_limit_no_limit_configured() {
let store = InMemorySessionStore::new();
let manager = SessionManager::new(store);
for i in 1..=10 {
let result = manager
.create_session(&format!("session-{}", i), "user-1", SessionMetadata::new())
.await
.unwrap();
assert!(result.created);
assert!(result.evicted_sessions.is_empty());
}
let sessions = manager.list_sessions("user-1", None).await.unwrap();
assert_eq!(sessions.len(), 10);
}
#[test]
fn test_session_limit_config_builder() {
let config = SessionLimitConfig::new(5).reject_new();
assert_eq!(config.max_sessions, 5);
assert_eq!(config.overflow_behavior, SessionOverflowBehavior::RejectNew);
let config = SessionLimitConfig::new(10).revoke_oldest();
assert_eq!(config.max_sessions, 10);
assert_eq!(
config.overflow_behavior,
SessionOverflowBehavior::RevokeOldest
);
let config = SessionLimitConfig::default();
assert_eq!(config.max_sessions, 5); assert_eq!(
config.overflow_behavior,
SessionOverflowBehavior::RevokeOldest
);
}
#[test]
fn test_session_create_result() {
let success = SessionCreateResult::success();
assert!(success.created);
assert!(success.evicted_sessions.is_empty());
let evicted = SessionCreateResult::success_with_evictions(vec!["s1".into(), "s2".into()]);
assert!(evicted.created);
assert_eq!(evicted.evicted_sessions, vec!["s1", "s2"]);
let rejected = SessionCreateResult::rejected();
assert!(!rejected.created);
assert!(rejected.evicted_sessions.is_empty());
}
}