I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go

Updated TODO with more stuff, added user delete endpoint

+14
.sqlx/query-4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM account_deletion_requests WHERE token = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14" 14 + }
+14
.sqlx/query-5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM app_passwords WHERE user_id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "5cbad5f679e8a1abe945778e3edd0b489a39b4ba5a1609ac1a256dc58c829419" 14 + }
+14
.sqlx/query-6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM account_deletion_requests WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "6aef2838d99c33014f59a5d315563426183d66d802a42698e8ad5f0e2c326ecc" 14 + }
+28
.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, password_hash FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "password_hash", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3" 28 + }
+28
.sqlx/query-a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did, expires_at FROM account_deletion_requests WHERE token = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "a33d91e53a9284a8d7c1dbd072bf6177e8734c28b9bb110250d700e14b0263d1" 28 + }
+56 -1
TODO.md
··· 31 31 - [x] Implement `com.atproto.server.createAppPassword`. 32 32 - [x] Implement `com.atproto.server.createInviteCode`. 33 33 - [x] Implement `com.atproto.server.createInviteCodes`. 34 - - [x] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`. 34 + - [x] Implement `com.atproto.server.deactivateAccount`. 35 + - [x] Implement `com.atproto.server.deleteAccount` (user-initiated, requires password + email token). 35 36 - [x] Implement `com.atproto.server.getAccountInviteCodes`. 36 37 - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 37 38 - [x] Implement `com.atproto.server.listAppPasswords`. ··· 106 107 ## Moderation (`com.atproto.moderation`) 107 108 - [x] Implement `com.atproto.moderation.createReport`. 108 109 110 + ## Temp Namespace (`com.atproto.temp`) 111 + - [ ] Implement `com.atproto.temp.checkSignupQueue` (signup queue status for gated signups). 112 + 113 + ## OAuth 2.0 Support 114 + The reference PDS implements full OAuth 2.0 provider functionality for native app authentication. 115 + - [ ] OAuth Provider Core 116 + - [ ] Implement `/.well-known/oauth-protected-resource` metadata endpoint. 117 + - [ ] Implement `/.well-known/oauth-authorization-server` metadata endpoint. 118 + - [ ] Implement `/oauth/authorize` authorization endpoint. 119 + - [ ] Implement `/oauth/par` Pushed Authorization Request endpoint. 120 + - [ ] Implement `/oauth/token` token endpoint. 121 + - [ ] Implement `/oauth/jwks` JSON Web Key Set endpoint. 122 + - [ ] OAuth Database Tables 123 + - [ ] Device table for tracking authorized devices. 124 + - [ ] Authorization request table. 125 + - [ ] Authorized client table. 126 + - [ ] Token table for OAuth tokens. 127 + - [ ] Used refresh token table. 128 + - [ ] DPoP (Demonstrating Proof-of-Possession) support. 129 + - [ ] Client metadata fetching and validation. 130 + 131 + ## PDS-Level App Endpoints 132 + These endpoints need to be implemented at the PDS level (not just proxied to appview). 133 + 134 + ### Actor (`app.bsky.actor`) 135 + - [ ] Implement `app.bsky.actor.getPreferences` (user preferences storage). 136 + - [ ] Implement `app.bsky.actor.putPreferences` (update user preferences). 137 + - [ ] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback). 138 + - [ ] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback). 139 + 140 + ### Feed (`app.bsky.feed`) 141 + These are implemented at PDS level to enable local-first reads: 142 + - [ ] Implement `app.bsky.feed.getTimeline` (PDS-level with proxy). 143 + - [ ] Implement `app.bsky.feed.getAuthorFeed` (PDS-level with proxy). 144 + - [ ] Implement `app.bsky.feed.getActorLikes` (PDS-level with proxy). 145 + - [ ] Implement `app.bsky.feed.getPostThread` (PDS-level with proxy). 146 + - [ ] Implement `app.bsky.feed.getFeed` (PDS-level with proxy). 147 + 148 + ### Notification (`app.bsky.notification`) 149 + - [ ] Implement `app.bsky.notification.registerPush` (push notification registration). 150 + 151 + ## Deprecated Sync Endpoints (for compatibility) 152 + - [ ] Implement `com.atproto.sync.getCheckout` (deprecated, still needed for compatibility). 153 + - [ ] Implement `com.atproto.sync.getHead` (deprecated, still needed for compatibility). 154 + 155 + ## Misc HTTP Endpoints 156 + - [ ] Implement `/robots.txt` endpoint. 157 + 109 158 ## Record Schema Validation 110 159 - [ ] Handle this generically. 160 + 161 + ## Preference Storage 162 + User preferences (for app.bsky.actor.getPreferences/putPreferences): 163 + - [ ] Create preferences table for storing user app preferences. 164 + - [ ] Implement `app.bsky.actor.getPreferences` handler (read from postgres, proxy fallback). 165 + - [ ] Implement `app.bsky.actor.putPreferences` handler (write to postgres). 111 166 112 167 ## Infrastructure & Core Components 113 168 - [x] Sequencer (Event Log)
+209
src/api/server/account_status.rs
··· 5 5 http::StatusCode, 6 6 response::{IntoResponse, Response}, 7 7 }; 8 + use bcrypt::verify; 8 9 use chrono::{Duration, Utc}; 9 10 use serde::{Deserialize, Serialize}; 10 11 use serde_json::json; ··· 391 392 392 393 (StatusCode::OK, Json(json!({}))).into_response() 393 394 } 395 + 396 + #[derive(Deserialize)] 397 + pub struct DeleteAccountInput { 398 + pub did: String, 399 + pub password: String, 400 + pub token: String, 401 + } 402 + 403 + pub async fn delete_account( 404 + State(state): State<AppState>, 405 + Json(input): Json<DeleteAccountInput>, 406 + ) -> Response { 407 + let did = input.did.trim(); 408 + let password = &input.password; 409 + let token = input.token.trim(); 410 + 411 + if did.is_empty() { 412 + return ( 413 + StatusCode::BAD_REQUEST, 414 + Json(json!({"error": "InvalidRequest", "message": "did is required"})), 415 + ) 416 + .into_response(); 417 + } 418 + 419 + if password.is_empty() { 420 + return ( 421 + StatusCode::BAD_REQUEST, 422 + Json(json!({"error": "InvalidRequest", "message": "password is required"})), 423 + ) 424 + .into_response(); 425 + } 426 + 427 + if token.is_empty() { 428 + return ( 429 + StatusCode::BAD_REQUEST, 430 + Json(json!({"error": "InvalidToken", "message": "token is required"})), 431 + ) 432 + .into_response(); 433 + } 434 + 435 + let user = sqlx::query!( 436 + "SELECT id, password_hash FROM users WHERE did = $1", 437 + did 438 + ) 439 + .fetch_optional(&state.db) 440 + .await; 441 + 442 + let (user_id, password_hash) = match user { 443 + Ok(Some(row)) => (row.id, row.password_hash), 444 + Ok(None) => { 445 + return ( 446 + StatusCode::BAD_REQUEST, 447 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 448 + ) 449 + .into_response(); 450 + } 451 + Err(e) => { 452 + error!("DB error in delete_account: {:?}", e); 453 + return ( 454 + StatusCode::INTERNAL_SERVER_ERROR, 455 + Json(json!({"error": "InternalError"})), 456 + ) 457 + .into_response(); 458 + } 459 + }; 460 + 461 + let password_valid = if verify(password, &password_hash).unwrap_or(false) { 462 + true 463 + } else { 464 + let app_pass_rows = sqlx::query!( 465 + "SELECT password_hash FROM app_passwords WHERE user_id = $1", 466 + user_id 467 + ) 468 + .fetch_all(&state.db) 469 + .await 470 + .unwrap_or_default(); 471 + 472 + app_pass_rows 473 + .iter() 474 + .any(|row| verify(password, &row.password_hash).unwrap_or(false)) 475 + }; 476 + 477 + if !password_valid { 478 + return ( 479 + StatusCode::UNAUTHORIZED, 480 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid password"})), 481 + ) 482 + .into_response(); 483 + } 484 + 485 + let deletion_request = sqlx::query!( 486 + "SELECT did, expires_at FROM account_deletion_requests WHERE token = $1", 487 + token 488 + ) 489 + .fetch_optional(&state.db) 490 + .await; 491 + 492 + let (token_did, expires_at) = match deletion_request { 493 + Ok(Some(row)) => (row.did, row.expires_at), 494 + Ok(None) => { 495 + return ( 496 + StatusCode::BAD_REQUEST, 497 + Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), 498 + ) 499 + .into_response(); 500 + } 501 + Err(e) => { 502 + error!("DB error fetching deletion token: {:?}", e); 503 + return ( 504 + StatusCode::INTERNAL_SERVER_ERROR, 505 + Json(json!({"error": "InternalError"})), 506 + ) 507 + .into_response(); 508 + } 509 + }; 510 + 511 + if token_did != did { 512 + return ( 513 + StatusCode::BAD_REQUEST, 514 + Json(json!({"error": "InvalidToken", "message": "Token does not match account"})), 515 + ) 516 + .into_response(); 517 + } 518 + 519 + if Utc::now() > expires_at { 520 + let _ = sqlx::query!("DELETE FROM account_deletion_requests WHERE token = $1", token) 521 + .execute(&state.db) 522 + .await; 523 + 524 + return ( 525 + StatusCode::BAD_REQUEST, 526 + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 527 + ) 528 + .into_response(); 529 + } 530 + 531 + let mut tx = match state.db.begin().await { 532 + Ok(tx) => tx, 533 + Err(e) => { 534 + error!("Failed to begin transaction: {:?}", e); 535 + return ( 536 + StatusCode::INTERNAL_SERVER_ERROR, 537 + Json(json!({"error": "InternalError"})), 538 + ) 539 + .into_response(); 540 + } 541 + }; 542 + 543 + let deletion_result: Result<(), sqlx::Error> = async { 544 + sqlx::query!("DELETE FROM sessions WHERE did = $1", did) 545 + .execute(&mut *tx) 546 + .await?; 547 + 548 + sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) 549 + .execute(&mut *tx) 550 + .await?; 551 + 552 + sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) 553 + .execute(&mut *tx) 554 + .await?; 555 + 556 + sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) 557 + .execute(&mut *tx) 558 + .await?; 559 + 560 + sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) 561 + .execute(&mut *tx) 562 + .await?; 563 + 564 + sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) 565 + .execute(&mut *tx) 566 + .await?; 567 + 568 + sqlx::query!("DELETE FROM account_deletion_requests WHERE did = $1", did) 569 + .execute(&mut *tx) 570 + .await?; 571 + 572 + sqlx::query!("DELETE FROM users WHERE id = $1", user_id) 573 + .execute(&mut *tx) 574 + .await?; 575 + 576 + Ok(()) 577 + } 578 + .await; 579 + 580 + match deletion_result { 581 + Ok(()) => { 582 + if let Err(e) = tx.commit().await { 583 + error!("Failed to commit account deletion transaction: {:?}", e); 584 + return ( 585 + StatusCode::INTERNAL_SERVER_ERROR, 586 + Json(json!({"error": "InternalError"})), 587 + ) 588 + .into_response(); 589 + } 590 + info!("Account {} deleted successfully", did); 591 + (StatusCode::OK, Json(json!({}))).into_response() 592 + } 593 + Err(e) => { 594 + error!("DB error deleting account, rolling back: {:?}", e); 595 + ( 596 + StatusCode::INTERNAL_SERVER_ERROR, 597 + Json(json!({"error": "InternalError"})), 598 + ) 599 + .into_response() 600 + } 601 + } 602 + }
+2 -1
src/api/server/mod.rs
··· 8 8 pub mod signing_key; 9 9 10 10 pub use account_status::{ 11 - activate_account, check_account_status, deactivate_account, request_account_delete, 11 + activate_account, check_account_status, deactivate_account, delete_account, 12 + request_account_delete, 12 13 }; 13 14 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 14 15 pub use email::{confirm_email, request_email_update, update_email};
+4
src/lib.rs
··· 161 161 post(api::server::request_account_delete), 162 162 ) 163 163 .route( 164 + "/xrpc/com.atproto.server.deleteAccount", 165 + post(api::server::delete_account), 166 + ) 167 + .route( 164 168 "/xrpc/com.atproto.server.requestPasswordReset", 165 169 post(api::server::request_password_reset), 166 170 )
+520
tests/delete_account.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use common::*; 5 + 6 + use chrono::Utc; 7 + use reqwest::StatusCode; 8 + use serde_json::{Value, json}; 9 + 10 + #[tokio::test] 11 + async fn test_delete_account_full_flow() { 12 + let client = client(); 13 + let ts = Utc::now().timestamp_millis(); 14 + let handle = format!("delete-test-{}.test", ts); 15 + let email = format!("delete-test-{}@test.com", ts); 16 + let password = "delete-password-123"; 17 + 18 + let create_payload = json!({ 19 + "handle": handle, 20 + "email": email, 21 + "password": password 22 + }); 23 + let create_res = client 24 + .post(format!( 25 + "{}/xrpc/com.atproto.server.createAccount", 26 + base_url().await 27 + )) 28 + .json(&create_payload) 29 + .send() 30 + .await 31 + .expect("Failed to create account"); 32 + assert_eq!(create_res.status(), StatusCode::OK); 33 + let create_body: Value = create_res.json().await.unwrap(); 34 + let did = create_body["did"].as_str().unwrap().to_string(); 35 + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); 36 + 37 + let request_delete_res = client 38 + .post(format!( 39 + "{}/xrpc/com.atproto.server.requestAccountDelete", 40 + base_url().await 41 + )) 42 + .bearer_auth(&jwt) 43 + .send() 44 + .await 45 + .expect("Failed to request account deletion"); 46 + assert_eq!(request_delete_res.status(), StatusCode::OK); 47 + 48 + let db_url = get_db_connection_string().await; 49 + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); 50 + 51 + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) 52 + .fetch_one(&pool) 53 + .await 54 + .expect("Failed to query deletion token"); 55 + let token = row.token; 56 + 57 + let delete_payload = json!({ 58 + "did": did, 59 + "password": password, 60 + "token": token 61 + }); 62 + let delete_res = client 63 + .post(format!( 64 + "{}/xrpc/com.atproto.server.deleteAccount", 65 + base_url().await 66 + )) 67 + .json(&delete_payload) 68 + .send() 69 + .await 70 + .expect("Failed to delete account"); 71 + assert_eq!(delete_res.status(), StatusCode::OK); 72 + 73 + let user_row = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 74 + .fetch_optional(&pool) 75 + .await 76 + .expect("Failed to query user"); 77 + assert!(user_row.is_none(), "User should be deleted from database"); 78 + 79 + let session_res = client 80 + .get(format!( 81 + "{}/xrpc/com.atproto.server.getSession", 82 + base_url().await 83 + )) 84 + .bearer_auth(&jwt) 85 + .send() 86 + .await 87 + .expect("Failed to check session"); 88 + assert_eq!(session_res.status(), StatusCode::UNAUTHORIZED); 89 + } 90 + 91 + #[tokio::test] 92 + async fn test_delete_account_wrong_password() { 93 + let client = client(); 94 + let ts = Utc::now().timestamp_millis(); 95 + let handle = format!("delete-wrongpw-{}.test", ts); 96 + let email = format!("delete-wrongpw-{}@test.com", ts); 97 + let password = "correct-password"; 98 + 99 + let create_payload = json!({ 100 + "handle": handle, 101 + "email": email, 102 + "password": password 103 + }); 104 + let create_res = client 105 + .post(format!( 106 + "{}/xrpc/com.atproto.server.createAccount", 107 + base_url().await 108 + )) 109 + .json(&create_payload) 110 + .send() 111 + .await 112 + .expect("Failed to create account"); 113 + assert_eq!(create_res.status(), StatusCode::OK); 114 + let create_body: Value = create_res.json().await.unwrap(); 115 + let did = create_body["did"].as_str().unwrap().to_string(); 116 + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); 117 + 118 + let request_delete_res = client 119 + .post(format!( 120 + "{}/xrpc/com.atproto.server.requestAccountDelete", 121 + base_url().await 122 + )) 123 + .bearer_auth(&jwt) 124 + .send() 125 + .await 126 + .expect("Failed to request account deletion"); 127 + assert_eq!(request_delete_res.status(), StatusCode::OK); 128 + 129 + let db_url = get_db_connection_string().await; 130 + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); 131 + 132 + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) 133 + .fetch_one(&pool) 134 + .await 135 + .expect("Failed to query deletion token"); 136 + let token = row.token; 137 + 138 + let delete_payload = json!({ 139 + "did": did, 140 + "password": "wrong-password", 141 + "token": token 142 + }); 143 + let delete_res = client 144 + .post(format!( 145 + "{}/xrpc/com.atproto.server.deleteAccount", 146 + base_url().await 147 + )) 148 + .json(&delete_payload) 149 + .send() 150 + .await 151 + .expect("Failed to send delete request"); 152 + assert_eq!(delete_res.status(), StatusCode::UNAUTHORIZED); 153 + 154 + let body: Value = delete_res.json().await.unwrap(); 155 + assert_eq!(body["error"], "AuthenticationFailed"); 156 + } 157 + 158 + #[tokio::test] 159 + async fn test_delete_account_invalid_token() { 160 + let client = client(); 161 + let ts = Utc::now().timestamp_millis(); 162 + let handle = format!("delete-badtoken-{}.test", ts); 163 + let email = format!("delete-badtoken-{}@test.com", ts); 164 + let password = "delete-password"; 165 + 166 + let create_payload = json!({ 167 + "handle": handle, 168 + "email": email, 169 + "password": password 170 + }); 171 + let create_res = client 172 + .post(format!( 173 + "{}/xrpc/com.atproto.server.createAccount", 174 + base_url().await 175 + )) 176 + .json(&create_payload) 177 + .send() 178 + .await 179 + .expect("Failed to create account"); 180 + assert_eq!(create_res.status(), StatusCode::OK); 181 + let create_body: Value = create_res.json().await.unwrap(); 182 + let did = create_body["did"].as_str().unwrap().to_string(); 183 + 184 + let delete_payload = json!({ 185 + "did": did, 186 + "password": password, 187 + "token": "invalid-token-12345" 188 + }); 189 + let delete_res = client 190 + .post(format!( 191 + "{}/xrpc/com.atproto.server.deleteAccount", 192 + base_url().await 193 + )) 194 + .json(&delete_payload) 195 + .send() 196 + .await 197 + .expect("Failed to send delete request"); 198 + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 199 + 200 + let body: Value = delete_res.json().await.unwrap(); 201 + assert_eq!(body["error"], "InvalidToken"); 202 + } 203 + 204 + #[tokio::test] 205 + async fn test_delete_account_expired_token() { 206 + let client = client(); 207 + let ts = Utc::now().timestamp_millis(); 208 + let handle = format!("delete-expired-{}.test", ts); 209 + let email = format!("delete-expired-{}@test.com", ts); 210 + let password = "delete-password"; 211 + 212 + let create_payload = json!({ 213 + "handle": handle, 214 + "email": email, 215 + "password": password 216 + }); 217 + let create_res = client 218 + .post(format!( 219 + "{}/xrpc/com.atproto.server.createAccount", 220 + base_url().await 221 + )) 222 + .json(&create_payload) 223 + .send() 224 + .await 225 + .expect("Failed to create account"); 226 + assert_eq!(create_res.status(), StatusCode::OK); 227 + let create_body: Value = create_res.json().await.unwrap(); 228 + let did = create_body["did"].as_str().unwrap().to_string(); 229 + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); 230 + 231 + let request_delete_res = client 232 + .post(format!( 233 + "{}/xrpc/com.atproto.server.requestAccountDelete", 234 + base_url().await 235 + )) 236 + .bearer_auth(&jwt) 237 + .send() 238 + .await 239 + .expect("Failed to request account deletion"); 240 + assert_eq!(request_delete_res.status(), StatusCode::OK); 241 + 242 + let db_url = get_db_connection_string().await; 243 + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); 244 + 245 + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) 246 + .fetch_one(&pool) 247 + .await 248 + .expect("Failed to query deletion token"); 249 + let token = row.token; 250 + 251 + sqlx::query!( 252 + "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", 253 + token 254 + ) 255 + .execute(&pool) 256 + .await 257 + .expect("Failed to expire token"); 258 + 259 + let delete_payload = json!({ 260 + "did": did, 261 + "password": password, 262 + "token": token 263 + }); 264 + let delete_res = client 265 + .post(format!( 266 + "{}/xrpc/com.atproto.server.deleteAccount", 267 + base_url().await 268 + )) 269 + .json(&delete_payload) 270 + .send() 271 + .await 272 + .expect("Failed to send delete request"); 273 + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 274 + 275 + let body: Value = delete_res.json().await.unwrap(); 276 + assert_eq!(body["error"], "ExpiredToken"); 277 + } 278 + 279 + #[tokio::test] 280 + async fn test_delete_account_token_mismatch() { 281 + let client = client(); 282 + let ts = Utc::now().timestamp_millis(); 283 + 284 + let handle1 = format!("delete-user1-{}.test", ts); 285 + let email1 = format!("delete-user1-{}@test.com", ts); 286 + let password1 = "user1-password"; 287 + 288 + let create1_res = client 289 + .post(format!( 290 + "{}/xrpc/com.atproto.server.createAccount", 291 + base_url().await 292 + )) 293 + .json(&json!({ 294 + "handle": handle1, 295 + "email": email1, 296 + "password": password1 297 + })) 298 + .send() 299 + .await 300 + .expect("Failed to create account 1"); 301 + assert_eq!(create1_res.status(), StatusCode::OK); 302 + let create1_body: Value = create1_res.json().await.unwrap(); 303 + let did1 = create1_body["did"].as_str().unwrap().to_string(); 304 + let jwt1 = create1_body["accessJwt"].as_str().unwrap().to_string(); 305 + 306 + let handle2 = format!("delete-user2-{}.test", ts); 307 + let email2 = format!("delete-user2-{}@test.com", ts); 308 + let password2 = "user2-password"; 309 + 310 + let create2_res = client 311 + .post(format!( 312 + "{}/xrpc/com.atproto.server.createAccount", 313 + base_url().await 314 + )) 315 + .json(&json!({ 316 + "handle": handle2, 317 + "email": email2, 318 + "password": password2 319 + })) 320 + .send() 321 + .await 322 + .expect("Failed to create account 2"); 323 + assert_eq!(create2_res.status(), StatusCode::OK); 324 + let create2_body: Value = create2_res.json().await.unwrap(); 325 + let did2 = create2_body["did"].as_str().unwrap().to_string(); 326 + 327 + let request_delete_res = client 328 + .post(format!( 329 + "{}/xrpc/com.atproto.server.requestAccountDelete", 330 + base_url().await 331 + )) 332 + .bearer_auth(&jwt1) 333 + .send() 334 + .await 335 + .expect("Failed to request account deletion"); 336 + assert_eq!(request_delete_res.status(), StatusCode::OK); 337 + 338 + let db_url = get_db_connection_string().await; 339 + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); 340 + 341 + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did1) 342 + .fetch_one(&pool) 343 + .await 344 + .expect("Failed to query deletion token"); 345 + let token = row.token; 346 + 347 + let delete_payload = json!({ 348 + "did": did2, 349 + "password": password2, 350 + "token": token 351 + }); 352 + let delete_res = client 353 + .post(format!( 354 + "{}/xrpc/com.atproto.server.deleteAccount", 355 + base_url().await 356 + )) 357 + .json(&delete_payload) 358 + .send() 359 + .await 360 + .expect("Failed to send delete request"); 361 + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 362 + 363 + let body: Value = delete_res.json().await.unwrap(); 364 + assert_eq!(body["error"], "InvalidToken"); 365 + } 366 + 367 + #[tokio::test] 368 + async fn test_delete_account_with_app_password() { 369 + let client = client(); 370 + let ts = Utc::now().timestamp_millis(); 371 + let handle = format!("delete-apppw-{}.test", ts); 372 + let email = format!("delete-apppw-{}@test.com", ts); 373 + let main_password = "main-password-123"; 374 + 375 + let create_payload = json!({ 376 + "handle": handle, 377 + "email": email, 378 + "password": main_password 379 + }); 380 + let create_res = client 381 + .post(format!( 382 + "{}/xrpc/com.atproto.server.createAccount", 383 + base_url().await 384 + )) 385 + .json(&create_payload) 386 + .send() 387 + .await 388 + .expect("Failed to create account"); 389 + assert_eq!(create_res.status(), StatusCode::OK); 390 + let create_body: Value = create_res.json().await.unwrap(); 391 + let did = create_body["did"].as_str().unwrap().to_string(); 392 + let jwt = create_body["accessJwt"].as_str().unwrap().to_string(); 393 + 394 + let app_password_res = client 395 + .post(format!( 396 + "{}/xrpc/com.atproto.server.createAppPassword", 397 + base_url().await 398 + )) 399 + .bearer_auth(&jwt) 400 + .json(&json!({ "name": "delete-test-app" })) 401 + .send() 402 + .await 403 + .expect("Failed to create app password"); 404 + assert_eq!(app_password_res.status(), StatusCode::OK); 405 + let app_password_body: Value = app_password_res.json().await.unwrap(); 406 + let app_password = app_password_body["password"].as_str().unwrap().to_string(); 407 + 408 + let request_delete_res = client 409 + .post(format!( 410 + "{}/xrpc/com.atproto.server.requestAccountDelete", 411 + base_url().await 412 + )) 413 + .bearer_auth(&jwt) 414 + .send() 415 + .await 416 + .expect("Failed to request account deletion"); 417 + assert_eq!(request_delete_res.status(), StatusCode::OK); 418 + 419 + let db_url = get_db_connection_string().await; 420 + let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB"); 421 + 422 + let row = sqlx::query!("SELECT token FROM account_deletion_requests WHERE did = $1", did) 423 + .fetch_one(&pool) 424 + .await 425 + .expect("Failed to query deletion token"); 426 + let token = row.token; 427 + 428 + let delete_payload = json!({ 429 + "did": did, 430 + "password": app_password, 431 + "token": token 432 + }); 433 + let delete_res = client 434 + .post(format!( 435 + "{}/xrpc/com.atproto.server.deleteAccount", 436 + base_url().await 437 + )) 438 + .json(&delete_payload) 439 + .send() 440 + .await 441 + .expect("Failed to delete account"); 442 + assert_eq!(delete_res.status(), StatusCode::OK); 443 + 444 + let user_row = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 445 + .fetch_optional(&pool) 446 + .await 447 + .expect("Failed to query user"); 448 + assert!(user_row.is_none(), "User should be deleted from database"); 449 + } 450 + 451 + #[tokio::test] 452 + async fn test_delete_account_missing_fields() { 453 + let client = client(); 454 + 455 + let res1 = client 456 + .post(format!( 457 + "{}/xrpc/com.atproto.server.deleteAccount", 458 + base_url().await 459 + )) 460 + .json(&json!({ 461 + "password": "test", 462 + "token": "test" 463 + })) 464 + .send() 465 + .await 466 + .expect("Failed to send request"); 467 + assert_eq!(res1.status(), StatusCode::UNPROCESSABLE_ENTITY); 468 + 469 + let res2 = client 470 + .post(format!( 471 + "{}/xrpc/com.atproto.server.deleteAccount", 472 + base_url().await 473 + )) 474 + .json(&json!({ 475 + "did": "did:web:test", 476 + "token": "test" 477 + })) 478 + .send() 479 + .await 480 + .expect("Failed to send request"); 481 + assert_eq!(res2.status(), StatusCode::UNPROCESSABLE_ENTITY); 482 + 483 + let res3 = client 484 + .post(format!( 485 + "{}/xrpc/com.atproto.server.deleteAccount", 486 + base_url().await 487 + )) 488 + .json(&json!({ 489 + "did": "did:web:test", 490 + "password": "test" 491 + })) 492 + .send() 493 + .await 494 + .expect("Failed to send request"); 495 + assert_eq!(res3.status(), StatusCode::UNPROCESSABLE_ENTITY); 496 + } 497 + 498 + #[tokio::test] 499 + async fn test_delete_account_nonexistent_user() { 500 + let client = client(); 501 + 502 + let delete_payload = json!({ 503 + "did": "did:web:nonexistent.user", 504 + "password": "any-password", 505 + "token": "any-token" 506 + }); 507 + let delete_res = client 508 + .post(format!( 509 + "{}/xrpc/com.atproto.server.deleteAccount", 510 + base_url().await 511 + )) 512 + .json(&delete_payload) 513 + .send() 514 + .await 515 + .expect("Failed to send delete request"); 516 + assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 517 + 518 + let body: Value = delete_res.json().await.unwrap(); 519 + assert_eq!(body["error"], "AccountNotFound"); 520 + }
+2
tests/helpers/mod.rs
··· 4 4 5 5 pub use crate::common::*; 6 6 7 + #[allow(dead_code)] 7 8 pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { 8 9 let client = client(); 9 10 let ts = Utc::now().timestamp_millis(); ··· 50 51 (new_did, new_jwt) 51 52 } 52 53 54 + #[allow(dead_code)] 53 55 pub async fn create_post( 54 56 client: &reqwest::Client, 55 57 did: &str,