From 6aebad1f0fac5a78b7afaa6a3c1fe62d2985ff9c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 26 Oct 2026 02:51:52 +0100 Subject: [PATCH] feat: classic ui account deletion --- crates/adapters/http-axum/src/html/users.rs | 3 + .../adapters/http-axum/templates/profile.html | 3 +- .../http-axum/templates/profile_settings.html | 4 +- .../bootstrap/tests/account_deletion_test.rs | 96 ++++++++++++++++++- 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/adapters/http-axum/src/html/users.rs b/crates/adapters/http-axum/src/html/users.rs index a2950f1b..fce35519 100644 --- a/crates/adapters/http-axum/src/html/users.rs +++ b/crates/adapters/http-axum/src/html/users.rs @@ -236,6 +236,9 @@ pub async fn post_delete_user( // An admin removing their own account ends their own session too. Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(), Ok(()) => axum::response::Redirect::to("/users").into_response(), + // Same as other forbidden classic actions: a plain 403, not a redirect + // to a page that would not show the error. + Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e), Err(e) => axum::response::Redirect::to(&format!( "/users/{target}?error={}", crate::html::helpers::encode_domain_error(&e) diff --git a/crates/adapters/http-axum/templates/profile.html b/crates/adapters/http-axum/templates/profile.html index bfbbd4fd..f8ba0a75 100644 --- a/crates/adapters/http-axum/templates/profile.html +++ b/crates/adapters/http-axum/templates/profile.html @@ -103,10 +103,11 @@ {% if let Some(err) = error %}

{{ err }}

{% endif %} +

This user and all their data will be permanently deleted. Enter your own password to confirm.

- +
{% endif %} diff --git a/crates/adapters/http-axum/templates/profile_settings.html b/crates/adapters/http-axum/templates/profile_settings.html index 6d24c2f2..0dcdc9c7 100644 --- a/crates/adapters/http-axum/templates/profile_settings.html +++ b/crates/adapters/http-axum/templates/profile_settings.html @@ -69,7 +69,7 @@

Delete account

- Permanently removes your account and everything on it: reviews, watchlist, goals, follows and uploaded images. This cannot be undone. + Deleting your account is permanent and cannot be undone. Your reviews, watchlist, goals and follows are removed, fediverse servers are notified, and your username can never be reused.

@@ -77,7 +77,7 @@ Confirm with your password
- +
{% endblock %} diff --git a/crates/bootstrap/tests/account_deletion_test.rs b/crates/bootstrap/tests/account_deletion_test.rs index d87ab695..cfbb758d 100644 --- a/crates/bootstrap/tests/account_deletion_test.rs +++ b/crates/bootstrap/tests/account_deletion_test.rs @@ -206,11 +206,7 @@ async fn classic_admin_delete_redirects_to_the_member_list() { let uri = format!("/users/{}/delete", bob.user_id); let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; - assert_eq!(status, 303); - assert!( - location(&headers).contains("error="), - "non-admin: {headers:?}" - ); + assert_eq!(status, 403, "non-admin: {headers:?}"); promote(&pool, &alice.user_id).await; let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; @@ -221,3 +217,93 @@ async fn classic_admin_delete_redirects_to_the_member_list() { let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; assert_eq!(status, 404); } + +#[tokio::test] +async fn classic_admin_delete_requires_the_csrf_token() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + let req = Request::builder() + .method(Method::POST) + .uri(format!("/users/{}/delete", bob.user_id)) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, &alice.cookie) + .body(Body::from(format!("password={PASSWORD}&_csrf=wrong"))) + .unwrap(); + let (status, _, _) = send(&router, req).await; + assert_eq!(status, 403); + let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; + assert_eq!(status, 200, "target must survive a CSRF rejection"); +} + +#[tokio::test] +async fn a_non_admin_classic_delete_of_someone_else_is_forbidden() { + let (router, _pool, alice, bob) = two_users().await; + let uri = format!("/users/{}/delete", bob.user_id); + let (status, _, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 403); + let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn classic_admin_self_delete_logs_out_unless_last_admin() { + let (router, pool, alice, bob) = two_users().await; + promote(&pool, &alice.user_id).await; + let uri = format!("/users/{}/delete", alice.user_id); + + // Sole admin: refused, redirected back with an error, still logged in. + let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 303); + assert!(location(&headers).contains("error="), "{headers:?}"); + assert!(support::cookie_value(&headers, "token").is_none()); + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 200); + + // With a second admin the self-delete goes through and ends the session. + promote(&pool, &bob.user_id).await; + let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await; + assert_eq!(status, 303); + assert_eq!(location(&headers), "/"); + assert_eq!( + support::cookie_value(&headers, "token").as_deref(), + Some("") + ); + let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await; + assert_eq!(status, 404); +} + +#[tokio::test] +async fn delete_forms_render_only_where_they_apply() { + let (router, pool, alice, bob) = two_users().await; + let own_delete = "action=\"/settings/account/delete\""; + let user_delete = |id: &str| format!("action=\"/users/{id}/delete\""); + + let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await; + assert!(body.contains(own_delete), "owner settings page"); + + // A non-admin viewing someone else, and anonymous viewers, see no form. + let bob_page = format!("/users/{}", bob.user_id); + let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await; + assert!(!body.contains(&user_delete(&bob.user_id)), "non-admin"); + + promote(&pool, &alice.user_id).await; + let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await; + assert!(body.contains(&user_delete(&bob.user_id)), "admin on other"); + + let (_, _, body) = send( + &router, + get(&format!("/users/{}", alice.user_id), Some(&alice.cookie)), + ) + .await; + assert!( + !body.contains(&user_delete(&alice.user_id)), + "admin on self" + ); + + let (_, _, body) = send( + &router, + get(&format!("{bob_page}?embed=true"), Some(&alice.cookie)), + ) + .await; + assert!(!body.contains("/delete"), "embed page"); +}