feat: account deletion request and active-user check
This commit is contained in:
@@ -9,6 +9,7 @@ pub const NOT_FOUND: &str = "NOT_FOUND";
|
|||||||
pub const INVALID_INPUT: &str = "INVALID_INPUT";
|
pub const INVALID_INPUT: &str = "INVALID_INPUT";
|
||||||
pub const UNAUTHORIZED: &str = "UNAUTHORIZED";
|
pub const UNAUTHORIZED: &str = "UNAUTHORIZED";
|
||||||
pub const FORBIDDEN: &str = "FORBIDDEN";
|
pub const FORBIDDEN: &str = "FORBIDDEN";
|
||||||
|
pub const CONFLICT: &str = "CONFLICT";
|
||||||
pub const RATE_LIMITED: &str = "RATE_LIMITED";
|
pub const RATE_LIMITED: &str = "RATE_LIMITED";
|
||||||
pub const INTERNAL: &str = "INTERNAL";
|
pub const INTERNAL: &str = "INTERNAL";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ pub fn domain_error_status(e: &DomainError) -> StatusCode {
|
|||||||
DomainError::NotFound(_) => StatusCode::NOT_FOUND,
|
DomainError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
DomainError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
|
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
DomainError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
DomainError::InfrastructureError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@ impl IntoResponse for ApiError {
|
|||||||
DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()),
|
DomainError::NotFound(_) => refuse(status, NOT_FOUND, self.0.to_string()),
|
||||||
DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()),
|
DomainError::Unauthorized(_) => refuse(status, UNAUTHORIZED, self.0.to_string()),
|
||||||
DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()),
|
DomainError::Forbidden(_) => refuse(status, FORBIDDEN, self.0.to_string()),
|
||||||
|
DomainError::Conflict(_) => refuse(status, CONFLICT, self.0.to_string()),
|
||||||
DomainError::InfrastructureError(_) => {
|
DomainError::InfrastructureError(_) => {
|
||||||
tracing::error!("Internal error: {:?}", self.0);
|
tracing::error!("Internal error: {:?}", self.0);
|
||||||
refuse(status, INTERNAL, "internal server error")
|
refuse(status, INTERNAL, "internal server error")
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{errors::DomainError, value_objects::UserId};
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
use crate::{errors::ApiError, state::AppState};
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
pub struct AuthenticatedUser(pub UserId);
|
pub struct AuthenticatedUser(pub UserId);
|
||||||
@@ -37,6 +42,7 @@ where
|
|||||||
.auth
|
.auth
|
||||||
.validate_token(token)
|
.validate_token(token)
|
||||||
.await?;
|
.await?;
|
||||||
|
ensure_active(&app_state, &user_id).await?;
|
||||||
Ok(AuthenticatedUser(user_id))
|
Ok(AuthenticatedUser(user_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +81,10 @@ where
|
|||||||
.validate_token(&token)
|
.validate_token(&token)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
let user_id = match user_id {
|
||||||
|
Some(id) if ensure_active(&app_state, &id).await.is_ok() => Some(id),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
Ok(OptionalCookieUser(user_id))
|
Ok(OptionalCookieUser(user_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +107,14 @@ where
|
|||||||
.validate_token(&token)
|
.validate_token(&token)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Redirect::to("/login").into_response())?;
|
.map_err(|_| Redirect::to("/login").into_response())?;
|
||||||
|
ensure_active(&app_state, &user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
DomainError::InfrastructureError(_) => {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
_ => Redirect::to("/login").into_response(),
|
||||||
|
})?;
|
||||||
Ok(RequiredCookieUser(user_id))
|
Ok(RequiredCookieUser(user_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ImageRefCommand for SqliteImageRefAdapter {
|
impl ImageRefCommand for SqliteImageRefAdapter {
|
||||||
|
// Tombstoned users (`deleted_at` set) are intentionally included: their
|
||||||
|
// avatar stays referenced, and so protected from GC, until the purge.
|
||||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
@@ -52,6 +54,8 @@ impl ImageRefCommand for SqliteImageRefAdapter {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ImageRefQuery for SqliteImageRefAdapter {
|
impl ImageRefQuery for SqliteImageRefAdapter {
|
||||||
|
// Tombstoned users are intentionally visible here until the purge removes
|
||||||
|
// the row, so their avatar is not garbage-collected early.
|
||||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
||||||
let rows: Vec<(String,)> = sqlx::query_as(
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
|
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
let stamped =
|
let stamped =
|
||||||
sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
|
sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
|
||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -288,3 +288,40 @@ async fn follow_lists_omit_deleted_local_users() {
|
|||||||
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
|
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follower_lists_omit_deleted_local_users() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let viewer = uuid::Uuid::new_v4();
|
||||||
|
let gone = uuid::Uuid::new_v4();
|
||||||
|
let live = uuid::Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)",
|
||||||
|
)
|
||||||
|
.bind(gone.to_string())
|
||||||
|
.bind(live.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'),
|
||||||
|
(?1, 'https://md.example/users/' || ?3, '', 'accepted'),
|
||||||
|
(?1, 'https://other.example/users/remote', '', 'accepted')",
|
||||||
|
)
|
||||||
|
.bind(viewer.to_string())
|
||||||
|
.bind(gone.to_string())
|
||||||
|
.bind(live.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let actors = FollowQuery::get_followers(&repo(pool), viewer)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(actors.len(), 2, "deleted local follower must not be listed");
|
||||||
|
assert!(!actors.iter().any(
|
||||||
|
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), Do
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if deps.user.find_by_username(&username).await?.is_some() {
|
if deps.user.find_by_username(&username).await?.is_some()
|
||||||
|
|| deps.user.is_username_retired(&username).await?
|
||||||
|
{
|
||||||
return Err(DomainError::ValidationError(
|
return Err(DomainError::ValidationError(
|
||||||
"Username already taken".into(),
|
"Username already taken".into(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ use crate::person::deps::{EnrichPersonDeps, GetPersonDeps};
|
|||||||
use crate::search::deps::SearchDeps;
|
use crate::search::deps::SearchDeps;
|
||||||
use crate::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
use crate::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||||
use crate::users::deps::{
|
use crate::users::deps::{
|
||||||
AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
||||||
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
||||||
GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps,
|
GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps,
|
||||||
UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
||||||
};
|
};
|
||||||
use crate::watchlist::deps::{
|
use crate::watchlist::deps::{
|
||||||
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
|
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
|
||||||
@@ -124,8 +124,8 @@ pub struct UsersGroup {
|
|||||||
pub get_profile_settings: GetProfileSettingsDeps,
|
pub get_profile_settings: GetProfileSettingsDeps,
|
||||||
pub get_users_list: GetUsersListDeps,
|
pub get_users_list: GetUsersListDeps,
|
||||||
pub update_profile: UpdateProfileDeps,
|
pub update_profile: UpdateProfileDeps,
|
||||||
/// Not reachable from the server binary; see the `Deps`-level note above. Pre-existing dead use case: `users::delete_account::execute` has zero callers anywhere in the workspace, worker included.
|
pub request_account_deletion: RequestAccountDeletionDeps,
|
||||||
pub delete_account: DeleteAccountDeps,
|
pub ensure_active: EnsureActiveDeps,
|
||||||
pub get_current_profile: GetCurrentProfileDeps,
|
pub get_current_profile: GetCurrentProfileDeps,
|
||||||
pub update_profile_fields: UpdateProfileFieldsDeps,
|
pub update_profile_fields: UpdateProfileFieldsDeps,
|
||||||
pub get_settings: GetSettingsDeps,
|
pub get_settings: GetSettingsDeps,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
|
||||||
|
|
||||||
use crate::users::deps::DeleteAccountDeps;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &DeleteAccountDeps, user_id: uuid::Uuid) -> Result<(), DomainError> {
|
|
||||||
let uid = UserId::from_uuid(user_id);
|
|
||||||
|
|
||||||
deps.user
|
|
||||||
.find_by_id(&uid)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
|
||||||
|
|
||||||
// Notify federation peers before any data is removed so they can process the tombstone.
|
|
||||||
deps.event_publisher
|
|
||||||
.publish(&DomainEvent::UserDeleted { user_id: uid })
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DiaryQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery,
|
DiaryQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery,
|
||||||
ObjectStorage, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
ObjectStorage, PasswordHasher, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||||
UserSettingsRepository,
|
UserSettingsRepository,
|
||||||
};
|
};
|
||||||
use domain::value_objects::InstanceIdentity;
|
use domain::value_objects::InstanceIdentity;
|
||||||
@@ -47,9 +47,13 @@ pub struct UpdateProfileDeps {
|
|||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DeleteAccountDeps {
|
pub struct RequestAccountDeletionDeps {
|
||||||
|
pub user: Arc<dyn UserRepository>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EnsureActiveDeps {
|
||||||
pub user: Arc<dyn UserRepository>,
|
pub user: Arc<dyn UserRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetCurrentProfileDeps {
|
pub struct GetCurrentProfileDeps {
|
||||||
|
|||||||
12
crates/application/src/users/ensure_active.rs
Normal file
12
crates/application/src/users/ensure_active.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
|
|
||||||
|
use crate::users::deps::EnsureActiveDeps;
|
||||||
|
|
||||||
|
/// A token outlives its account; this is the check that stops a deleted
|
||||||
|
/// (or vanished) user from acting on one.
|
||||||
|
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())),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
pub mod authorize_admin;
|
pub mod authorize_admin;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod delete_account;
|
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
mod diary_filter;
|
mod diary_filter;
|
||||||
|
pub mod ensure_active;
|
||||||
pub mod get_current_profile;
|
pub mod get_current_profile;
|
||||||
pub mod get_federated_profile;
|
pub mod get_federated_profile;
|
||||||
pub mod get_federated_profile_stats;
|
pub mod get_federated_profile_stats;
|
||||||
@@ -12,6 +12,7 @@ pub mod get_profile_settings;
|
|||||||
pub mod get_settings;
|
pub mod get_settings;
|
||||||
pub mod get_users;
|
pub mod get_users;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod request_account_deletion;
|
||||||
pub mod resolve_username_to_id;
|
pub mod resolve_username_to_id;
|
||||||
pub mod update_profile;
|
pub mod update_profile;
|
||||||
pub mod update_profile_fields;
|
pub mod update_profile_fields;
|
||||||
|
|||||||
60
crates/application/src/users/request_account_deletion.rs
Normal file
60
crates/application/src/users/request_account_deletion.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use domain::{errors::DomainError, models::UserRole, value_objects::UserId};
|
||||||
|
|
||||||
|
use crate::users::deps::RequestAccountDeletionDeps;
|
||||||
|
|
||||||
|
/// Tombstones `target`. A user may delete themselves; an admin may delete
|
||||||
|
/// anyone. Either way the *actor's* password is the proof, and the last
|
||||||
|
/// active admin can never be removed. `UserDeleted` rides the repository
|
||||||
|
/// write, so the tombstone and its event commit together.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &RequestAccountDeletionDeps,
|
||||||
|
actor: UserId,
|
||||||
|
target: UserId,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let actor_user = deps
|
||||||
|
.user
|
||||||
|
.find_by_id(&actor)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::Unauthorized("account is not active".into()))?;
|
||||||
|
|
||||||
|
let is_self = actor == target;
|
||||||
|
if !is_self && !matches!(actor_user.role(), UserRole::Admin) {
|
||||||
|
return Err(DomainError::Forbidden(
|
||||||
|
"only an admin can delete another account".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !deps
|
||||||
|
.password_hasher
|
||||||
|
.verify(password, actor_user.password_hash())
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(DomainError::Unauthorized("incorrect password".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_user = if is_self {
|
||||||
|
actor_user
|
||||||
|
} else {
|
||||||
|
deps.user
|
||||||
|
.find_by_id(&target)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("User not found".into()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(target_user.role(), UserRole::Admin) && deps.user.count_active_admins().await? <= 1
|
||||||
|
{
|
||||||
|
return Err(DomainError::Conflict(
|
||||||
|
"the last active admin cannot be deleted".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.user
|
||||||
|
.mark_deleted(
|
||||||
|
&target,
|
||||||
|
&[domain::events::DomainEvent::UserDeleted {
|
||||||
|
user_id: target.clone(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -71,3 +71,34 @@ async fn registering_with_a_short_password_fails() {
|
|||||||
"expected password length error, got: {err}"
|
"expected password length error, got: {err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registration_refuses_a_retired_username() {
|
||||||
|
let users = InMemoryStore::new();
|
||||||
|
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||||
|
let deps = RegisterDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
password_hasher: b.password_hasher.clone(),
|
||||||
|
config: b.config.clone(),
|
||||||
|
};
|
||||||
|
register::execute(&deps, register_command("old@example.com", "ghost"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let old = users
|
||||||
|
.find_by_email(&Email::new("old@example.com".into()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
users.mark_deleted(old.id(), &[]).await.unwrap();
|
||||||
|
// The purge removes the row; only the retired name keeps the username taken.
|
||||||
|
users.delete_user(old.id());
|
||||||
|
|
||||||
|
let err = register::execute(&deps, register_command("new@example.com", "Ghost"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(&err, domain::errors::DomainError::ValidationError(m) if m == "Username already taken"),
|
||||||
|
"got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ mod support;
|
|||||||
|
|
||||||
#[path = "users/authorize_admin_test.rs"]
|
#[path = "users/authorize_admin_test.rs"]
|
||||||
mod authorize_admin_test;
|
mod authorize_admin_test;
|
||||||
#[path = "users/delete_account_test.rs"]
|
#[path = "users/ensure_active_test.rs"]
|
||||||
mod delete_account_test;
|
mod ensure_active_test;
|
||||||
#[path = "users/get_current_profile_test.rs"]
|
#[path = "users/get_current_profile_test.rs"]
|
||||||
mod get_current_profile_test;
|
mod get_current_profile_test;
|
||||||
#[path = "users/get_federated_profile_stats_test.rs"]
|
#[path = "users/get_federated_profile_stats_test.rs"]
|
||||||
@@ -20,6 +20,8 @@ mod get_profile_settings_test;
|
|||||||
mod get_settings_test;
|
mod get_settings_test;
|
||||||
#[path = "users/get_users_test.rs"]
|
#[path = "users/get_users_test.rs"]
|
||||||
mod get_users_test;
|
mod get_users_test;
|
||||||
|
#[path = "users/request_account_deletion_test.rs"]
|
||||||
|
mod request_account_deletion_test;
|
||||||
#[path = "users/resolve_username_to_id_test.rs"]
|
#[path = "users/resolve_username_to_id_test.rs"]
|
||||||
mod resolve_username_to_id_test;
|
mod resolve_username_to_id_test;
|
||||||
#[path = "users/update_profile_fields_test.rs"]
|
#[path = "users/update_profile_fields_test.rs"]
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
use crate::support::{TestContextBuilder, register_command};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
events::DomainEvent,
|
|
||||||
ports::UserRepository,
|
|
||||||
testing::{InMemoryStore, NoopEventPublisher, RefusingEventPublisher},
|
|
||||||
value_objects::{Email, UserId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use application::{
|
|
||||||
auth::{deps::RegisterDeps, register},
|
|
||||||
users::{delete_account, deps::DeleteAccountDeps},
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryStore>) -> Uuid {
|
|
||||||
let reg_deps = RegisterDeps {
|
|
||||||
user: b.user_repo.clone(),
|
|
||||||
password_hasher: b.password_hasher.clone(),
|
|
||||||
config: b.config.clone(),
|
|
||||||
};
|
|
||||||
register::execute(®_deps, register_command("alice@example.com", "alice"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
users
|
|
||||||
.find_by_email(&Email::new("alice@example.com".into()).unwrap())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.id()
|
|
||||||
.value()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_an_unknown_user_is_not_found() {
|
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let b = TestContextBuilder::new();
|
|
||||||
let deps = DeleteAccountDeps {
|
|
||||||
user: b.user_repo.clone(),
|
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
|
||||||
|
|
||||||
let err = delete_account::execute(&deps, Uuid::new_v4())
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(err, DomainError::NotFound(_)));
|
|
||||||
assert!(events.published().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_an_existing_user_publishes_user_deleted() {
|
|
||||||
let users = InMemoryStore::new();
|
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
|
||||||
let deps = DeleteAccountDeps {
|
|
||||||
user: b.user_repo.clone(),
|
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
|
||||||
let uid = register_user(&b, &users).await;
|
|
||||||
|
|
||||||
delete_account::execute(&deps, uid).await.unwrap();
|
|
||||||
|
|
||||||
let published = events.published();
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
published.as_slice(),
|
|
||||||
[DomainEvent::UserDeleted { user_id }] if *user_id == UserId::from_uuid(uid)
|
|
||||||
),
|
|
||||||
"published: {published:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_fails_when_the_publisher_refuses() {
|
|
||||||
let users = InMemoryStore::new();
|
|
||||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
|
||||||
let deps = DeleteAccountDeps {
|
|
||||||
user: b.user_repo.clone(),
|
|
||||||
event_publisher: Arc::new(RefusingEventPublisher),
|
|
||||||
};
|
|
||||||
let uid = register_user(&b, &users).await;
|
|
||||||
|
|
||||||
assert!(delete_account::execute(&deps, uid).await.is_err());
|
|
||||||
}
|
|
||||||
84
crates/application/tests/users/ensure_active_test.rs
Normal file
84
crates/application/tests/users/ensure_active_test.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::UserRepository,
|
||||||
|
testing::InMemoryStore,
|
||||||
|
value_objects::{Email, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
auth::{deps::RegisterDeps, register},
|
||||||
|
users::{deps::EnsureActiveDeps, ensure_active},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::support::{TestContextBuilder, register_command};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_live_user_is_active() {
|
||||||
|
let store = InMemoryStore::new();
|
||||||
|
let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _);
|
||||||
|
let reg = RegisterDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
password_hasher: b.password_hasher.clone(),
|
||||||
|
config: b.config.clone(),
|
||||||
|
};
|
||||||
|
register::execute(®, register_command("alice@example.com", "alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let id = store
|
||||||
|
.find_by_email(&Email::new("alice@example.com".into()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id()
|
||||||
|
.clone();
|
||||||
|
let deps = EnsureActiveDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ensure_active::execute(&deps, &id).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_deleted_user_is_rejected() {
|
||||||
|
let store = InMemoryStore::new();
|
||||||
|
let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _);
|
||||||
|
let reg = RegisterDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
password_hasher: b.password_hasher.clone(),
|
||||||
|
config: b.config.clone(),
|
||||||
|
};
|
||||||
|
register::execute(®, register_command("alice@example.com", "alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let id = store
|
||||||
|
.find_by_email(&Email::new("alice@example.com".into()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id()
|
||||||
|
.clone();
|
||||||
|
store.mark_deleted(&id, &[]).await.unwrap();
|
||||||
|
let deps = EnsureActiveDeps {
|
||||||
|
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())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||||
|
}
|
||||||
185
crates/application/tests/users/request_account_deletion_test.rs
Normal file
185
crates/application/tests/users/request_account_deletion_test.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::UserRole,
|
||||||
|
ports::UserRepository,
|
||||||
|
testing::InMemoryStore,
|
||||||
|
value_objects::{Email, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
auth::{deps::RegisterDeps, register},
|
||||||
|
users::{deps::RequestAccountDeletionDeps, request_account_deletion},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::support::{TestContextBuilder, register_command};
|
||||||
|
|
||||||
|
const PASSWORD: &str = "password123";
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
store: Arc<InMemoryStore>,
|
||||||
|
deps: RequestAccountDeletionDeps,
|
||||||
|
register: RegisterDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let store = InMemoryStore::new();
|
||||||
|
let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _);
|
||||||
|
Fixture {
|
||||||
|
deps: RequestAccountDeletionDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
password_hasher: b.password_hasher.clone(),
|
||||||
|
},
|
||||||
|
register: RegisterDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
password_hasher: b.password_hasher.clone(),
|
||||||
|
config: b.config.clone(),
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
async fn user(&self, name: &str, role: UserRole) -> UserId {
|
||||||
|
let email = format!("{name}@example.com");
|
||||||
|
let mut cmd = register_command(email.clone(), name);
|
||||||
|
cmd.role = role;
|
||||||
|
register::execute(&self.register, cmd).await.unwrap();
|
||||||
|
self.store
|
||||||
|
.find_by_email(&Email::new(email).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_live(&self, id: &UserId) -> bool {
|
||||||
|
self.store.find_by_id(id).await.unwrap().is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_user_deletes_their_own_account_with_the_right_password() {
|
||||||
|
let f = fixture();
|
||||||
|
let alice = f.user("alice", UserRole::Standard).await;
|
||||||
|
|
||||||
|
request_account_deletion::execute(&f.deps, alice.clone(), alice.clone(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!f.is_live(&alice).await);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
f.store.recorded_events().as_slice(),
|
||||||
|
[DomainEvent::UserDeleted { user_id }] if *user_id == alice
|
||||||
|
),
|
||||||
|
"events: {:?}",
|
||||||
|
f.store.recorded_events()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_wrong_password_is_unauthorized_and_changes_nothing() {
|
||||||
|
let f = fixture();
|
||||||
|
let alice = f.user("alice", UserRole::Standard).await;
|
||||||
|
|
||||||
|
let err = request_account_deletion::execute(&f.deps, alice.clone(), alice.clone(), "nope")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||||
|
assert!(f.is_live(&alice).await);
|
||||||
|
assert!(f.store.recorded_events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_non_admin_cannot_delete_someone_else() {
|
||||||
|
let f = fixture();
|
||||||
|
let alice = f.user("alice", UserRole::Standard).await;
|
||||||
|
let bob = f.user("bob", UserRole::Standard).await;
|
||||||
|
|
||||||
|
let err = request_account_deletion::execute(&f.deps, alice, bob.clone(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}");
|
||||||
|
assert!(f.is_live(&bob).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_admin_deletes_another_user_with_their_own_password() {
|
||||||
|
let f = fixture();
|
||||||
|
let admin = f.user("root", UserRole::Admin).await;
|
||||||
|
let bob = f.user("bob", UserRole::Standard).await;
|
||||||
|
|
||||||
|
let err = request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), "bobs-pw")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||||
|
assert!(f.is_live(&bob).await);
|
||||||
|
|
||||||
|
request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!f.is_live(&bob).await);
|
||||||
|
assert!(f.is_live(&admin).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_last_active_admin_cannot_delete_themselves() {
|
||||||
|
let f = fixture();
|
||||||
|
let admin = f.user("root", UserRole::Admin).await;
|
||||||
|
|
||||||
|
let err = request_account_deletion::execute(&f.deps, admin.clone(), admin.clone(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, DomainError::Conflict(_)), "got {err:?}");
|
||||||
|
assert!(f.is_live(&admin).await);
|
||||||
|
assert!(f.store.recorded_events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_admin_can_delete_themselves_when_another_admin_remains() {
|
||||||
|
let f = fixture();
|
||||||
|
let first = f.user("root", UserRole::Admin).await;
|
||||||
|
let _second = f.user("root2", UserRole::Admin).await;
|
||||||
|
|
||||||
|
request_account_deletion::execute(&f.deps, first.clone(), first.clone(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!f.is_live(&first).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_target_is_not_found() {
|
||||||
|
let f = fixture();
|
||||||
|
let admin = f.user("root", UserRole::Admin).await;
|
||||||
|
|
||||||
|
let err = request_account_deletion::execute(&f.deps, admin, UserId::generate(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_already_deleted_target_is_not_found() {
|
||||||
|
let f = fixture();
|
||||||
|
let admin = f.user("root", UserRole::Admin).await;
|
||||||
|
let bob = f.user("bob", UserRole::Standard).await;
|
||||||
|
request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = request_account_deletion::execute(&f.deps, admin, bob, PASSWORD)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
|
||||||
|
}
|
||||||
@@ -29,10 +29,10 @@ use application::person::deps::{EnrichPersonDeps, GetPersonDeps};
|
|||||||
use application::search::deps::SearchDeps;
|
use application::search::deps::SearchDeps;
|
||||||
use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||||
use application::users::deps::{
|
use application::users::deps::{
|
||||||
AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
||||||
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
||||||
GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps,
|
GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps,
|
||||||
UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
||||||
};
|
};
|
||||||
use application::watchlist::deps::{
|
use application::watchlist::deps::{
|
||||||
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
|
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
|
||||||
@@ -280,9 +280,12 @@ pub fn build_deps(
|
|||||||
object_storage: services.object_storage.clone(),
|
object_storage: services.object_storage.clone(),
|
||||||
event_publisher: services.event_publisher.clone(),
|
event_publisher: services.event_publisher.clone(),
|
||||||
},
|
},
|
||||||
delete_account: DeleteAccountDeps {
|
request_account_deletion: RequestAccountDeletionDeps {
|
||||||
|
user: repos.user.clone(),
|
||||||
|
password_hasher: services.password_hasher.clone(),
|
||||||
|
},
|
||||||
|
ensure_active: EnsureActiveDeps {
|
||||||
user: repos.user.clone(),
|
user: repos.user.clone(),
|
||||||
event_publisher: services.event_publisher.clone(),
|
|
||||||
},
|
},
|
||||||
get_current_profile: GetCurrentProfileDeps {
|
get_current_profile: GetCurrentProfileDeps {
|
||||||
user: repos.user.clone(),
|
user: repos.user.clone(),
|
||||||
|
|||||||
@@ -215,3 +215,41 @@ async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
|
|||||||
assert_eq!(blocked, 1);
|
assert_eq!(blocked, 1);
|
||||||
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
|
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A token outlives the account that owns it, so every authenticated route
|
||||||
|
/// must re-check the account: a deleted user gets exactly what an anonymous
|
||||||
|
/// caller gets.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_deleted_users_token_is_rejected() {
|
||||||
|
let (router, pool) = support::server().await;
|
||||||
|
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||||
|
let bearer = |token: &str| {
|
||||||
|
axum::http::Request::builder()
|
||||||
|
.uri("/api/v1/diary/export?format=json")
|
||||||
|
.header("authorization", format!("Bearer {token}"))
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
|
||||||
|
assert_eq!(status, 200, "token works while the account is live: {body}");
|
||||||
|
let (status, _, _) =
|
||||||
|
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
|
||||||
|
assert_eq!(status, 200, "cookie works while the account is live");
|
||||||
|
|
||||||
|
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
|
||||||
|
.bind(&alice.user_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
|
||||||
|
assert_eq!(status, 401, "json route: {body}");
|
||||||
|
let (anon_status, anon_headers, _) =
|
||||||
|
support::send(&router, support::get("/diary/export", None)).await;
|
||||||
|
let (status, headers, _) =
|
||||||
|
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
|
||||||
|
assert_eq!(status, anon_status, "classic owner page");
|
||||||
|
assert_eq!(headers.get("location"), anon_headers.get("location"));
|
||||||
|
assert_eq!(headers.get("location").unwrap(), "/login");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph};
|
use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_repos};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -6,6 +6,8 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::UserRepository;
|
||||||
|
use domain::testing::InMemoryStore;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -257,8 +259,19 @@ impl domain::ports::FollowGraphQuery for FixedPendingCount {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pending_count_endpoint_reports_the_use_case_result() {
|
async fn pending_count_endpoint_reports_the_use_case_result() {
|
||||||
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
|
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
|
||||||
let state = test_app_state_with_follow_graph(
|
let users = InMemoryStore::new();
|
||||||
Arc::new(FixedPendingCount(3)),
|
users
|
||||||
|
.save(&domain::testing::test_user_with_role(
|
||||||
|
uid.clone(),
|
||||||
|
domain::models::UserRole::Standard,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let state = test_app_state_with_repos(
|
||||||
|
|repos| {
|
||||||
|
repos.follow_graph = Arc::new(FixedPendingCount(3));
|
||||||
|
repos.user = users as _;
|
||||||
|
},
|
||||||
Arc::new(AcceptingAuth(uid)),
|
Arc::new(AcceptingAuth(uid)),
|
||||||
);
|
);
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|||||||
@@ -246,7 +246,8 @@ async fn admin_api_user_rejects_an_unknown_user() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
// An account the store does not hold is inactive, so it is unauthenticated.
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -337,7 +338,9 @@ async fn admin_user_rejects_an_unknown_user() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
// An account the store does not hold is inactive: back to the login page.
|
||||||
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use bootstrap::Repositories;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::GeneratedToken,
|
models::GeneratedToken,
|
||||||
ports::{AuthService, FollowGraphQuery, PersonQuery, SearchPort, UserRepository},
|
ports::{AuthService, PersonQuery, SearchPort, UserRepository},
|
||||||
testing::{
|
testing::{
|
||||||
PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser,
|
PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser,
|
||||||
PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository,
|
PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository,
|
||||||
@@ -168,15 +168,15 @@ pub fn test_app_state_with_search_port(
|
|||||||
build_test_state_from(repos, Arc::new(PanicAuthService))
|
build_test_state_from(repos, Arc::new(PanicAuthService))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the
|
/// Test state whose repositories are adjusted by `adjust` BEFORE `build_deps`
|
||||||
/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt
|
/// runs, for tests that need more than one stub swapped in (e.g. a follow graph
|
||||||
/// `deps.social.query` that the social handlers actually read from.
|
/// plus the live user that authenticated routes now look up).
|
||||||
pub fn test_app_state_with_follow_graph(
|
pub fn test_app_state_with_repos(
|
||||||
follow_graph: Arc<dyn FollowGraphQuery>,
|
adjust: impl FnOnce(&mut Repositories),
|
||||||
auth_service: Arc<dyn AuthService>,
|
auth_service: Arc<dyn AuthService>,
|
||||||
) -> http_axum::state::AppState {
|
) -> http_axum::state::AppState {
|
||||||
let mut repos = test_repositories();
|
let mut repos = test_repositories();
|
||||||
repos.follow_graph = follow_graph;
|
adjust(&mut repos);
|
||||||
build_test_state_from(repos, auth_service)
|
build_test_state_from(repos, auth_service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub enum DomainError {
|
|||||||
|
|
||||||
#[error("Forbidden: {0}")]
|
#[error("Forbidden: {0}")]
|
||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DomainError {
|
impl DomainError {
|
||||||
|
|||||||
@@ -78,13 +78,18 @@ impl UserRepository for InMemoryStore {
|
|||||||
events: &[crate::events::DomainEvent],
|
events: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id = user_id.value();
|
let id = user_id.value();
|
||||||
if let Some(user) = self.users.read().unwrap().get(&id) {
|
let users = self.users.read().unwrap();
|
||||||
self.retired_usernames
|
let mut gone = self.tombstoned.write().unwrap();
|
||||||
.write()
|
let Some(user) = users.get(&id).filter(|_| !gone.contains(&id)) else {
|
||||||
.unwrap()
|
return Err(DomainError::NotFound("User not found".into()));
|
||||||
.insert(user.username().value().to_lowercase());
|
};
|
||||||
self.tombstoned.write().unwrap().insert(id);
|
self.retired_usernames
|
||||||
}
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.insert(user.username().value().to_lowercase());
|
||||||
|
gone.insert(id);
|
||||||
|
drop(gone);
|
||||||
|
drop(users);
|
||||||
self.refresh_sessions
|
self.refresh_sessions
|
||||||
.write()
|
.write()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -628,3 +628,33 @@ async fn fake_mark_deleted_mirrors_the_sqlite_tombstone() {
|
|||||||
assert_eq!(store.refresh_session_count(), 0);
|
assert_eq!(store.refresh_session_count(), 0);
|
||||||
assert_eq!(store.recorded_events().len(), 1);
|
assert_eq!(store.recorded_events().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fake_mark_deleted_refuses_unknown_and_already_deleted_users() {
|
||||||
|
let store = InMemoryStore::new();
|
||||||
|
let user = test_user("ghost");
|
||||||
|
UserRepository::save(&*store, &user).await.unwrap();
|
||||||
|
let event = domain::events::DomainEvent::UserDeleted {
|
||||||
|
user_id: user.id().clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let unknown = store
|
||||||
|
.mark_deleted(&UserId::generate(), std::slice::from_ref(&event))
|
||||||
|
.await;
|
||||||
|
assert!(matches!(unknown, Err(DomainError::NotFound(_))));
|
||||||
|
assert!(store.recorded_events().is_empty());
|
||||||
|
|
||||||
|
store
|
||||||
|
.mark_deleted(user.id(), std::slice::from_ref(&event))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let again = store
|
||||||
|
.mark_deleted(user.id(), std::slice::from_ref(&event))
|
||||||
|
.await;
|
||||||
|
assert!(matches!(again, Err(DomainError::NotFound(_))));
|
||||||
|
assert_eq!(
|
||||||
|
store.recorded_events().len(),
|
||||||
|
1,
|
||||||
|
"second call records nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ and each then builds only the deps it can honestly use from it.
|
|||||||
with no server-reachable consumer — `enrich_movie`, `reindex_search`, `merge_duplicates`,
|
with no server-reachable consumer — `enrich_movie`, `reindex_search`, `merge_duplicates`,
|
||||||
`enrich_person`, `handle_requested` — moved into a new `application::WorkerDeps`, built by
|
`enrich_person`, `handle_requested` — moved into a new `application::WorkerDeps`, built by
|
||||||
`composition::build_worker_deps` and consumed only by `crates/worker`. Every field of `Deps` is
|
`composition::build_worker_deps` and consumed only by `crates/worker`. Every field of `Deps` is
|
||||||
now reachable from `presentation` except `users.delete_account` — see the dead-code exception
|
now reachable from `presentation` and every field of `WorkerDeps` is reachable from `worker`. No field needs an
|
||||||
recorded below — and every field of `WorkerDeps` is reachable from `worker`. No field needs an
|
|
||||||
`Option` or a Noop port to express "not for this binary" — which container it lives in already
|
`Option` or a Noop port to express "not for this binary" — which container it lives in already
|
||||||
says that.
|
says that.
|
||||||
- `application::WorkerServices` is the analogous split on the services side: the strict subset of
|
- `application::WorkerServices` is the analogous split on the services side: the strict subset of
|
||||||
@@ -42,11 +41,6 @@ and each then builds only the deps it can honestly use from it.
|
|||||||
sixth, `federated_profile`, is already `Option` in `Repositories` for unrelated reasons) have no
|
sixth, `federated_profile`, is already `Option` in `Repositories` for unrelated reasons) have no
|
||||||
worker-side adapter. `DatabaseOutput` only carries fields both binaries can genuinely fill, so
|
worker-side adapter. `DatabaseOutput` only carries fields both binaries can genuinely fill, so
|
||||||
passing it instead of `Repositories` needed no new `Option` and no fake adapter.
|
passing it instead of `Repositories` needed no new `Option` and no fake adapter.
|
||||||
- Exactly one field is unreachable from its own binary, and it is called out by name rather than
|
|
||||||
folded into a group comment: `Deps.users.delete_account` (`DeleteAccountDeps`) has zero callers
|
|
||||||
anywhere in the workspace — a pre-existing dead use case, not a consequence of this split. Every
|
|
||||||
other field that used to be described as "worker-only" or "not reachable" is now reachable from
|
|
||||||
the binary that reaches it, because it lives in that binary's own container.
|
|
||||||
- Both binaries were booted under the default feature set (`sqlite`, `sqlite-federation`) to prove
|
- Both binaries were booted under the default feature set (`sqlite`, `sqlite-federation`) to prove
|
||||||
this: the worker reached its steady polling state without panicking, and the server served both
|
this: the worker reached its steady polling state without panicking, and the server served both
|
||||||
a Bearer-authenticated API call and a cookie-authenticated HTML page. A container wired to the
|
a Bearer-authenticated API call and a cookie-authenticated HTML page. A container wired to the
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ rate limiter's own 429 text. The refusals were documented without a body schema.
|
|||||||
|
|
||||||
**One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the
|
**One body.** `api_types::ErrorResponse { error, code }`. `error` keeps its name and meaning (the
|
||||||
SPA and existing clients read it); `code` is an additive, stable machine-readable field:
|
SPA and existing clients read it); `code` is an additive, stable machine-readable field:
|
||||||
`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `INTERNAL`.
|
`NOT_FOUND`, `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `RATE_LIMITED`, `INTERNAL`.
|
||||||
|
|
||||||
**One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON
|
**One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON
|
||||||
error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that
|
error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that
|
||||||
|
|||||||
Reference in New Issue
Block a user