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