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