use std::sync::Arc; use axum::extract::Path; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; use crate::auth::imap_auth::{self, AuthResult}; use crate::auth::session::{SessionState, SessionStore}; use crate::auth::user_data; use crate::config::AppConfig; use crate::db; pub const BROWSER_COOKIE: &str = "rustbox_browser"; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AuthPolicyResponse { remember_me_enabled: bool, remember_me_days: u64, session_timeout_hours: u64, } pub async fn auth_policy(Extension(config): Extension>) -> Json { Json(AuthPolicyResponse { remember_me_enabled: config.remember_me_enabled, remember_me_days: config.remember_me_days, session_timeout_hours: config.session_timeout_hours, }) } /// JSON body expected on `POST /api/auth/login`. #[derive(Deserialize)] pub struct LoginRequest { pub email: String, pub password: String, #[serde(default)] pub remember: bool, pub browser_id: Option, } fn same_site_policy(_config: &AppConfig) -> &'static str { // RustBox UI and API are same-origin in RustBox, so Strict is always valid. "Strict" } fn cookie_path(config: &AppConfig) -> &str { config.normalized_base_path() } fn browser_cookie( browser_id: &str, max_age_secs: u64, secure: bool, same_site: &str, path: &str, ) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "{}={};{} HttpOnly; SameSite={}; Path={}; Max-Age={}", BROWSER_COOKIE, browser_id, secure_flag, same_site, path, max_age_secs ) } fn account_session_cookie( account_id: &str, token: &str, max_age_secs: u64, secure: bool, same_site: &str, path: &str, ) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "rustbox_session_{}={};{} HttpOnly; SameSite={}; Path={}; Max-Age={}", account_id, token, secure_flag, same_site, path, max_age_secs ) } fn clearing_browser_cookie(secure: bool, same_site: &str, path: &str) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "{}=;{} HttpOnly; SameSite={}; Path={}; Max-Age=0", BROWSER_COOKIE, secure_flag, same_site, path ) } fn clearing_account_cookie(account_id: &str, secure: bool, same_site: &str, path: &str) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "rustbox_session_{}=;{} HttpOnly; SameSite={}; Path={}; Max-Age=0", account_id, secure_flag, same_site, path ) } fn extract_browser_id(headers: &axum::http::HeaderMap) -> Option { headers .get_all("cookie") .iter() .filter_map(|v| v.to_str().ok()) .find_map(|cookie| { for segment in cookie.split(';') { let trimmed = segment.trim(); if let Some(id) = trimmed.strip_prefix("rustbox_browser=") { let id = id.trim(); if !id.is_empty() { return Some(id.to_string()); } } } None }) } fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, String)> { let prefix = "rustbox_session_"; let mut sessions = Vec::new(); for cookie in headers .get_all("cookie") .iter() .filter_map(|v| v.to_str().ok()) { for segment in cookie.split(';') { let trimmed = segment.trim(); if let Some(rest) = trimmed.strip_prefix(prefix) && let Some(eq_pos) = rest.find('=') { let account_id = &rest[..eq_pos]; let token = &rest[eq_pos + 1..]; if !account_id.is_empty() && !token.is_empty() { sessions.push((account_id.to_string(), token.to_string())); } } } } sessions } /// `POST /api/auth/login` /// /// Validates the user's credentials against the configured IMAP server. /// On success, creates a session, provisions the user's data directory, /// and returns session cookies. pub async fn login( Extension(store): Extension>, Extension(config): Extension>, headers: axum::http::HeaderMap, Json(body): Json, ) -> Response { if body.email.trim().is_empty() || body.password.trim().is_empty() { return ( StatusCode::BAD_REQUEST, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "BAD_REQUEST", "message": "Email and password are required", "status": 400 } }) .to_string(), ) .into_response(); } // Server endpoints are administrator-owned configuration. The login // request supplies only user credentials; per-request host overrides are // intentionally ignored so credentials cannot be redirected to an // arbitrary mail server. let Some(imap_host) = config .imap_host .clone() .filter(|host| !host.trim().is_empty()) else { return ( StatusCode::SERVICE_UNAVAILABLE, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "SERVICE_UNAVAILABLE", "message": "IMAP server is not configured in RustBox", "status": 503 } }) .to_string(), ) .into_response(); }; let smtp_host = config .smtp_host .clone() .filter(|host| !host.trim().is_empty()) .unwrap_or_else(|| imap_host.clone()); let imap_port = config.imap_port; let imap_tls = config.tls_enabled; let smtp_port = config.smtp_port; let smtp_tls = config.tls_enabled; let result = imap_auth::validate_imap_credentials( &imap_host, imap_port, imap_tls, &body.email, &body.password, ) .await; match result { AuthResult::Success => { let provided_browser_id = body .browser_id .clone() .or_else(|| extract_browser_id(&headers)); if let Some(ref browser_id) = provided_browser_id { let existing_accounts = store.get_browser_accounts(browser_id); let duplicate = existing_accounts .iter() .find(|a| a.email == body.email && a.imap_host == imap_host); if let Some(dup) = duplicate { tracing::debug!( email = %body.email, imap_host = %imap_host, existing_account_id = %dup.account_id, "login: duplicate account detected" ); return ( StatusCode::CONFLICT, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "DUPLICATE_ACCOUNT", "message": "This account is already logged in", "status": 409 } }) .to_string(), ) .into_response(); } } let user_hash = user_data::hash_email(&body.email); if let Err(e) = user_data::provision_user_data(&config.data_dir, &user_hash) { tracing::error!(error = %e, "failed to provision user data directory"); return ( StatusCode::INTERNAL_SERVER_ERROR, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "INTERNAL_ERROR", "message": "Failed to provision user data", "status": 500 } }) .to_string(), ) .into_response(); } if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &user_hash) { match db::identities::has_identities(&conn) { Ok(false) => { let default_identity = db::identities::CreateIdentity { email: body.email.clone(), display_name: String::new(), signature_html: String::new(), is_default: true, reply_to: String::new(), bcc: String::new(), organization: String::new(), signature_text: String::new(), signature_new: true, signature_reply: true, signature_forward: true, signature_position: "above".to_string(), pgp_fingerprint: None, pgp_sign_default: false, pgp_encrypt_default: false, pgp_inline_default: false, }; if let Err(e) = db::identities::create_identity(&conn, &default_identity) { tracing::warn!(error = %e, "Failed to auto-create default identity"); } } Err(e) => { tracing::warn!(error = %e, "Failed to check for existing identities"); } _ => {} } } let remember = body.remember && config.remember_me_enabled; let session_hours = if remember { config.remember_me_days.saturating_mul(24) } else { config.session_timeout_hours }; let browser_id = provided_browser_id .clone() .unwrap_or_else(|| store.create_browser()); tracing::debug!( email = %body.email, provided_browser_id = ?provided_browser_id, effective_browser_id = %browser_id, "login: creating session" ); let timeout_override = if remember { Some(std::time::Duration::from_secs(session_hours * 3600)) } else { None }; let (token, account_id) = store.add_account_to_browser_with_timeout( &browser_id, body.email.clone(), body.password, user_hash, imap_host.clone(), imap_port, imap_tls, smtp_host.clone(), smtp_port, smtp_tls, timeout_override, ); let user_agent = headers .get(axum::http::header::USER_AGENT) .and_then(|value| value.to_str().ok()) .unwrap_or("") .to_string(); store.set_user_agent(&account_id, user_agent); let max_age = session_hours * 3600; let secure = config.environment != "development"; let same_site = same_site_policy(&config); let path = cookie_path(&config); let browser_cookie_header = browser_cookie(&browser_id, max_age, secure, same_site, path); let session_cookie_header = account_session_cookie(&account_id, &token, max_age, secure, same_site, path); let response = ( StatusCode::CREATED, [("content-type", "application/json")], serde_json::json!({ "account": { "id": account_id, "email": body.email } }) .to_string(), ) .into_response(); let mut response = response; let headers = response.headers_mut(); if let Ok(header_value) = browser_cookie_header.parse() { headers.append("set-cookie", header_value); } if let Ok(header_value) = session_cookie_header.parse() { headers.append("set-cookie", header_value); } response } AuthResult::InvalidCredentials => ( StatusCode::UNAUTHORIZED, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "UNAUTHORIZED", "message": "Invalid email or password", "status": 401 } }) .to_string(), ) .into_response(), AuthResult::ServerUnreachable(msg) => ( StatusCode::SERVICE_UNAVAILABLE, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "SERVER_UNREACHABLE", "message": msg, "status": 503 } }) .to_string(), ) .into_response(), } } /// `GET /api/auth/session` /// /// Returns the current user's session information. Requires authentication /// (the `auth_guard` middleware injects `SessionState` into extensions). pub async fn get_session(Extension(session): Extension) -> Response { ( StatusCode::OK, [("content-type", "application/json")], serde_json::json!({ "user": { "email": session.email } }).to_string(), ) .into_response() } pub async fn list_accounts( Extension(store): Extension>, Extension(config): Extension>, headers: axum::http::HeaderMap, ) -> Response { let secure = config.environment != "development"; let same_site = same_site_policy(&config); let browser_id = extract_browser_id(&headers); let Some(browser_id) = browser_id else { tracing::debug!("list_accounts: no browser_id cookie found"); return ( StatusCode::OK, [("content-type", "application/json")], serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(), ) .into_response(); }; let browser_exists = store.browser_exists(&browser_id); if !browser_exists { tracing::debug!(browser_id = %browser_id, "list_accounts: browser_id not found on server"); let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config)); return ( StatusCode::OK, [ ("content-type", "application/json"), ("set-cookie", &clear_browser), ], serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(), ) .into_response(); } let session_cookies = extract_session_cookies(&headers); let session_map: std::collections::HashMap = session_cookies.into_iter().collect(); let browser_accounts = store.get_browser_accounts(&browser_id); let browser_account_ids: Vec = browser_accounts .iter() .map(|s| s.account_id.clone()) .collect(); let mut valid_accounts: Vec = Vec::new(); let mut clear_cookies: Vec = Vec::new(); for session in &browser_accounts { if let Some(token) = session_map.get(&session.account_id) { if let Some(valid_session) = store.get(token) { valid_accounts.push(serde_json::json!({ "id": valid_session.account_id, "email": valid_session.email })); } else { store.remove_account(&session.account_id); clear_cookies.push(clearing_account_cookie( &session.account_id, secure, same_site, cookie_path(&config), )); } } else { store.remove_account(&session.account_id); } } for account_id in session_map.keys() { if !browser_account_ids.contains(account_id) { clear_cookies.push(clearing_account_cookie( account_id, secure, same_site, cookie_path(&config), )); } } if valid_accounts.is_empty() { tracing::debug!( browser_id = %browser_id, "list_accounts: no valid accounts found" ); let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config)); let body = serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(); let mut response = axum::response::Response::new(axum::body::Body::from(body)); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("application/json"), ); response.headers_mut().append( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&clear_browser).unwrap(), ); for cookie in clear_cookies { response.headers_mut().append( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&cookie).unwrap(), ); } return response; } tracing::debug!( browser_id = %browser_id, account_count = valid_accounts.len(), accounts = ?valid_accounts.iter().filter_map(|a| a.get("email").and_then(|e| e.as_str())).collect::>(), "list_accounts: returning valid accounts" ); let body = serde_json::json!({ "accounts": valid_accounts }).to_string(); let mut response = axum::response::Response::new(axum::body::Body::from(body)); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("application/json"), ); for cookie in clear_cookies { response.headers_mut().append( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&cookie).unwrap(), ); } response } pub async fn remove_account( Extension(store): Extension>, Extension(config): Extension>, Path(account_id): Path, headers: axum::http::HeaderMap, ) -> Response { let browser_id = extract_browser_id(&headers); let Some(browser_id) = browser_id else { return ( StatusCode::UNAUTHORIZED, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "UNAUTHORIZED", "message": "No browser session", "status": 401 } }) .to_string(), ) .into_response(); }; let browser_accounts = store.get_browser_accounts(&browser_id); let account_belongs_to_browser = browser_accounts.iter().any(|s| s.account_id == account_id); if !account_belongs_to_browser { return ( StatusCode::FORBIDDEN, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "FORBIDDEN", "message": "Account does not belong to this browser session", "status": 403 } }) .to_string(), ) .into_response(); } store.remove_account(&account_id); let secure = config.environment != "development"; let same_site = same_site_policy(&config); let cookie = clearing_account_cookie(&account_id, secure, same_site, cookie_path(&config)); ( StatusCode::OK, [ ("content-type", "application/json"), ("set-cookie", &cookie), ], serde_json::json!({ "status": "logged_out" }).to_string(), ) .into_response() } fn friendly_user_agent(user_agent: &str) -> String { let ua = user_agent.trim(); if ua.is_empty() { return "Unknown device".to_string(); } let browser = if ua.contains("Edg/") { "Edge" } else if ua.contains("Firefox/") || ua.contains("FxiOS/") { "Firefox" } else if ua.contains("OPR/") || ua.contains("Opera/") { "Opera" } else if ua.contains("Chrome/") || ua.contains("CriOS/") { "Chrome" } else if ua.contains("Safari/") && (ua.contains("Version/") || ua.contains("Mobile/")) { "Safari" } else { "Browser" }; let device = if ua.contains("iPhone") { "iPhone" } else if ua.contains("iPad") { "iPad" } else if ua.contains("Android") { "Android" } else if ua.contains("Windows") { "Windows" } else if ua.contains("Macintosh") || ua.contains("Mac OS X") { "macOS" } else if ua.contains("CrOS") { "ChromeOS" } else if ua.contains("Linux") { "Linux" } else { "Unknown OS" }; format!("{browser} · {device}") } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ActiveSessionResponse { id: String, email: String, created_at: u64, last_active: u64, expires_at: u64, remembered: bool, current: bool, user_agent: String, client_name: String, } /// `GET /api/auth/sessions` /// /// Lists all live RustBox sessions for the currently authenticated user across /// browser sessions. Session tokens and server connection data are never /// returned to the UI. pub async fn list_active_sessions( Extension(store): Extension>, Extension(config): Extension>, Extension(session): Extension, ) -> Response { let sessions: Vec = store .get_user_sessions(&session.user_hash) .into_iter() .map(|item| { let timeout_secs = item .timeout_override .map(|duration| duration.as_secs()) .unwrap_or(config.session_timeout_hours * 3600); ActiveSessionResponse { current: item.account_id == session.account_id, id: item.account_id, email: item.email, created_at: item.created_at_unix, last_active: item.last_accessed_unix, expires_at: item.last_accessed_unix.saturating_add(timeout_secs), remembered: item.timeout_override.is_some(), client_name: friendly_user_agent(&item.user_agent), user_agent: item.user_agent, } }) .collect(); Json(serde_json::json!({ "sessions": sessions })).into_response() } /// `DELETE /api/auth/sessions/{id}` — revoke another active session. pub async fn revoke_active_session( Extension(store): Extension>, Extension(session): Extension, Path(account_id): Path, ) -> Response { if account_id == session.account_id { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": { "code": "CURRENT_SESSION", "message": "Use Log out to close the current session", "status": 400 } })), ) .into_response(); } if store.remove_user_session(&session.user_hash, &account_id) { Json(serde_json::json!({ "status": "revoked" })).into_response() } else { ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": { "code": "NOT_FOUND", "message": "Session not found", "status": 404 } })), ) .into_response() } } /// `POST /api/auth/sessions/revoke-others` — keep the current session and /// revoke every other live session for the same RustBox user. pub async fn revoke_other_sessions( Extension(store): Extension>, Extension(session): Extension, ) -> Response { let revoked = store.remove_other_user_sessions(&session.user_hash, &session.account_id); Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response() } /// `POST /api/auth/logout` /// /// Removes the current session from the store and clears the session cookie. /// Requires authentication. pub async fn logout( Extension(store): Extension>, Extension(config): Extension>, headers: axum::http::HeaderMap, ) -> Response { let browser_id = extract_browser_id(&headers); let mut cookies_to_clear: Vec = Vec::new(); let secure = config.environment != "development"; let same_site = same_site_policy(&config); if let Some(ref browser_id) = browser_id { let accounts = store.get_browser_accounts(browser_id); for account in accounts { cookies_to_clear.push(clearing_account_cookie( &account.account_id, secure, same_site, cookie_path(&config), )); } store.remove_browser(browser_id); } cookies_to_clear.push(clearing_browser_cookie( secure, same_site, cookie_path(&config), )); let response = ( StatusCode::OK, [("content-type", "application/json")], serde_json::json!({ "status": "logged_out" }).to_string(), ) .into_response(); let mut response = response; let headers = response.headers_mut(); for cookie in cookies_to_clear { if let Ok(header_value) = cookie.parse() { headers.append("set-cookie", header_value); } } response } #[cfg(test)] mod tests { use super::*; use serde::Serialize; #[derive(Serialize)] struct UserResponse { user: UserInfo, } #[derive(Serialize)] struct UserInfo { email: String, } #[derive(Serialize)] struct LogoutResponse { status: &'static str, } #[test] fn friendly_user_agent_labels_common_clients() { assert_eq!( friendly_user_agent( "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" ), "Firefox · Linux" ); assert_eq!( friendly_user_agent( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1" ), "Safari · iPhone" ); assert_eq!(friendly_user_agent(""), "Unknown device"); } #[test] fn user_response_serialization() { let resp = UserResponse { user: UserInfo { email: "test@example.com".to_string(), }, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["user"]["email"], "test@example.com"); } #[test] fn logout_response_serialization() { let resp = LogoutResponse { status: "logged_out", }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["status"], "logged_out"); } fn test_config(environment: &str, _cors_origin: Option<&str>) -> AppConfig { AppConfig { host: "127.0.0.1".to_string(), port: 3001, imap_host: None, imap_port: 993, smtp_host: None, smtp_port: 587, tls_enabled: true, data_dir: "/data".to_string(), session_timeout_hours: 24, environment: environment.to_string(), base_path: None, sieve_host: None, sieve_port: 4190, sieve_tls_mode: "starttls".to_string(), sieve_allow_insecure_auth: false, sieve_timeout_secs: 10, trusted_proxies: None, outgoing_headers: Vec::new(), } } #[tokio::test] async fn login_reuses_scoped_browser_cookie_when_body_omits_browser_id() { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let imap = tokio::spawn(async move { for _ in 0..3 { let (stream, _) = listener.accept().await.unwrap(); let mut stream = BufReader::new(stream); stream .get_mut() .write_all(b"* OK test IMAP ready\r\n") .await .unwrap(); let mut command = String::new(); stream.read_line(&mut command).await.unwrap(); let tag = command.split_whitespace().next().unwrap(); let response = format!("{tag} OK LOGIN completed\r\n"); stream .get_mut() .write_all(response.as_bytes()) .await .unwrap(); command.clear(); stream.read_line(&mut command).await.unwrap(); let tag = command.split_whitespace().next().unwrap(); let response = format!("* BYE logging out\r\n{tag} OK LOGOUT completed\r\n"); stream .get_mut() .write_all(response.as_bytes()) .await .unwrap(); } }); let data_dir = tempfile::tempdir().unwrap(); let mut config = test_config("development", Some("http://localhost:3000")); config.base_path = Some("/mail".to_string()); config.data_dir = data_dir.path().to_str().unwrap().to_string(); config.imap_host = Some("127.0.0.1".to_string()); config.imap_port = port; config.smtp_host = Some("127.0.0.1".to_string()); config.tls_enabled = false; let config = Arc::new(config); let store = Arc::new(SessionStore::new(std::time::Duration::from_secs(3600))); let request = |email: &str| LoginRequest { email: email.to_string(), password: "password".to_string(), remember: false, browser_id: None, }; let first = login( Extension(store.clone()), Extension(config.clone()), axum::http::HeaderMap::new(), Json(request("first@example.com")), ) .await; assert_eq!(first.status(), StatusCode::CREATED); let cookie = first .headers() .get_all("set-cookie") .iter() .map(|value| value.to_str().unwrap()) .find(|value| value.starts_with("rustbox_browser=")) .unwrap(); assert!(cookie.contains("Path=/mail;")); let cookie_pair = cookie.split(';').next().unwrap(); let browser_id = cookie_pair.strip_prefix("rustbox_browser=").unwrap(); let mut headers = axum::http::HeaderMap::new(); headers.insert("cookie", cookie_pair.parse().unwrap()); let second = login( Extension(store.clone()), Extension(config.clone()), headers.clone(), Json(request("second@example.com")), ) .await; assert_eq!(second.status(), StatusCode::CREATED); assert!(second.headers().get_all("set-cookie").iter().any(|value| { value .to_str() .unwrap() .starts_with(&format!("{cookie_pair};")) })); let accounts = store.get_browser_accounts(browser_id); assert_eq!(accounts.len(), 2); assert!( accounts .iter() .any(|account| account.email == "first@example.com") ); assert!( accounts .iter() .any(|account| account.email == "second@example.com") ); let duplicate = login( Extension(store.clone()), Extension(config), headers, Json(request("second@example.com")), ) .await; assert_eq!(duplicate.status(), StatusCode::CONFLICT); assert_eq!(store.get_browser_accounts(browser_id).len(), 2); imap.await.unwrap(); } #[test] fn issued_and_clearing_cookies_use_the_normalized_base_path() { for (base_path, expected_path) in [ (None, "/"), (Some(""), "/"), (Some("/"), "/"), (Some("/oxi"), "/oxi"), ] { let mut config = test_config("development", None); config.base_path = base_path.map(str::to_string); let path = cookie_path(&config); let cookies = [ browser_cookie("browser", 3600, false, "Strict", path), account_session_cookie("account", "token", 3600, false, "Strict", path), clearing_browser_cookie(false, "Strict", path), clearing_account_cookie("account", false, "Strict", path), ]; for cookie in cookies { let path_attribute = cookie .split(';') .map(str::trim) .find_map(|attribute| attribute.strip_prefix("Path=")); assert_eq!( path_attribute, Some(expected_path), "{base_path:?}: {cookie}" ); } } } #[test] fn same_site_is_strict_for_rustbox_development() { let config = test_config("development", Some("http://localhost:3000")); assert_eq!(same_site_policy(&config), "Strict"); let cookie = browser_cookie( "browser", 3600, false, same_site_policy(&config), cookie_path(&config), ); assert!(cookie.contains("SameSite=Strict")); } #[test] fn same_site_stays_strict_outside_development() { let config = test_config("production", Some("https://app.example.com")); assert_eq!(same_site_policy(&config), "Strict"); let cookie = browser_cookie( "browser", 3600, true, same_site_policy(&config), cookie_path(&config), ); assert!(cookie.contains("SameSite=Strict")); } }