/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ use super::*; use crate::{ database::Database, state::AppState, storage::Storage, }; use std::sync::Arc; async fn logout_test_state() -> SharedState { let db = Database::connect("sqlite::memory:", 1) .await .expect("test database"); crate::run_migrations(&db).await.expect("test migrations"); Arc::new(AppState::new( db, "test".into(), Storage::Local { root: std::env::temp_dir().join("rustpad-logout-tests"), }, 1_000_000, true, 1_000_000, 0, None, None, true, false, false, "error".into(), 7, 7, 7, None, )) } #[tokio::test] async fn logout_only_revokes_current_browser_session_and_password_access() { let state = logout_test_state().await; sqlx::query( "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)", ) .bind("Logout Test") .bind("logout test") .bind("logout@example.test") .bind("logout@example.test") .bind("password-hash") .execute(state.db.pool()) .await .unwrap(); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); for session in ["current-session", "other-device-session"] { sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)") .bind(session) .bind(&expires_at) .execute(state.db.pool()) .await .unwrap(); } let current_access_token = "a".repeat(64); let other_access_token = "b".repeat(64); for (token, slug) in [ (current_access_token.as_str(), "current-pad"), (other_access_token.as_str(), "other-pad"), ] { sqlx::query( "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, 'pad', ?, ?)", ) .bind(hash_token(token)) .bind(slug) .bind(&expires_at) .execute(state.db.pool()) .await .unwrap(); } let access_cookie = crate::security::resource_cookie( "pad", "current-pad", ¤t_access_token, 7, ); let access_cookie_name = access_cookie .to_str() .unwrap() .split_once('=') .unwrap() .0 .to_string(); let share_cookie_name = "__Host-rustpad_share_0123456789abcdef01234567"; let mut headers = HeaderMap::new(); headers.insert( header::COOKIE, axum::http::HeaderValue::from_str(&format!( "{}=current-session; {}={}; {}=share-token", crate::security::SESSION_COOKIE, access_cookie_name, current_access_token, share_cookie_name, )) .unwrap(), ); let response = match logout(State(state.clone()), headers).await { Ok(response) => response, Err(error) => panic!("logout failed: {}", error.message), }; let current_session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?") .bind("current-session") .fetch_one(state.db.pool()) .await .unwrap(); let other_session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?") .bind("other-device-session") .fetch_one(state.db.pool()) .await .unwrap(); let current_access_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?", ) .bind(hash_token(¤t_access_token)) .fetch_one(state.db.pool()) .await .unwrap(); let other_access_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?", ) .bind(hash_token(&other_access_token)) .fetch_one(state.db.pool()) .await .unwrap(); let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1") .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(current_session_count, 0); assert_eq!(other_session_count, 1); assert_eq!(current_access_count, 0); assert_eq!(other_access_count, 1); assert_eq!(user_count, 1); let set_cookies = response .headers() .get_all(header::SET_COOKIE) .iter() .map(|value| value.to_str().unwrap()) .collect::>(); assert!(set_cookies.iter().any(|value| { value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE)) })); assert!(set_cookies.iter().any(|value| { value.starts_with(&format!("{access_cookie_name}=;")) })); assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name))); }