+14
.sqlx/query-1dfc53ab016cfc704e94aa2cfd9fec2d1f3591bb0e141231506dc76f9da30c4a.json
+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
+2
-1
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
+28
.sqlx/query-40bd5b538224352dd8912e2c13a71b920ee3874f4acc12ebf4e6f62aae86c556.json
+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
+2
-1
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
+34
.sqlx/query-8786517e60ebcbc4150930ef766b14ee6766359ef9ca09d54116a40450a439b8.json
+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
+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
+2
-1
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
+15
.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json
+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
+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
+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
+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
+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
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
+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
+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
+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
+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
+1
src/notifications/types.rs
+188
tests/admin_email.rs
+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
+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
+
}