`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero. Most were mechanical — needless borrows, `assert_eq!` against a bool literal, `vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only to index — and were applied with `clippy --fix`, then reviewed line by line. That review caught one auto-fix that was *not* semantically neutral: dropping the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned directly above `SERVICE_NAME`, which would have silently cfg'd the constant out of every non-Linux build. Removed the stray attribute with the import. Where a lint asked for a risky change rather than a better one, it is suppressed with a comment saying why: - `too_many_arguments` on five `#[tauri::command]` handlers and `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>` injection, and a parameter struct would change the IPC contract and the generated TypeScript for no readability gain. - `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are serde + specta wire types emitted a handful of times a second, never bulk allocated; boxing would have to stay invisible to the generated bindings while every match arm gained a deref. - `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE` flag, and the await it spans *is* the critical section. Each `#[tokio::test]` gets its own single-threaded runtime, so this is not the production deadlock class the lint targets; restructuring would reintroduce the flag race. Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it is now `into_media_item`; the five-tuple episode row in the download commands has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches `name: "pause"` instead of guarding on it. Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`, completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be in test modules — production code was already clean — so this is consistency rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose: those tests deliberately poison a mutex to prove the helpers recover from it. Pure refactoring: all 698 tests still pass.
477 lines
15 KiB
Rust
477 lines
15 KiB
Rust
//! Authentication and session-lifecycle commands.
|
|
//!
|
|
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
|
|
|
|
use std::sync::Arc;
|
|
use tauri::State;
|
|
|
|
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
|
|
|
|
/// Wrapper for AuthManager to manage in Tauri state
|
|
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
|
|
|
/// Wrapper for SessionVerifier to manage in Tauri state
|
|
pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
|
|
|
|
/// Initialize the auth manager (call on app startup)
|
|
/// Restores session from storage if available
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_initialize(
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
database: State<'_, crate::commands::DatabaseWrapper>,
|
|
credentials: State<'_, crate::commands::CredentialStoreWrapper>,
|
|
) -> Result<Option<Session>, String> {
|
|
// First check if we already have a session in memory
|
|
if let Some(session) = auth_manager.0.get_session().await {
|
|
return Ok(Some(session));
|
|
}
|
|
|
|
// Try to restore session from storage
|
|
log::info!("[AuthManager] Restoring session from storage...");
|
|
|
|
// Use the existing storage_get_active_session function
|
|
let active_session =
|
|
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
|
Ok(Some(session)) => session,
|
|
Ok(None) => {
|
|
log::info!("[AuthManager] No active session in storage");
|
|
return Ok(None);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[AuthManager] Failed to get active session: {}", e);
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
// Create session object from active session with normalized URL
|
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
|
|
|
let session = Session {
|
|
user_id: active_session.user_id,
|
|
username: active_session.username,
|
|
server_id: active_session.server_id,
|
|
server_url: normalized_url,
|
|
server_name: active_session.server_name,
|
|
access_token: active_session.access_token,
|
|
verified: false, // Will be verified in background
|
|
needs_reauth: false,
|
|
};
|
|
|
|
// Store in AuthManager
|
|
auth_manager.0.set_session(Some(session.clone())).await;
|
|
|
|
log::info!(
|
|
"[AuthManager] Session restored for user: {} with normalized URL: {}",
|
|
session.username,
|
|
session.server_url
|
|
);
|
|
Ok(Some(session))
|
|
}
|
|
|
|
/// Connect to a Jellyfin server and get server info
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_connect_to_server(
|
|
server_url: String,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
) -> Result<ServerInfo, String> {
|
|
auth_manager.0.connect_to_server(&server_url).await
|
|
}
|
|
|
|
/// Login with username and password
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_login(
|
|
server_url: String,
|
|
username: String,
|
|
password: String,
|
|
device_id: String,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
) -> Result<AuthResult, String> {
|
|
let result = auth_manager
|
|
.0
|
|
.login(&server_url, &username, &password, &device_id)
|
|
.await?;
|
|
|
|
// Create session from auth result with normalized URL
|
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
|
|
|
let session = Session {
|
|
user_id: result.user.id.clone(),
|
|
username: result.user.name.clone(),
|
|
server_id: result.server_id.clone(),
|
|
server_url: normalized_url,
|
|
server_name: String::new(), // Will be set by frontend
|
|
access_token: result.access_token.clone(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
auth_manager.0.set_session(Some(session)).await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Verify current session
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_verify_session(
|
|
server_url: String,
|
|
user_id: String,
|
|
access_token: String,
|
|
device_id: String,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
) -> Result<bool, String> {
|
|
match auth_manager
|
|
.0
|
|
.verify_session(&server_url, &user_id, &access_token, &device_id)
|
|
.await
|
|
{
|
|
Ok(_) => Ok(true),
|
|
Err(e) => {
|
|
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Logout (clear session and call Jellyfin logout endpoint)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_logout(
|
|
server_url: String,
|
|
access_token: String,
|
|
device_id: String,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
|
) -> Result<(), String> {
|
|
// Stop session verification
|
|
let mut verifier_guard = session_verifier.0.lock().await;
|
|
if let Some(verifier) = verifier_guard.take() {
|
|
verifier.stop();
|
|
}
|
|
drop(verifier_guard);
|
|
|
|
// Call Jellyfin logout endpoint
|
|
auth_manager
|
|
.0
|
|
.logout(&server_url, &access_token, &device_id)
|
|
.await?;
|
|
|
|
// Clear session
|
|
auth_manager.0.set_session(None).await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get current session
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_get_session(
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
) -> Result<Option<Session>, String> {
|
|
Ok(auth_manager.0.get_session().await)
|
|
}
|
|
|
|
/// Set current session (for restoration from storage)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_set_session(
|
|
session: Option<Session>,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
) -> Result<(), String> {
|
|
// Normalize the server URL if session is provided
|
|
let normalized_session = match session {
|
|
Some(mut s) => {
|
|
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
|
Some(s)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
auth_manager.0.set_session(normalized_session).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Start background session verification
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_start_verification(
|
|
device_id: String,
|
|
app_handle: tauri::AppHandle,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
|
) -> Result<(), String> {
|
|
let mut verifier_guard = session_verifier.0.lock().await;
|
|
|
|
// Stop existing verifier if any
|
|
if let Some(verifier) = verifier_guard.take() {
|
|
verifier.stop();
|
|
}
|
|
|
|
// Get AuthManager Arc
|
|
let manager = auth_manager.0.clone();
|
|
|
|
// Create new verifier
|
|
let mut verifier = SessionVerifier::new(manager, device_id);
|
|
verifier.set_app_handle(app_handle);
|
|
verifier.start().await;
|
|
|
|
*verifier_guard = Some(verifier);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop background session verification
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_stop_verification(
|
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
|
) -> Result<(), String> {
|
|
let mut verifier_guard = session_verifier.0.lock().await;
|
|
|
|
if let Some(verifier) = verifier_guard.take() {
|
|
verifier.stop();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Re-authenticate with password (when session expired)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn auth_reauthenticate(
|
|
password: String,
|
|
device_id: String,
|
|
auth_manager: State<'_, AuthManagerWrapper>,
|
|
) -> Result<AuthResult, String> {
|
|
// Get current session to extract server_url and username
|
|
let session = auth_manager
|
|
.0
|
|
.get_session()
|
|
.await
|
|
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
|
|
|
// Re-login with stored credentials
|
|
let result = auth_manager
|
|
.0
|
|
.login(
|
|
&session.server_url,
|
|
&session.username,
|
|
&password,
|
|
&device_id,
|
|
)
|
|
.await?;
|
|
|
|
// Update session with new token
|
|
let updated_session = Session {
|
|
user_id: result.user.id.clone(),
|
|
username: result.user.name.clone(),
|
|
server_id: result.server_id.clone(),
|
|
server_url: session.server_url,
|
|
server_name: session.server_name,
|
|
access_token: result.access_token.clone(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
auth_manager.0.set_session(Some(updated_session)).await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_session_serialization() {
|
|
let session = Session {
|
|
user_id: "user-123".to_string(),
|
|
username: "john_doe".to_string(),
|
|
server_id: "server-456".to_string(),
|
|
server_url: "https://jellyfin.example.com".to_string(),
|
|
server_name: "My Jellyfin".to_string(),
|
|
access_token: "token-789-xyz".to_string(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
// Should serialize successfully
|
|
let json = serde_json::to_string(&session);
|
|
assert!(json.is_ok());
|
|
let serialized = json.unwrap();
|
|
assert!(serialized.contains("user-123"));
|
|
assert!(serialized.contains("john_doe"));
|
|
assert!(serialized.contains("server-456"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_deserialization() {
|
|
let json = r#"{
|
|
"userId": "user-123",
|
|
"username": "john_doe",
|
|
"serverId": "server-456",
|
|
"serverUrl": "https://jellyfin.example.com",
|
|
"serverName": "My Jellyfin",
|
|
"accessToken": "token-789",
|
|
"verified": true,
|
|
"needsReauth": false
|
|
}"#;
|
|
|
|
let result: Result<Session, _> = serde_json::from_str(json);
|
|
assert!(result.is_ok());
|
|
|
|
let session = result.unwrap();
|
|
assert_eq!(session.user_id, "user-123");
|
|
assert_eq!(session.username, "john_doe");
|
|
assert_eq!(session.server_id, "server-456");
|
|
assert!(session.verified);
|
|
assert!(!session.needs_reauth);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_roundtrip() {
|
|
let original = Session {
|
|
user_id: "user-999".to_string(),
|
|
username: "alice".to_string(),
|
|
server_id: "server-111".to_string(),
|
|
server_url: "https://server.local".to_string(),
|
|
server_name: "Home Server".to_string(),
|
|
access_token: "very-long-token-string".to_string(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(original.user_id, deserialized.user_id);
|
|
assert_eq!(original.username, deserialized.username);
|
|
assert_eq!(original.server_id, deserialized.server_id);
|
|
assert_eq!(original.server_url, deserialized.server_url);
|
|
assert_eq!(original.access_token, deserialized.access_token);
|
|
assert_eq!(original.verified, deserialized.verified);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_clone() {
|
|
let session = Session {
|
|
user_id: "user-clone".to_string(),
|
|
username: "test_user".to_string(),
|
|
server_id: "server-clone".to_string(),
|
|
server_url: "https://clone.example.com".to_string(),
|
|
server_name: "Clone Server".to_string(),
|
|
access_token: "clone-token".to_string(),
|
|
verified: false,
|
|
needs_reauth: true,
|
|
};
|
|
|
|
let cloned = session.clone();
|
|
assert_eq!(session.user_id, cloned.user_id);
|
|
assert_eq!(session.username, cloned.username);
|
|
assert_eq!(session.verified, cloned.verified);
|
|
assert_eq!(session.needs_reauth, cloned.needs_reauth);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_unverified() {
|
|
let session = Session {
|
|
user_id: "user-unverified".to_string(),
|
|
username: "newuser".to_string(),
|
|
server_id: "server-new".to_string(),
|
|
server_url: "https://new.example.com".to_string(),
|
|
server_name: "New Server".to_string(),
|
|
access_token: "new-token".to_string(),
|
|
verified: false,
|
|
needs_reauth: true,
|
|
};
|
|
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
assert!(json.contains("false")); // verified: false
|
|
assert!(json.contains("true")); // needs_reauth: true
|
|
|
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
|
assert!(!deserialized.verified);
|
|
assert!(deserialized.needs_reauth);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_debug() {
|
|
let session = Session {
|
|
user_id: "user-debug".to_string(),
|
|
username: "debug_user".to_string(),
|
|
server_id: "server-debug".to_string(),
|
|
server_url: "https://debug.example.com".to_string(),
|
|
server_name: "Debug Server".to_string(),
|
|
access_token: "debug-token".to_string(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
let debug_str = format!("{:?}", session);
|
|
assert!(debug_str.contains("user-debug"));
|
|
assert!(debug_str.contains("Session"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_auth_manager_wrapper_structure() {
|
|
// Verify wrapper type exists and has correct structure
|
|
assert!(std::mem::size_of::<AuthManagerWrapper>() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_verifier_wrapper_structure() {
|
|
// Verify wrapper type exists and has correct structure
|
|
assert!(std::mem::size_of::<SessionVerifierWrapper>() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_with_special_characters() {
|
|
let session = Session {
|
|
user_id: "user-special-éñ".to_string(),
|
|
username: "user@example.com".to_string(),
|
|
server_id: "server/123".to_string(),
|
|
server_url: "https://jellyfin.example.com:8096".to_string(),
|
|
server_name: "My Jellyfin (v10.8.0)".to_string(),
|
|
access_token: "token+with/special=chars".to_string(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(session.username, deserialized.username);
|
|
assert_eq!(session.server_name, deserialized.server_name);
|
|
assert_eq!(session.access_token, deserialized.access_token);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_field_presence() {
|
|
let session = Session {
|
|
user_id: "u1".to_string(),
|
|
username: "user1".to_string(),
|
|
server_id: "s1".to_string(),
|
|
server_url: "url1".to_string(),
|
|
server_name: "name1".to_string(),
|
|
access_token: "token1".to_string(),
|
|
verified: true,
|
|
needs_reauth: false,
|
|
};
|
|
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
|
|
// Verify camelCase serialization (serde rename_all = "camelCase")
|
|
assert!(json.contains("userId"));
|
|
assert!(json.contains("username"));
|
|
assert!(json.contains("serverId"));
|
|
assert!(json.contains("serverUrl"));
|
|
assert!(json.contains("serverName"));
|
|
assert!(json.contains("accessToken"));
|
|
assert!(json.contains("verified"));
|
|
assert!(json.contains("needsReauth"));
|
|
}
|
|
}
|