diff --git a/crates/presentation/src/event_handlers.rs b/crates/presentation/src/event_handlers.rs index 45caa8d..b1bfd5b 100644 --- a/crates/presentation/src/event_handlers.rs +++ b/crates/presentation/src/event_handlers.rs @@ -134,6 +134,7 @@ mod tests { impl UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &Email) -> Result, DomainError> { panic!("unexpected") } async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") } + async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result, DomainError> { panic!("unexpected") } } #[async_trait] diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index c4e951a..dc8364d 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -1,6 +1,7 @@ use axum::{ extract::{FromRef, FromRequestParts}, - http::{header::AUTHORIZATION, request::Parts}, + http::{header, header::AUTHORIZATION, request::Parts}, + response::{IntoResponse, Redirect}, }; use domain::{errors::DomainError, value_objects::UserId}; @@ -36,6 +37,64 @@ where } } +pub struct OptionalCookieUser(pub Option); +pub struct RequiredCookieUser(pub UserId); + +fn extract_token_from_cookie(parts: &Parts) -> Option { + parts + .headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|cookies| { + cookies + .split(';') + .find_map(|c| c.trim().strip_prefix("token=").map(str::to_string)) + }) +} + +impl FromRequestParts for OptionalCookieUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let Some(token) = extract_token_from_cookie(parts) else { + return Ok(OptionalCookieUser(None)); + }; + let user_id = app_state + .app_ctx + .auth_service + .validate_token(&token) + .await + .ok(); + Ok(OptionalCookieUser(user_id)) + } +} + +impl FromRequestParts for RequiredCookieUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = axum::response::Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let token = extract_token_from_cookie(parts) + .ok_or_else(|| Redirect::to("/login").into_response())?; + let user_id = app_state + .app_ctx + .auth_service + .validate_token(&token) + .await + .map_err(|_| Redirect::to("/login").into_response())?; + Ok(RequiredCookieUser(user_id)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -125,4 +184,195 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + + // Reusable helpers for cookie extractor tests + async fn optional_cookie_handler(user: OptionalCookieUser) -> String { + match user.0 { + Some(id) => id.value().to_string(), + None => "none".to_string(), + } + } + + async fn required_cookie_handler(user: RequiredCookieUser) -> String { + user.0.value().to_string() + } + + fn test_router_optional(state: crate::state::AppState) -> Router { + Router::new() + .route("/optional", get(optional_cookie_handler)) + .with_state(state) + } + + fn test_router_required(state: crate::state::AppState) -> Router { + Router::new() + .route("/required", get(required_cookie_handler)) + .with_state(state) + } + + struct RejectingAuth; + #[async_trait::async_trait] + impl domain::ports::AuthService for RejectingAuth { + async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result { panic!() } + async fn validate_token(&self, _: &str) -> Result { + Err(domain::errors::DomainError::Unauthorized("bad token".into())) + } + } + + fn panic_state() -> crate::state::AppState { + use std::sync::Arc; + use application::context::AppContext; + struct PanicRepo2; + #[async_trait::async_trait] + impl domain::ports::MovieRepository for PanicRepo2 { + async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, domain::errors::DomainError> { panic!() } + async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result, domain::errors::DomainError> { panic!() } + async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result, domain::errors::DomainError> { panic!() } + async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } + async fn save_review(&self, _: &domain::models::Review) -> Result { panic!() } + async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } + async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } + } + struct PanicMeta2; struct PanicFetcher2; struct PanicStorage2; struct PanicEvent2; struct PanicHasher2; struct PanicUserRepo2; + #[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta2 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher2 { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result, domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage2 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result, domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent2 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher2 { async fn hash(&self, _: &str) -> Result { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result { panic!() } } + #[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result { panic!() } async fn validate_token(&self, _: &str) -> Result { panic!() } } + #[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result, domain::errors::DomainError> { panic!() } } + struct PanicRenderer2; + impl crate::ports::HtmlRenderer for PanicRenderer2 { + fn render_diary_page(&self, _: &domain::models::collections::Paginated, _: application::ports::HtmlPageContext) -> Result { panic!() } + fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result { panic!() } + fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result { panic!() } + fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result { panic!() } + } + struct PanicRssRenderer2; + impl crate::ports::RssFeedRenderer for PanicRssRenderer2 { + fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result { panic!() } + } + struct PanicAuth2; + crate::state::AppState { + app_ctx: AppContext { + repository: Arc::new(PanicRepo2), + metadata_client: Arc::new(PanicMeta2), + poster_fetcher: Arc::new(PanicFetcher2), + poster_storage: Arc::new(PanicStorage2), + event_publisher: Arc::new(PanicEvent2), + auth_service: Arc::new(PanicAuth2), + password_hasher: Arc::new(PanicHasher2), + user_repository: Arc::new(PanicUserRepo2), + config: application::config::AppConfig { allow_registration: false }, + }, + html_renderer: Arc::new(PanicRenderer2), + rss_renderer: Arc::new(PanicRssRenderer2), + } + } + + fn rejecting_state() -> crate::state::AppState { + use std::sync::Arc; + use application::context::AppContext; + struct PanicRepo3; + #[async_trait::async_trait] + impl domain::ports::MovieRepository for PanicRepo3 { + async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, domain::errors::DomainError> { panic!() } + async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result, domain::errors::DomainError> { panic!() } + async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result, domain::errors::DomainError> { panic!() } + async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } + async fn save_review(&self, _: &domain::models::Review) -> Result { panic!() } + async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } + async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } + } + struct PanicMeta3; struct PanicFetcher3; struct PanicStorage3; struct PanicEvent3; struct PanicHasher3; struct PanicUserRepo3; + #[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta3 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher3 { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result, domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage3 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result, domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent3 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } } + #[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher3 { async fn hash(&self, _: &str) -> Result { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result { panic!() } } + #[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result, domain::errors::DomainError> { panic!() } } + struct PanicRenderer3; + impl crate::ports::HtmlRenderer for PanicRenderer3 { + fn render_diary_page(&self, _: &domain::models::collections::Paginated, _: application::ports::HtmlPageContext) -> Result { panic!() } + fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result { panic!() } + fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result { panic!() } + fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result { panic!() } + } + struct PanicRssRenderer3; + impl crate::ports::RssFeedRenderer for PanicRssRenderer3 { + fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result { panic!() } + } + crate::state::AppState { + app_ctx: AppContext { + repository: Arc::new(PanicRepo3), + metadata_client: Arc::new(PanicMeta3), + poster_fetcher: Arc::new(PanicFetcher3), + poster_storage: Arc::new(PanicStorage3), + event_publisher: Arc::new(PanicEvent3), + auth_service: Arc::new(RejectingAuth), + password_hasher: Arc::new(PanicHasher3), + user_repository: Arc::new(PanicUserRepo3), + config: application::config::AppConfig { allow_registration: false }, + }, + html_renderer: Arc::new(PanicRenderer3), + rss_renderer: Arc::new(PanicRssRenderer3), + } + } + + #[tokio::test] + async fn optional_cookie_user_returns_none_without_cookie() { + let app = test_router_optional(panic_state()); + let response = app + .oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"none"); + } + + #[tokio::test] + async fn optional_cookie_user_returns_none_with_invalid_token() { + let app = test_router_optional(rejecting_state()); + let response = app + .oneshot( + Request::builder() + .uri("/optional") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"none"); + } + + #[tokio::test] + async fn required_cookie_user_redirects_without_cookie() { + let app = test_router_required(panic_state()); + let response = app + .oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } + + #[tokio::test] + async fn required_cookie_user_redirects_with_invalid_token() { + let app = test_router_required(rejecting_state()); + let response = app + .oneshot( + Request::builder() + .uri("/required") + .header("cookie", "token=bad.token.here") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } } diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 76d2000..5cf64b5 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -8,6 +8,7 @@ pub mod html { use application::{ commands::LogReviewCommand, + ports::HtmlPageContext, queries::GetDiaryQuery, use_cases::{get_diary, log_review}, }; @@ -38,9 +39,10 @@ pub mod html { }; let page = get_diary::execute(&state.app_ctx, query).await?; + let ctx = HtmlPageContext { user_email: None, register_enabled: state.app_ctx.config.allow_registration }; let html = state .html_renderer - .render_diary_page(&page) + .render_diary_page(&page, ctx) .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; Ok(Html(html)) diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 8a20325..27e06c4 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -84,6 +84,7 @@ struct NobodyUserRepo; impl UserRepository for NobodyUserRepo { async fn find_by_email(&self, _: &Email) -> Result, DomainError> { Ok(None) } async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() } + async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { panic!() } } async fn test_app() -> Router {