From 44c3d4129ea9bbf034f81fd7332065dfac3d4f0f Mon Sep 17 00:00:00 2001 From: lewis Date: Wed, 10 Dec 2025 17:22:22 +0200 Subject: [PATCH] Updated TODO with more stuff, added user delete endpoint --- ...708b00ae7f33970a63090a816327d698b1d14.json | 14 + ...d0b489a39b4ba5a1609ac1a256dc58c829419.json | 14 + ...63426183d66d802a42698e8ad5f0e2c326ecc.json | 14 + ...21c9e3eae1ce87c223b706ed81ebf973875f3.json | 28 + ...f6177e8734c28b9bb110250d700e14b0263d1.json | 28 + TODO.md | 57 +- src/api/server/account_status.rs | 209 +++++++ src/api/server/mod.rs | 3 +- src/lib.rs | 4 + tests/delete_account.rs | 520 ++++++++++++++++++ tests/helpers/mod.rs | 2 + 11 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 .sqlx/query-4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14.json create mode 100644 .sqlx/query-5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419.json create mode 100644 .sqlx/query-6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc.json create mode 100644 .sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json create mode 100644 .sqlx/query-a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1.json create mode 100644 tests/delete_account.rs diff --git a/.sqlx/query-4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14.json b/.sqlx/query-4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14.json new file mode 100644 index 0000000..2ba7edf --- /dev/null +++ b/.sqlx/query-4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM account_deletion_requests WHERE token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14" +} diff --git a/.sqlx/query-5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419.json b/.sqlx/query-5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419.json new file mode 100644 index 0000000..a3a1c08 --- /dev/null +++ b/.sqlx/query-5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM app_passwords WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419" +} diff --git a/.sqlx/query-6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc.json b/.sqlx/query-6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc.json new file mode 100644 index 0000000..7e9d35d --- /dev/null +++ b/.sqlx/query-6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM account_deletion_requests WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc" +} diff --git a/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json b/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json new file mode 100644 index 0000000..a63e9a8 --- /dev/null +++ b/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, password_hash FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3" +} diff --git a/.sqlx/query-a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1.json b/.sqlx/query-a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1.json new file mode 100644 index 0000000..a0d839d --- /dev/null +++ b/.sqlx/query-a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT did, expires_at FROM account_deletion_requests WHERE token = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1" +} diff --git a/TODO.md b/TODO.md index 40c155b..1ecb55b 100644 --- a/TODO.md +++ b/TODO.md @@ -31,7 +31,8 @@ Lewis' corrected big boy todofile - [x] Implement `com.atproto.server.createAppPassword`. - [x] Implement `com.atproto.server.createInviteCode`. - [x] Implement `com.atproto.server.createInviteCodes`. - - [x] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`. + - [x] Implement `com.atproto.server.deactivateAccount`. + - [x] Implement `com.atproto.server.deleteAccount` (user-initiated, requires password + email token). - [x] Implement `com.atproto.server.getAccountInviteCodes`. - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). - [x] Implement `com.atproto.server.listAppPasswords`. @@ -106,9 +107,63 @@ Lewis' corrected big boy todofile ## Moderation (`com.atproto.moderation`) - [x] Implement `com.atproto.moderation.createReport`. +## Temp Namespace (`com.atproto.temp`) +- [ ] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups). + +## OAuth 2.0 Support +The reference PDS implements full OAuth 2.0 provider functionality for native app authentication. +- [ ] OAuth Provider Core + - [ ] Implement `/.well-known/oauth-protected-resource` metadata endpoint. + - [ ] Implement `/.well-known/oauth-authorization-server` metadata endpoint. + - [ ] Implement `/oauth/authorize` authorization endpoint. + - [ ] Implement `/oauth/par` Pushed Authorization Request endpoint. + - [ ] Implement `/oauth/token` token endpoint. + - [ ] Implement `/oauth/jwks` JSON Web Key Set endpoint. +- [ ] OAuth Database Tables + - [ ] Device table for tracking authorized devices. + - [ ] Authorization request table. + - [ ] Authorized client table. + - [ ] Token table for OAuth tokens. + - [ ] Used refresh token table. +- [ ] DPoP (Demonstrating Proof-of-Possession) support. +- [ ] Client metadata fetching and validation. + +## PDS-Level App Endpoints +These endpoints need to be implemented at the PDS level (not just proxied to appview). + +### Actor (`app.bsky.actor`) +- [ ] Implement `app.bsky.actor.getPreferences` (user preferences storage). +- [ ] Implement `app.bsky.actor.putPreferences` (update user preferences). +- [ ] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback). +- [ ] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback). + +### Feed (`app.bsky.feed`) +These are implemented at PDS level to enable local-first reads: +- [ ] Implement `app.bsky.feed.getTimeline` (PDS-level with proxy). +- [ ] Implement `app.bsky.feed.getAuthorFeed` (PDS-level with proxy). +- [ ] Implement `app.bsky.feed.getActorLikes` (PDS-level with proxy). +- [ ] Implement `app.bsky.feed.getPostThread` (PDS-level with proxy). +- [ ] Implement `app.bsky.feed.getFeed` (PDS-level with proxy). + +### Notification (`app.bsky.notification`) +- [ ] Implement `app.bsky.notification.registerPush` (push notification registration). + +## Deprecated Sync Endpoints (for compatibility) +- [ ] Implement `com.atproto.sync.getCheckout` (deprecated, still needed for compatibility). +- [ ] Implement `com.atproto.sync.getHead` (deprecated, still needed for compatibility). + +## Misc HTTP Endpoints +- [ ] Implement `/robots.txt` endpoint. + ## Record Schema Validation - [ ] Handle this generically. +## Preference Storage +User preferences (for app.bsky.actor.getPreferences/putPreferences): +- [ ] Create preferences table for storing user app preferences. +- [ ] Implement `app.bsky.actor.getPreferences` handler (read from postgres, proxy fallback). +- [ ] Implement `app.bsky.actor.putPreferences` handler (write to postgres). + ## Infrastructure & Core Components - [x] Sequencer (Event Log) - [x] Implement a `Sequencer` (backed by `repo_seq` table). diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 894b357..e3827f6 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -5,6 +5,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use bcrypt::verify; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -391,3 +392,211 @@ pub async fn request_account_delete( (StatusCode::OK, Json(json!({}))).into_response() } + +#[derive(Deserialize)] +pub struct DeleteAccountInput { + pub did: String, + pub password: String, + pub token: String, +} + +pub async fn delete_account( + State(state): State, + Json(input): Json, +) -> Response { + let did = input.did.trim(); + let password = &input.password; + let token = input.token.trim(); + + if did.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "did is required"})), + ) + .into_response(); + } + + if password.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "password is required"})), + ) + .into_response(); + } + + if token.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "token is required"})), + ) + .into_response(); + } + + let user = sqlx::query!( + "SELECT id, password_hash FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await; + + let (user_id, password_hash) = match user { + Ok(Some(row)) => (row.id, row.password_hash), + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in delete_account: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let password_valid = if verify(password, &password_hash).unwrap_or(false) { + true + } else { + let app_pass_rows = sqlx::query!( + "SELECT password_hash FROM app_passwords WHERE user_id = $1", + user_id + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + app_pass_rows + .iter() + .any(|row| verify(password, &row.password_hash).unwrap_or(false)) + }; + + if !password_valid { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid password"})), + ) + .into_response(); + } + + let deletion_request = sqlx::query!( + "SELECT did, expires_at FROM account_deletion_requests WHERE token = $1", + token + ) + .fetch_optional(&state.db) + .await; + + let (token_did, expires_at) = match deletion_request { + Ok(Some(row)) => (row.did, row.expires_at), + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching deletion token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if token_did != did { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Token does not match account"})), + ) + .into_response(); + } + + if Utc::now() > expires_at { + let _ = sqlx::query!("DELETE FROM account_deletion_requests WHERE token = $1", token) + .execute(&state.db) + .await; + + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), + ) + .into_response(); + } + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let deletion_result: Result<(), sqlx::Error> = async { + sqlx::query!("DELETE FROM sessions WHERE did = $1", did) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM account_deletion_requests WHERE did = $1", did) + .execute(&mut *tx) + .await?; + + sqlx::query!("DELETE FROM users WHERE id = $1", user_id) + .execute(&mut *tx) + .await?; + + Ok(()) + } + .await; + + match deletion_result { + Ok(()) => { + if let Err(e) = tx.commit().await { + error!("Failed to commit account deletion transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + info!("Account {} deleted successfully", did); + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error deleting account, rolling back: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index eeb6687..5117208 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -8,7 +8,8 @@ pub mod session; pub mod signing_key; pub use account_status::{ - activate_account, check_account_status, deactivate_account, request_account_delete, + activate_account, check_account_status, deactivate_account, delete_account, + request_account_delete, }; pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; pub use email::{confirm_email, request_email_update, update_email}; diff --git a/src/lib.rs b/src/lib.rs index 20dcb38..be60215 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -160,6 +160,10 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.requestAccountDelete", post(api::server::request_account_delete), ) + .route( + "/xrpc/com.atproto.server.deleteAccount", + post(api::server::delete_account), + ) .route( "/xrpc/com.atproto.server.requestPasswordReset", post(api::server::request_password_reset), diff --git a/tests/delete_account.rs b/tests/delete_account.rs new file mode 100644 index 0000000..68ae36a --- /dev/null +++ b/tests/delete_account.rs @@ -0,0 +1,520 @@ +mod common; +mod helpers; + +use common::*; + +use chrono::Utc; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_delete_account_full_flow() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("delete-test-{}.test", ts); + let email = format!("delete-test-{}@test.com", ts); + let password = "delete-password-123"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap().to_string(); + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); + + let request_delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestAccountDelete", + base_url().await + )) + .bearer_auth(&jwt) + .send() + .await + .expect("Failed to request account deletion"); + assert_eq!(request_delete_res.status(), StatusCode::OK); + + let db_url = get_db_connection_string().await; + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); + + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); + let token = row.token; + + let delete_payload = json!({ + "did": did, + "password": password, + "token": token + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete account"); + assert_eq!(delete_res.status(), StatusCode::OK); + + let user_row = sqlx::query!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&pool) + .await + .expect("Failed to query user"); + assert!(user_row.is_none(), "User should be deleted from database"); + + let session_res = client + .get(format!( + "{}/xrpc/com.atproto.server.getSession", + base_url().await + )) + .bearer_auth(&jwt) + .send() + .await + .expect("Failed to check session"); + assert_eq!(session_res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_delete_account_wrong_password() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("delete-wrongpw-{}.test", ts); + let email = format!("delete-wrongpw-{}@test.com", ts); + let password = "correct-password"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap().to_string(); + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); + + let request_delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestAccountDelete", + base_url().await + )) + .bearer_auth(&jwt) + .send() + .await + .expect("Failed to request account deletion"); + assert_eq!(request_delete_res.status(), StatusCode::OK); + + let db_url = get_db_connection_string().await; + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); + + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); + let token = row.token; + + let delete_payload = json!({ + "did": did, + "password": "wrong-password", + "token": token + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + assert_eq!(delete_res.status(), StatusCode::UNAUTHORIZED); + + let body: Value = delete_res.json().await.unwrap(); + assert_eq!(body["error"], "AuthenticationFailed"); +} + +#[tokio::test] +async fn test_delete_account_invalid_token() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("delete-badtoken-{}.test", ts); + let email = format!("delete-badtoken-{}@test.com", ts); + let password = "delete-password"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap().to_string(); + + let delete_payload = json!({ + "did": did, + "password": password, + "token": "invalid-token-12345" + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); + + let body: Value = delete_res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidToken"); +} + +#[tokio::test] +async fn test_delete_account_expired_token() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("delete-expired-{}.test", ts); + let email = format!("delete-expired-{}@test.com", ts); + let password = "delete-password"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap().to_string(); + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); + + let request_delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestAccountDelete", + base_url().await + )) + .bearer_auth(&jwt) + .send() + .await + .expect("Failed to request account deletion"); + assert_eq!(request_delete_res.status(), StatusCode::OK); + + let db_url = get_db_connection_string().await; + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); + + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); + let token = row.token; + + sqlx::query!( + "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", + token + ) + .execute(&pool) + .await + .expect("Failed to expire token"); + + let delete_payload = json!({ + "did": did, + "password": password, + "token": token + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); + + let body: Value = delete_res.json().await.unwrap(); + assert_eq!(body["error"], "ExpiredToken"); +} + +#[tokio::test] +async fn test_delete_account_token_mismatch() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + + let handle1 = format!("delete-user1-{}.test", ts); + let email1 = format!("delete-user1-{}@test.com", ts); + let password1 = "user1-password"; + + let create1_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&json!({ + "handle": handle1, + "email": email1, + "password": password1 + })) + .send() + .await + .expect("Failed to create account 1"); + assert_eq!(create1_res.status(), StatusCode::OK); + let create1_body: Value = create1_res.json().await.unwrap(); + let did1 = create1_body["did"].as_str().unwrap().to_string(); + let jwt1 = create1_body["accessJwt"].as_str().unwrap().to_string(); + + let handle2 = format!("delete-user2-{}.test", ts); + let email2 = format!("delete-user2-{}@test.com", ts); + let password2 = "user2-password"; + + let create2_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&json!({ + "handle": handle2, + "email": email2, + "password": password2 + })) + .send() + .await + .expect("Failed to create account 2"); + assert_eq!(create2_res.status(), StatusCode::OK); + let create2_body: Value = create2_res.json().await.unwrap(); + let did2 = create2_body["did"].as_str().unwrap().to_string(); + + let request_delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestAccountDelete", + base_url().await + )) + .bearer_auth(&jwt1) + .send() + .await + .expect("Failed to request account deletion"); + assert_eq!(request_delete_res.status(), StatusCode::OK); + + let db_url = get_db_connection_string().await; + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); + + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did1) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); + let token = row.token; + + let delete_payload = json!({ + "did": did2, + "password": password2, + "token": token + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); + + let body: Value = delete_res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidToken"); +} + +#[tokio::test] +async fn test_delete_account_with_app_password() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("delete-apppw-{}.test", ts); + let email = format!("delete-apppw-{}@test.com", ts); + let main_password = "main-password-123"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": main_password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let create_body: Value = create_res.json().await.unwrap(); + let did = create_body["did"].as_str().unwrap().to_string(); + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); + + let app_password_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAppPassword", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ "name": "delete-test-app" })) + .send() + .await + .expect("Failed to create app password"); + assert_eq!(app_password_res.status(), StatusCode::OK); + let app_password_body: Value = app_password_res.json().await.unwrap(); + let app_password = app_password_body["password"].as_str().unwrap().to_string(); + + let request_delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.requestAccountDelete", + base_url().await + )) + .bearer_auth(&jwt) + .send() + .await + .expect("Failed to request account deletion"); + assert_eq!(request_delete_res.status(), StatusCode::OK); + + let db_url = get_db_connection_string().await; + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); + + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("Failed to query deletion token"); + let token = row.token; + + let delete_payload = json!({ + "did": did, + "password": app_password, + "token": token + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete account"); + assert_eq!(delete_res.status(), StatusCode::OK); + + let user_row = sqlx::query!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&pool) + .await + .expect("Failed to query user"); + assert!(user_row.is_none(), "User should be deleted from database"); +} + +#[tokio::test] +async fn test_delete_account_missing_fields() { + let client = client(); + + let res1 = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&json!({ + "password": "test", + "token": "test" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res1.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let res2 = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&json!({ + "did": "did:web:test", + "token": "test" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res2.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let res3 = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&json!({ + "did": "did:web:test", + "password": "test" + })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res3.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_delete_account_nonexistent_user() { + let client = client(); + + let delete_payload = json!({ + "did": "did:web:nonexistent.user", + "password": "any-password", + "token": "any-token" + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.server.deleteAccount", + base_url().await + )) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); + + let body: Value = delete_res.json().await.unwrap(); + assert_eq!(body["error"], "AccountNotFound"); +} diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 2b5cda1..d7c4a0b 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -4,6 +4,7 @@ use serde_json::{Value, json}; pub use crate::common::*; +#[allow(dead_code)] pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { let client = client(); let ts = Utc::now().timestamp_millis(); @@ -50,6 +51,7 @@ pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { (new_did, new_jwt) } +#[allow(dead_code)] pub async fn create_post( client: &reqwest::Client, did: &str,