feat: user tombstone and retired usernames
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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<String> =
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user