diff --git a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs index ee0fd676..ed84b580 100644 --- a/crates/adapters/sqlite/src/repositories/account_purge/mod.rs +++ b/crates/adapters/sqlite/src/repositories/account_purge/mod.rs @@ -45,8 +45,12 @@ pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[ ]; /// Columns holding serialized events and activities that carry the user's id -/// (the outbox log keeps finished rows, including authored activity JSON). -pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[("event_queue", "payload")]; +/// (the outbox log keeps finished rows, including authored activity JSON) and +/// the global wrap-up reports, whose `most_active_user` names a member. +pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[ + ("event_queue", "payload"), + ("wrap_up_records", "report_json"), +]; pub struct SqliteAccountPurger { pool: SqlitePool, @@ -142,6 +146,19 @@ impl AccountPurger for SqliteAccountPurger { .await .map_err(map_sqlx_error)?; + // Global wrap-up reports name their most active member; redact it + // (the type allows none) and keep the rest of the report. + sqlx::query( + "UPDATE wrap_up_records + SET report_json = json_set(report_json, '$.most_active_user', NULL) + WHERE user_id IS NULL AND report_json IS NOT NULL + AND instr(report_json, ?) > 0", + ) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(map_sqlx_error)?; + // Other accounts' follows of this one. for table in ["ap_followers", "ap_following"] { sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?")) diff --git a/crates/adapters/sqlite/tests/account_purge_test.rs b/crates/adapters/sqlite/tests/account_purge_test.rs index 0e5a40fa..cbb1fcc5 100644 --- a/crates/adapters/sqlite/tests/account_purge_test.rs +++ b/crates/adapters/sqlite/tests/account_purge_test.rs @@ -728,3 +728,48 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o .await; assert_eq!(retired, 1, "the username stays retired for good"); } + +#[tokio::test] +async fn purge_redacts_the_user_from_global_wrap_up_reports_only() { + let pool = migrated_pool().await; + let parts = sqlite::wire_pool(pool.clone()).await.unwrap(); + let s = seed(&pool, &parts).await; + let (a, b) = (s.user.value().to_string(), s.other.value().to_string()); + let report = |id: &str, name: &str| { + format!( + r#"{{"total_watched":3,"most_active_user":{{"user_id":"{id}","display_name":"{name}"}},"total_users_active":2}}"# + ) + }; + for (rid, id, name) in [("g-alice", &a, "Zelda Quux"), ("g-bob", &b, "Bobby Tables")] { + exec( + &pool, + "INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) + VALUES (?, NULL, '2024-01-01', '2024-12-31', 'completed', ?)", + &[rid, &report(id, name)], + ) + .await; + } + tombstone(&pool, &s.user).await; + + purger(&pool).purge(&s.user).await.unwrap(); + + let all: Vec<(String, String)> = + sqlx::query_as("SELECT id, report_json FROM wrap_up_records WHERE user_id IS NULL") + .fetch_all(&pool) + .await + .unwrap(); + for (_, json) in &all { + assert!(!json.contains(&a), "user id left in {json}"); + assert!(!json.contains("Zelda Quux"), "display name left in {json}"); + } + let alice_row = all.iter().find(|(id, _)| id == "g-alice").unwrap(); + let v: serde_json::Value = serde_json::from_str(&alice_row.1).unwrap(); + assert!(v["most_active_user"].is_null(), "{v}"); + assert_eq!(v["total_watched"], 3, "rest of the report is kept"); + let bob_row = all.iter().find(|(id, _)| id == "g-bob").unwrap(); + assert_eq!( + bob_row.1, + report(&b, "Bobby Tables"), + "other user untouched" + ); +}