feat: user tombstone and retired usernames

This commit is contained in:
2026-10-26 02:03:36 +01:00
parent bde8040cc3
commit 31f5d5f337
15 changed files with 550 additions and 56 deletions

View File

@@ -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]

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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()

View File

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