fixes and functions

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 23:52:41 +02:00
parent 4643de4309
commit a7329f5535
13 changed files with 330 additions and 15 deletions
+170
View File
@@ -0,0 +1,170 @@
/*
* 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",
&current_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(&current_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::<Vec<_>>();
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)));
}
+1
View File
@@ -19,6 +19,7 @@ fn every_backend_has_explicit_queries() {
Q033,
USER_LIST_WORKSPACES,
USER_LIST_PADS,
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
SHARE_LINK_SESSION_SOURCE,
SHARE_SESSION_INSERT,
SHARE_SESSION_PERMISSION,
+36
View File
@@ -104,6 +104,42 @@ fn share_sessions_use_separate_scoped_opaque_cookies() {
assert!(value.contains("SameSite=Lax"));
}
#[test]
fn logout_cookie_discovery_only_selects_password_access_cookies() {
let valid_name = "__Host-rustpad_access_0123456789abcdef01234567";
let valid_token = "a".repeat(64);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!(
"{SESSION_COOKIE}=session-token; {valid_name}={valid_token}; __Host-rustpad_share_0123456789abcdef01234567=share-token; __Host-rustpad_access_too-short={valid_token}"
))
.unwrap(),
);
assert_eq!(
resource_access_cookies(&headers),
vec![(valid_name.to_string(), Some(valid_token))]
);
}
#[test]
fn malformed_password_access_token_is_cleared_but_not_revoked() {
let name = "__Host-rustpad_access_0123456789abcdef01234567";
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{name}=not-a-valid-token")).unwrap(),
);
assert_eq!(
resource_access_cookies(&headers),
vec![(name.to_string(), None)]
);
let cleared = clear_resource_access_cookie(name).unwrap();
assert!(cleared.to_str().unwrap().starts_with(&format!("{name}=;")));
}
#[test]
fn csrf_requires_matching_cookie_and_header() {
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);