feat(extractors): add OptionalCookieUser and RequiredCookieUser

This commit is contained in:
2026-05-04 13:34:31 +02:00
parent 450468ef3d
commit e5097c22dd
4 changed files with 256 additions and 2 deletions

View File

@@ -134,6 +134,7 @@ mod tests {
impl UserRepository for PanicUserRepo { impl UserRepository for PanicUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") } async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") } async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") }
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<User>, DomainError> { panic!("unexpected") }
} }
#[async_trait] #[async_trait]

View File

@@ -1,6 +1,7 @@
use axum::{ use axum::{
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
http::{header::AUTHORIZATION, request::Parts}, http::{header, header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Redirect},
}; };
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
@@ -36,6 +37,64 @@ where
} }
} }
pub struct OptionalCookieUser(pub Option<UserId>);
pub struct RequiredCookieUser(pub UserId);
fn extract_token_from_cookie(parts: &Parts) -> Option<String> {
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<S> FromRequestParts<S> for OptionalCookieUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
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<S> FromRequestParts<S> for RequiredCookieUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = axum::response::Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -125,4 +184,195 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 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<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() }
async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> {
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<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, 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<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { 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<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, 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<Vec<u8>, 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<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, 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<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, 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<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
struct PanicRenderer2;
impl crate::ports::HtmlRenderer for PanicRenderer2 {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
}
struct PanicRssRenderer2;
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { 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<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, 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<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { 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<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, 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<Vec<u8>, 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<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, 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<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, 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<Option<domain::models::User>, domain::errors::DomainError> { panic!() } }
struct PanicRenderer3;
impl crate::ports::HtmlRenderer for PanicRenderer3 {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
}
struct PanicRssRenderer3;
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { 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");
}
} }

View File

@@ -8,6 +8,7 @@ pub mod html {
use application::{ use application::{
commands::LogReviewCommand, commands::LogReviewCommand,
ports::HtmlPageContext,
queries::GetDiaryQuery, queries::GetDiaryQuery,
use_cases::{get_diary, log_review}, use_cases::{get_diary, log_review},
}; };
@@ -38,9 +39,10 @@ pub mod html {
}; };
let page = get_diary::execute(&state.app_ctx, query).await?; 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 let html = state
.html_renderer .html_renderer
.render_diary_page(&page) .render_diary_page(&page, ctx)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok(Html(html)) Ok(Html(html))

View File

@@ -84,6 +84,7 @@ struct NobodyUserRepo;
impl UserRepository for NobodyUserRepo { impl UserRepository for NobodyUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) } async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() } async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() }
} }
async fn test_app() -> Router { async fn test_app() -> Router {