+14
.sqlx/query-4018f515492ac54792fe7d44e6e708b00ae7f33970a63090a816327d698b1d14.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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,