this repo has no description

Add more email-centric endpoints

lewis 45f7f2c1 32968c93

+14
.sqlx/query-1dfc53ab016cfc704e94aa2cfd9fec2d1f3591bb0e141231506dc76f9da30c4a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "1dfc53ab016cfc704e94aa2cfd9fec2d1f3591bb0e141231506dc76f9da30c4a" 14 + }
+2 -1
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
··· 34 34 "email_verification", 35 35 "password_reset", 36 36 "email_update", 37 - "account_deletion" 37 + "account_deletion", 38 + "admin_email" 38 39 ] 39 40 } 40 41 }
+28
.sqlx/query-40bd5b538224352dd8912e2c13a71b920ee3874f4acc12ebf4e6f62aae86c556.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle FROM users WHERE LOWER(email) = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "40bd5b538224352dd8912e2c13a71b920ee3874f4acc12ebf4e6f62aae86c556" 28 + }
+2 -1
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
··· 34 34 "email_verification", 35 35 "password_reset", 36 36 "email_update", 37 - "account_deletion" 37 + "account_deletion", 38 + "admin_email" 38 39 ] 39 40 } 40 41 }
+34
.sqlx/query-8786517e60ebcbc4150930ef766b14ee6766359ef9ca09d54116a40450a439b8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "password_reset_code", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "password_reset_code_expires_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + true 31 + ] 32 + }, 33 + "hash": "8786517e60ebcbc4150930ef766b14ee6766359ef9ca09d54116a40450a439b8" 34 + }
+16
.sqlx/query-9387c8162414807caaf9380a57fc720b6d25cccfb54b9feda15c08560a7562cc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_reset_code = $1, password_reset_code_expires_at = $2 WHERE id = $3", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Timestamptz", 10 + "Uuid" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "9387c8162414807caaf9380a57fc720b6d25cccfb54b9feda15c08560a7562cc" 16 + }
+2 -1
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
··· 42 42 "email_verification", 43 43 "password_reset", 44 44 "email_update", 45 - "account_deletion" 45 + "account_deletion", 46 + "admin_email" 46 47 ] 47 48 } 48 49 }
+15
.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b" 15 + }
+34
.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, email, handle 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": "email", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "handle", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67" 34 + }
+14
.sqlx/query-fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "fe9d108977af562e9e0439e755749253e52d92031e27a71d18b21265b20a4535" 14 + }
+2 -2
TODO.md
··· 36 36 - [x] Implement `com.atproto.server.listAppPasswords`. 37 37 - [x] Implement `com.atproto.server.requestAccountDelete`. 38 38 - [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 39 - - [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 39 + - [x] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 40 40 - [ ] Implement `com.atproto.server.reserveSigningKey`. 41 41 - [x] Implement `com.atproto.server.revokeAppPassword`. 42 42 - [ ] Implement `com.atproto.server.updateEmail`. ··· 97 97 - [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. 98 98 - [x] Implement `com.atproto.admin.getInviteCodes`. 99 99 - [x] Implement `com.atproto.admin.getSubjectStatus`. 100 - - [ ] Implement `com.atproto.admin.sendEmail`. 100 + - [x] Implement `com.atproto.admin.sendEmail`. 101 101 - [x] Implement `com.atproto.admin.updateAccountEmail`. 102 102 - [x] Implement `com.atproto.admin.updateAccountHandle`. 103 103 - [x] Implement `com.atproto.admin.updateAccountPassword`.
+4
migrations/202512212100_password_reset.sql
··· 1 + ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_code TEXT; 2 + ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_code_expires_at TIMESTAMPTZ; 3 + 4 + CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
+1
migrations/202512212200_admin_email_type.sql
··· 1 + ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'admin_email';
+107 -1
src/api/admin/mod.rs
··· 7 7 }; 8 8 use serde::{Deserialize, Serialize}; 9 9 use serde_json::json; 10 - use tracing::error; 10 + use tracing::{error, warn}; 11 11 12 12 #[derive(Deserialize)] 13 13 #[serde(rename_all = "camelCase")] ··· 1115 1115 } 1116 1116 } 1117 1117 } 1118 + 1119 + #[derive(Deserialize)] 1120 + #[serde(rename_all = "camelCase")] 1121 + pub struct SendEmailInput { 1122 + pub recipient_did: String, 1123 + pub sender_did: String, 1124 + pub content: String, 1125 + pub subject: Option<String>, 1126 + pub comment: Option<String>, 1127 + } 1128 + 1129 + #[derive(Serialize)] 1130 + pub struct SendEmailOutput { 1131 + pub sent: bool, 1132 + } 1133 + 1134 + pub async fn send_email( 1135 + State(state): State<AppState>, 1136 + headers: axum::http::HeaderMap, 1137 + Json(input): Json<SendEmailInput>, 1138 + ) -> Response { 1139 + let auth_header = headers.get("Authorization"); 1140 + if auth_header.is_none() { 1141 + return ( 1142 + StatusCode::UNAUTHORIZED, 1143 + Json(json!({"error": "AuthenticationRequired"})), 1144 + ) 1145 + .into_response(); 1146 + } 1147 + 1148 + let recipient_did = input.recipient_did.trim(); 1149 + let content = input.content.trim(); 1150 + 1151 + if recipient_did.is_empty() { 1152 + return ( 1153 + StatusCode::BAD_REQUEST, 1154 + Json(json!({"error": "InvalidRequest", "message": "recipientDid is required"})), 1155 + ) 1156 + .into_response(); 1157 + } 1158 + 1159 + if content.is_empty() { 1160 + return ( 1161 + StatusCode::BAD_REQUEST, 1162 + Json(json!({"error": "InvalidRequest", "message": "content is required"})), 1163 + ) 1164 + .into_response(); 1165 + } 1166 + 1167 + let user = sqlx::query!( 1168 + "SELECT id, email, handle FROM users WHERE did = $1", 1169 + recipient_did 1170 + ) 1171 + .fetch_optional(&state.db) 1172 + .await; 1173 + 1174 + let (user_id, email, handle) = match user { 1175 + Ok(Some(row)) => (row.id, row.email, row.handle), 1176 + Ok(None) => { 1177 + return ( 1178 + StatusCode::NOT_FOUND, 1179 + Json(json!({"error": "AccountNotFound", "message": "Recipient account not found"})), 1180 + ) 1181 + .into_response(); 1182 + } 1183 + Err(e) => { 1184 + error!("DB error in send_email: {:?}", e); 1185 + return ( 1186 + StatusCode::INTERNAL_SERVER_ERROR, 1187 + Json(json!({"error": "InternalError"})), 1188 + ) 1189 + .into_response(); 1190 + } 1191 + }; 1192 + 1193 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1194 + let subject = input 1195 + .subject 1196 + .clone() 1197 + .unwrap_or_else(|| format!("Message from {}", hostname)); 1198 + 1199 + let notification = crate::notifications::NewNotification::email( 1200 + user_id, 1201 + crate::notifications::NotificationType::AdminEmail, 1202 + email, 1203 + subject, 1204 + content.to_string(), 1205 + ); 1206 + 1207 + let result = crate::notifications::enqueue_notification(&state.db, notification).await; 1208 + 1209 + match result { 1210 + Ok(_) => { 1211 + tracing::info!( 1212 + "Admin email queued for {} ({})", 1213 + handle, 1214 + recipient_did 1215 + ); 1216 + (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() 1217 + } 1218 + Err(e) => { 1219 + warn!("Failed to enqueue admin email: {:?}", e); 1220 + (StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response() 1221 + } 1222 + } 1223 + }
+2 -1
src/api/server/mod.rs
··· 7 7 pub use session::{ 8 8 activate_account, check_account_status, create_app_password, create_session, 9 9 deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords, 10 - refresh_session, request_account_delete, revoke_app_password, 10 + refresh_session, request_account_delete, request_password_reset, reset_password, 11 + revoke_app_password, 11 12 };
+211 -2
src/api/server/session.rs
··· 5 5 http::StatusCode, 6 6 response::{IntoResponse, Response}, 7 7 }; 8 - use bcrypt::verify; 8 + use bcrypt::{hash, verify, DEFAULT_COST}; 9 9 use chrono::{Duration, Utc}; 10 - use uuid::Uuid; 10 + use rand::Rng; 11 11 use serde::{Deserialize, Serialize}; 12 12 use serde_json::json; 13 13 use tracing::{error, info, warn}; 14 + use uuid::Uuid; 14 15 15 16 #[derive(Deserialize)] 16 17 pub struct GetServiceAuthParams { ··· 1210 1211 } 1211 1212 } 1212 1213 } 1214 + 1215 + fn generate_reset_code() -> String { 1216 + let mut rng = rand::thread_rng(); 1217 + let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); 1218 + let part1: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); 1219 + let part2: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); 1220 + format!("{}-{}", part1, part2) 1221 + } 1222 + 1223 + #[derive(Deserialize)] 1224 + pub struct RequestPasswordResetInput { 1225 + pub email: String, 1226 + } 1227 + 1228 + pub async fn request_password_reset( 1229 + State(state): State<AppState>, 1230 + Json(input): Json<RequestPasswordResetInput>, 1231 + ) -> Response { 1232 + let email = input.email.trim().to_lowercase(); 1233 + if email.is_empty() { 1234 + return ( 1235 + StatusCode::BAD_REQUEST, 1236 + Json(json!({"error": "InvalidRequest", "message": "email is required"})), 1237 + ) 1238 + .into_response(); 1239 + } 1240 + 1241 + let user = sqlx::query!( 1242 + "SELECT id, handle FROM users WHERE LOWER(email) = $1", 1243 + email 1244 + ) 1245 + .fetch_optional(&state.db) 1246 + .await; 1247 + 1248 + let (user_id, handle) = match user { 1249 + Ok(Some(row)) => (row.id, row.handle), 1250 + Ok(None) => { 1251 + info!("Password reset requested for unknown email: {}", email); 1252 + return (StatusCode::OK, Json(json!({}))).into_response(); 1253 + } 1254 + Err(e) => { 1255 + error!("DB error in request_password_reset: {:?}", e); 1256 + return ( 1257 + StatusCode::INTERNAL_SERVER_ERROR, 1258 + Json(json!({"error": "InternalError"})), 1259 + ) 1260 + .into_response(); 1261 + } 1262 + }; 1263 + 1264 + let code = generate_reset_code(); 1265 + let expires_at = Utc::now() + Duration::minutes(10); 1266 + 1267 + let update = sqlx::query!( 1268 + "UPDATE users SET password_reset_code = $1, password_reset_code_expires_at = $2 WHERE id = $3", 1269 + code, 1270 + expires_at, 1271 + user_id 1272 + ) 1273 + .execute(&state.db) 1274 + .await; 1275 + 1276 + if let Err(e) = update { 1277 + error!("DB error setting reset code: {:?}", e); 1278 + return ( 1279 + StatusCode::INTERNAL_SERVER_ERROR, 1280 + Json(json!({"error": "InternalError"})), 1281 + ) 1282 + .into_response(); 1283 + } 1284 + 1285 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1286 + if let Err(e) = crate::notifications::enqueue_password_reset( 1287 + &state.db, 1288 + user_id, 1289 + &email, 1290 + &handle, 1291 + &code, 1292 + &hostname, 1293 + ) 1294 + .await 1295 + { 1296 + warn!("Failed to enqueue password reset notification: {:?}", e); 1297 + } 1298 + 1299 + info!("Password reset requested for user {}", user_id); 1300 + 1301 + (StatusCode::OK, Json(json!({}))).into_response() 1302 + } 1303 + 1304 + #[derive(Deserialize)] 1305 + pub struct ResetPasswordInput { 1306 + pub token: String, 1307 + pub password: String, 1308 + } 1309 + 1310 + pub async fn reset_password( 1311 + State(state): State<AppState>, 1312 + Json(input): Json<ResetPasswordInput>, 1313 + ) -> Response { 1314 + let token = input.token.trim(); 1315 + let password = &input.password; 1316 + 1317 + if token.is_empty() { 1318 + return ( 1319 + StatusCode::BAD_REQUEST, 1320 + Json(json!({"error": "InvalidToken", "message": "token is required"})), 1321 + ) 1322 + .into_response(); 1323 + } 1324 + 1325 + if password.is_empty() { 1326 + return ( 1327 + StatusCode::BAD_REQUEST, 1328 + Json(json!({"error": "InvalidRequest", "message": "password is required"})), 1329 + ) 1330 + .into_response(); 1331 + } 1332 + 1333 + let user = sqlx::query!( 1334 + "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 1335 + token 1336 + ) 1337 + .fetch_optional(&state.db) 1338 + .await; 1339 + 1340 + let (user_id, expires_at) = match user { 1341 + Ok(Some(row)) => { 1342 + let expires = row.password_reset_code_expires_at; 1343 + (row.id, expires) 1344 + } 1345 + Ok(None) => { 1346 + return ( 1347 + StatusCode::BAD_REQUEST, 1348 + Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), 1349 + ) 1350 + .into_response(); 1351 + } 1352 + Err(e) => { 1353 + error!("DB error in reset_password: {:?}", e); 1354 + return ( 1355 + StatusCode::INTERNAL_SERVER_ERROR, 1356 + Json(json!({"error": "InternalError"})), 1357 + ) 1358 + .into_response(); 1359 + } 1360 + }; 1361 + 1362 + if let Some(exp) = expires_at { 1363 + if Utc::now() > exp { 1364 + let _ = sqlx::query!( 1365 + "UPDATE users SET password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $1", 1366 + user_id 1367 + ) 1368 + .execute(&state.db) 1369 + .await; 1370 + 1371 + return ( 1372 + StatusCode::BAD_REQUEST, 1373 + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 1374 + ) 1375 + .into_response(); 1376 + } 1377 + } else { 1378 + return ( 1379 + StatusCode::BAD_REQUEST, 1380 + Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), 1381 + ) 1382 + .into_response(); 1383 + } 1384 + 1385 + let password_hash = match hash(password, DEFAULT_COST) { 1386 + Ok(h) => h, 1387 + Err(e) => { 1388 + error!("Failed to hash password: {:?}", e); 1389 + return ( 1390 + StatusCode::INTERNAL_SERVER_ERROR, 1391 + Json(json!({"error": "InternalError"})), 1392 + ) 1393 + .into_response(); 1394 + } 1395 + }; 1396 + 1397 + let update = sqlx::query!( 1398 + "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", 1399 + password_hash, 1400 + user_id 1401 + ) 1402 + .execute(&state.db) 1403 + .await; 1404 + 1405 + if let Err(e) = update { 1406 + error!("DB error updating password: {:?}", e); 1407 + return ( 1408 + StatusCode::INTERNAL_SERVER_ERROR, 1409 + Json(json!({"error": "InternalError"})), 1410 + ) 1411 + .into_response(); 1412 + } 1413 + 1414 + let _ = sqlx::query!("DELETE FROM sessions WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) 1415 + .execute(&state.db) 1416 + .await; 1417 + 1418 + info!("Password reset completed for user {}", user_id); 1419 + 1420 + (StatusCode::OK, Json(json!({}))).into_response() 1421 + }
+12
src/lib.rs
··· 156 156 post(api::server::request_account_delete), 157 157 ) 158 158 .route( 159 + "/xrpc/com.atproto.server.requestPasswordReset", 160 + post(api::server::request_password_reset), 161 + ) 162 + .route( 163 + "/xrpc/com.atproto.server.resetPassword", 164 + post(api::server::reset_password), 165 + ) 166 + .route( 159 167 "/xrpc/com.atproto.identity.updateHandle", 160 168 post(api::identity::update_handle), 161 169 ) ··· 222 230 .route( 223 231 "/xrpc/com.atproto.admin.updateSubjectStatus", 224 232 post(api::admin::update_subject_status), 233 + ) 234 + .route( 235 + "/xrpc/com.atproto.admin.sendEmail", 236 + post(api::admin::send_email), 225 237 ) 226 238 // I know I know, I'm not supposed to implement appview endpoints. Leave me be 227 239 .route(
+1
src/notifications/types.rs
··· 29 29 PasswordReset, 30 30 EmailUpdate, 31 31 AccountDeletion, 32 + AdminEmail, 32 33 } 33 34 34 35 #[derive(Debug, Clone, FromRow)]
+188
tests/admin_email.rs
··· 1 + mod common; 2 + 3 + use reqwest::StatusCode; 4 + use serde_json::{json, Value}; 5 + use sqlx::PgPool; 6 + 7 + async fn get_pool() -> PgPool { 8 + let conn_str = common::get_db_connection_string().await; 9 + sqlx::postgres::PgPoolOptions::new() 10 + .max_connections(5) 11 + .connect(&conn_str) 12 + .await 13 + .expect("Failed to connect to test database") 14 + } 15 + 16 + #[tokio::test] 17 + async fn test_send_email_success() { 18 + let client = common::client(); 19 + let base_url = common::base_url().await; 20 + let pool = get_pool().await; 21 + 22 + let (access_jwt, did) = common::create_account_and_login(&client).await; 23 + 24 + let res = client 25 + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 26 + .bearer_auth(&access_jwt) 27 + .json(&json!({ 28 + "recipientDid": did, 29 + "senderDid": "did:plc:admin", 30 + "content": "Hello, this is a test email from the admin.", 31 + "subject": "Test Admin Email" 32 + })) 33 + .send() 34 + .await 35 + .expect("Failed to send email"); 36 + 37 + assert_eq!(res.status(), StatusCode::OK); 38 + let body: Value = res.json().await.expect("Invalid JSON"); 39 + assert_eq!(body["sent"], true); 40 + 41 + let user = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 42 + .fetch_one(&pool) 43 + .await 44 + .expect("User not found"); 45 + 46 + let notification = sqlx::query!( 47 + "SELECT subject, body, notification_type as \"notification_type: String\" FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", 48 + user.id 49 + ) 50 + .fetch_one(&pool) 51 + .await 52 + .expect("Notification not found"); 53 + 54 + assert_eq!(notification.subject.as_deref(), Some("Test Admin Email")); 55 + assert!(notification.body.contains("Hello, this is a test email from the admin.")); 56 + } 57 + 58 + #[tokio::test] 59 + async fn test_send_email_default_subject() { 60 + let client = common::client(); 61 + let base_url = common::base_url().await; 62 + let pool = get_pool().await; 63 + 64 + let (access_jwt, did) = common::create_account_and_login(&client).await; 65 + 66 + let res = client 67 + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 68 + .bearer_auth(&access_jwt) 69 + .json(&json!({ 70 + "recipientDid": did, 71 + "senderDid": "did:plc:admin", 72 + "content": "Email without subject" 73 + })) 74 + .send() 75 + .await 76 + .expect("Failed to send email"); 77 + 78 + assert_eq!(res.status(), StatusCode::OK); 79 + let body: Value = res.json().await.expect("Invalid JSON"); 80 + assert_eq!(body["sent"], true); 81 + 82 + let user = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 83 + .fetch_one(&pool) 84 + .await 85 + .expect("User not found"); 86 + 87 + let notification = sqlx::query!( 88 + "SELECT subject FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", 89 + user.id 90 + ) 91 + .fetch_one(&pool) 92 + .await 93 + .expect("Notification not found"); 94 + 95 + assert!(notification.subject.is_some()); 96 + assert!(notification.subject.unwrap().contains("Message from")); 97 + } 98 + 99 + #[tokio::test] 100 + async fn test_send_email_recipient_not_found() { 101 + let client = common::client(); 102 + let base_url = common::base_url().await; 103 + 104 + let (access_jwt, _) = common::create_account_and_login(&client).await; 105 + 106 + let res = client 107 + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 108 + .bearer_auth(&access_jwt) 109 + .json(&json!({ 110 + "recipientDid": "did:plc:nonexistent", 111 + "senderDid": "did:plc:admin", 112 + "content": "Test content" 113 + })) 114 + .send() 115 + .await 116 + .expect("Failed to send email"); 117 + 118 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 119 + let body: Value = res.json().await.expect("Invalid JSON"); 120 + assert_eq!(body["error"], "AccountNotFound"); 121 + } 122 + 123 + #[tokio::test] 124 + async fn test_send_email_missing_content() { 125 + let client = common::client(); 126 + let base_url = common::base_url().await; 127 + 128 + let (access_jwt, did) = common::create_account_and_login(&client).await; 129 + 130 + let res = client 131 + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 132 + .bearer_auth(&access_jwt) 133 + .json(&json!({ 134 + "recipientDid": did, 135 + "senderDid": "did:plc:admin", 136 + "content": "" 137 + })) 138 + .send() 139 + .await 140 + .expect("Failed to send email"); 141 + 142 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 143 + let body: Value = res.json().await.expect("Invalid JSON"); 144 + assert_eq!(body["error"], "InvalidRequest"); 145 + } 146 + 147 + #[tokio::test] 148 + async fn test_send_email_missing_recipient() { 149 + let client = common::client(); 150 + let base_url = common::base_url().await; 151 + 152 + let (access_jwt, _) = common::create_account_and_login(&client).await; 153 + 154 + let res = client 155 + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 156 + .bearer_auth(&access_jwt) 157 + .json(&json!({ 158 + "recipientDid": "", 159 + "senderDid": "did:plc:admin", 160 + "content": "Test content" 161 + })) 162 + .send() 163 + .await 164 + .expect("Failed to send email"); 165 + 166 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 167 + let body: Value = res.json().await.expect("Invalid JSON"); 168 + assert_eq!(body["error"], "InvalidRequest"); 169 + } 170 + 171 + #[tokio::test] 172 + async fn test_send_email_requires_auth() { 173 + let client = common::client(); 174 + let base_url = common::base_url().await; 175 + 176 + let res = client 177 + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 178 + .json(&json!({ 179 + "recipientDid": "did:plc:test", 180 + "senderDid": "did:plc:admin", 181 + "content": "Test content" 182 + })) 183 + .send() 184 + .await 185 + .expect("Failed to send email"); 186 + 187 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 188 + }
+393
tests/password_reset.rs
··· 1 + mod common; 2 + 3 + use reqwest::StatusCode; 4 + use serde_json::{json, Value}; 5 + use sqlx::PgPool; 6 + 7 + async fn get_pool() -> PgPool { 8 + let conn_str = common::get_db_connection_string().await; 9 + sqlx::postgres::PgPoolOptions::new() 10 + .max_connections(5) 11 + .connect(&conn_str) 12 + .await 13 + .expect("Failed to connect to test database") 14 + } 15 + 16 + #[tokio::test] 17 + async fn test_request_password_reset_creates_code() { 18 + let client = common::client(); 19 + let base_url = common::base_url().await; 20 + let pool = get_pool().await; 21 + 22 + let handle = format!("pwreset_{}", uuid::Uuid::new_v4()); 23 + let email = format!("{}@example.com", handle); 24 + let payload = json!({ 25 + "handle": handle, 26 + "email": email, 27 + "password": "oldpassword" 28 + }); 29 + 30 + let res = client 31 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 32 + .json(&payload) 33 + .send() 34 + .await 35 + .expect("Failed to create account"); 36 + assert_eq!(res.status(), StatusCode::OK); 37 + 38 + let res = client 39 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 40 + .json(&json!({"email": email})) 41 + .send() 42 + .await 43 + .expect("Failed to request password reset"); 44 + 45 + assert_eq!(res.status(), StatusCode::OK); 46 + 47 + let user = sqlx::query!( 48 + "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", 49 + email 50 + ) 51 + .fetch_one(&pool) 52 + .await 53 + .expect("User not found"); 54 + 55 + assert!(user.password_reset_code.is_some()); 56 + assert!(user.password_reset_code_expires_at.is_some()); 57 + 58 + let code = user.password_reset_code.unwrap(); 59 + assert!(code.contains('-')); 60 + assert_eq!(code.len(), 11); 61 + } 62 + 63 + #[tokio::test] 64 + async fn test_request_password_reset_unknown_email_returns_ok() { 65 + let client = common::client(); 66 + let base_url = common::base_url().await; 67 + 68 + let res = client 69 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 70 + .json(&json!({"email": "nonexistent@example.com"})) 71 + .send() 72 + .await 73 + .expect("Failed to request password reset"); 74 + 75 + assert_eq!(res.status(), StatusCode::OK); 76 + } 77 + 78 + #[tokio::test] 79 + async fn test_reset_password_with_valid_token() { 80 + let client = common::client(); 81 + let base_url = common::base_url().await; 82 + let pool = get_pool().await; 83 + 84 + let handle = format!("pwreset2_{}", uuid::Uuid::new_v4()); 85 + let email = format!("{}@example.com", handle); 86 + let old_password = "oldpassword"; 87 + let new_password = "newpassword123"; 88 + 89 + let payload = json!({ 90 + "handle": handle, 91 + "email": email, 92 + "password": old_password 93 + }); 94 + 95 + let res = client 96 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 97 + .json(&payload) 98 + .send() 99 + .await 100 + .expect("Failed to create account"); 101 + assert_eq!(res.status(), StatusCode::OK); 102 + 103 + let res = client 104 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 105 + .json(&json!({"email": email})) 106 + .send() 107 + .await 108 + .expect("Failed to request password reset"); 109 + assert_eq!(res.status(), StatusCode::OK); 110 + 111 + let user = sqlx::query!( 112 + "SELECT password_reset_code FROM users WHERE email = $1", 113 + email 114 + ) 115 + .fetch_one(&pool) 116 + .await 117 + .expect("User not found"); 118 + 119 + let token = user.password_reset_code.expect("No reset code"); 120 + 121 + let res = client 122 + .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) 123 + .json(&json!({ 124 + "token": token, 125 + "password": new_password 126 + })) 127 + .send() 128 + .await 129 + .expect("Failed to reset password"); 130 + 131 + assert_eq!(res.status(), StatusCode::OK); 132 + 133 + let user = sqlx::query!( 134 + "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", 135 + email 136 + ) 137 + .fetch_one(&pool) 138 + .await 139 + .expect("User not found"); 140 + assert!(user.password_reset_code.is_none()); 141 + assert!(user.password_reset_code_expires_at.is_none()); 142 + 143 + let res = client 144 + .post(format!("{}/xrpc/com.atproto.server.createSession", base_url)) 145 + .json(&json!({ 146 + "identifier": handle, 147 + "password": new_password 148 + })) 149 + .send() 150 + .await 151 + .expect("Failed to login"); 152 + assert_eq!(res.status(), StatusCode::OK); 153 + 154 + let res = client 155 + .post(format!("{}/xrpc/com.atproto.server.createSession", base_url)) 156 + .json(&json!({ 157 + "identifier": handle, 158 + "password": old_password 159 + })) 160 + .send() 161 + .await 162 + .expect("Failed to login attempt"); 163 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 164 + } 165 + 166 + #[tokio::test] 167 + async fn test_reset_password_with_invalid_token() { 168 + let client = common::client(); 169 + let base_url = common::base_url().await; 170 + 171 + let res = client 172 + .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) 173 + .json(&json!({ 174 + "token": "invalid-token", 175 + "password": "newpassword" 176 + })) 177 + .send() 178 + .await 179 + .expect("Failed to reset password"); 180 + 181 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 182 + let body: Value = res.json().await.expect("Invalid JSON"); 183 + assert_eq!(body["error"], "InvalidToken"); 184 + } 185 + 186 + #[tokio::test] 187 + async fn test_reset_password_with_expired_token() { 188 + let client = common::client(); 189 + let base_url = common::base_url().await; 190 + let pool = get_pool().await; 191 + 192 + let handle = format!("pwreset3_{}", uuid::Uuid::new_v4()); 193 + let email = format!("{}@example.com", handle); 194 + 195 + let payload = json!({ 196 + "handle": handle, 197 + "email": email, 198 + "password": "oldpassword" 199 + }); 200 + 201 + let res = client 202 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 203 + .json(&payload) 204 + .send() 205 + .await 206 + .expect("Failed to create account"); 207 + assert_eq!(res.status(), StatusCode::OK); 208 + 209 + let res = client 210 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 211 + .json(&json!({"email": email})) 212 + .send() 213 + .await 214 + .expect("Failed to request password reset"); 215 + assert_eq!(res.status(), StatusCode::OK); 216 + 217 + let user = sqlx::query!( 218 + "SELECT password_reset_code FROM users WHERE email = $1", 219 + email 220 + ) 221 + .fetch_one(&pool) 222 + .await 223 + .expect("User not found"); 224 + 225 + let token = user.password_reset_code.expect("No reset code"); 226 + 227 + sqlx::query!( 228 + "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", 229 + email 230 + ) 231 + .execute(&pool) 232 + .await 233 + .expect("Failed to expire token"); 234 + 235 + let res = client 236 + .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) 237 + .json(&json!({ 238 + "token": token, 239 + "password": "newpassword" 240 + })) 241 + .send() 242 + .await 243 + .expect("Failed to reset password"); 244 + 245 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 246 + let body: Value = res.json().await.expect("Invalid JSON"); 247 + assert_eq!(body["error"], "ExpiredToken"); 248 + } 249 + 250 + #[tokio::test] 251 + async fn test_reset_password_invalidates_sessions() { 252 + let client = common::client(); 253 + let base_url = common::base_url().await; 254 + let pool = get_pool().await; 255 + 256 + let handle = format!("pwreset4_{}", uuid::Uuid::new_v4()); 257 + let email = format!("{}@example.com", handle); 258 + 259 + let payload = json!({ 260 + "handle": handle, 261 + "email": email, 262 + "password": "oldpassword" 263 + }); 264 + 265 + let res = client 266 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 267 + .json(&payload) 268 + .send() 269 + .await 270 + .expect("Failed to create account"); 271 + assert_eq!(res.status(), StatusCode::OK); 272 + let body: Value = res.json().await.expect("Invalid JSON"); 273 + let original_token = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 274 + 275 + let res = client 276 + .get(format!("{}/xrpc/com.atproto.server.getSession", base_url)) 277 + .bearer_auth(&original_token) 278 + .send() 279 + .await 280 + .expect("Failed to get session"); 281 + assert_eq!(res.status(), StatusCode::OK); 282 + 283 + let res = client 284 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 285 + .json(&json!({"email": email})) 286 + .send() 287 + .await 288 + .expect("Failed to request password reset"); 289 + assert_eq!(res.status(), StatusCode::OK); 290 + 291 + let user = sqlx::query!( 292 + "SELECT password_reset_code FROM users WHERE email = $1", 293 + email 294 + ) 295 + .fetch_one(&pool) 296 + .await 297 + .expect("User not found"); 298 + 299 + let token = user.password_reset_code.expect("No reset code"); 300 + 301 + let res = client 302 + .post(format!("{}/xrpc/com.atproto.server.resetPassword", base_url)) 303 + .json(&json!({ 304 + "token": token, 305 + "password": "newpassword123" 306 + })) 307 + .send() 308 + .await 309 + .expect("Failed to reset password"); 310 + assert_eq!(res.status(), StatusCode::OK); 311 + 312 + let res = client 313 + .get(format!("{}/xrpc/com.atproto.server.getSession", base_url)) 314 + .bearer_auth(&original_token) 315 + .send() 316 + .await 317 + .expect("Failed to get session"); 318 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 319 + } 320 + 321 + #[tokio::test] 322 + async fn test_request_password_reset_empty_email() { 323 + let client = common::client(); 324 + let base_url = common::base_url().await; 325 + 326 + let res = client 327 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 328 + .json(&json!({"email": ""})) 329 + .send() 330 + .await 331 + .expect("Failed to request password reset"); 332 + 333 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 334 + let body: Value = res.json().await.expect("Invalid JSON"); 335 + assert_eq!(body["error"], "InvalidRequest"); 336 + } 337 + 338 + #[tokio::test] 339 + async fn test_reset_password_creates_notification() { 340 + let pool = get_pool().await; 341 + let client = common::client(); 342 + let base_url = common::base_url().await; 343 + 344 + let handle = format!("pwreset5_{}", uuid::Uuid::new_v4()); 345 + let email = format!("{}@example.com", handle); 346 + 347 + let payload = json!({ 348 + "handle": handle, 349 + "email": email, 350 + "password": "oldpassword" 351 + }); 352 + 353 + let res = client 354 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base_url)) 355 + .json(&payload) 356 + .send() 357 + .await 358 + .expect("Failed to create account"); 359 + assert_eq!(res.status(), StatusCode::OK); 360 + 361 + let user = sqlx::query!("SELECT id FROM users WHERE email = $1", email) 362 + .fetch_one(&pool) 363 + .await 364 + .expect("User not found"); 365 + 366 + let initial_count: i64 = sqlx::query_scalar!( 367 + "SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'", 368 + user.id 369 + ) 370 + .fetch_one(&pool) 371 + .await 372 + .expect("Failed to count") 373 + .unwrap_or(0); 374 + 375 + let res = client 376 + .post(format!("{}/xrpc/com.atproto.server.requestPasswordReset", base_url)) 377 + .json(&json!({"email": email})) 378 + .send() 379 + .await 380 + .expect("Failed to request password reset"); 381 + assert_eq!(res.status(), StatusCode::OK); 382 + 383 + let final_count: i64 = sqlx::query_scalar!( 384 + "SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'", 385 + user.id 386 + ) 387 + .fetch_one(&pool) 388 + .await 389 + .expect("Failed to count") 390 + .unwrap_or(0); 391 + 392 + assert_eq!(final_count - initial_count, 1); 393 + }