feat: user tombstone and retired usernames
This commit is contained in:
@@ -14,6 +14,8 @@ pub trait AuthService: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
// The finders and `list_with_stats` serve live users only: tombstoned
|
||||
// (deleted) accounts are invisible to them.
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||
@@ -25,6 +27,19 @@ pub trait UserRepository: Send + Sync {
|
||||
profile: &crate::models::UserProfile,
|
||||
events: &[crate::events::DomainEvent],
|
||||
) -> Result<(), DomainError>;
|
||||
/// Tombstones the account in one transaction: stamps `deleted_at`,
|
||||
/// retires the username, drops the user's refresh sessions and records
|
||||
/// `events`. Afterwards every reader below behaves as if the user is gone.
|
||||
async fn mark_deleted(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
events: &[crate::events::DomainEvent],
|
||||
) -> Result<(), DomainError>;
|
||||
/// The tombstoned user, for the purge. Live users are not returned.
|
||||
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||
/// Whether the name belonged to a deleted account (case-insensitive).
|
||||
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError>;
|
||||
async fn count_active_admins(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -668,6 +668,24 @@ impl UserRepository for PanicUserRepository {
|
||||
) -> Result<(), DomainError> {
|
||||
panic!("PanicUserRepository called")
|
||||
}
|
||||
async fn mark_deleted(&self, _: &UserId, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
panic!("PanicUserRepository called")
|
||||
}
|
||||
async fn find_tombstoned_by_id(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Option<crate::models::User>, DomainError> {
|
||||
panic!("PanicUserRepository called")
|
||||
}
|
||||
async fn is_username_retired(
|
||||
&self,
|
||||
_: &crate::value_objects::Username,
|
||||
) -> Result<bool, DomainError> {
|
||||
panic!("PanicUserRepository called")
|
||||
}
|
||||
async fn count_active_admins(&self) -> Result<u64, DomainError> {
|
||||
panic!("PanicUserRepository called")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicEventPublisher;
|
||||
|
||||
@@ -59,6 +59,10 @@ pub struct InMemoryStore {
|
||||
pub(super) movie_profiles: RwLock<HashMap<Uuid, MovieProfile>>,
|
||||
pub(super) reviews: RwLock<HashMap<Uuid, Review>>,
|
||||
pub(super) users: RwLock<HashMap<Uuid, User>>,
|
||||
/// Ids of `users` rows carrying a `deleted_at` tombstone.
|
||||
pub(super) tombstoned: RwLock<std::collections::HashSet<Uuid>>,
|
||||
/// Lower-cased `retired_usernames` (the column is NOCASE).
|
||||
pub(super) retired_usernames: RwLock<std::collections::HashSet<String>>,
|
||||
pub(super) profile_fields: RwLock<HashMap<Uuid, Vec<ProfileField>>>,
|
||||
pub(super) user_settings: RwLock<HashMap<Uuid, UserSettings>>,
|
||||
pub(super) refresh_sessions: RwLock<Vec<RefreshSession>>,
|
||||
@@ -82,6 +86,8 @@ impl InMemoryStore {
|
||||
movie_profiles: RwLock::default(),
|
||||
reviews: RwLock::default(),
|
||||
users: RwLock::default(),
|
||||
tombstoned: RwLock::default(),
|
||||
retired_usernames: RwLock::default(),
|
||||
profile_fields: RwLock::default(),
|
||||
user_settings: RwLock::default(),
|
||||
refresh_sessions: RwLock::default(),
|
||||
|
||||
@@ -16,16 +16,20 @@ use super::InMemoryStore;
|
||||
impl UserRepository for InMemoryStore {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let store = self.users.read().unwrap();
|
||||
let gone = self.tombstoned.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|u| !gone.contains(&u.id().value()))
|
||||
.find(|u| u.email().value() == email.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let store = self.users.read().unwrap();
|
||||
let gone = self.tombstoned.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|u| !gone.contains(&u.id().value()))
|
||||
.find(|u| u.username().value() == username.value())
|
||||
.cloned())
|
||||
}
|
||||
@@ -55,14 +59,69 @@ impl UserRepository for InMemoryStore {
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
if self.tombstoned.read().unwrap().contains(&id.value()) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.users.read().unwrap().get(&id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
if !self.tombstoned.read().unwrap().contains(&id.value()) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.users.read().unwrap().get(&id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn mark_deleted(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
events: &[crate::events::DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = user_id.value();
|
||||
if let Some(user) = self.users.read().unwrap().get(&id) {
|
||||
self.retired_usernames
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(user.username().value().to_lowercase());
|
||||
self.tombstoned.write().unwrap().insert(id);
|
||||
}
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|s| s.user_id != *user_id);
|
||||
self.record_events(events);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError> {
|
||||
Ok(self
|
||||
.retired_usernames
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains(&username.value().to_lowercase()))
|
||||
}
|
||||
|
||||
async fn count_active_admins(&self) -> Result<u64, DomainError> {
|
||||
let gone = self.tombstoned.read().unwrap();
|
||||
Ok(self
|
||||
.users
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|u| matches!(u.role(), crate::models::UserRole::Admin))
|
||||
.filter(|u| !gone.contains(&u.id().value()))
|
||||
.count() as u64)
|
||||
}
|
||||
|
||||
/// Ordered by email ascending like the real query. Diary stats are stubbed
|
||||
/// (review count 0, no last activity); only the identity fields are real.
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
let store = self.users.read().unwrap();
|
||||
let mut users: Vec<_> = store.values().collect();
|
||||
let gone = self.tombstoned.read().unwrap();
|
||||
let mut users: Vec<_> = store
|
||||
.values()
|
||||
.filter(|u| !gone.contains(&u.id().value()))
|
||||
.collect();
|
||||
users.sort_by(|a, b| a.email().value().cmp(b.email().value()));
|
||||
Ok(users
|
||||
.into_iter()
|
||||
@@ -161,6 +220,7 @@ impl InMemoryStore {
|
||||
pub fn delete_user(&self, user_id: &UserId) {
|
||||
let id = user_id.value();
|
||||
self.users.write().unwrap().remove(&id);
|
||||
self.tombstoned.write().unwrap().remove(&id);
|
||||
self.watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
|
||||
@@ -575,3 +575,56 @@ async fn deleting_a_review_while_another_remains_keeps_the_movie_and_records_one
|
||||
assert_eq!(store.movie_count(), 1);
|
||||
assert_eq!(store.recorded_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_mark_deleted_mirrors_the_sqlite_tombstone() {
|
||||
let store = InMemoryStore::new();
|
||||
let user = test_user("ghost");
|
||||
let other = test_user("other");
|
||||
UserRepository::save(&*store, &user).await.unwrap();
|
||||
UserRepository::save(&*store, &other).await.unwrap();
|
||||
RefreshSessionRepository::create(
|
||||
&*store,
|
||||
&RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id().clone(),
|
||||
token: "t".into(),
|
||||
expires_at: Utc::now(),
|
||||
created_at: Utc::now(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let event = domain::events::DomainEvent::UserDeleted {
|
||||
user_id: user.id().clone(),
|
||||
};
|
||||
|
||||
store
|
||||
.mark_deleted(user.id(), std::slice::from_ref(&event))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.find_by_id(user.id()).await.unwrap().is_none());
|
||||
assert!(store.find_by_email(user.email()).await.unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.find_by_username(user.username())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(store.list_with_stats().await.unwrap().len(), 1);
|
||||
assert!(
|
||||
store
|
||||
.find_tombstoned_by_id(user.id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(store.is_username_retired(user.username()).await.unwrap());
|
||||
let upper =
|
||||
domain::value_objects::Username::new(user.username().value().to_uppercase()).unwrap();
|
||||
assert!(store.is_username_retired(&upper).await.unwrap());
|
||||
assert_eq!(store.refresh_session_count(), 0);
|
||||
assert_eq!(store.recorded_events().len(), 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user