mark_deleted: atomic last-admin guard; review minors

This commit is contained in:
2026-10-26 02:16:35 +01:00
parent 0b5d18c4ee
commit 9a03de8956
5 changed files with 212 additions and 15 deletions

View File

@@ -16,6 +16,54 @@ use application::{
use crate::support::{TestContextBuilder, register_command};
use async_trait::async_trait;
use domain::models::{User, UserSummary};
use domain::ports::UserRepository as UserRepositoryPort;
use domain::value_objects::Username;
/// Delegates to a store but reports a stale admin count, as a concurrent
/// deletion would leave the use case's pre-check.
struct StaleAdminCount(Arc<InMemoryStore>);
#[async_trait]
impl UserRepositoryPort for StaleAdminCount {
async fn find_by_email(&self, e: &Email) -> Result<Option<User>, DomainError> {
self.0.find_by_email(e).await
}
async fn find_by_username(&self, u: &Username) -> Result<Option<User>, DomainError> {
self.0.find_by_username(u).await
}
async fn save(&self, u: &User) -> Result<(), DomainError> {
UserRepositoryPort::save(&*self.0, u).await
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
self.0.list_with_stats().await
}
async fn update_profile(
&self,
id: &UserId,
p: &domain::models::UserProfile,
ev: &[DomainEvent],
) -> Result<(), DomainError> {
self.0.update_profile(id, p, ev).await
}
async fn mark_deleted(&self, id: &UserId, ev: &[DomainEvent]) -> Result<(), DomainError> {
self.0.mark_deleted(id, ev).await
}
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_tombstoned_by_id(id).await
}
async fn is_username_retired(&self, u: &Username) -> Result<bool, DomainError> {
self.0.is_username_retired(u).await
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
Ok(2)
}
}
const PASSWORD: &str = "password123";
struct Fixture {
@@ -183,3 +231,53 @@ async fn an_already_deleted_target_is_not_found() {
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn a_conflict_from_the_repository_is_propagated() {
let f = fixture();
let admin = f.user("root", UserRole::Admin).await;
let deps = RequestAccountDeletionDeps {
user: Arc::new(StaleAdminCount(Arc::clone(&f.store))),
password_hasher: f.deps.password_hasher.clone(),
};
let err = request_account_deletion::execute(&deps, admin.clone(), admin.clone(), PASSWORD)
.await
.unwrap_err();
assert!(matches!(err, DomainError::Conflict(_)), "got {err:?}");
assert!(f.is_live(&admin).await);
}
#[tokio::test]
async fn an_unknown_or_deleted_actor_is_unauthorized() {
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, UserId::generate(), bob.clone(), PASSWORD)
.await
.unwrap_err();
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD)
.await
.unwrap();
let err = request_account_deletion::execute(&f.deps, bob.clone(), bob, PASSWORD)
.await
.unwrap_err();
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
}
#[tokio::test]
async fn a_non_admin_gets_forbidden_before_any_password_check() {
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, "wrong")
.await
.unwrap_err();
assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}");
}