coil-runtime 0.1.1

HTTP runtime and request handling for the Coil framework.
Documentation
use super::session::SessionStoreBackend;
use super::*;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, OnceLock};

#[cfg(test)]
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionStoreState {
    pub(crate) sessions: BTreeMap<String, BrowserSessionRecord>,
}

#[cfg(test)]
impl SessionStoreState {
    pub(crate) fn issue(&mut self, record: BrowserSessionRecord) {
        self.sessions.insert(record.session_id.clone(), record);
    }

    pub(crate) fn session(&self, session_id: &str) -> Option<BrowserSessionRecord> {
        self.sessions.get(session_id).cloned()
    }

    pub(crate) fn revoke(
        &mut self,
        session_id: &str,
        now: BrowserInstant,
    ) -> Result<(), RuntimeBrowserError> {
        let existing = self.sessions.get_mut(session_id).ok_or_else(|| {
            RuntimeBrowserError::UnknownSession {
                session_id: session_id.to_string(),
            }
        })?;
        existing.revoked_at = Some(now);
        Ok(())
    }

    pub(crate) fn touch_active_session(
        &mut self,
        session_id: &str,
        idle_timeout: std::time::Duration,
        now: BrowserInstant,
    ) -> Result<Option<String>, RuntimeBrowserError> {
        let record = self.sessions.get_mut(session_id).ok_or_else(|| {
            RuntimeBrowserError::UnknownSession {
                session_id: session_id.to_string(),
            }
        })?;

        match record.status_at(now) {
            super::BrowserSessionStatus::Active => {
                record.last_seen_at = now;
                record.idle_expires_at = now.saturating_add(idle_timeout);
                Ok(record.principal_id.clone())
            }
            super::BrowserSessionStatus::IdleExpired
            | super::BrowserSessionStatus::AbsoluteExpired => {
                self.sessions.remove(session_id);
                Err(RuntimeBrowserError::ExpiredSession {
                    session_id: session_id.to_string(),
                })
            }
            super::BrowserSessionStatus::Revoked => Err(RuntimeBrowserError::RevokedSession {
                session_id: session_id.to_string(),
            }),
        }
    }
}

#[cfg(test)]
#[derive(Debug, Default)]
struct TestOnlySqliteSharedDistributedSessionStoreRuntime {
    state: Mutex<SessionStoreState>,
}

#[cfg(test)]
impl TestOnlySqliteSharedDistributedSessionStoreRuntime {
    fn new() -> Self {
        Self {
            state: Mutex::new(SessionStoreState::default()),
        }
    }
}

#[cfg(test)]
impl DistributedSessionStoreRuntime for TestOnlySqliteSharedDistributedSessionStoreRuntime {
    fn issue(&self, record: BrowserSessionRecord) -> Result<(), RuntimeBrowserError> {
        let mut guard = self.state.lock().expect("session backend mutex poisoned");
        guard.issue(record);
        Ok(())
    }

    fn session(
        &self,
        session_id: &str,
    ) -> Result<Option<BrowserSessionRecord>, RuntimeBrowserError> {
        let guard = self.state.lock().expect("session backend mutex poisoned");
        Ok(guard.session(session_id))
    }

    fn delete(&self, session_id: &str) -> Result<(), RuntimeBrowserError> {
        let mut guard = self.state.lock().expect("session backend mutex poisoned");
        guard.sessions.remove(session_id);
        Ok(())
    }

    fn revoke(&self, session_id: &str, now: BrowserInstant) -> Result<(), RuntimeBrowserError> {
        let mut guard = self.state.lock().expect("session backend mutex poisoned");
        guard.revoke(session_id, now)
    }

    fn touch_active_session(
        &self,
        session_id: &str,
        idle_timeout: std::time::Duration,
        now: BrowserInstant,
    ) -> Result<Option<String>, RuntimeBrowserError> {
        let mut guard = self.state.lock().expect("session backend mutex poisoned");
        guard.touch_active_session(session_id, idle_timeout, now)
    }

    fn is_shared_backend(&self) -> bool {
        true
    }

    fn supports_live_shared_state(&self) -> bool {
        true
    }
}

#[cfg(test)]
pub(crate) fn test_only_sqlite_shared_runtime(
    kind: SessionStoreBackendKind,
    scope: String,
) -> Arc<dyn DistributedSessionStoreRuntime> {
    static REGISTRY: OnceLock<Mutex<BTreeMap<String, Arc<dyn DistributedSessionStoreRuntime>>>> =
        OnceLock::new();

    let key = format!("{}:{kind:?}:{scope}", test_scope());
    let registry = REGISTRY.get_or_init(|| Mutex::new(BTreeMap::new()));
    let mut guard = registry
        .lock()
        .expect("test session store registry mutex poisoned");
    guard
        .entry(key)
        .or_insert_with(|| Arc::new(TestOnlySqliteSharedDistributedSessionStoreRuntime::new()))
        .clone()
}

#[cfg(test)]
fn test_scope() -> String {
    std::thread::current()
        .name()
        .unwrap_or("unnamed-test")
        .to_string()
}

#[cfg(test)]
impl DistributedSessionStoreClient {
    pub(crate) fn local_for_testing(kind: SessionStoreBackendKind) -> Self {
        Self::new(
            kind,
            Arc::new(TestOnlySqliteSharedDistributedSessionStoreRuntime::new()),
        )
    }
}

#[cfg(test)]
impl SessionStoreBackend {
    pub(crate) fn local(
        _customer_app: &str,
        services: &coil_core::SessionSecurityServices,
    ) -> (SessionStoreBackendKind, Self) {
        match services.store {
            coil_core::SessionStoreTopology::Memory => (
                SessionStoreBackendKind::Local,
                Self::Local(SessionStoreState::default()),
            ),
            coil_core::SessionStoreTopology::Database => (
                SessionStoreBackendKind::Database,
                Self::Distributed(DistributedSessionStoreClient::local_for_testing(
                    SessionStoreBackendKind::Database,
                )),
            ),
            coil_core::SessionStoreTopology::Redis => (
                SessionStoreBackendKind::Redis,
                Self::Distributed(DistributedSessionStoreClient::local_for_testing(
                    SessionStoreBackendKind::Redis,
                )),
            ),
            coil_core::SessionStoreTopology::Valkey => (
                SessionStoreBackendKind::Valkey,
                Self::Distributed(DistributedSessionStoreClient::local_for_testing(
                    SessionStoreBackendKind::Valkey,
                )),
            ),
        }
    }
}

#[cfg(test)]
impl BrowserHost {
    pub(crate) fn local_for_testing(
        customer_app: String,
        services: BrowserSecurityServices,
    ) -> Self {
        let (session_store_kind, sessions) =
            SessionStoreBackend::local(&customer_app, &services.sessions);
        Self {
            customer_app,
            services,
            session_store_kind,
            sessions,
        }
    }
}