diff --git a/crates/adapters/sqlite/migrations/0102_account_deletion.sql b/crates/adapters/sqlite/migrations/0102_account_deletion.sql new file mode 100644 index 00000000..7918be5c --- /dev/null +++ b/crates/adapters/sqlite/migrations/0102_account_deletion.sql @@ -0,0 +1,6 @@ +ALTER TABLE users ADD COLUMN deleted_at TEXT; + +CREATE TABLE retired_usernames ( + username TEXT PRIMARY KEY COLLATE NOCASE, + retired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); diff --git a/crates/adapters/sqlite/src/repositories/diary/query.rs b/crates/adapters/sqlite/src/repositories/diary/query.rs index 1ce028c8..2d53de26 100644 --- a/crates/adapters/sqlite/src/repositories/diary/query.rs +++ b/crates/adapters/sqlite/src/repositories/diary/query.rs @@ -131,11 +131,16 @@ impl DiaryQuery for SqliteDiaryRepository { FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC", }; + // Tombstoned local authors vanish from the feed; remote and orphan + // rows have no `users` match, so `deleted_at` is NULL for them. + where_parts.push("u.deleted_at IS NULL".to_string()); + let where_clause = where_parts.join(" AND "); let count_sql = format!( "SELECT COUNT(*) FROM reviews r INNER JOIN movies m ON m.id = r.movie_id + LEFT JOIN users u ON u.id = r.user_id WHERE {}", where_clause ); @@ -304,12 +309,14 @@ impl DiaryQuery for SqliteDiaryRepository { let limit = page.limit as i64; let offset = page.offset as i64; - let total: i64 = - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?") - .bind(&id_str) - .fetch_one(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; + let total: i64 = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM reviews r LEFT JOIN users u ON u.id = r.user_id + WHERE r.movie_id = ? AND u.deleted_at IS NULL", + ) + .bind(&id_str) + .fetch_one(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; let rows = sqlx::query_as::<_, FeedRow>( "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, @@ -321,7 +328,7 @@ impl DiaryQuery for SqliteDiaryRepository { INNER JOIN movies m ON m.id = r.movie_id LEFT JOIN users u ON u.id = r.user_id LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url - WHERE r.movie_id = ? + WHERE r.movie_id = ? AND u.deleted_at IS NULL ORDER BY r.watched_at DESC LIMIT ? OFFSET ?", ) diff --git a/crates/adapters/sqlite/src/repositories/social/follow/query.rs b/crates/adapters/sqlite/src/repositories/social/follow/query.rs index 05e7c3ea..92ceaa27 100644 --- a/crates/adapters/sqlite/src/repositories/social/follow/query.rs +++ b/crates/adapters/sqlite/src/repositories/social/follow/query.rs @@ -20,7 +20,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_following f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'accepted'", + WHERE f.local_user_id = ?2 AND f.status = 'accepted' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) @@ -42,7 +43,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_followers f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'accepted'", + WHERE f.local_user_id = ?2 AND f.status = 'accepted' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) @@ -67,7 +69,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_followers f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'pending'", + WHERE f.local_user_id = ?2 AND f.status = 'pending' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) @@ -92,7 +95,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository { FROM ap_following f LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?2 AND f.status = 'pending'", + WHERE f.local_user_id = ?2 AND f.status = 'pending' + AND u.deleted_at IS NULL", ) .bind(self.instance.base_url()) .bind(&uid) diff --git a/crates/adapters/sqlite/src/repositories/users/mod.rs b/crates/adapters/sqlite/src/repositories/users/mod.rs index e856ea06..4abb6cf7 100644 --- a/crates/adapters/sqlite/src/repositories/users/mod.rs +++ b/crates/adapters/sqlite/src/repositories/users/mod.rs @@ -66,11 +66,13 @@ const USER_COLS: &str = "id, email, username, password_hash, role, display_name, impl UserRepository for SqliteUserRepository { async fn find_by_email(&self, email: &Email) -> Result, DomainError> { let email_str = email.value(); - let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?")) - .bind(email_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE email = ? AND deleted_at IS NULL" + )) + .bind(email_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; row.as_ref() .map(|r| Self::row_to_user(r, vec![])) @@ -79,11 +81,13 @@ impl UserRepository for SqliteUserRepository { async fn find_by_username(&self, username: &Username) -> Result, DomainError> { let username_str = username.value(); - let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?")) - .bind(username_str) - .fetch_optional(&self.pool) - .await - .map_err(crate::shared::map_sqlx_error)?; + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE username = ? AND deleted_at IS NULL" + )) + .bind(username_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; row.as_ref() .map(|r| Self::row_to_user(r, vec![])) @@ -91,12 +95,12 @@ impl UserRepository for SqliteUserRepository { } async fn save(&self, user: &User) -> Result<(), DomainError> { - if self.find_by_email(user.email()).await?.is_some() { + if self.row_exists("email", user.email().value()).await? { return Err(DomainError::ValidationError( "Email already registered".into(), )); } - if self.find_by_username(user.username()).await?.is_some() { + if self.row_exists("username", user.username().value()).await? { return Err(DomainError::ValidationError( "Username already taken".into(), )); @@ -130,31 +134,86 @@ impl UserRepository for SqliteUserRepository { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { let id_str = id.value().to_string(); - let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?")) - .bind(&id_str) - .fetch_optional(&self.pool) + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NULL" + )) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + self.load_with_fields(&id_str, row).await + } + + async fn find_tombstoned_by_id(&self, id: &UserId) -> Result, DomainError> { + let id_str = id.value().to_string(); + let row = sqlx::query(&format!( + "SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NOT NULL" + )) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(crate::shared::map_sqlx_error)?; + + self.load_with_fields(&id_str, row).await + } + + async fn mark_deleted( + &self, + user_id: &UserId, + events: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + let id_str = user_id.value().to_string(); + let mut tx = self + .pool + .begin() .await .map_err(crate::shared::map_sqlx_error)?; - - let Some(r) = row else { return Ok(None) }; - - let field_rows = sqlx::query( - "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + let stamped = + sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL") + .bind(Utc::now().to_rfc3339()) + .bind(&id_str) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + if stamped.rows_affected() == 0 { + return Err(DomainError::NotFound("User not found".into())); + } + sqlx::query( + "INSERT OR IGNORE INTO retired_usernames (username) SELECT username FROM users WHERE id = ?", ) .bind(&id_str) - .fetch_all(&self.pool) + .execute(&mut *tx) .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + .map_err(crate::shared::map_sqlx_error)?; + sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?") + .bind(&id_str) + .execute(&mut *tx) + .await + .map_err(crate::shared::map_sqlx_error)?; + crate::shared::enqueue_events(&mut tx, events).await?; + tx.commit().await.map_err(crate::shared::map_sqlx_error) + } - let profile_fields = field_rows - .iter() - .map(|f| ProfileField { - name: f.get("name"), - value: f.get("value"), - }) - .collect(); + async fn is_username_retired(&self, username: &Username) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM retired_usernames WHERE username = ?)", + ) + .bind(username.value()) + .fetch_one(&self.pool) + .await + .map(|n| n != 0) + .map_err(crate::shared::map_sqlx_error) + } - Self::row_to_user(&r, profile_fields).map(Some) + async fn count_active_admins(&self) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM users WHERE role = 'admin' AND deleted_at IS NULL", + ) + .fetch_one(&self.pool) + .await + .map(|n| n as u64) + .map_err(crate::shared::map_sqlx_error) } async fn update_profile( @@ -193,6 +252,7 @@ impl UserRepository for SqliteUserRepository { u.avatar_path FROM users u LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL + WHERE u.deleted_at IS NULL GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path ORDER BY u.email ASC"#, ) @@ -204,3 +264,44 @@ impl UserRepository for SqliteUserRepository { .collect() } } + +impl SqliteUserRepository { + /// Uniqueness check over every row, tombstones included: a deleted + /// account keeps its email and username until the purge removes the row. + async fn row_exists(&self, column: &str, value: &str) -> Result { + sqlx::query_scalar::<_, i64>(&format!( + "SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)" + )) + .bind(value) + .fetch_one(&self.pool) + .await + .map(|n| n != 0) + .map_err(crate::shared::map_sqlx_error) + } + + async fn load_with_fields( + &self, + id_str: &str, + row: Option, + ) -> Result, DomainError> { + let Some(r) = row else { return Ok(None) }; + + let field_rows = sqlx::query( + "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + ) + .bind(id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + let profile_fields = field_rows + .iter() + .map(|f| ProfileField { + name: f.get("name"), + value: f.get("value"), + }) + .collect(); + + Self::row_to_user(&r, profile_fields).map(Some) + } +} diff --git a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt index 704ea667..fedc5c9c 100644 --- a/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt +++ b/crates/adapters/sqlite/tests/fixtures/migration_ledger.txt @@ -30,3 +30,4 @@ 30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D 100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F 101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88 +102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF diff --git a/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs index 59a0d7c4..645ebe40 100644 --- a/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/diary_repository_test.rs @@ -205,3 +205,32 @@ async fn count_local_posts_excludes_remote_reviews() { let count = repo.count_local_posts().await.unwrap(); assert_eq!(count, 2); } + +#[tokio::test] +async fn feeds_hide_reviews_of_deleted_local_authors() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup(&pool).await; + sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE username = 'alice'") + .execute(&pool) + .await + .unwrap(); + let repo = SqliteDiaryRepository::new(pool); + + let page = PageParams::new(Some(10), Some(0)).unwrap(); + let feed = repo + .query_activity_feed_filtered(&page, &FeedSortBy::Date, None, None) + .await + .unwrap(); + assert_eq!(feed.total_count, 2); + assert!( + feed.items + .iter() + .all(|e| e.movie().title().value() != "Inception") + ); + + let movie = domain::value_objects::MovieId::from_uuid( + uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(), + ); + let social = repo.get_movie_social_feed(&movie, &page).await.unwrap(); + assert!(social.items.is_empty()); +} diff --git a/crates/adapters/sqlite/tests/repositories/social_repository_test.rs b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs index 526b6dbf..b27ba033 100644 --- a/crates/adapters/sqlite/tests/repositories/social_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/social_repository_test.rs @@ -13,7 +13,7 @@ async fn test_pool() -> SqlitePool { follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))", "CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, - display_name TEXT, avatar_path TEXT)", + display_name TEXT, avatar_path TEXT, deleted_at TEXT)", "CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL, display_name TEXT, avatar_url TEXT)", ] { @@ -251,3 +251,40 @@ async fn list_all_followed_remote_actors_deduplicates_across_users() { assert_eq!(actors.len(), 1); assert_eq!(actors[0].handle, "alice@other.social"); } + +#[tokio::test] +async fn follow_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_following (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_following(&repo(pool), viewer) + .await + .unwrap(); + + assert_eq!(actors.len(), 2, "deleted local user must not be listed"); + assert!(!actors.iter().any( + |a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone)) + )); +} diff --git a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs index 42586f4b..5c86355e 100644 --- a/crates/adapters/sqlite/tests/repositories/user_repository_test.rs +++ b/crates/adapters/sqlite/tests/repositories/user_repository_test.rs @@ -5,20 +5,10 @@ use domain::value_objects::UserId; use sqlite::SqliteUserRepository; use sqlx::SqlitePool; +use super::outbox_support::{count, fail_event_inserts, migrated_pool}; + async fn setup() -> (SqlitePool, SqliteUserRepository) { - let pool = SqlitePool::connect(":memory:").await.unwrap(); - sqlx::query( - "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)" - ) - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)" - ) - .execute(&pool) - .await - .unwrap(); + let pool = migrated_pool().await; let repo = SqliteUserRepository::new(pool.clone()); (pool, repo) } @@ -101,3 +91,126 @@ async fn update_profile_clears_fields_with_none() { assert_eq!(found.bio(), None); assert_eq!(found.avatar_path(), None); } + +fn deleted_event(user: &domain::models::User) -> domain::events::DomainEvent { + domain::events::DomainEvent::UserDeleted { + user_id: user.id().clone(), + } +} + +async fn add_session(pool: &SqlitePool, user_id: &UserId, token: &str) { + sqlx::query( + "INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(user_id.value().to_string()) + .bind(token) + .bind("2099-01-01T00:00:00+00:00") + .bind("2026-01-01T00:00:00+00:00") + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn marking_a_user_deleted_hides_them_from_every_lookup() { + let (_, repo) = setup().await; + let admin = + domain::testing::test_user_with_role(UserId::generate(), domain::models::UserRole::Admin); + repo.save(&admin).await.unwrap(); + let other = test_user("other"); + repo.save(&other).await.unwrap(); + assert_eq!(repo.count_active_admins().await.unwrap(), 1); + + repo.mark_deleted(admin.id(), &[deleted_event(&admin)]) + .await + .unwrap(); + + assert!(repo.find_by_id(admin.id()).await.unwrap().is_none()); + assert!(repo.find_by_email(admin.email()).await.unwrap().is_none()); + assert!( + repo.find_by_username(admin.username()) + .await + .unwrap() + .is_none() + ); + let listed = repo.list_with_stats().await.unwrap(); + let emails: Vec<_> = listed.iter().map(|u| u.email()).collect(); + assert!(emails.contains(&other.email().value())); + assert!(!emails.contains(&admin.email().value())); + assert_eq!(repo.count_active_admins().await.unwrap(), 0); + // The purge can still read the tombstone; a live user is not one. + assert!( + repo.find_tombstoned_by_id(admin.id()) + .await + .unwrap() + .is_some() + ); + assert!( + repo.find_tombstoned_by_id(other.id()) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn marking_deleted_retires_the_username_and_drops_sessions_atomically() { + let (pool, repo) = setup().await; + let user = test_user("leaver"); + let keeper = test_user("keeper"); + repo.save(&user).await.unwrap(); + repo.save(&keeper).await.unwrap(); + add_session(&pool, user.id(), "t1").await; + add_session(&pool, user.id(), "t2").await; + add_session(&pool, keeper.id(), "t3").await; + + repo.mark_deleted(user.id(), &[deleted_event(&user)]) + .await + .unwrap(); + + assert!(repo.is_username_retired(user.username()).await.unwrap()); + assert!(!repo.is_username_retired(keeper.username()).await.unwrap()); + assert_eq!(count(&pool, "refresh_sessions").await, 1, "only keeper's"); + assert_eq!(count(&pool, "event_queue").await, 1); + let deleted_at: Option = + sqlx::query_scalar("SELECT deleted_at FROM users WHERE id = ?") + .bind(user.id().value().to_string()) + .fetch_one(&pool) + .await + .unwrap(); + assert!(deleted_at.is_some()); +} + +#[tokio::test] +async fn a_failed_enqueue_rolls_back_tombstone_retirement_and_session_drop() { + let (pool, repo) = setup().await; + let user = test_user("stayer"); + repo.save(&user).await.unwrap(); + add_session(&pool, user.id(), "t1").await; + fail_event_inserts(&pool).await; + + let result = repo.mark_deleted(user.id(), &[deleted_event(&user)]).await; + + assert!(result.is_err()); + assert!(repo.find_by_id(user.id()).await.unwrap().is_some()); + assert!(!repo.is_username_retired(user.username()).await.unwrap()); + assert_eq!(count(&pool, "retired_usernames").await, 0); + assert_eq!(count(&pool, "refresh_sessions").await, 1); + assert_eq!(count(&pool, "event_queue").await, 0); +} + +#[tokio::test] +async fn retired_names_match_case_insensitively() { + let (_, repo) = setup().await; + let user = test_user("MixedCase"); + repo.save(&user).await.unwrap(); + repo.mark_deleted(user.id(), &[deleted_event(&user)]) + .await + .unwrap(); + + let lower = domain::value_objects::Username::new("mixedcase".to_string()).unwrap(); + let upper = domain::value_objects::Username::new("MIXEDCASE".to_string()).unwrap(); + assert!(repo.is_username_retired(&lower).await.unwrap()); + assert!(repo.is_username_retired(&upper).await.unwrap()); +} diff --git a/crates/application/tests/users/update_profile_test.rs b/crates/application/tests/users/update_profile_test.rs index 7bde7f73..2f22e6df 100644 --- a/crates/application/tests/users/update_profile_test.rs +++ b/crates/application/tests/users/update_profile_test.rs @@ -314,6 +314,28 @@ impl UserRepository for ProfileWriteRefusingUsers { "refused".into(), )) } + async fn mark_deleted( + &self, + id: &domain::value_objects::UserId, + events: &[DomainEvent], + ) -> Result<(), domain::errors::DomainError> { + self.0.mark_deleted(id, events).await + } + async fn find_tombstoned_by_id( + &self, + id: &domain::value_objects::UserId, + ) -> Result, domain::errors::DomainError> { + self.0.find_tombstoned_by_id(id).await + } + async fn is_username_retired( + &self, + u: &domain::value_objects::Username, + ) -> Result { + self.0.is_username_retired(u).await + } + async fn count_active_admins(&self) -> Result { + self.0.count_active_admins().await + } } #[tokio::test] diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs index b5477228..0999592f 100644 --- a/crates/bootstrap/tests/http_handlers/support.rs +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -227,6 +227,28 @@ impl UserRepository for ErroringUserRepo { ) -> Result<(), DomainError> { panic!() } + async fn mark_deleted( + &self, + _: &UserId, + _: &[domain::events::DomainEvent], + ) -> Result<(), DomainError> { + panic!() + } + async fn find_tombstoned_by_id( + &self, + _: &UserId, + ) -> Result, DomainError> { + panic!() + } + async fn is_username_retired( + &self, + _: &domain::value_objects::Username, + ) -> Result { + panic!() + } + async fn count_active_admins(&self) -> Result { + panic!() + } } /// Auth stub that accepts any bearer token and resolves it to a fixed user id, diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs index b4337cb2..e96f0152 100644 --- a/crates/domain/src/ports/auth.rs +++ b/crates/domain/src/ports/auth.rs @@ -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, DomainError>; async fn find_by_username(&self, username: &Username) -> Result, 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, DomainError>; + /// Whether the name belonged to a deleted account (case-insensitive). + async fn is_username_retired(&self, username: &Username) -> Result; + async fn count_active_admins(&self) -> Result; } #[async_trait] diff --git a/crates/domain/src/testing/fakes/panics.rs b/crates/domain/src/testing/fakes/panics.rs index d2f754a2..5f7ee6c3 100644 --- a/crates/domain/src/testing/fakes/panics.rs +++ b/crates/domain/src/testing/fakes/panics.rs @@ -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, DomainError> { + panic!("PanicUserRepository called") + } + async fn is_username_retired( + &self, + _: &crate::value_objects::Username, + ) -> Result { + panic!("PanicUserRepository called") + } + async fn count_active_admins(&self) -> Result { + panic!("PanicUserRepository called") + } } pub struct PanicEventPublisher; diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs index 71e9c6ca..91d5efae 100644 --- a/crates/domain/src/testing/fakes/store.rs +++ b/crates/domain/src/testing/fakes/store.rs @@ -59,6 +59,10 @@ pub struct InMemoryStore { pub(super) movie_profiles: RwLock>, pub(super) reviews: RwLock>, pub(super) users: RwLock>, + /// Ids of `users` rows carrying a `deleted_at` tombstone. + pub(super) tombstoned: RwLock>, + /// Lower-cased `retired_usernames` (the column is NOCASE). + pub(super) retired_usernames: RwLock>, pub(super) profile_fields: RwLock>>, pub(super) user_settings: RwLock>, pub(super) refresh_sessions: RwLock>, @@ -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(), diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs index 83f07176..c6092c1b 100644 --- a/crates/domain/src/testing/fakes/store_user.rs +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -16,16 +16,20 @@ use super::InMemoryStore; impl UserRepository for InMemoryStore { async fn find_by_email(&self, email: &Email) -> Result, 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, 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, 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, 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 { + Ok(self + .retired_usernames + .read() + .unwrap() + .contains(&username.value().to_lowercase())) + } + + async fn count_active_admins(&self) -> Result { + 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, 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() diff --git a/crates/domain/tests/testing/in_memory_store_test.rs b/crates/domain/tests/testing/in_memory_store_test.rs index 2d93af6e..833c7e00 100644 --- a/crates/domain/tests/testing/in_memory_store_test.rs +++ b/crates/domain/tests/testing/in_memory_store_test.rs @@ -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); +}