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.
{% 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");
+}