feat: account deletion request and active-user check

This commit is contained in:
2026-10-26 02:11:44 +01:00
parent 83bb5d9847
commit 0b5d18c4ee
27 changed files with 577 additions and 151 deletions

View File

@@ -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(&reg_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());
}

View 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(&reg, 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(&reg, 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:?}");
}

View 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:?}");
}