From 00395357ac8163f4497223b38e0b1ab907a9c789 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 10:51:32 +0100 Subject: [PATCH] handlers: image/reindex/auth via use cases; auth::authenticate replaces ensure_active; check-handler-services guard --- .gitea/workflows/ci.yml | 2 +- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- Makefile | 14 +++- crates/adapters/http-axum/src/api/search.rs | 5 +- .../adapters/http-axum/src/extractors/mod.rs | 46 +++-------- crates/adapters/http-axum/src/html/images.rs | 3 +- crates/application/src/auth/authenticate.rs | 11 +++ crates/application/src/auth/deps.rs | 5 ++ crates/application/src/auth/mod.rs | 1 + crates/application/src/deps.rs | 17 ++++- crates/application/src/images/deps.rs | 7 ++ crates/application/src/images/get_image.rs | 7 ++ crates/application/src/images/mod.rs | 2 + crates/application/src/lib.rs | 1 + crates/application/src/search/deps.rs | 6 +- crates/application/src/search/mod.rs | 1 + .../application/src/search/request_reindex.rs | 9 +++ crates/application/src/users/deps.rs | 4 - crates/application/src/users/ensure_active.rs | 10 --- crates/application/src/users/mod.rs | 1 - crates/application/tests/auth.rs | 2 + .../authenticate_test.rs} | 67 +++++++++++----- crates/application/tests/images.rs | 2 + .../tests/images/get_image_test.rs | 76 +++++++++++++++++++ crates/application/tests/search.rs | 3 + .../tests/search/request_reindex_test.rs | 33 ++++++++ crates/application/tests/users.rs | 2 - crates/bootstrap/src/build.rs | 28 ++++--- docs/adr/0016-account-deletion.md | 2 +- 30 files changed, 275 insertions(+), 96 deletions(-) create mode 100644 crates/application/src/auth/authenticate.rs create mode 100644 crates/application/src/images/deps.rs create mode 100644 crates/application/src/images/get_image.rs create mode 100644 crates/application/src/images/mod.rs create mode 100644 crates/application/src/search/request_reindex.rs delete mode 100644 crates/application/src/users/ensure_active.rs rename crates/application/tests/{users/ensure_active_test.rs => auth/authenticate_test.rs} (60%) create mode 100644 crates/application/tests/images.rs create mode 100644 crates/application/tests/images/get_image_test.rs create mode 100644 crates/application/tests/search/request_reindex_test.rs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 08b7564a..b2ea3a8c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -40,4 +40,4 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack + run: make check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48b0ea2b..d96073a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: # Layering guards. These live in the Makefile and were previously local-only, # so a PR could go green in CI while violating them. - name: guards - run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack + run: make check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack docker: name: Build & Push Docker Image diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd80ea59..9db26796 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ cd spa && bun install && bun run dev make check # the full gate; plain `make` runs it too ``` -`make check` runs, in order: `fmt-check`, `clippy` (workspace, all targets, `-D warnings`), `test`, `test-federation-off` (workspace tests with default features off), then the layering guards (`check-appcontext`, `check-handler-deps`, `check-handler-repos`, `check-http-adapter-deps`, `check-federation-off-social`, `check-sqlite-is-ap-free`, `check-binaries-are-thin`, `check-federation-off-deps`, `check-nats-isolated`, `check-no-second-web-stack`). CI runs the same steps. +`make check` runs, in order: `fmt-check`, `clippy` (workspace, all targets, `-D warnings`), `test`, `test-federation-off` (workspace tests with default features off), then the layering guards (`check-appcontext`, `check-handler-deps`, `check-handler-repos`, `check-handler-services`, `check-http-adapter-deps`, `check-federation-off-social`, `check-sqlite-is-ap-free`, `check-binaries-are-thin`, `check-federation-off-deps`, `check-nats-isolated`, `check-no-second-web-stack`). CI runs the same steps. For the SPA also run: diff --git a/Makefile b/Makefile index 14fa0b5d..f9ac3804 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := check -check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack +check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack @echo "✅ All checks passed" check-appcontext: @@ -30,6 +30,16 @@ check-handler-repos: HTTP_ADAPTER_ALLOWED_ADAPTERS := +check-handler-services: + @hits=$$(find crates/adapters/http-axum/src -name '*.rs' -not -path '*/tests/*' -not -name tests.rs \ + | xargs perl -0ne 'while (/app_ctx\s*\.\s*services\s*\.\s*([a-z_]+)/g) { print "$$ARGV: services.$$1\n" unless $$1 eq "review_logger" }'); \ + if [ -n "$$hits" ]; then \ + echo "❌ http-axum reaches a service port directly (call a use case instead):"; \ + echo "$$hits"; \ + exit 1; \ + fi; \ + echo "✅ No direct service-port calls in http-axum" + check-http-adapter-deps: @deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \ | sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \ @@ -167,4 +177,4 @@ dev-spa: spa-dev: @npm --prefix spa run dev -.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack +.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack diff --git a/crates/adapters/http-axum/src/api/search.rs b/crates/adapters/http-axum/src/api/search.rs index e0c28512..772f50e7 100644 --- a/crates/adapters/http-axum/src/api/search.rs +++ b/crates/adapters/http-axum/src/api/search.rs @@ -163,8 +163,9 @@ pub async fn post_reindex_search( State(state): State, _admin: crate::extractors::AdminApiUser, ) -> impl IntoResponse { - let event = domain::events::DomainEvent::SearchReindexRequested; - match state.app_ctx.services.event_publisher.publish(&event).await { + match application::search::request_reindex::execute(&state.app_ctx.deps.search.request_reindex) + .await + { Ok(()) => StatusCode::ACCEPTED.into_response(), Err(e) => { tracing::error!("failed to publish reindex event: {:?}", e); diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index 7000f100..eebd43ce 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -10,9 +10,8 @@ use domain::{errors::DomainError, value_objects::UserId}; use crate::{errors::ApiError, state::AppState}; -async fn ensure_active(state: &AppState, user_id: &UserId) -> Result<(), DomainError> { - application::users::ensure_active::execute(&state.app_ctx.deps.users.ensure_active, user_id) - .await +async fn authenticate(state: &AppState, token: &str) -> Result { + application::auth::authenticate::execute(&state.app_ctx.deps.auth.authenticate, token).await } pub struct AuthenticatedUser(pub UserId); @@ -36,13 +35,7 @@ where "Missing or invalid auth token".into(), )) })?; - let user_id = app_state - .app_ctx - .services - .auth - .validate_token(token) - .await?; - ensure_active(&app_state, &user_id).await?; + let user_id = authenticate(&app_state, token).await?; Ok(AuthenticatedUser(user_id)) } } @@ -74,24 +67,14 @@ where let Some(token) = extract_token_from_cookie(parts) else { return Ok(OptionalCookieUser(None)); }; - let user_id = app_state - .app_ctx - .services - .auth - .validate_token(&token) - .await - .ok(); - let user_id = match user_id { - Some(id) => match ensure_active(&app_state, &id).await { - Ok(()) => Some(id), - Err(e) => { - if e.is_transient() { - tracing::warn!("ensure_active failed, treating as anonymous: {e:?}"); - } - None + let user_id = match authenticate(&app_state, &token).await { + Ok(id) => Some(id), + Err(e) => { + if e.is_transient() { + tracing::warn!("authenticate failed, treating as anonymous: {e:?}"); } - }, - None => None, + None + } }; Ok(OptionalCookieUser(user_id)) } @@ -108,14 +91,7 @@ where 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 - .services - .auth - .validate_token(&token) - .await - .map_err(|_| Redirect::to("/login").into_response())?; - ensure_active(&app_state, &user_id) + let user_id = authenticate(&app_state, &token) .await .map_err(|e| match e { DomainError::InfrastructureError(_) => { diff --git a/crates/adapters/http-axum/src/html/images.rs b/crates/adapters/http-axum/src/html/images.rs index 6595f5f5..aa0b6b84 100644 --- a/crates/adapters/http-axum/src/html/images.rs +++ b/crates/adapters/http-axum/src/html/images.rs @@ -12,7 +12,8 @@ pub async fn get_image( if key.starts_with("http://") || key.starts_with("https://") { return axum::response::Redirect::temporary(&key).into_response(); } - match state.app_ctx.services.object_storage.get(&key).await { + match application::images::get_image::execute(&state.app_ctx.deps.images.get_image, &key).await + { Ok(bytes) => { let mime = infer::get(&bytes) .map(|t| t.mime_type()) diff --git a/crates/application/src/auth/authenticate.rs b/crates/application/src/auth/authenticate.rs new file mode 100644 index 00000000..255f788f --- /dev/null +++ b/crates/application/src/auth/authenticate.rs @@ -0,0 +1,11 @@ +use domain::{errors::DomainError, value_objects::UserId}; + +use crate::auth::deps::AuthenticateDeps; + +pub async fn execute(deps: &AuthenticateDeps, token: &str) -> Result { + let user_id = deps.auth.validate_token(token).await?; + match deps.user.find_by_id(&user_id).await? { + Some(_) => Ok(user_id), + None => Err(DomainError::Unauthorized("account is not active".into())), + } +} diff --git a/crates/application/src/auth/deps.rs b/crates/application/src/auth/deps.rs index 20a373e0..939b83be 100644 --- a/crates/application/src/auth/deps.rs +++ b/crates/application/src/auth/deps.rs @@ -32,6 +32,11 @@ pub struct RegisterAndLoginDeps { pub config: AppSettings, } +pub struct AuthenticateDeps { + pub auth: Arc, + pub user: Arc, +} + pub struct LogoutDeps { pub refresh_session: Arc, } diff --git a/crates/application/src/auth/mod.rs b/crates/application/src/auth/mod.rs index 8bf84b8a..252e9ba9 100644 --- a/crates/application/src/auth/mod.rs +++ b/crates/application/src/auth/mod.rs @@ -1,3 +1,4 @@ +pub mod authenticate; pub mod commands; pub mod deps; pub mod login; diff --git a/crates/application/src/deps.rs b/crates/application/src/deps.rs index cb305d6c..63e3e1cb 100644 --- a/crates/application/src/deps.rs +++ b/crates/application/src/deps.rs @@ -2,12 +2,15 @@ use std::sync::Arc; use domain::ports::{EventPublisher, MediaServerParser, ObjectStorage, PersonEnrichmentClient}; -use crate::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps}; +use crate::auth::deps::{ + AuthenticateDeps, LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps, +}; use crate::diary::deps::{ DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps, GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps, }; use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps}; +use crate::images::deps::GetImageDeps; use crate::import::deps::{ ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps, DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps, @@ -23,10 +26,10 @@ use crate::movies::deps::{ }; use crate::movies::merge_duplicates::MergeDuplicatesDeps; use crate::person::deps::{EnrichPersonDeps, GetPersonDeps}; -use crate::search::deps::SearchDeps; +use crate::search::deps::{RequestReindexDeps, SearchDeps}; use crate::social::deps::{SocialCommandDeps, SocialQueryDeps}; use crate::users::deps::{ - AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, + AuthorizeAdminDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps, GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps, UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps, @@ -46,6 +49,7 @@ pub struct AuthGroup { pub refresh: RefreshDeps, pub register_and_login: RegisterAndLoginDeps, pub logout: LogoutDeps, + pub authenticate: AuthenticateDeps, } pub struct DiaryGroup { @@ -64,6 +68,10 @@ pub struct GoalsGroup { pub query: GoalQueryDeps, } +pub struct ImagesGroup { + pub get_image: GetImageDeps, +} + pub struct ImportGroup { pub create_session: CreateSessionDeps, pub apply_mapping: ApplyMappingDeps, @@ -109,6 +117,7 @@ pub struct PersonGroup { pub struct SearchGroup { pub execute: SearchDeps, + pub request_reindex: RequestReindexDeps, } pub struct SocialGroup { @@ -125,7 +134,6 @@ pub struct UsersGroup { pub get_users_list: GetUsersListDeps, pub update_profile: UpdateProfileDeps, pub request_account_deletion: RequestAccountDeletionDeps, - pub ensure_active: EnsureActiveDeps, pub get_current_profile: GetCurrentProfileDeps, pub update_profile_fields: UpdateProfileFieldsDeps, pub get_settings: GetSettingsDeps, @@ -163,6 +171,7 @@ pub struct Deps { pub auth: AuthGroup, pub diary: DiaryGroup, pub goals: GoalsGroup, + pub images: ImagesGroup, pub import: ImportGroup, pub insights: InsightsGroup, pub integrations: IntegrationsGroup, diff --git a/crates/application/src/images/deps.rs b/crates/application/src/images/deps.rs new file mode 100644 index 00000000..d0cbde3b --- /dev/null +++ b/crates/application/src/images/deps.rs @@ -0,0 +1,7 @@ +use std::sync::Arc; + +use domain::ports::ObjectStorage; + +pub struct GetImageDeps { + pub object_storage: Arc, +} diff --git a/crates/application/src/images/get_image.rs b/crates/application/src/images/get_image.rs new file mode 100644 index 00000000..d6dcae2b --- /dev/null +++ b/crates/application/src/images/get_image.rs @@ -0,0 +1,7 @@ +use domain::errors::DomainError; + +use crate::images::deps::GetImageDeps; + +pub async fn execute(deps: &GetImageDeps, key: &str) -> Result, DomainError> { + deps.object_storage.get(key).await +} diff --git a/crates/application/src/images/mod.rs b/crates/application/src/images/mod.rs new file mode 100644 index 00000000..703a2b8f --- /dev/null +++ b/crates/application/src/images/mod.rs @@ -0,0 +1,2 @@ +pub mod deps; +pub mod get_image; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index e3eaeca3..95f025c0 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -8,6 +8,7 @@ pub mod worker; pub mod auth; pub mod diary; pub mod goals; +pub mod images; pub mod import; pub mod insights; pub mod integrations; diff --git a/crates/application/src/search/deps.rs b/crates/application/src/search/deps.rs index b150aec5..b77d65ba 100644 --- a/crates/application/src/search/deps.rs +++ b/crates/application/src/search/deps.rs @@ -1,7 +1,11 @@ use std::sync::Arc; -use domain::ports::SearchPort; +use domain::ports::{EventPublisher, SearchPort}; pub struct SearchDeps { pub search_port: Arc, } + +pub struct RequestReindexDeps { + pub event_publisher: Arc, +} diff --git a/crates/application/src/search/mod.rs b/crates/application/src/search/mod.rs index 6017ec0d..23ca8a45 100644 --- a/crates/application/src/search/mod.rs +++ b/crates/application/src/search/mod.rs @@ -1,2 +1,3 @@ pub mod deps; pub mod execute; +pub mod request_reindex; diff --git a/crates/application/src/search/request_reindex.rs b/crates/application/src/search/request_reindex.rs new file mode 100644 index 00000000..835d8106 --- /dev/null +++ b/crates/application/src/search/request_reindex.rs @@ -0,0 +1,9 @@ +use domain::{errors::DomainError, events::DomainEvent}; + +use crate::search::deps::RequestReindexDeps; + +pub async fn execute(deps: &RequestReindexDeps) -> Result<(), DomainError> { + deps.event_publisher + .publish(&DomainEvent::SearchReindexRequested) + .await +} diff --git a/crates/application/src/users/deps.rs b/crates/application/src/users/deps.rs index 7fe09032..9524839f 100644 --- a/crates/application/src/users/deps.rs +++ b/crates/application/src/users/deps.rs @@ -52,10 +52,6 @@ pub struct RequestAccountDeletionDeps { pub password_hasher: Arc, } -pub struct EnsureActiveDeps { - pub user: Arc, -} - pub struct GetCurrentProfileDeps { pub user: Arc, } diff --git a/crates/application/src/users/ensure_active.rs b/crates/application/src/users/ensure_active.rs deleted file mode 100644 index 03411f6c..00000000 --- a/crates/application/src/users/ensure_active.rs +++ /dev/null @@ -1,10 +0,0 @@ -use domain::{errors::DomainError, value_objects::UserId}; - -use crate::users::deps::EnsureActiveDeps; - -pub async fn execute(deps: &EnsureActiveDeps, user: &UserId) -> Result<(), DomainError> { - match deps.user.find_by_id(user).await? { - Some(_) => Ok(()), - None => Err(DomainError::Unauthorized("account is not active".into())), - } -} diff --git a/crates/application/src/users/mod.rs b/crates/application/src/users/mod.rs index 804075eb..d286d062 100644 --- a/crates/application/src/users/mod.rs +++ b/crates/application/src/users/mod.rs @@ -2,7 +2,6 @@ pub mod authorize_admin; pub mod commands; pub mod deps; mod diary_filter; -pub mod ensure_active; pub mod get_current_profile; pub mod get_federated_profile; pub mod get_federated_profile_stats; diff --git a/crates/application/tests/auth.rs b/crates/application/tests/auth.rs index 2d11147f..a482a4a4 100644 --- a/crates/application/tests/auth.rs +++ b/crates/application/tests/auth.rs @@ -1,5 +1,7 @@ mod support; +#[path = "auth/authenticate_test.rs"] +mod authenticate_test; #[path = "auth/login_test.rs"] mod login_test; #[path = "auth/logout_test.rs"] diff --git a/crates/application/tests/users/ensure_active_test.rs b/crates/application/tests/auth/authenticate_test.rs similarity index 60% rename from crates/application/tests/users/ensure_active_test.rs rename to crates/application/tests/auth/authenticate_test.rs index ab8d4a0f..75a55fc5 100644 --- a/crates/application/tests/users/ensure_active_test.rs +++ b/crates/application/tests/auth/authenticate_test.rs @@ -7,15 +7,16 @@ use domain::{ value_objects::{Email, UserId}, }; -use application::{ - auth::{deps::RegisterDeps, register}, - users::{deps::EnsureActiveDeps, ensure_active}, +use application::auth::{ + authenticate, + deps::{AuthenticateDeps, RegisterDeps}, + register, }; use crate::support::{TestContextBuilder, register_command}; #[tokio::test] -async fn a_live_user_is_active() { +async fn a_live_users_token_authenticates() { let store = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _); let reg = RegisterDeps { @@ -33,15 +34,20 @@ async fn a_live_user_is_active() { .unwrap() .id() .clone(); - let deps = EnsureActiveDeps { + let deps = AuthenticateDeps { + auth: b.auth_service.clone(), user: b.user_repo.clone(), }; - ensure_active::execute(&deps, &id).await.unwrap(); + let authenticated = authenticate::execute(&deps, &id.value().to_string()) + .await + .unwrap(); + + assert_eq!(authenticated, id); } #[tokio::test] -async fn a_deleted_user_is_rejected() { +async fn a_deleted_users_token_is_rejected() { let store = InMemoryStore::new(); let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _); let reg = RegisterDeps { @@ -60,23 +66,42 @@ async fn a_deleted_user_is_rejected() { .id() .clone(); store.mark_deleted(&id, &[]).await.unwrap(); - let deps = EnsureActiveDeps { + let deps = AuthenticateDeps { + auth: b.auth_service.clone(), user: b.user_repo.clone(), }; - let err = ensure_active::execute(&deps, &id).await.unwrap_err(); - - assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); -} - -#[tokio::test] -async fn an_unknown_user_is_rejected() { - let b = TestContextBuilder::new(); - let deps = EnsureActiveDeps { - user: b.user_repo.clone(), - }; - - let err = ensure_active::execute(&deps, &UserId::generate()) + let err = authenticate::execute(&deps, &id.value().to_string()) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); +} + +#[tokio::test] +async fn an_unknown_users_token_is_rejected() { + let b = TestContextBuilder::new(); + let deps = AuthenticateDeps { + auth: b.auth_service.clone(), + user: b.user_repo.clone(), + }; + + let err = authenticate::execute(&deps, &UserId::generate().value().to_string()) + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}"); +} + +#[tokio::test] +async fn an_invalid_token_is_rejected() { + let b = TestContextBuilder::new(); + let deps = AuthenticateDeps { + auth: b.auth_service.clone(), + user: b.user_repo.clone(), + }; + + let err = authenticate::execute(&deps, "not-a-token") .await .unwrap_err(); diff --git a/crates/application/tests/images.rs b/crates/application/tests/images.rs new file mode 100644 index 00000000..4db8361f --- /dev/null +++ b/crates/application/tests/images.rs @@ -0,0 +1,2 @@ +#[path = "images/get_image_test.rs"] +mod get_image_test; diff --git a/crates/application/tests/images/get_image_test.rs b/crates/application/tests/images/get_image_test.rs new file mode 100644 index 00000000..0124abf9 --- /dev/null +++ b/crates/application/tests/images/get_image_test.rs @@ -0,0 +1,76 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use domain::{errors::DomainError, ports::ObjectStorage}; + +use application::images::{deps::GetImageDeps, get_image}; + +#[derive(Default)] +struct Storage { + objects: Mutex>>, +} + +#[async_trait] +impl ObjectStorage for Storage { + async fn store(&self, key: &str, image_bytes: &[u8]) -> Result { + self.objects + .lock() + .unwrap() + .insert(key.to_string(), image_bytes.to_vec()); + Ok(key.to_string()) + } + + async fn get(&self, key: &str) -> Result, DomainError> { + self.objects + .lock() + .unwrap() + .get(key) + .cloned() + .ok_or_else(|| DomainError::NotFound(key.to_string())) + } + + async fn get_stream( + &self, + _key: &str, + ) -> Result>, DomainError> + { + unimplemented!() + } + + async fn delete(&self, _key: &str) -> Result<(), DomainError> { + unimplemented!() + } + + async fn exists(&self, key: &str) -> Result { + Ok(self.objects.lock().unwrap().contains_key(key)) + } +} + +#[tokio::test] +async fn returns_the_stored_bytes() { + let storage = Arc::new(Storage::default()); + storage.store("posters/1.webp", b"image").await.unwrap(); + let deps = GetImageDeps { + object_storage: storage, + }; + + let bytes = get_image::execute(&deps, "posters/1.webp").await.unwrap(); + + assert_eq!(bytes, b"image"); +} + +#[tokio::test] +async fn a_missing_key_is_not_found() { + let deps = GetImageDeps { + object_storage: Arc::new(Storage::default()), + }; + + let err = get_image::execute(&deps, "posters/missing.webp") + .await + .unwrap_err(); + + assert!(matches!(err, DomainError::NotFound(_))); +} diff --git a/crates/application/tests/search.rs b/crates/application/tests/search.rs index e97757d0..c84ac5c9 100644 --- a/crates/application/tests/search.rs +++ b/crates/application/tests/search.rs @@ -2,3 +2,6 @@ mod support; #[path = "search/execute_test.rs"] mod execute_test; + +#[path = "search/request_reindex_test.rs"] +mod request_reindex_test; diff --git a/crates/application/tests/search/request_reindex_test.rs b/crates/application/tests/search/request_reindex_test.rs new file mode 100644 index 00000000..f954469a --- /dev/null +++ b/crates/application/tests/search/request_reindex_test.rs @@ -0,0 +1,33 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + testing::{NoopEventPublisher, RefusingEventPublisher}, +}; + +use application::search::{deps::RequestReindexDeps, request_reindex}; + +#[tokio::test] +async fn publishes_a_reindex_request() { + let publisher = NoopEventPublisher::new(); + let deps = RequestReindexDeps { + event_publisher: publisher.clone(), + }; + + request_reindex::execute(&deps).await.unwrap(); + + assert!(matches!( + publisher.published().as_slice(), + [DomainEvent::SearchReindexRequested] + )); +} + +#[tokio::test] +async fn a_publish_failure_is_returned() { + let deps = RequestReindexDeps { + event_publisher: std::sync::Arc::new(RefusingEventPublisher), + }; + + let err = request_reindex::execute(&deps).await.unwrap_err(); + + assert!(matches!(err, DomainError::InfrastructureError(_))); +} diff --git a/crates/application/tests/users.rs b/crates/application/tests/users.rs index dd227b63..eaa3d1f8 100644 --- a/crates/application/tests/users.rs +++ b/crates/application/tests/users.rs @@ -2,8 +2,6 @@ mod support; #[path = "users/authorize_admin_test.rs"] mod authorize_admin_test; -#[path = "users/ensure_active_test.rs"] -mod ensure_active_test; #[path = "users/get_current_profile_test.rs"] mod get_current_profile_test; #[path = "users/get_federated_profile_stats_test.rs"] diff --git a/crates/bootstrap/src/build.rs b/crates/bootstrap/src/build.rs index 2a1a6f69..dff18417 100644 --- a/crates/bootstrap/src/build.rs +++ b/crates/bootstrap/src/build.rs @@ -1,16 +1,17 @@ use application::auth::deps::{ - LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps, + AuthenticateDeps, LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps, }; use application::deps::{ - AuthGroup, Deps, DiaryGroup, GoalsGroup, ImportGroup, InsightsGroup, IntegrationsGroup, - MoviesGroup, PersonGroup, SearchGroup, SocialGroup, UsersGroup, WatchlistGroup, WorkerDeps, - WrapupGroup, + AuthGroup, Deps, DiaryGroup, GoalsGroup, ImagesGroup, ImportGroup, InsightsGroup, + IntegrationsGroup, MoviesGroup, PersonGroup, SearchGroup, SocialGroup, UsersGroup, + WatchlistGroup, WorkerDeps, WrapupGroup, }; use application::diary::deps::{ DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps, GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps, }; use application::goals::deps::{GoalCommandDeps, GoalQueryDeps}; +use application::images::deps::GetImageDeps; use application::import::deps::{ ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps, DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps, @@ -26,10 +27,10 @@ use application::movies::deps::{ }; use application::movies::merge_duplicates::MergeDuplicatesDeps; use application::person::deps::{EnrichPersonDeps, GetPersonDeps}; -use application::search::deps::SearchDeps; +use application::search::deps::{RequestReindexDeps, SearchDeps}; use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; use application::users::deps::{ - AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, + AuthorizeAdminDeps, GetCurrentProfileDeps, GetFederatedProfileDeps, GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps, GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps, UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps, @@ -84,6 +85,10 @@ pub fn build_deps( logout: LogoutDeps { refresh_session: repos.refresh_session.clone(), }, + authenticate: AuthenticateDeps { + auth: services.auth.clone(), + user: repos.user.clone(), + }, }, diary: DiaryGroup { delete_review: DeleteReviewDeps { @@ -129,6 +134,11 @@ pub fn build_deps( stats: repos.stats.clone(), }, }, + images: ImagesGroup { + get_image: GetImageDeps { + object_storage: services.object_storage.clone(), + }, + }, import: ImportGroup { create_session: CreateSessionDeps { import_session: repos.import_session.clone(), @@ -236,6 +246,9 @@ pub fn build_deps( execute: SearchDeps { search_port: repos.search_port.clone(), }, + request_reindex: RequestReindexDeps { + event_publisher: services.event_publisher.clone(), + }, }, social: SocialGroup { command: SocialCommandDeps { @@ -284,9 +297,6 @@ pub fn build_deps( user: repos.user.clone(), password_hasher: services.password_hasher.clone(), }, - ensure_active: EnsureActiveDeps { - user: repos.user.clone(), - }, get_current_profile: GetCurrentProfileDeps { user: repos.user.clone(), }, diff --git a/docs/adr/0016-account-deletion.md b/docs/adr/0016-account-deletion.md index 11088ee9..4516208c 100644 --- a/docs/adr/0016-account-deletion.md +++ b/docs/adr/0016-account-deletion.md @@ -29,7 +29,7 @@ exists), so two admins deleting each other concurrently cannot both succeed; the `count_active_admins` check is only the early, friendly refusal (409). **Immediate effects, before the purge runs.** Login is refused and tokens are rejected -(`ensure_active` in the extractors; the user lookups filter `deleted_at IS NULL`). The user's +(`auth::authenticate` in the extractors; the user lookups filter `deleted_at IS NULL`). The user's webhook tokens stop ingesting at once (`find_by_token_hash` ignores tokens of tombstoned users, so the endpoint answers 401). The profile and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer