diff --git a/crates/application/src/auth/logout.rs b/crates/application/src/auth/logout.rs index 3bdb557..efc2087 100644 --- a/crates/application/src/auth/logout.rs +++ b/crates/application/src/auth/logout.rs @@ -5,3 +5,7 @@ use crate::context::AppContext; pub async fn execute(ctx: &AppContext, refresh_token: &str) -> Result<(), DomainError> { ctx.repos.refresh_session.revoke(refresh_token).await } + +#[cfg(test)] +#[path = "tests/logout.rs"] +mod tests; diff --git a/crates/application/src/auth/refresh.rs b/crates/application/src/auth/refresh.rs index a2c0761..186d301 100644 --- a/crates/application/src/auth/refresh.rs +++ b/crates/application/src/auth/refresh.rs @@ -23,18 +23,12 @@ pub async fn execute( .ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".into()))?; if session.expires_at < Utc::now() { - ctx.repos - .refresh_session - .revoke(old_refresh_token) - .await?; + ctx.repos.refresh_session.revoke(old_refresh_token).await?; return Err(DomainError::Unauthorized("Refresh token expired".into())); } // Revoke old token (rotation) - ctx.repos - .refresh_session - .revoke(old_refresh_token) - .await?; + ctx.repos.refresh_session.revoke(old_refresh_token).await?; // Generate new access token let generated = ctx.services.auth.generate_token(&session.user_id).await?; @@ -57,3 +51,7 @@ pub async fn execute( expires_at: generated.expires_at, }) } + +#[cfg(test)] +#[path = "tests/refresh.rs"] +mod tests; diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs index e333239..c3d7294 100644 --- a/crates/application/src/auth/tests/login.rs +++ b/crates/application/src/auth/tests/login.rs @@ -44,6 +44,7 @@ async fn test_login_valid_credentials_returns_token() { .unwrap(); assert!(!result.token.is_empty()); + assert!(!result.refresh_token.is_empty()); assert_eq!(result.email, "carol@example.com"); } diff --git a/crates/application/src/auth/tests/logout.rs b/crates/application/src/auth/tests/logout.rs new file mode 100644 index 0000000..2a7e903 --- /dev/null +++ b/crates/application/src/auth/tests/logout.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use domain::models::UserRole; +use domain::testing::InMemoryUserRepository; + +use crate::{ + auth::commands::RegisterCommand, + auth::queries::LoginQuery, + auth::{login, logout, refresh, register}, + test_helpers::TestContextBuilder, +}; + +#[tokio::test] +async fn logout_revokes_refresh_token() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + register::execute( + &ctx, + RegisterCommand { + email: "bob@example.com".to_string(), + username: "bob".to_string(), + password: "password123".to_string(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + let login_result = login::execute( + &ctx, + LoginQuery { + email: "bob@example.com".into(), + password: "password123".into(), + }, + ) + .await + .unwrap(); + + logout::execute(&ctx, &login_result.refresh_token) + .await + .unwrap(); + + let refresh_attempt = refresh::execute(&ctx, &login_result.refresh_token).await; + assert!(refresh_attempt.is_err()); +} + +#[tokio::test] +async fn logout_with_unknown_token_succeeds() { + let ctx = TestContextBuilder::new().build(); + let result = logout::execute(&ctx, "nonexistent-token").await; + assert!(result.is_ok()); +} diff --git a/crates/application/src/auth/tests/refresh.rs b/crates/application/src/auth/tests/refresh.rs new file mode 100644 index 0000000..4714615 --- /dev/null +++ b/crates/application/src/auth/tests/refresh.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; + +use domain::models::UserRole; +use domain::testing::InMemoryUserRepository; + +use crate::{ + auth::commands::RegisterCommand, + auth::queries::LoginQuery, + auth::{login, refresh, register}, + test_helpers::TestContextBuilder, +}; + +async fn login_user(ctx: &crate::context::AppContext) -> login::LoginResult { + register::execute( + ctx, + RegisterCommand { + email: "alice@example.com".to_string(), + username: "alice".to_string(), + password: "password123".to_string(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + login::execute( + ctx, + LoginQuery { + email: "alice@example.com".into(), + password: "password123".into(), + }, + ) + .await + .unwrap() +} + +#[tokio::test] +async fn refresh_returns_new_tokens() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + let login_result = login_user(&ctx).await; + + let result = refresh::execute(&ctx, &login_result.refresh_token) + .await + .unwrap(); + + assert!(!result.token.is_empty()); + assert!(!result.refresh_token.is_empty()); + assert_ne!(result.refresh_token, login_result.refresh_token); +} + +#[tokio::test] +async fn refresh_rotates_token_old_one_invalid() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + let login_result = login_user(&ctx).await; + let old_token = login_result.refresh_token.clone(); + + refresh::execute(&ctx, &old_token).await.unwrap(); + + let retry = refresh::execute(&ctx, &old_token).await; + assert!(retry.is_err()); +} + +#[tokio::test] +async fn refresh_with_new_token_works() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + let login_result = login_user(&ctx).await; + + let first = refresh::execute(&ctx, &login_result.refresh_token) + .await + .unwrap(); + + let second = refresh::execute(&ctx, &first.refresh_token) + .await + .unwrap(); + + assert!(!second.token.is_empty()); + assert_ne!(second.refresh_token, first.refresh_token); +} + +#[tokio::test] +async fn refresh_with_unknown_token_fails() { + let ctx = TestContextBuilder::new().build(); + + let result = refresh::execute(&ctx, "nonexistent-token").await; + assert!(result.is_err()); +}