use super::*; use crate::context::AppContext; use application::config::AppConfig; use axum::{ Router, body::Body, http::{Request, StatusCode}, routing::get, }; use bootstrap::Repositories; use domain::{ errors::DomainError, events::DomainEvent, models::{ DiaryEntry, DiaryFilter, EntityType, FeedEntry, GeneratedToken, IndexableDocument, Movie, Person, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, collections::{PageParams, Paginated}, insights::WatchRow, }, ports::{ AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, InsightsQuery, MetadataClient, MovieCommand, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository, }, testing::InMemoryUserRepository, value_objects::{ Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, }, }; use std::sync::Arc; use tower::ServiceExt; // --- Panic stubs (defined once) --- pub struct Panic; #[async_trait::async_trait] impl MovieCommand for Panic { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!() } async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl MovieQuery for Panic { async fn get_movie_by_external_id( &self, _: &ExternalMetadataId, ) -> Result, DomainError> { panic!() } async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!() } async fn get_movies_by_title_and_year( &self, _: &MovieTitle, _: &ReleaseYear, ) -> Result, DomainError> { panic!() } async fn existing_external_ids( &self, _: &[ExternalMetadataId], ) -> Result, DomainError> { panic!() } async fn existing_title_year_pairs( &self, _: &[(MovieTitle, ReleaseYear)], ) -> Result, DomainError> { panic!() } async fn list_movies( &self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter, ) -> Result, DomainError> { panic!() } async fn list_movies_with_external_id( &self, ) -> Result, DomainError> { Ok(vec![]) } } #[async_trait::async_trait] impl ReviewRepository for Panic { async fn save_review(&self, _: &Review) -> Result<(), DomainError> { panic!() } async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { panic!() } async fn update_review(&self, _: &Review) -> Result<(), DomainError> { panic!() } async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() } async fn get_all_reviews_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl DiaryQuery for Panic { async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { panic!() } async fn query_activity_feed( &self, _: &PageParams, ) -> Result, DomainError> { panic!() } async fn query_activity_feed_filtered( &self, _: &PageParams, _: &domain::models::FeedSortBy, _: Option<&str>, _: Option<&domain::models::FollowingFilter>, ) -> Result, DomainError> { panic!() } async fn get_review_history(&self, _: &MovieId) -> Result { panic!() } async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { panic!() } fn stream_user_history( &self, _: UserId, ) -> futures::stream::BoxStream<'static, Result> { panic!() } async fn get_movie_stats( &self, _: &MovieId, ) -> Result { panic!() } async fn get_movie_social_feed( &self, _: &MovieId, _: &PageParams, ) -> Result, DomainError> { panic!() } async fn count_local_posts(&self) -> Result { panic!() } } #[async_trait::async_trait] impl StatsRepository for Panic { async fn get_user_stats(&self, _: &UserId) -> Result { panic!() } async fn get_user_trends(&self, _: &UserId) -> Result { panic!() } async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result { panic!() } } #[async_trait::async_trait] impl InsightsQuery for Panic { async fn get_watch_rows(&self, _: &UserId) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl MetadataClient for Panic { async fn fetch_movie_metadata( &self, _: &domain::models::MetadataSearchCriteria, ) -> Result { panic!() } async fn get_poster_url( &self, _: &ExternalMetadataId, ) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl PosterFetcherClient for Panic { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl ObjectStorage for Panic { async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } async fn get(&self, _: &str) -> Result, DomainError> { panic!() } async fn get_stream( &self, _: &str, ) -> Result>, DomainError> { panic!() } async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl AuthService for Panic { async fn generate_token(&self, _: &UserId) -> Result { panic!() } async fn validate_token(&self, _: &str) -> Result { panic!() } } #[async_trait::async_trait] impl PasswordHasher for Panic { async fn hash(&self, _: &str) -> Result { panic!() } async fn verify(&self, _: &str, _: &PasswordHash) -> Result { panic!() } } #[async_trait::async_trait] impl UserRepository for Panic { async fn find_by_email(&self, _: &Email) -> Result, DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { panic!() } async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { panic!() } async fn find_by_username( &self, _: &domain::value_objects::Username, ) -> Result, DomainError> { panic!() } async fn list_with_stats(&self) -> Result, DomainError> { panic!() } async fn update_profile( &self, _: &UserId, _: &domain::models::UserProfile, ) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::UserProfileFieldsRepository for Panic { async fn get_fields( &self, _: &UserId, ) -> Result, DomainError> { panic!() } async fn set_fields( &self, _: &UserId, _: Vec, ) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl EventPublisher for Panic { async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::ImportSessionRepository for Panic { async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } async fn get( &self, _: &domain::value_objects::ImportSessionId, _: &UserId, ) -> Result, DomainError> { panic!() } async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } async fn delete_expired(&self) -> Result { panic!() } async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::ImportProfileRepository for Panic { async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } async fn list_for_user( &self, _: &UserId, ) -> Result, DomainError> { panic!() } async fn get( &self, _: &domain::value_objects::ImportProfileId, _: &UserId, ) -> Result, DomainError> { panic!() } async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl WatchlistRepository for Panic { async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() } async fn remove( &self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId, ) -> Result<(), DomainError> { panic!() } async fn remove_if_present( &self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId, ) -> Result { Ok(false) } async fn get_for_user( &self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams, ) -> Result< domain::models::collections::Paginated, DomainError, > { panic!() } async fn contains( &self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId, ) -> Result { Ok(false) } } #[async_trait::async_trait] impl domain::ports::MovieProfileRepository for Panic { async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } async fn get_by_movie_id( &self, _: &domain::value_objects::MovieId, ) -> Result, DomainError> { Ok(None) } async fn list_stale( &self, ) -> Result, DomainError> { Ok(vec![]) } } impl domain::ports::DiaryExporter for Panic { fn stream_entries( &self, _stream: futures::stream::BoxStream< 'static, Result, >, _format: domain::models::ExportFormat, ) -> futures::stream::BoxStream<'static, Result> { panic!("Panic DiaryExporter called") } } impl domain::ports::DocumentParser for Panic { fn parse( &self, _: &[u8], _: domain::models::FileFormat, ) -> Result { panic!() } fn apply_mapping( &self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping], ) -> Vec { panic!() } } impl domain::ports::RssFeedRenderer for Panic { fn render_feed( &self, _: &[DiaryEntry], _: &domain::ports::FeedChannel<'_>, ) -> Result { panic!() } } struct RejectingAuth; #[async_trait::async_trait] impl AuthService for RejectingAuth { async fn generate_token(&self, _: &UserId) -> Result { panic!() } async fn validate_token(&self, _: &str) -> Result { Err(DomainError::Unauthorized("bad token".into())) } } #[async_trait::async_trait] impl PersonCommand for Panic { async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() } async fn backfill_from_credits_batch( &self, _batch_size: u32, ) -> Result<(u64, bool), DomainError> { panic!() } async fn update_enrichment( &self, _: &PersonId, _: &PersonEnrichmentData, ) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl PersonQuery for Panic { async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { panic!() } async fn get_by_external_id( &self, _: &domain::models::ExternalPersonId, ) -> Result, DomainError> { panic!() } async fn get_credits(&self, _: &PersonId) -> Result { panic!() } async fn list_orphaned_persons(&self) -> Result, DomainError> { panic!() } async fn list_page( &self, _limit: u32, _offset: u32, ) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl SearchPort for Panic { async fn search(&self, _: &SearchQuery) -> Result { panic!() } } #[async_trait::async_trait] impl SearchCommand for Panic { async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() } async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::RemoteWatchlistRepository for Panic { async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) } async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) } async fn get_by_actor_url( &self, _: &str, ) -> Result, DomainError> { Ok(vec![]) } async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) } async fn get_by_derived_uuid( &self, _: uuid::Uuid, ) -> Result, DomainError> { Ok(vec![]) } } #[async_trait::async_trait] impl domain::ports::WatchEventCommand for Panic { async fn save(&self, _: &domain::models::WatchEvent) -> Result<(), DomainError> { panic!() } async fn update_status( &self, _: &domain::value_objects::WatchEventId, _: domain::models::WatchEventStatus, ) -> Result<(), DomainError> { panic!() } async fn update_status_batch( &self, _: &[domain::value_objects::WatchEventId], _: domain::models::WatchEventStatus, ) -> Result { panic!() } async fn delete_non_pending_older_than( &self, _: chrono::NaiveDateTime, ) -> Result { panic!() } } #[async_trait::async_trait] impl domain::ports::WatchEventQuery for Panic { async fn list_pending( &self, _: &domain::value_objects::UserId, ) -> Result, DomainError> { panic!() } async fn get_by_id( &self, _: &domain::value_objects::WatchEventId, ) -> Result, DomainError> { panic!() } async fn get_by_ids( &self, _: &[domain::value_objects::WatchEventId], ) -> Result, DomainError> { panic!() } async fn find_duplicate( &self, _: &domain::value_objects::UserId, _: &str, _: chrono::NaiveDateTime, ) -> Result { panic!() } } #[async_trait::async_trait] impl domain::ports::WebhookTokenRepository for Panic { async fn save(&self, _: &domain::models::WebhookToken) -> Result<(), DomainError> { panic!() } async fn find_by_token_hash( &self, _: &str, ) -> Result, DomainError> { panic!() } async fn list_by_user( &self, _: &domain::value_objects::UserId, ) -> Result, DomainError> { panic!() } async fn delete( &self, _: &domain::value_objects::WebhookTokenId, _: &domain::value_objects::UserId, ) -> Result<(), DomainError> { panic!() } async fn touch_last_used( &self, _: &domain::value_objects::WebhookTokenId, ) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::WrapUpStatsQuery for Panic { async fn get_reviews_with_profiles( &self, _: &domain::models::wrapup::WrapUpScope, _: &domain::models::wrapup::DateRange, ) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::WrapUpRepository for Panic { async fn create(&self, _: &domain::models::wrapup::WrapUpRecord) -> Result<(), DomainError> { panic!() } async fn update_status( &self, _: &domain::value_objects::WrapUpId, _: &domain::models::wrapup::WrapUpStatus, _: Option<&str>, ) -> Result<(), DomainError> { panic!() } async fn set_complete( &self, _: &domain::value_objects::WrapUpId, _: &domain::models::wrapup::WrapUpReport, ) -> Result<(), DomainError> { panic!() } async fn get_by_id( &self, _: &domain::value_objects::WrapUpId, ) -> Result, DomainError> { panic!() } async fn list_for_user( &self, _: uuid::Uuid, ) -> Result, DomainError> { panic!() } async fn list_global(&self) -> Result, DomainError> { panic!() } async fn find_existing( &self, _: Option, _: chrono::NaiveDate, _: chrono::NaiveDate, ) -> Result, DomainError> { panic!() } async fn delete(&self, _: &domain::value_objects::WrapUpId) -> Result<(), DomainError> { panic!() } async fn delete_failed_older_than(&self, _: chrono::NaiveDateTime) -> Result { panic!() } } #[async_trait::async_trait] impl domain::ports::GoalCommand for Panic { async fn save(&self, _: &domain::models::Goal) -> Result<(), DomainError> { panic!() } async fn update(&self, _: &domain::models::Goal) -> Result<(), DomainError> { panic!() } async fn delete( &self, _: &domain::value_objects::GoalId, _: &domain::value_objects::UserId, ) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::GoalQuery for Panic { async fn find_by_user_and_year( &self, _: &domain::value_objects::UserId, _: u16, ) -> Result, DomainError> { panic!() } async fn list_for_user( &self, _: &domain::value_objects::UserId, ) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::UserSettingsRepository for Panic { async fn get( &self, _: &domain::value_objects::UserId, ) -> Result { panic!() } async fn save(&self, _: &domain::models::UserSettings) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::RemoteGoalRepository for Panic { async fn save(&self, _: domain::models::RemoteGoalEntry) -> Result<(), DomainError> { panic!() } async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> { panic!() } async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { panic!() } async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { panic!() } async fn get_by_actor_url( &self, _: &str, ) -> Result, DomainError> { panic!() } } #[async_trait::async_trait] impl domain::ports::RefreshSessionRepository for Panic { async fn create(&self, _: &domain::models::RefreshSession) -> Result<(), DomainError> { panic!() } async fn get_by_token( &self, _: &str, ) -> Result, DomainError> { panic!() } async fn revoke(&self, _: &str) -> Result<(), DomainError> { panic!() } async fn revoke_all_for_user( &self, _: &domain::value_objects::UserId, ) -> Result<(), DomainError> { panic!() } async fn delete_expired(&self) -> Result { panic!() } } #[async_trait::async_trait] impl application::ports::ReviewLogger for Panic { async fn log_review( &self, _: application::diary::commands::LogReviewCommand, ) -> Result<(), DomainError> { panic!() } } // --- Repositories factory — `Panic` stubs everywhere --- pub(crate) fn test_repositories() -> Repositories { let repo = Arc::new(Panic); Repositories { movie_command: Arc::clone(&repo) as _, movie_query: Arc::clone(&repo) as _, review: Arc::clone(&repo) as _, diary: Arc::clone(&repo) as _, stats: Arc::clone(&repo) as _, user: Arc::clone(&repo) as _, import_session: Arc::clone(&repo) as _, import_profile: Arc::clone(&repo) as _, movie_profile: Arc::clone(&repo) as _, watchlist: Arc::clone(&repo) as _, watch_event_command: Arc::clone(&repo) as _, watch_event_query: Arc::clone(&repo) as _, webhook_token: Arc::clone(&repo) as _, profile_fields: Arc::clone(&repo) as _, person_command: Arc::clone(&repo) as _, person_query: Arc::clone(&repo) as _, search_port: Arc::clone(&repo) as _, search_command: Arc::clone(&repo) as _, remote_watchlist: Arc::clone(&repo) as _, social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _, follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _, block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _, federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _, wrapup_stats: Arc::clone(&repo) as _, wrapup_repo: Arc::clone(&repo) as _, insights: Arc::clone(&repo) as _, goal_command: Arc::clone(&repo) as _, goal_query: Arc::clone(&repo) as _, user_settings: Arc::clone(&repo) as _, remote_goal: Arc::clone(&repo) as _, refresh_session: Arc::clone(&repo) as _, federated_profile: None, } } // --- Single state factory — builds `deps` from whatever `repos` it is given --- /// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps` /// AFTER any overrides the caller already applied to `repos`. This is the only place /// `deps` gets built for tests, so `repos` and `deps` can never disagree. pub(crate) fn build_test_state_from( repos: Repositories, auth_service: Arc, ) -> crate::state::AppState { let repo = Arc::new(Panic); let services = application::Services { auth: auth_service, password_hasher: Arc::clone(&repo) as _, metadata: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _, object_storage: Arc::clone(&repo) as _, event_publisher: Arc::clone(&repo) as _, diary_exporter: Arc::clone(&repo) as _, document_parser: Arc::clone(&repo) as _, review_logger: Arc::clone(&repo) as _, person_enrichment: None, }; let config = AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, refresh_ttl_seconds: 2_592_000, secure_cookies: false, cors_origins: String::new(), wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, bg_dir: None, }, }; let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000"); let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance)); crate::state::AppState { app_ctx: AppContext { deps, services, config, instance, #[cfg(feature = "federation")] ap_document: Arc::new(domain::ports::noop::NoopApDocument), #[cfg(feature = "federation")] ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist), }, rss_renderer: Arc::new(Panic), } } pub fn make_test_state(auth_service: Arc) -> crate::state::AppState { build_test_state_from(test_repositories(), auth_service) } /// Reusable default test `AppState`: `Panic` stubs everywhere, real prebuilt `deps`. /// Callers that need a specific stub swapped in should use one of the /// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps` /// runs — mutating the returned `AppState` afterwards would only reach `repos`, /// not the prebuilt `deps`. pub(crate) fn test_app_state() -> crate::state::AppState { make_test_state(Arc::new(Panic)) } /// Test state with `person_query` overridden BEFORE `build_deps` runs, so the /// stub is visible from both `app_ctx.repos.person_query` and every prebuilt /// deps struct that carries a `person_query` (e.g. `deps.person.get_person`). pub(crate) fn test_app_state_with_person_query( person_query: Arc, ) -> crate::state::AppState { let mut repos = test_repositories(); repos.person_query = person_query; build_test_state_from(repos, Arc::new(Panic)) } /// Test state with `search_port` overridden BEFORE `build_deps` runs, so the /// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps /// struct that carries a `search_port`. pub(crate) fn test_app_state_with_search_port( search_port: Arc, ) -> crate::state::AppState { let mut repos = test_repositories(); repos.search_port = search_port; build_test_state_from(repos, Arc::new(Panic)) } /// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the /// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt /// `deps.social.query` that the social handlers actually read from. pub(crate) fn test_app_state_with_follow_graph( follow_graph: Arc, auth_service: Arc, ) -> crate::state::AppState { let mut repos = test_repositories(); repos.follow_graph = follow_graph; build_test_state_from(repos, auth_service) } /// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is /// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user` /// — the field the `AdminApiUser`/`AdminUser` extractors now read through. pub(crate) fn test_app_state_with_user( user: Arc, auth_service: Arc, ) -> crate::state::AppState { let mut repos = test_repositories(); repos.user = user; build_test_state_from(repos, auth_service) } /// A `UserRepository` whose `find_by_id` always fails, for exercising the /// repository-error rejection path of the admin extractors. Every other /// method panics — the admin extractors only ever call `find_by_id`. pub(crate) struct ErroringUserRepo; #[async_trait::async_trait] impl UserRepository for ErroringUserRepo { async fn find_by_email( &self, _: &domain::value_objects::Email, ) -> Result, DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { panic!() } async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { Err(DomainError::InfrastructureError("db down".into())) } async fn find_by_username( &self, _: &domain::value_objects::Username, ) -> Result, DomainError> { panic!() } async fn list_with_stats(&self) -> Result, DomainError> { panic!() } async fn update_profile( &self, _: &UserId, _: &domain::models::UserProfile, ) -> Result<(), DomainError> { panic!() } } /// Builds a bare `User` with the given role, for the admin/non-admin extractor /// tests — only `id()` and `role()` are read by `authorize_admin::execute`. fn make_user_with_role(id: UserId, role: domain::models::UserRole) -> domain::models::User { domain::models::User::from_persistence( id, domain::value_objects::Email::new("extractor-test@example.com".into()).unwrap(), domain::value_objects::Username::new("extractor_test_user".into()).unwrap(), domain::value_objects::PasswordHash::new("hashed".into()).unwrap(), role, domain::models::UserProfile { display_name: None, bio: None, avatar_path: None, banner_path: None, also_known_as: None, profile_fields: vec![], }, ) } /// Auth stub that accepts any bearer token and resolves it to a fixed user id, /// for tests that need to authenticate as a specific user rather than merely /// exercise the unauthenticated-request path. pub(crate) struct AcceptingAuth(pub UserId); #[async_trait::async_trait] impl AuthService for AcceptingAuth { async fn generate_token(&self, _: &UserId) -> Result { panic!() } async fn validate_token(&self, _: &str) -> Result { Ok(self.0.clone()) } } // --- Routers --- async fn protected_handler(user: AuthenticatedUser) -> String { user.0.value().to_string() } 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() } async fn admin_api_handler(user: AdminApiUser) -> String { user.0.value().to_string() } async fn admin_cookie_handler(user: AdminUser) -> String { user.0.value().to_string() } fn router_protected(state: crate::state::AppState) -> Router { Router::new() .route("/protected", get(protected_handler)) .with_state(state) } fn router_optional(state: crate::state::AppState) -> Router { Router::new() .route("/optional", get(optional_cookie_handler)) .with_state(state) } fn router_admin_api(state: crate::state::AppState) -> Router { Router::new() .route("/admin-api", get(admin_api_handler)) .with_state(state) } fn router_admin_cookie(state: crate::state::AppState) -> Router { Router::new() .route("/admin-cookie", get(admin_cookie_handler)) .with_state(state) } fn router_required(state: crate::state::AppState) -> Router { Router::new() .route("/required", get(required_cookie_handler)) .with_state(state) } // --- Tests --- #[tokio::test] async fn missing_auth_header_returns_401() { let app = router_protected(test_app_state()); let resp = app .oneshot( Request::builder() .uri("/protected") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn optional_cookie_user_returns_none_without_cookie() { let app = router_optional(test_app_state()); let resp = app .oneshot( Request::builder() .uri("/optional") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.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 = router_optional(make_test_state(Arc::new(RejectingAuth))); let resp = app .oneshot( Request::builder() .uri("/optional") .header("cookie", "token=bad.token.here") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); assert_eq!(&body[..], b"none"); } #[tokio::test] async fn required_cookie_user_redirects_without_cookie() { let app = router_required(test_app_state()); let resp = app .oneshot( Request::builder() .uri("/required") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.headers().get("location").unwrap(), "/login"); } #[tokio::test] async fn required_cookie_user_redirects_with_invalid_token() { let app = router_required(make_test_state(Arc::new(RejectingAuth))); let resp = app .oneshot( Request::builder() .uri("/required") .header("cookie", "token=bad.token.here") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.headers().get("location").unwrap(), "/login"); } // --- AdminApiUser / AdminUser: per-path rejection pinning --- // // These pin the exact rejection for every path `authorize_admin::execute` can // produce, now that both extractors go through it instead of calling // `repos.user.find_by_id` directly. Each test name states the path; the // asserted status is the byte-identical-to-before-this-task value recorded in // the task-4 report's before/after table. fn admin_id() -> UserId { UserId::generate() } #[tokio::test] async fn admin_api_user_allows_an_admin_user() { let id = admin_id(); let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), make_user_with_role(id.clone(), domain::models::UserRole::Admin), ); let app = router_admin_api(test_app_state_with_user( user_repo as _, Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-api") .header("authorization", "Bearer anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn admin_api_user_rejects_a_non_admin_user() { let id = admin_id(); let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), make_user_with_role(id.clone(), domain::models::UserRole::Standard), ); let app = router_admin_api(test_app_state_with_user( user_repo as _, Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-api") .header("authorization", "Bearer anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn admin_api_user_rejects_an_unknown_user() { let id = admin_id(); let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store let app = router_admin_api(test_app_state_with_user( user_repo as _, Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-api") .header("authorization", "Bearer anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn admin_api_user_surfaces_a_repository_error_as_500() { let id = admin_id(); let app = router_admin_api(test_app_state_with_user( Arc::new(ErroringUserRepo), Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-api") .header("authorization", "Bearer anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] async fn admin_user_allows_an_admin_user() { let id = admin_id(); let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), make_user_with_role(id.clone(), domain::models::UserRole::Admin), ); let app = router_admin_cookie(test_app_state_with_user( user_repo as _, Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-cookie") .header("cookie", "token=anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn admin_user_rejects_a_non_admin_user() { let id = admin_id(); let user_repo = InMemoryUserRepository::new(); user_repo.store.lock().unwrap().insert( id.value(), make_user_with_role(id.clone(), domain::models::UserRole::Standard), ); let app = router_admin_cookie(test_app_state_with_user( user_repo as _, Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-cookie") .header("cookie", "token=anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn admin_user_rejects_an_unknown_user() { let id = admin_id(); let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store let app = router_admin_cookie(test_app_state_with_user( user_repo as _, Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-cookie") .header("cookie", "token=anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn admin_user_surfaces_a_repository_error_as_500() { let id = admin_id(); let app = router_admin_cookie(test_app_state_with_user( Arc::new(ErroringUserRepo), Arc::new(AcceptingAuth(id)), )); let resp = app .oneshot( Request::builder() .uri("/admin-cookie") .header("cookie", "token=anything") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); }