Fix Friends & Social member search; purge all account data on deletion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
10 files changed
+430
-43
No files matched your search
@@ -11,6 +11,7 @@ pub mod livemap;
|
||||
pub mod net;
|
||||
pub mod packs;
|
||||
pub mod progression;
|
||||
pub mod purge;
|
||||
pub mod routes;
|
||||
pub mod seed;
|
||||
pub mod state;
|
||||
|
||||
@@ -408,6 +408,13 @@ impl LiveMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a player from every server's live roster (account deleted).
|
||||
pub fn forget_player(&self, uuid: &str) {
|
||||
for snap in self.players.lock().unwrap().values_mut() {
|
||||
snap.players.retain(|p| !p.uuid.eq_ignore_ascii_case(uuid));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn live_players(&self, id: i64) -> Vec<LivePlayer> {
|
||||
self.players.lock().unwrap().get(&id).filter(|s| s.at.elapsed() < PLAYERS_FRESH).map(|s| s.players.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Removing an account removes everything the panel knows about its player.
|
||||
//!
|
||||
//! Rows that belong to the player are deleted. Rows that belong to *other*
|
||||
//! people but mention the player (the other side of an economy transfer, who
|
||||
//! performed a guild-bank deposit) are kept with the player anonymised.
|
||||
|
||||
use crate::auth::UserRow;
|
||||
use crate::error::AppResult;
|
||||
use crate::state::AppState;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// Stand-in UUID for deleted players in other people's records.
|
||||
pub const DELETED_UUID: &str = "00000000-0000-0000-0000-000000000000";
|
||||
pub const DELETED_NAME: &str = "Deleted player";
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct PurgeReport {
|
||||
/// Rows deleted, by area.
|
||||
pub removed: std::collections::BTreeMap<&'static str, u64>,
|
||||
/// Guilds dissolved because the player was their only member.
|
||||
pub guilds_dissolved: u64,
|
||||
/// Guilds whose leadership was handed to someone else.
|
||||
pub guilds_transferred: u64,
|
||||
pub skin_file_removed: bool,
|
||||
}
|
||||
|
||||
impl PurgeReport {
|
||||
fn add(&mut self, area: &'static str, n: u64) {
|
||||
if n > 0 {
|
||||
*self.removed.entry(area).or_default() += n;
|
||||
}
|
||||
}
|
||||
pub fn total(&self) -> u64 {
|
||||
self.removed.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(conn: &mut SqliteConnection, sql: &str, binds: &[&str]) -> AppResult<u64> {
|
||||
let mut q = sqlx::query(sql);
|
||||
for b in binds {
|
||||
q = q.bind(*b);
|
||||
}
|
||||
Ok(q.execute(&mut *conn).await?.rows_affected())
|
||||
}
|
||||
|
||||
/// Delete an account and all data tied to it. Runs in one transaction.
|
||||
pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeReport> {
|
||||
let uuid = user.uuid.as_str();
|
||||
let name = user.username.as_str();
|
||||
let mut report = PurgeReport::default();
|
||||
let mut tx = state.db.begin().await?;
|
||||
let c = &mut *tx;
|
||||
|
||||
// ---- progression ----
|
||||
report.add("levels", run(c, "DELETE FROM user_levels WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("levels", run(c, "DELETE FROM server_levels WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("rewards", run(c, "DELETE FROM granted_rewards WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("quests", run(c, "DELETE FROM user_quests WHERE user_uuid = ?", &[uuid]).await?);
|
||||
report.add("quests", run(c, "DELETE FROM quest_assignments WHERE user_uuid = ?", &[uuid]).await?);
|
||||
report.add("achievements", run(c, "DELETE FROM user_achievements WHERE user_uuid = ?", &[uuid]).await?);
|
||||
|
||||
// ---- gameplay records ----
|
||||
report.add("stats", run(c, "DELETE FROM player_stats WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("stats", run(c, "DELETE FROM server_online WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("activity", run(c, "DELETE FROM server_events WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("activity", run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?);
|
||||
|
||||
// ---- social ----
|
||||
report.add("friends", run(c, "DELETE FROM friendships WHERE user_uuid = ? OR friend_uuid = ?", &[uuid, uuid]).await?);
|
||||
report.add("messages", run(c, "DELETE FROM direct_messages WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?);
|
||||
report.add("invites", run(c, "DELETE FROM game_invites WHERE sender_uuid = ? OR recipient_uuid = ?", &[uuid, uuid]).await?);
|
||||
report.add("profile", run(c, "DELETE FROM user_profiles WHERE uuid = ?", &[uuid]).await?);
|
||||
// Likes they gave come off other people's post counters.
|
||||
run(
|
||||
c,
|
||||
"UPDATE user_posts SET likes_count = MAX(0, likes_count - 1) WHERE id IN (SELECT post_id FROM user_post_likes WHERE user_uuid = ?)",
|
||||
&[uuid],
|
||||
)
|
||||
.await?;
|
||||
report.add("posts", run(c, "DELETE FROM user_post_likes WHERE user_uuid = ?", &[uuid]).await?);
|
||||
report.add("posts", run(c, "DELETE FROM user_posts WHERE user_uuid = ?", &[uuid]).await?);
|
||||
|
||||
// ---- guilds ----
|
||||
let led: Vec<String> = sqlx::query_scalar("SELECT id FROM guilds WHERE leader_uuid = ?").bind(uuid).fetch_all(&mut *c).await?;
|
||||
for guild in led {
|
||||
// Next in line: an officer, then whoever has been a member longest.
|
||||
let heir: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, name FROM guild_members WHERE guild_id = ? AND uuid <> ?
|
||||
ORDER BY (role = 'officer') DESC, joined_at ASC LIMIT 1",
|
||||
)
|
||||
.bind(&guild)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut *c)
|
||||
.await?;
|
||||
match heir {
|
||||
Some((heir_uuid, _)) => {
|
||||
run(c, "UPDATE guilds SET leader_uuid = ? WHERE id = ?", &[&heir_uuid, &guild]).await?;
|
||||
run(c, "UPDATE guild_members SET role = 'leader' WHERE guild_id = ? AND uuid = ?", &[&guild, &heir_uuid]).await?;
|
||||
report.guilds_transferred += 1;
|
||||
}
|
||||
None => {
|
||||
// Claims, wallet, posts and roles go with the guild.
|
||||
report.add("guild_data", run(c, "DELETE FROM guilds WHERE id = ?", &[&guild]).await?);
|
||||
report.guilds_dissolved += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.add("guild_membership", run(c, "DELETE FROM guild_members WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("guild_posts", run(c, "DELETE FROM guild_posts WHERE author_uuid = ?", &[uuid]).await?);
|
||||
// Territory they claimed stays with the guild.
|
||||
run(c, "UPDATE guild_claims SET claimed_by_uuid = ? WHERE claimed_by_uuid = ?", &[DELETED_UUID, uuid]).await?;
|
||||
run(c, "UPDATE guild_wallet_transactions SET actor_uuid = ? WHERE actor_uuid = ?", &[DELETED_UUID, uuid]).await?;
|
||||
|
||||
// ---- economy ----
|
||||
report.add("economy", run(c, "DELETE FROM server_economy WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("economy", run(c, "DELETE FROM server_market WHERE seller_uuid = ?", &[uuid]).await?);
|
||||
run(c, "UPDATE economy_transactions SET from_uuid = ?, from_name = ? WHERE from_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
run(c, "UPDATE economy_transactions SET to_uuid = ?, to_name = ? WHERE to_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
|
||||
// ---- identity ----
|
||||
report.add("name_reservations", run(c, "DELETE FROM reserved_usernames WHERE uuid = ?", &[uuid]).await?);
|
||||
// Explicit, in case a foreign key is ever relaxed.
|
||||
let id = user.id.to_string();
|
||||
for (area, sql) in [
|
||||
("sessions", "DELETE FROM ygg_tokens WHERE user_id = ?"),
|
||||
("sessions", "DELETE FROM ygg_sessions WHERE user_id = ?"),
|
||||
("sessions", "DELETE FROM player_keys WHERE user_id = ?"),
|
||||
("sessions", "DELETE FROM launcher_sessions WHERE user_id = ?"),
|
||||
("connections", "DELETE FROM account_connections WHERE user_id = ?"),
|
||||
("connections", "DELETE FROM oauth_attempts WHERE user_id = ?"),
|
||||
("connections", "DELETE FROM password_resets WHERE user_id = ?"),
|
||||
("groups", "DELETE FROM user_groups WHERE user_id = ?"),
|
||||
] {
|
||||
report.add(area, run(c, sql, &[&id]).await?);
|
||||
}
|
||||
report.add("account", run(c, "DELETE FROM users WHERE id = ?", &[&id]).await?);
|
||||
|
||||
// A skin nobody else uses is deleted from disk.
|
||||
let skin = user.skin_hash.clone();
|
||||
let skin_shared: bool = match &skin {
|
||||
Some(h) => sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE skin_hash = ?)").bind(h).fetch_one(&mut *c).await?,
|
||||
None => true,
|
||||
};
|
||||
tx.commit().await?;
|
||||
|
||||
if let (Some(hash), false) = (skin, skin_shared) {
|
||||
if let Some(path) = crate::textures::path(&state.cfg.textures_dir(), &hash) {
|
||||
report.skin_file_removed = tokio::fs::remove_file(path).await.is_ok();
|
||||
}
|
||||
}
|
||||
// Live map: stop showing them.
|
||||
state.livemap.forget_player(uuid);
|
||||
Ok(report)
|
||||
}
|
||||
@@ -227,8 +227,15 @@ pub async fn delete_user(AdminUser(me): AdminUser, State(state): State<AppState>
|
||||
if me.id == id {
|
||||
return Err(AppError::bad_request("you can't delete your own account"));
|
||||
}
|
||||
sqlx::query("DELETE FROM users WHERE id = ?").bind(id).execute(&state.db).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("player not found"))?;
|
||||
// Removes the account and everything tied to its UUID, not just the login.
|
||||
let report = crate::purge::purge_user(&state, &user).await?;
|
||||
crate::routes::activity::record(&state, &me, "panel", "account_deleted", Some(&format!("{} ({} records removed)", user.username, report.total()))).await?;
|
||||
Ok(Json(json!({ "ok": true, "report": report })))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
||||
@@ -304,6 +304,20 @@ pub async fn send_direct_message(
|
||||
return Err(AppError::bad_request("Message cannot be empty"));
|
||||
}
|
||||
|
||||
if target_uuid == auth.uuid {
|
||||
return Err(AppError::bad_request("You can't message yourself"));
|
||||
}
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')")
|
||||
.bind(&target_uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(AppError::not_found("Player not found"));
|
||||
}
|
||||
if content.chars().count() > 1000 {
|
||||
return Err(AppError::bad_request("Messages can be up to 1000 characters"));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
@@ -400,6 +414,24 @@ pub async fn send_game_invite(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SendInvitePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let friends: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM friendships WHERE status = 'accepted'
|
||||
AND ((user_uuid = ?1 AND friend_uuid = ?2) OR (user_uuid = ?2 AND friend_uuid = ?1)))",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&payload.recipient_uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !friends {
|
||||
return Err(AppError::forbidden("You can only invite friends"));
|
||||
}
|
||||
let instance: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)")
|
||||
.bind(&payload.instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !instance {
|
||||
return Err(AppError::not_found("Instance not found"));
|
||||
}
|
||||
let invite_id = format!("inv_{}", uuid::Uuid::new_v4().simple());
|
||||
let now = chrono::Utc::now();
|
||||
let expires = now + chrono::Duration::minutes(30);
|
||||
@@ -700,54 +732,54 @@ pub async fn search_members(
|
||||
Query(query): Query<SearchMembersQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<scopenet_shared::MemberProfile>>> {
|
||||
let q = query.q.unwrap_or_default().trim().to_lowercase();
|
||||
// `%` and `_` are LIKE wildcards; players shouldn't be able to inject them.
|
||||
let q = query.q.unwrap_or_default().trim().to_lowercase().replace(['%', '_', '\\'], "");
|
||||
let pattern = format!("%{q}%");
|
||||
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, u.role, u.status, u.skin_url,
|
||||
ul.global_level, ul.title, u.playtime_secs, u.last_seen_ingame,
|
||||
f.status as friendship_status
|
||||
FROM users u
|
||||
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
|
||||
LEFT JOIN friendships f ON (f.user_uuid = ? AND f.friend_uuid = u.uuid)
|
||||
WHERE (? = '' OR LOWER(u.username) LIKE ?)
|
||||
ORDER BY (CASE WHEN LOWER(u.username) = ? THEN 1 ELSE 2 END), u.playtime_secs DESC
|
||||
LIMIT 60",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&q)
|
||||
.bind(&pattern)
|
||||
.bind(&q)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let now_ts = chrono::Utc::now().timestamp();
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, bool, Option<String>, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, u.role, u.status, u.skin_hash,
|
||||
ul.global_level, ul.title,
|
||||
COALESCE((SELECT SUM(ps.playtime_secs) FROM player_stats ps WHERE ps.uuid = u.uuid), 0),
|
||||
(SELECT MAX(ps.last_seen) FROM player_stats ps WHERE ps.uuid = u.uuid),
|
||||
EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid),
|
||||
f.status, f.action_uuid
|
||||
FROM users u
|
||||
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
|
||||
LEFT JOIN friendships f ON (f.user_uuid = ?1 AND f.friend_uuid = u.uuid) OR (f.friend_uuid = ?1 AND f.user_uuid = u.uuid)
|
||||
WHERE u.status = 'active' AND u.uuid <> ?1 AND (?2 = '' OR LOWER(u.username) LIKE ?3)
|
||||
ORDER BY (CASE WHEN LOWER(u.username) = ?2 THEN 0 ELSE 1 END), 10 DESC, u.username COLLATE NOCASE
|
||||
LIMIT 60",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&q)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(uuid, username, role, status, skin_url, glvl, title, playtime, last_seen, friend_status)| {
|
||||
let online = if let Some(ref ls) = last_seen {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ls) {
|
||||
(now_ts - dt.timestamp()).abs() < 180
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let is_friend = friend_status.as_deref() == Some("accepted");
|
||||
.map(|(uuid, username, role, status, skin_hash, glvl, title, playtime, last_seen, online, friend_status, action)| {
|
||||
let friendship_status = match (friend_status.as_deref(), action.as_deref()) {
|
||||
(Some("accepted"), _) => "accepted",
|
||||
(Some(_), Some(a)) if a == auth.uuid => "pending_outgoing",
|
||||
(Some(_), _) => "pending_incoming",
|
||||
_ => "none",
|
||||
}
|
||||
.to_string();
|
||||
scopenet_shared::MemberProfile {
|
||||
uuid,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
skin_url,
|
||||
skin_url: skin_hash.map(|h| format!("/textures/{h}")),
|
||||
global_level: glvl.unwrap_or(1),
|
||||
title,
|
||||
playtime_secs: playtime,
|
||||
last_seen,
|
||||
online,
|
||||
is_friend,
|
||||
is_friend: friendship_status == "accepted",
|
||||
friendship_status,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Friends & Social, and removing every trace of a deleted account.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
async fn player(t: &TestApp, admin: &str, name: &str) -> (String, String, i64) {
|
||||
let (s, v) = t.call("POST", "/api/admin/users", Some(admin), Some(json!({"username": name, "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
(t.login(name, "password123").await, t.uuid(name).await, v["id"].as_i64().unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_befriend_message_invite() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (alex, alex_uuid, _) = player(&t, &admin, "Alexandra").await;
|
||||
let (steve, steve_uuid, _) = player(&t, &admin, "Steve").await;
|
||||
player(&t, &admin, "Stevie_B").await;
|
||||
|
||||
// Search: name match, wildcards are literal, never yourself.
|
||||
let (s, v) = t.call("GET", "/api/v1/social/members?q=ste", Some(&alex), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let names: Vec<&str> = v.as_array().unwrap().iter().map(|m| m["username"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"Steve") && names.contains(&"Stevie_B") && !names.contains(&"Alexandra"), "{names:?}");
|
||||
assert_eq!(v[0]["friendship_status"], "none");
|
||||
let (_, all) = t.call("GET", "/api/v1/members/search", Some(&alex), None).await;
|
||||
assert_eq!(all.as_array().unwrap().len(), 3, "admin + Steve + Stevie_B");
|
||||
let (_, wild) = t.call("GET", "/api/v1/social/members?q=%25", Some(&alex), None).await;
|
||||
assert_eq!(wild.as_array().unwrap().len(), 3, "a bare % is ignored, not a wildcard");
|
||||
let (s, _) = t.call("GET", "/api/v1/social/members?q=x", None, None).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
|
||||
// Friend request flow shows up in search results for both sides.
|
||||
let (s, _) = t.call("POST", "/api/v1/friends/request", Some(&alex), Some(json!({"username": "steve"}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (_, v) = t.call("GET", "/api/v1/social/members?q=steve", Some(&alex), None).await;
|
||||
assert_eq!(v[0]["friendship_status"], "pending_outgoing");
|
||||
let (_, v) = t.call("GET", "/api/v1/social/members?q=alexandra", Some(&steve), None).await;
|
||||
assert_eq!(v[0]["friendship_status"], "pending_incoming");
|
||||
let (s, _) = t.call("POST", "/api/v1/friends/respond", Some(&steve), Some(json!({"target_uuid": alex_uuid, "accept": true}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (_, v) = t.call("GET", "/api/v1/social/members?q=steve", Some(&alex), None).await;
|
||||
assert_eq!(v[0]["friendship_status"], "accepted");
|
||||
assert_eq!(v[0]["is_friend"], true);
|
||||
let (_, friends) = t.call("GET", "/api/v1/friends", Some(&alex), None).await;
|
||||
assert_eq!(friends[0]["username"], "Steve");
|
||||
|
||||
// Messages: only to real players.
|
||||
let (s, m) = t.call("POST", &format!("/api/v1/messages/{steve_uuid}"), Some(&alex), Some(json!({"content": "hello"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{m}");
|
||||
let (s, _) = t.call("POST", "/api/v1/messages/nobody", Some(&alex), Some(json!({"content": "hello"}))).await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
let (_, thread) = t.call("GET", &format!("/api/v1/messages/{alex_uuid}"), Some(&steve), None).await;
|
||||
assert_eq!(thread[0]["content"], "hello");
|
||||
|
||||
// Invites: friends only.
|
||||
let (_, stevie) = t.call("GET", "/api/v1/social/members?q=stevie", Some(&alex), None).await;
|
||||
let stevie_uuid = stevie[0]["uuid"].as_str().unwrap().to_string();
|
||||
let (s, _) = t.call("POST", "/api/v1/invites", Some(&alex), Some(json!({"recipient_uuid": stevie_uuid, "instance_id": "x"}))).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
|
||||
// Profile carries the relationship so the launcher can offer the right actions.
|
||||
let (s, p) = t.call("GET", &format!("/api/v1/profiles/{steve_uuid}"), Some(&alex), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{p}");
|
||||
let (s, p) = t.call("GET", &format!("/api/v1/profiles/{stevie_uuid}"), Some(&alex), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{p}");
|
||||
}
|
||||
|
||||
/// Every table and column is scanned, so a table added later is covered too.
|
||||
async fn mentions(t: &TestApp, needle: &str) -> Vec<String> {
|
||||
let tables: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetch_all(&t.db).await.unwrap();
|
||||
let mut hits = Vec::new();
|
||||
for table in tables {
|
||||
let cols: Vec<String> = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap();
|
||||
for col in cols {
|
||||
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{table}\" WHERE CAST(\"{col}\" AS TEXT) = ?")).bind(needle).fetch_one(&t.db).await.unwrap();
|
||||
if n > 0 {
|
||||
hits.push(format!("{table}.{col}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_removes_all_its_data() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (alex, alex_uuid, _) = player(&t, &admin, "Alex").await;
|
||||
let (steve, steve_uuid, steve_id) = player(&t, &admin, "Steve").await;
|
||||
let (_, mia_uuid, _) = player(&t, &admin, "Mia").await;
|
||||
|
||||
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
|
||||
let sid = srv["server"]["id"].as_i64().unwrap();
|
||||
let server_token = srv["token"].as_str().unwrap().to_string();
|
||||
|
||||
// Gameplay: stats, XP, quests, an achievement, events.
|
||||
let (s, v) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
Some(&server_token),
|
||||
Some(json!({
|
||||
"online": [{"uuid": &steve_uuid, "name": "Steve"}],
|
||||
"stats": [{"uuid": &steve_uuid, "name": "Steve", "blocks_broken": 200, "mob_kills": 20, "playtime_secs": 4000, "joins": 1}],
|
||||
"events": [{"uuid": &steve_uuid, "name": "Steve", "kind": "join"}, {"uuid": &steve_uuid, "kind": "action", "detail": "block_broken:STONE@minecraft:overworld +10"}]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
t.call("GET", "/api/v1/quests/my", Some(&steve), None).await;
|
||||
|
||||
// Social: friends, DMs both ways, profile, posts, likes.
|
||||
t.call("POST", "/api/v1/friends/request", Some(&steve), Some(json!({"username": "Alex"}))).await;
|
||||
t.call("POST", "/api/v1/friends/respond", Some(&alex), Some(json!({"target_uuid": &steve_uuid, "accept": true}))).await;
|
||||
t.call("POST", &format!("/api/v1/messages/{alex_uuid}"), Some(&steve), Some(json!({"content": "hi"}))).await;
|
||||
t.call("POST", &format!("/api/v1/messages/{steve_uuid}"), Some(&alex), Some(json!({"content": "yo"}))).await;
|
||||
t.call("PUT", "/api/v1/profiles/me", Some(&steve), Some(json!({"bio": "I like rocks"}))).await;
|
||||
let (_, post) = t.call("POST", "/api/v1/profiles/me/posts", Some(&steve), Some(json!({"content": "hello world"}))).await;
|
||||
let (_, alex_post) = t.call("POST", "/api/v1/profiles/me/posts", Some(&alex), Some(json!({"content": "alex post"}))).await;
|
||||
t.call("POST", &format!("/api/v1/posts/{}/like", alex_post["id"]), Some(&steve), None).await;
|
||||
t.call("POST", &format!("/api/v1/posts/{}/like", post["id"]), Some(&alex), None).await;
|
||||
|
||||
// Economy: balance, a transfer to Alex, a market listing.
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 50, 'now'), (?, ?, 'Alex', 50, 'now')")
|
||||
.bind(sid).bind(&steve_uuid).bind(sid).bind(&alex_uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, 'Steve', ?, 'Alex', 5, 'pay', 'now')")
|
||||
.bind(sid).bind(&steve_uuid).bind(&alex_uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at) VALUES (?, ?, 'Steve', 'DIAMOND', 'Diamond', 1, 10, 'now')")
|
||||
.bind(sid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
|
||||
// Guilds: Steve leads one with Alex in it, and is sole member of another.
|
||||
let (s, g) = t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Rockheads", "tag": "ROCK"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{g}");
|
||||
let gid = g["id"].as_str().unwrap().to_string();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Alex', 'member', '2026-01-01')").bind(&gid).bind(&alex_uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guild_wallet_transactions (server_id, guild_id, actor_uuid, kind, amount, created_at) VALUES (?, ?, ?, 'deposit', 5, 'now')").bind(sid).bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
let (s, g2) = t.call("POST", "/api/v1/guilds", Some(&admin), Some(json!({"instance_id": "smp", "name": "Solo", "tag": "SOLO"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{g2}");
|
||||
|
||||
assert!(!mentions(&t, &steve_uuid).await.is_empty());
|
||||
|
||||
// Delete the account.
|
||||
let (s, v) = t.call("DELETE", &format!("/api/admin/users/{steve_id}"), Some(&admin), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert!(v["report"]["guilds_transferred"] == 1 && v["report"]["removed"]["stats"].as_u64().unwrap() > 0, "{v}");
|
||||
|
||||
// Nothing refers to them any more (their name appears only as plain text in free-form rows we don't keep).
|
||||
assert_eq!(mentions(&t, &steve_uuid).await, Vec::<String>::new());
|
||||
for table in ["users", "user_levels", "player_stats", "friendships", "direct_messages", "user_profiles", "user_posts", "server_market", "server_economy", "reserved_usernames"] {
|
||||
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE CAST(uuid AS TEXT) = ?")).bind(&steve_uuid).fetch_one(&t.db).await.unwrap_or(0);
|
||||
assert_eq!(n, 0, "{table}");
|
||||
}
|
||||
let name_hits: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'Steve' COLLATE NOCASE").fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(name_hits, 0);
|
||||
|
||||
// Other people keep what is theirs: Alex is now the guild's leader; the
|
||||
// transfer shows the anonymised counterparty; Alex's like count dropped.
|
||||
let leader: String = sqlx::query_scalar("SELECT leader_uuid FROM guilds WHERE id = ?").bind(&gid).fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(leader, alex_uuid);
|
||||
let from: String = sqlx::query_scalar("SELECT from_name FROM economy_transactions").fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(from, "Deleted player");
|
||||
let likes: i64 = sqlx::query_scalar("SELECT likes_count FROM user_posts WHERE id = ?").bind(alex_post["id"].as_i64().unwrap()).fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(likes, 0);
|
||||
let (_, friends) = t.call("GET", "/api/v1/friends", Some(&alex), None).await;
|
||||
assert!(friends.as_array().unwrap().is_empty());
|
||||
let (_, list) = t.call("GET", "/api/v1/members/search", Some(&alex), None).await;
|
||||
assert!(list.as_array().unwrap().iter().all(|m| m["username"] != "Steve"));
|
||||
// The old name can be taken again, and the deleted token no longer works.
|
||||
let (s, _) = t.call("GET", "/api/v1/auth/me", Some(&steve), None).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
player(&t, &admin, "Steve").await;
|
||||
assert_ne!(mia_uuid, steve_uuid);
|
||||
}
|
||||
Reference in new issue
Block a user