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

@@ -30,3 +30,4 @@
30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D
100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F
101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88
102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF

View File

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

View File

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

View File

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