+9
-9
TODO.md
+9
-9
TODO.md
···
28
28
- [x] Implement `com.atproto.server.activateAccount`.
29
29
- [x] Implement `com.atproto.server.checkAccountStatus`.
30
30
- [x] Implement `com.atproto.server.createAppPassword`.
31
-
- [ ] Implement `com.atproto.server.createInviteCode`.
32
-
- [ ] Implement `com.atproto.server.createInviteCodes`.
31
+
- [x] Implement `com.atproto.server.createInviteCode`.
32
+
- [x] Implement `com.atproto.server.createInviteCodes`.
33
33
- [x] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`.
34
-
- [ ] Implement `com.atproto.server.getAccountInviteCodes`.
34
+
- [x] Implement `com.atproto.server.getAccountInviteCodes`.
35
35
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
36
36
- [x] Implement `com.atproto.server.listAppPasswords`.
37
37
- [ ] Implement `com.atproto.server.requestAccountDelete`.
···
91
91
92
92
## Admin Management (`com.atproto.admin`)
93
93
- [x] Implement `com.atproto.admin.deleteAccount`.
94
-
- [ ] Implement `com.atproto.admin.disableAccountInvites`.
95
-
- [ ] Implement `com.atproto.admin.disableInviteCodes`.
96
-
- [ ] Implement `com.atproto.admin.enableAccountInvites`.
94
+
- [x] Implement `com.atproto.admin.disableAccountInvites`.
95
+
- [x] Implement `com.atproto.admin.disableInviteCodes`.
96
+
- [x] Implement `com.atproto.admin.enableAccountInvites`.
97
97
- [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`.
98
-
- [ ] Implement `com.atproto.admin.getInviteCodes`.
99
-
- [ ] Implement `com.atproto.admin.getSubjectStatus`.
98
+
- [x] Implement `com.atproto.admin.getInviteCodes`.
99
+
- [x] Implement `com.atproto.admin.getSubjectStatus`.
100
100
- [ ] 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`.
104
-
- [ ] Implement `com.atproto.admin.updateSubjectStatus`.
104
+
- [x] Implement `com.atproto.admin.updateSubjectStatus`.
105
105
106
106
## Moderation (`com.atproto.moderation`)
107
107
- [x] Implement `com.atproto.moderation.createReport`.
+3
migrations/202512211700_invite_enhancements.sql
+3
migrations/202512211700_invite_enhancements.sql
+5
migrations/202512211800_takedown_refs.sql
+5
migrations/202512211800_takedown_refs.sql
+659
src/api/admin/mod.rs
+659
src/api/admin/mod.rs
···
10
10
use tracing::error;
11
11
12
12
#[derive(Deserialize)]
13
+
#[serde(rename_all = "camelCase")]
14
+
pub struct DisableInviteCodesInput {
15
+
pub codes: Option<Vec<String>>,
16
+
pub accounts: Option<Vec<String>>,
17
+
}
18
+
19
+
pub async fn disable_invite_codes(
20
+
State(state): State<AppState>,
21
+
headers: axum::http::HeaderMap,
22
+
Json(input): Json<DisableInviteCodesInput>,
23
+
) -> Response {
24
+
let auth_header = headers.get("Authorization");
25
+
if auth_header.is_none() {
26
+
return (
27
+
StatusCode::UNAUTHORIZED,
28
+
Json(json!({"error": "AuthenticationRequired"})),
29
+
)
30
+
.into_response();
31
+
}
32
+
33
+
if let Some(codes) = &input.codes {
34
+
for code in codes {
35
+
let _ = sqlx::query!("UPDATE invite_codes SET disabled = TRUE WHERE code = $1", code)
36
+
.execute(&state.db)
37
+
.await;
38
+
}
39
+
}
40
+
41
+
if let Some(accounts) = &input.accounts {
42
+
for account in accounts {
43
+
let user = sqlx::query!("SELECT id FROM users WHERE did = $1", account)
44
+
.fetch_optional(&state.db)
45
+
.await;
46
+
47
+
if let Ok(Some(user_row)) = user {
48
+
let _ = sqlx::query!(
49
+
"UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1",
50
+
user_row.id
51
+
)
52
+
.execute(&state.db)
53
+
.await;
54
+
}
55
+
}
56
+
}
57
+
58
+
(StatusCode::OK, Json(json!({}))).into_response()
59
+
}
60
+
61
+
#[derive(Deserialize)]
62
+
pub struct GetSubjectStatusParams {
63
+
pub did: Option<String>,
64
+
pub uri: Option<String>,
65
+
pub blob: Option<String>,
66
+
}
67
+
68
+
#[derive(Serialize)]
69
+
pub struct SubjectStatus {
70
+
pub subject: serde_json::Value,
71
+
pub takedown: Option<StatusAttr>,
72
+
pub deactivated: Option<StatusAttr>,
73
+
}
74
+
75
+
#[derive(Serialize)]
76
+
#[serde(rename_all = "camelCase")]
77
+
pub struct StatusAttr {
78
+
pub applied: bool,
79
+
pub r#ref: Option<String>,
80
+
}
81
+
82
+
pub async fn get_subject_status(
83
+
State(state): State<AppState>,
84
+
headers: axum::http::HeaderMap,
85
+
Query(params): Query<GetSubjectStatusParams>,
86
+
) -> Response {
87
+
let auth_header = headers.get("Authorization");
88
+
if auth_header.is_none() {
89
+
return (
90
+
StatusCode::UNAUTHORIZED,
91
+
Json(json!({"error": "AuthenticationRequired"})),
92
+
)
93
+
.into_response();
94
+
}
95
+
96
+
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
97
+
return (
98
+
StatusCode::BAD_REQUEST,
99
+
Json(json!({"error": "InvalidRequest", "message": "Must provide did, uri, or blob"})),
100
+
)
101
+
.into_response();
102
+
}
103
+
104
+
if let Some(did) = ¶ms.did {
105
+
let user = sqlx::query!(
106
+
"SELECT did, deactivated_at, takedown_ref FROM users WHERE did = $1",
107
+
did
108
+
)
109
+
.fetch_optional(&state.db)
110
+
.await;
111
+
112
+
match user {
113
+
Ok(Some(row)) => {
114
+
let deactivated = row.deactivated_at.map(|_| StatusAttr {
115
+
applied: true,
116
+
r#ref: None,
117
+
});
118
+
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
119
+
applied: true,
120
+
r#ref: Some(r.clone()),
121
+
});
122
+
123
+
return (
124
+
StatusCode::OK,
125
+
Json(SubjectStatus {
126
+
subject: json!({
127
+
"$type": "com.atproto.admin.defs#repoRef",
128
+
"did": row.did
129
+
}),
130
+
takedown,
131
+
deactivated,
132
+
}),
133
+
)
134
+
.into_response();
135
+
}
136
+
Ok(None) => {
137
+
return (
138
+
StatusCode::NOT_FOUND,
139
+
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
140
+
)
141
+
.into_response();
142
+
}
143
+
Err(e) => {
144
+
error!("DB error in get_subject_status: {:?}", e);
145
+
return (
146
+
StatusCode::INTERNAL_SERVER_ERROR,
147
+
Json(json!({"error": "InternalError"})),
148
+
)
149
+
.into_response();
150
+
}
151
+
}
152
+
}
153
+
154
+
if let Some(uri) = ¶ms.uri {
155
+
let record = sqlx::query!(
156
+
"SELECT r.id, r.takedown_ref FROM records r WHERE r.record_cid = $1",
157
+
uri
158
+
)
159
+
.fetch_optional(&state.db)
160
+
.await;
161
+
162
+
match record {
163
+
Ok(Some(row)) => {
164
+
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
165
+
applied: true,
166
+
r#ref: Some(r.clone()),
167
+
});
168
+
169
+
return (
170
+
StatusCode::OK,
171
+
Json(SubjectStatus {
172
+
subject: json!({
173
+
"$type": "com.atproto.repo.strongRef",
174
+
"uri": uri,
175
+
"cid": uri
176
+
}),
177
+
takedown,
178
+
deactivated: None,
179
+
}),
180
+
)
181
+
.into_response();
182
+
}
183
+
Ok(None) => {
184
+
return (
185
+
StatusCode::NOT_FOUND,
186
+
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
187
+
)
188
+
.into_response();
189
+
}
190
+
Err(e) => {
191
+
error!("DB error in get_subject_status: {:?}", e);
192
+
return (
193
+
StatusCode::INTERNAL_SERVER_ERROR,
194
+
Json(json!({"error": "InternalError"})),
195
+
)
196
+
.into_response();
197
+
}
198
+
}
199
+
}
200
+
201
+
if let Some(blob_cid) = ¶ms.blob {
202
+
let blob = sqlx::query!("SELECT cid, takedown_ref FROM blobs WHERE cid = $1", blob_cid)
203
+
.fetch_optional(&state.db)
204
+
.await;
205
+
206
+
match blob {
207
+
Ok(Some(row)) => {
208
+
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
209
+
applied: true,
210
+
r#ref: Some(r.clone()),
211
+
});
212
+
213
+
return (
214
+
StatusCode::OK,
215
+
Json(SubjectStatus {
216
+
subject: json!({
217
+
"$type": "com.atproto.admin.defs#repoBlobRef",
218
+
"did": "",
219
+
"cid": row.cid
220
+
}),
221
+
takedown,
222
+
deactivated: None,
223
+
}),
224
+
)
225
+
.into_response();
226
+
}
227
+
Ok(None) => {
228
+
return (
229
+
StatusCode::NOT_FOUND,
230
+
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
231
+
)
232
+
.into_response();
233
+
}
234
+
Err(e) => {
235
+
error!("DB error in get_subject_status: {:?}", e);
236
+
return (
237
+
StatusCode::INTERNAL_SERVER_ERROR,
238
+
Json(json!({"error": "InternalError"})),
239
+
)
240
+
.into_response();
241
+
}
242
+
}
243
+
}
244
+
245
+
(
246
+
StatusCode::BAD_REQUEST,
247
+
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
248
+
)
249
+
.into_response()
250
+
}
251
+
252
+
#[derive(Deserialize)]
253
+
#[serde(rename_all = "camelCase")]
254
+
pub struct UpdateSubjectStatusInput {
255
+
pub subject: serde_json::Value,
256
+
pub takedown: Option<StatusAttrInput>,
257
+
pub deactivated: Option<StatusAttrInput>,
258
+
}
259
+
260
+
#[derive(Deserialize)]
261
+
pub struct StatusAttrInput {
262
+
pub apply: bool,
263
+
pub r#ref: Option<String>,
264
+
}
265
+
266
+
pub async fn update_subject_status(
267
+
State(state): State<AppState>,
268
+
headers: axum::http::HeaderMap,
269
+
Json(input): Json<UpdateSubjectStatusInput>,
270
+
) -> Response {
271
+
let auth_header = headers.get("Authorization");
272
+
if auth_header.is_none() {
273
+
return (
274
+
StatusCode::UNAUTHORIZED,
275
+
Json(json!({"error": "AuthenticationRequired"})),
276
+
)
277
+
.into_response();
278
+
}
279
+
280
+
let subject_type = input.subject.get("$type").and_then(|t| t.as_str());
281
+
282
+
match subject_type {
283
+
Some("com.atproto.admin.defs#repoRef") => {
284
+
let did = input.subject.get("did").and_then(|d| d.as_str());
285
+
if let Some(did) = did {
286
+
if let Some(takedown) = &input.takedown {
287
+
let takedown_ref = if takedown.apply {
288
+
takedown.r#ref.clone()
289
+
} else {
290
+
None
291
+
};
292
+
let _ = sqlx::query!(
293
+
"UPDATE users SET takedown_ref = $1 WHERE did = $2",
294
+
takedown_ref,
295
+
did
296
+
)
297
+
.execute(&state.db)
298
+
.await;
299
+
}
300
+
301
+
if let Some(deactivated) = &input.deactivated {
302
+
if deactivated.apply {
303
+
let _ = sqlx::query!(
304
+
"UPDATE users SET deactivated_at = NOW() WHERE did = $1",
305
+
did
306
+
)
307
+
.execute(&state.db)
308
+
.await;
309
+
} else {
310
+
let _ = sqlx::query!(
311
+
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
312
+
did
313
+
)
314
+
.execute(&state.db)
315
+
.await;
316
+
}
317
+
}
318
+
319
+
return (
320
+
StatusCode::OK,
321
+
Json(json!({
322
+
"subject": input.subject,
323
+
"takedown": input.takedown.as_ref().map(|t| json!({
324
+
"applied": t.apply,
325
+
"ref": t.r#ref
326
+
})),
327
+
"deactivated": input.deactivated.as_ref().map(|d| json!({
328
+
"applied": d.apply
329
+
}))
330
+
})),
331
+
)
332
+
.into_response();
333
+
}
334
+
}
335
+
Some("com.atproto.repo.strongRef") => {
336
+
let uri = input.subject.get("uri").and_then(|u| u.as_str());
337
+
if let Some(uri) = uri {
338
+
if let Some(takedown) = &input.takedown {
339
+
let takedown_ref = if takedown.apply {
340
+
takedown.r#ref.clone()
341
+
} else {
342
+
None
343
+
};
344
+
let _ = sqlx::query!(
345
+
"UPDATE records SET takedown_ref = $1 WHERE record_cid = $2",
346
+
takedown_ref,
347
+
uri
348
+
)
349
+
.execute(&state.db)
350
+
.await;
351
+
}
352
+
353
+
return (
354
+
StatusCode::OK,
355
+
Json(json!({
356
+
"subject": input.subject,
357
+
"takedown": input.takedown.as_ref().map(|t| json!({
358
+
"applied": t.apply,
359
+
"ref": t.r#ref
360
+
}))
361
+
})),
362
+
)
363
+
.into_response();
364
+
}
365
+
}
366
+
Some("com.atproto.admin.defs#repoBlobRef") => {
367
+
let cid = input.subject.get("cid").and_then(|c| c.as_str());
368
+
if let Some(cid) = cid {
369
+
if let Some(takedown) = &input.takedown {
370
+
let takedown_ref = if takedown.apply {
371
+
takedown.r#ref.clone()
372
+
} else {
373
+
None
374
+
};
375
+
let _ = sqlx::query!(
376
+
"UPDATE blobs SET takedown_ref = $1 WHERE cid = $2",
377
+
takedown_ref,
378
+
cid
379
+
)
380
+
.execute(&state.db)
381
+
.await;
382
+
}
383
+
384
+
return (
385
+
StatusCode::OK,
386
+
Json(json!({
387
+
"subject": input.subject,
388
+
"takedown": input.takedown.as_ref().map(|t| json!({
389
+
"applied": t.apply,
390
+
"ref": t.r#ref
391
+
}))
392
+
})),
393
+
)
394
+
.into_response();
395
+
}
396
+
}
397
+
_ => {}
398
+
}
399
+
400
+
(
401
+
StatusCode::BAD_REQUEST,
402
+
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
403
+
)
404
+
.into_response()
405
+
}
406
+
407
+
#[derive(Deserialize)]
408
+
pub struct GetInviteCodesParams {
409
+
pub sort: Option<String>,
410
+
pub limit: Option<i64>,
411
+
pub cursor: Option<String>,
412
+
}
413
+
414
+
#[derive(Serialize)]
415
+
#[serde(rename_all = "camelCase")]
416
+
pub struct InviteCodeInfo {
417
+
pub code: String,
418
+
pub available: i32,
419
+
pub disabled: bool,
420
+
pub for_account: String,
421
+
pub created_by: String,
422
+
pub created_at: String,
423
+
pub uses: Vec<InviteCodeUseInfo>,
424
+
}
425
+
426
+
#[derive(Serialize)]
427
+
#[serde(rename_all = "camelCase")]
428
+
pub struct InviteCodeUseInfo {
429
+
pub used_by: String,
430
+
pub used_at: String,
431
+
}
432
+
433
+
#[derive(Serialize)]
434
+
pub struct GetInviteCodesOutput {
435
+
pub cursor: Option<String>,
436
+
pub codes: Vec<InviteCodeInfo>,
437
+
}
438
+
439
+
pub async fn get_invite_codes(
440
+
State(state): State<AppState>,
441
+
headers: axum::http::HeaderMap,
442
+
Query(params): Query<GetInviteCodesParams>,
443
+
) -> Response {
444
+
let auth_header = headers.get("Authorization");
445
+
if auth_header.is_none() {
446
+
return (
447
+
StatusCode::UNAUTHORIZED,
448
+
Json(json!({"error": "AuthenticationRequired"})),
449
+
)
450
+
.into_response();
451
+
}
452
+
453
+
let limit = params.limit.unwrap_or(100).min(500);
454
+
let sort = params.sort.as_deref().unwrap_or("recent");
455
+
456
+
let order_clause = match sort {
457
+
"usage" => "available_uses DESC",
458
+
_ => "created_at DESC",
459
+
};
460
+
461
+
let codes_result = if let Some(cursor) = ¶ms.cursor {
462
+
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
463
+
r#"
464
+
SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
465
+
FROM invite_codes ic
466
+
WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1)
467
+
ORDER BY {}
468
+
LIMIT $2
469
+
"#,
470
+
order_clause
471
+
))
472
+
.bind(cursor)
473
+
.bind(limit)
474
+
.fetch_all(&state.db)
475
+
.await
476
+
} else {
477
+
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
478
+
r#"
479
+
SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
480
+
FROM invite_codes ic
481
+
ORDER BY {}
482
+
LIMIT $1
483
+
"#,
484
+
order_clause
485
+
))
486
+
.bind(limit)
487
+
.fetch_all(&state.db)
488
+
.await
489
+
};
490
+
491
+
let codes_rows = match codes_result {
492
+
Ok(rows) => rows,
493
+
Err(e) => {
494
+
error!("DB error fetching invite codes: {:?}", e);
495
+
return (
496
+
StatusCode::INTERNAL_SERVER_ERROR,
497
+
Json(json!({"error": "InternalError"})),
498
+
)
499
+
.into_response();
500
+
}
501
+
};
502
+
503
+
let mut codes = Vec::new();
504
+
for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows {
505
+
let creator_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
506
+
.fetch_optional(&state.db)
507
+
.await
508
+
.ok()
509
+
.flatten()
510
+
.unwrap_or_else(|| "unknown".to_string());
511
+
512
+
let uses_result = sqlx::query!(
513
+
r#"
514
+
SELECT u.did, icu.used_at
515
+
FROM invite_code_uses icu
516
+
JOIN users u ON icu.used_by_user = u.id
517
+
WHERE icu.code = $1
518
+
ORDER BY icu.used_at DESC
519
+
"#,
520
+
code
521
+
)
522
+
.fetch_all(&state.db)
523
+
.await;
524
+
525
+
let uses = match uses_result {
526
+
Ok(use_rows) => use_rows
527
+
.iter()
528
+
.map(|u| InviteCodeUseInfo {
529
+
used_by: u.did.clone(),
530
+
used_at: u.used_at.to_rfc3339(),
531
+
})
532
+
.collect(),
533
+
Err(_) => Vec::new(),
534
+
};
535
+
536
+
codes.push(InviteCodeInfo {
537
+
code: code.clone(),
538
+
available: *available_uses,
539
+
disabled: disabled.unwrap_or(false),
540
+
for_account: creator_did.clone(),
541
+
created_by: creator_did,
542
+
created_at: created_at.to_rfc3339(),
543
+
uses,
544
+
});
545
+
}
546
+
547
+
let next_cursor = if codes_rows.len() == limit as usize {
548
+
codes_rows.last().map(|(code, _, _, _, _)| code.clone())
549
+
} else {
550
+
None
551
+
};
552
+
553
+
(
554
+
StatusCode::OK,
555
+
Json(GetInviteCodesOutput {
556
+
cursor: next_cursor,
557
+
codes,
558
+
}),
559
+
)
560
+
.into_response()
561
+
}
562
+
563
+
#[derive(Deserialize)]
564
+
pub struct DisableAccountInvitesInput {
565
+
pub account: String,
566
+
}
567
+
568
+
pub async fn disable_account_invites(
569
+
State(state): State<AppState>,
570
+
headers: axum::http::HeaderMap,
571
+
Json(input): Json<DisableAccountInvitesInput>,
572
+
) -> Response {
573
+
let auth_header = headers.get("Authorization");
574
+
if auth_header.is_none() {
575
+
return (
576
+
StatusCode::UNAUTHORIZED,
577
+
Json(json!({"error": "AuthenticationRequired"})),
578
+
)
579
+
.into_response();
580
+
}
581
+
582
+
let account = input.account.trim();
583
+
if account.is_empty() {
584
+
return (
585
+
StatusCode::BAD_REQUEST,
586
+
Json(json!({"error": "InvalidRequest", "message": "account is required"})),
587
+
)
588
+
.into_response();
589
+
}
590
+
591
+
let result = sqlx::query!("UPDATE users SET invites_disabled = TRUE WHERE did = $1", account)
592
+
.execute(&state.db)
593
+
.await;
594
+
595
+
match result {
596
+
Ok(r) => {
597
+
if r.rows_affected() == 0 {
598
+
return (
599
+
StatusCode::NOT_FOUND,
600
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
601
+
)
602
+
.into_response();
603
+
}
604
+
(StatusCode::OK, Json(json!({}))).into_response()
605
+
}
606
+
Err(e) => {
607
+
error!("DB error disabling account invites: {:?}", e);
608
+
(
609
+
StatusCode::INTERNAL_SERVER_ERROR,
610
+
Json(json!({"error": "InternalError"})),
611
+
)
612
+
.into_response()
613
+
}
614
+
}
615
+
}
616
+
617
+
#[derive(Deserialize)]
618
+
pub struct EnableAccountInvitesInput {
619
+
pub account: String,
620
+
}
621
+
622
+
pub async fn enable_account_invites(
623
+
State(state): State<AppState>,
624
+
headers: axum::http::HeaderMap,
625
+
Json(input): Json<EnableAccountInvitesInput>,
626
+
) -> Response {
627
+
let auth_header = headers.get("Authorization");
628
+
if auth_header.is_none() {
629
+
return (
630
+
StatusCode::UNAUTHORIZED,
631
+
Json(json!({"error": "AuthenticationRequired"})),
632
+
)
633
+
.into_response();
634
+
}
635
+
636
+
let account = input.account.trim();
637
+
if account.is_empty() {
638
+
return (
639
+
StatusCode::BAD_REQUEST,
640
+
Json(json!({"error": "InvalidRequest", "message": "account is required"})),
641
+
)
642
+
.into_response();
643
+
}
644
+
645
+
let result = sqlx::query!("UPDATE users SET invites_disabled = FALSE WHERE did = $1", account)
646
+
.execute(&state.db)
647
+
.await;
648
+
649
+
match result {
650
+
Ok(r) => {
651
+
if r.rows_affected() == 0 {
652
+
return (
653
+
StatusCode::NOT_FOUND,
654
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
655
+
)
656
+
.into_response();
657
+
}
658
+
(StatusCode::OK, Json(json!({}))).into_response()
659
+
}
660
+
Err(e) => {
661
+
error!("DB error enabling account invites: {:?}", e);
662
+
(
663
+
StatusCode::INTERNAL_SERVER_ERROR,
664
+
Json(json!({"error": "InternalError"})),
665
+
)
666
+
.into_response()
667
+
}
668
+
}
669
+
}
670
+
671
+
#[derive(Deserialize)]
13
672
pub struct GetAccountInfoParams {
14
673
pub did: String,
15
674
}
+502
src/api/server/invite.rs
+502
src/api/server/invite.rs
···
1
+
use crate::state::AppState;
2
+
use axum::{
3
+
Json,
4
+
extract::State,
5
+
http::StatusCode,
6
+
response::{IntoResponse, Response},
7
+
};
8
+
use serde::{Deserialize, Serialize};
9
+
use serde_json::json;
10
+
use tracing::error;
11
+
use uuid::Uuid;
12
+
13
+
#[derive(Deserialize)]
14
+
#[serde(rename_all = "camelCase")]
15
+
pub struct CreateInviteCodeInput {
16
+
pub use_count: i32,
17
+
pub for_account: Option<String>,
18
+
}
19
+
20
+
#[derive(Serialize)]
21
+
pub struct CreateInviteCodeOutput {
22
+
pub code: String,
23
+
}
24
+
25
+
pub async fn create_invite_code(
26
+
State(state): State<AppState>,
27
+
headers: axum::http::HeaderMap,
28
+
Json(input): Json<CreateInviteCodeInput>,
29
+
) -> Response {
30
+
let auth_header = headers.get("Authorization");
31
+
if auth_header.is_none() {
32
+
return (
33
+
StatusCode::UNAUTHORIZED,
34
+
Json(json!({"error": "AuthenticationRequired"})),
35
+
)
36
+
.into_response();
37
+
}
38
+
39
+
if input.use_count < 1 {
40
+
return (
41
+
StatusCode::BAD_REQUEST,
42
+
Json(json!({"error": "InvalidRequest", "message": "useCount must be at least 1"})),
43
+
)
44
+
.into_response();
45
+
}
46
+
47
+
let token = auth_header
48
+
.unwrap()
49
+
.to_str()
50
+
.unwrap_or("")
51
+
.replace("Bearer ", "");
52
+
53
+
let session = sqlx::query!(
54
+
r#"
55
+
SELECT s.did, k.key_bytes, u.id as user_id
56
+
FROM sessions s
57
+
JOIN users u ON s.did = u.did
58
+
JOIN user_keys k ON u.id = k.user_id
59
+
WHERE s.access_jwt = $1
60
+
"#,
61
+
token
62
+
)
63
+
.fetch_optional(&state.db)
64
+
.await;
65
+
66
+
let (did, key_bytes, user_id) = match session {
67
+
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
68
+
Ok(None) => {
69
+
return (
70
+
StatusCode::UNAUTHORIZED,
71
+
Json(json!({"error": "AuthenticationFailed"})),
72
+
)
73
+
.into_response();
74
+
}
75
+
Err(e) => {
76
+
error!("DB error in create_invite_code: {:?}", e);
77
+
return (
78
+
StatusCode::INTERNAL_SERVER_ERROR,
79
+
Json(json!({"error": "InternalError"})),
80
+
)
81
+
.into_response();
82
+
}
83
+
};
84
+
85
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
86
+
return (
87
+
StatusCode::UNAUTHORIZED,
88
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
89
+
)
90
+
.into_response();
91
+
}
92
+
93
+
let creator_user_id = if let Some(for_account) = &input.for_account {
94
+
let target = sqlx::query!("SELECT id FROM users WHERE did = $1", for_account)
95
+
.fetch_optional(&state.db)
96
+
.await;
97
+
98
+
match target {
99
+
Ok(Some(row)) => row.id,
100
+
Ok(None) => {
101
+
return (
102
+
StatusCode::NOT_FOUND,
103
+
Json(json!({"error": "AccountNotFound", "message": "Target account not found"})),
104
+
)
105
+
.into_response();
106
+
}
107
+
Err(e) => {
108
+
error!("DB error looking up target account: {:?}", e);
109
+
return (
110
+
StatusCode::INTERNAL_SERVER_ERROR,
111
+
Json(json!({"error": "InternalError"})),
112
+
)
113
+
.into_response();
114
+
}
115
+
}
116
+
} else {
117
+
user_id
118
+
};
119
+
120
+
let user_invites_disabled = sqlx::query_scalar!(
121
+
"SELECT invites_disabled FROM users WHERE did = $1",
122
+
did
123
+
)
124
+
.fetch_optional(&state.db)
125
+
.await
126
+
.ok()
127
+
.flatten()
128
+
.flatten()
129
+
.unwrap_or(false);
130
+
131
+
if user_invites_disabled {
132
+
return (
133
+
StatusCode::FORBIDDEN,
134
+
Json(json!({"error": "InvitesDisabled", "message": "Invites are disabled for this account"})),
135
+
)
136
+
.into_response();
137
+
}
138
+
139
+
let code = Uuid::new_v4().to_string();
140
+
141
+
let result = sqlx::query!(
142
+
"INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)",
143
+
code,
144
+
input.use_count,
145
+
creator_user_id
146
+
)
147
+
.execute(&state.db)
148
+
.await;
149
+
150
+
match result {
151
+
Ok(_) => (StatusCode::OK, Json(CreateInviteCodeOutput { code })).into_response(),
152
+
Err(e) => {
153
+
error!("DB error creating invite code: {:?}", e);
154
+
(
155
+
StatusCode::INTERNAL_SERVER_ERROR,
156
+
Json(json!({"error": "InternalError"})),
157
+
)
158
+
.into_response()
159
+
}
160
+
}
161
+
}
162
+
163
+
#[derive(Deserialize)]
164
+
#[serde(rename_all = "camelCase")]
165
+
pub struct CreateInviteCodesInput {
166
+
pub code_count: Option<i32>,
167
+
pub use_count: i32,
168
+
pub for_accounts: Option<Vec<String>>,
169
+
}
170
+
171
+
#[derive(Serialize)]
172
+
pub struct CreateInviteCodesOutput {
173
+
pub codes: Vec<AccountCodes>,
174
+
}
175
+
176
+
#[derive(Serialize)]
177
+
pub struct AccountCodes {
178
+
pub account: String,
179
+
pub codes: Vec<String>,
180
+
}
181
+
182
+
pub async fn create_invite_codes(
183
+
State(state): State<AppState>,
184
+
headers: axum::http::HeaderMap,
185
+
Json(input): Json<CreateInviteCodesInput>,
186
+
) -> Response {
187
+
let auth_header = headers.get("Authorization");
188
+
if auth_header.is_none() {
189
+
return (
190
+
StatusCode::UNAUTHORIZED,
191
+
Json(json!({"error": "AuthenticationRequired"})),
192
+
)
193
+
.into_response();
194
+
}
195
+
196
+
if input.use_count < 1 {
197
+
return (
198
+
StatusCode::BAD_REQUEST,
199
+
Json(json!({"error": "InvalidRequest", "message": "useCount must be at least 1"})),
200
+
)
201
+
.into_response();
202
+
}
203
+
204
+
let token = auth_header
205
+
.unwrap()
206
+
.to_str()
207
+
.unwrap_or("")
208
+
.replace("Bearer ", "");
209
+
210
+
let session = sqlx::query!(
211
+
r#"
212
+
SELECT s.did, k.key_bytes, u.id as user_id
213
+
FROM sessions s
214
+
JOIN users u ON s.did = u.did
215
+
JOIN user_keys k ON u.id = k.user_id
216
+
WHERE s.access_jwt = $1
217
+
"#,
218
+
token
219
+
)
220
+
.fetch_optional(&state.db)
221
+
.await;
222
+
223
+
let (_did, key_bytes, user_id) = match session {
224
+
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
225
+
Ok(None) => {
226
+
return (
227
+
StatusCode::UNAUTHORIZED,
228
+
Json(json!({"error": "AuthenticationFailed"})),
229
+
)
230
+
.into_response();
231
+
}
232
+
Err(e) => {
233
+
error!("DB error in create_invite_codes: {:?}", e);
234
+
return (
235
+
StatusCode::INTERNAL_SERVER_ERROR,
236
+
Json(json!({"error": "InternalError"})),
237
+
)
238
+
.into_response();
239
+
}
240
+
};
241
+
242
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
243
+
return (
244
+
StatusCode::UNAUTHORIZED,
245
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
246
+
)
247
+
.into_response();
248
+
}
249
+
250
+
let code_count = input.code_count.unwrap_or(1).max(1);
251
+
let for_accounts = input.for_accounts.unwrap_or_default();
252
+
253
+
let mut result_codes = Vec::new();
254
+
255
+
if for_accounts.is_empty() {
256
+
let mut codes = Vec::new();
257
+
for _ in 0..code_count {
258
+
let code = Uuid::new_v4().to_string();
259
+
260
+
let insert = sqlx::query!(
261
+
"INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)",
262
+
code,
263
+
input.use_count,
264
+
user_id
265
+
)
266
+
.execute(&state.db)
267
+
.await;
268
+
269
+
if let Err(e) = insert {
270
+
error!("DB error creating invite code: {:?}", e);
271
+
return (
272
+
StatusCode::INTERNAL_SERVER_ERROR,
273
+
Json(json!({"error": "InternalError"})),
274
+
)
275
+
.into_response();
276
+
}
277
+
278
+
codes.push(code);
279
+
}
280
+
281
+
result_codes.push(AccountCodes {
282
+
account: "admin".to_string(),
283
+
codes,
284
+
});
285
+
} else {
286
+
for account_did in for_accounts {
287
+
let target = sqlx::query!("SELECT id FROM users WHERE did = $1", account_did)
288
+
.fetch_optional(&state.db)
289
+
.await;
290
+
291
+
let target_user_id = match target {
292
+
Ok(Some(row)) => row.id,
293
+
Ok(None) => {
294
+
continue;
295
+
}
296
+
Err(e) => {
297
+
error!("DB error looking up target account: {:?}", e);
298
+
return (
299
+
StatusCode::INTERNAL_SERVER_ERROR,
300
+
Json(json!({"error": "InternalError"})),
301
+
)
302
+
.into_response();
303
+
}
304
+
};
305
+
306
+
let mut codes = Vec::new();
307
+
for _ in 0..code_count {
308
+
let code = Uuid::new_v4().to_string();
309
+
310
+
let insert = sqlx::query!(
311
+
"INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)",
312
+
code,
313
+
input.use_count,
314
+
target_user_id
315
+
)
316
+
.execute(&state.db)
317
+
.await;
318
+
319
+
if let Err(e) = insert {
320
+
error!("DB error creating invite code: {:?}", e);
321
+
return (
322
+
StatusCode::INTERNAL_SERVER_ERROR,
323
+
Json(json!({"error": "InternalError"})),
324
+
)
325
+
.into_response();
326
+
}
327
+
328
+
codes.push(code);
329
+
}
330
+
331
+
result_codes.push(AccountCodes {
332
+
account: account_did,
333
+
codes,
334
+
});
335
+
}
336
+
}
337
+
338
+
(StatusCode::OK, Json(CreateInviteCodesOutput { codes: result_codes })).into_response()
339
+
}
340
+
341
+
#[derive(Deserialize)]
342
+
#[serde(rename_all = "camelCase")]
343
+
pub struct GetAccountInviteCodesParams {
344
+
pub include_used: Option<bool>,
345
+
pub create_available: Option<bool>,
346
+
}
347
+
348
+
#[derive(Serialize)]
349
+
#[serde(rename_all = "camelCase")]
350
+
pub struct InviteCode {
351
+
pub code: String,
352
+
pub available: i32,
353
+
pub disabled: bool,
354
+
pub for_account: String,
355
+
pub created_by: String,
356
+
pub created_at: String,
357
+
pub uses: Vec<InviteCodeUse>,
358
+
}
359
+
360
+
#[derive(Serialize)]
361
+
#[serde(rename_all = "camelCase")]
362
+
pub struct InviteCodeUse {
363
+
pub used_by: String,
364
+
pub used_at: String,
365
+
}
366
+
367
+
#[derive(Serialize)]
368
+
pub struct GetAccountInviteCodesOutput {
369
+
pub codes: Vec<InviteCode>,
370
+
}
371
+
372
+
pub async fn get_account_invite_codes(
373
+
State(state): State<AppState>,
374
+
headers: axum::http::HeaderMap,
375
+
axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>,
376
+
) -> Response {
377
+
let auth_header = headers.get("Authorization");
378
+
if auth_header.is_none() {
379
+
return (
380
+
StatusCode::UNAUTHORIZED,
381
+
Json(json!({"error": "AuthenticationRequired"})),
382
+
)
383
+
.into_response();
384
+
}
385
+
386
+
let token = auth_header
387
+
.unwrap()
388
+
.to_str()
389
+
.unwrap_or("")
390
+
.replace("Bearer ", "");
391
+
392
+
let session = sqlx::query!(
393
+
r#"
394
+
SELECT s.did, k.key_bytes, u.id as user_id
395
+
FROM sessions s
396
+
JOIN users u ON s.did = u.did
397
+
JOIN user_keys k ON u.id = k.user_id
398
+
WHERE s.access_jwt = $1
399
+
"#,
400
+
token
401
+
)
402
+
.fetch_optional(&state.db)
403
+
.await;
404
+
405
+
let (did, key_bytes, user_id) = match session {
406
+
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id),
407
+
Ok(None) => {
408
+
return (
409
+
StatusCode::UNAUTHORIZED,
410
+
Json(json!({"error": "AuthenticationFailed"})),
411
+
)
412
+
.into_response();
413
+
}
414
+
Err(e) => {
415
+
error!("DB error in get_account_invite_codes: {:?}", e);
416
+
return (
417
+
StatusCode::INTERNAL_SERVER_ERROR,
418
+
Json(json!({"error": "InternalError"})),
419
+
)
420
+
.into_response();
421
+
}
422
+
};
423
+
424
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
425
+
return (
426
+
StatusCode::UNAUTHORIZED,
427
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
428
+
)
429
+
.into_response();
430
+
}
431
+
432
+
let include_used = params.include_used.unwrap_or(true);
433
+
434
+
let codes_result = sqlx::query!(
435
+
r#"
436
+
SELECT code, available_uses, created_at, disabled
437
+
FROM invite_codes
438
+
WHERE created_by_user = $1
439
+
ORDER BY created_at DESC
440
+
"#,
441
+
user_id
442
+
)
443
+
.fetch_all(&state.db)
444
+
.await;
445
+
446
+
let codes_rows = match codes_result {
447
+
Ok(rows) => {
448
+
if include_used {
449
+
rows
450
+
} else {
451
+
rows.into_iter().filter(|r| r.available_uses > 0).collect()
452
+
}
453
+
}
454
+
Err(e) => {
455
+
error!("DB error fetching invite codes: {:?}", e);
456
+
return (
457
+
StatusCode::INTERNAL_SERVER_ERROR,
458
+
Json(json!({"error": "InternalError"})),
459
+
)
460
+
.into_response();
461
+
}
462
+
};
463
+
464
+
let mut codes = Vec::new();
465
+
for row in codes_rows {
466
+
let uses_result = sqlx::query!(
467
+
r#"
468
+
SELECT u.did, icu.used_at
469
+
FROM invite_code_uses icu
470
+
JOIN users u ON icu.used_by_user = u.id
471
+
WHERE icu.code = $1
472
+
ORDER BY icu.used_at DESC
473
+
"#,
474
+
row.code
475
+
)
476
+
.fetch_all(&state.db)
477
+
.await;
478
+
479
+
let uses = match uses_result {
480
+
Ok(use_rows) => use_rows
481
+
.iter()
482
+
.map(|u| InviteCodeUse {
483
+
used_by: u.did.clone(),
484
+
used_at: u.used_at.to_rfc3339(),
485
+
})
486
+
.collect(),
487
+
Err(_) => Vec::new(),
488
+
};
489
+
490
+
codes.push(InviteCode {
491
+
code: row.code,
492
+
available: row.available_uses,
493
+
disabled: row.disabled.unwrap_or(false),
494
+
for_account: did.clone(),
495
+
created_by: did.clone(),
496
+
created_at: row.created_at.to_rfc3339(),
497
+
uses,
498
+
});
499
+
}
500
+
501
+
(StatusCode::OK, Json(GetAccountInviteCodesOutput { codes })).into_response()
502
+
}
+2
src/api/server/mod.rs
+2
src/api/server/mod.rs
···
1
+
pub mod invite;
1
2
pub mod meta;
2
3
pub mod session;
3
4
5
+
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
4
6
pub use meta::{describe_server, health};
5
7
pub use session::{
6
8
activate_account, check_account_status, create_app_password, create_session,
+36
src/lib.rs
+36
src/lib.rs
···
182
182
"/xrpc/com.atproto.server.revokeAppPassword",
183
183
post(api::server::revoke_app_password),
184
184
)
185
+
.route(
186
+
"/xrpc/com.atproto.server.createInviteCode",
187
+
post(api::server::create_invite_code),
188
+
)
189
+
.route(
190
+
"/xrpc/com.atproto.server.createInviteCodes",
191
+
post(api::server::create_invite_codes),
192
+
)
193
+
.route(
194
+
"/xrpc/com.atproto.server.getAccountInviteCodes",
195
+
get(api::server::get_account_invite_codes),
196
+
)
197
+
.route(
198
+
"/xrpc/com.atproto.admin.getInviteCodes",
199
+
get(api::admin::get_invite_codes),
200
+
)
201
+
.route(
202
+
"/xrpc/com.atproto.admin.disableAccountInvites",
203
+
post(api::admin::disable_account_invites),
204
+
)
205
+
.route(
206
+
"/xrpc/com.atproto.admin.enableAccountInvites",
207
+
post(api::admin::enable_account_invites),
208
+
)
209
+
.route(
210
+
"/xrpc/com.atproto.admin.disableInviteCodes",
211
+
post(api::admin::disable_invite_codes),
212
+
)
213
+
.route(
214
+
"/xrpc/com.atproto.admin.getSubjectStatus",
215
+
get(api::admin::get_subject_status),
216
+
)
217
+
.route(
218
+
"/xrpc/com.atproto.admin.updateSubjectStatus",
219
+
post(api::admin::update_subject_status),
220
+
)
185
221
// I know I know, I'm not supposed to implement appview endpoints. Leave me be
186
222
.route(
187
223
"/xrpc/app.bsky.feed.getTimeline",
+378
tests/admin_invite.rs
+378
tests/admin_invite.rs
···
1
+
mod common;
2
+
use common::*;
3
+
4
+
use reqwest::StatusCode;
5
+
use serde_json::{Value, json};
6
+
7
+
#[tokio::test]
8
+
async fn test_admin_get_invite_codes_success() {
9
+
let client = client();
10
+
let (access_jwt, _did) = create_account_and_login(&client).await;
11
+
12
+
let create_payload = json!({
13
+
"useCount": 3
14
+
});
15
+
let _ = client
16
+
.post(format!(
17
+
"{}/xrpc/com.atproto.server.createInviteCode",
18
+
base_url().await
19
+
))
20
+
.bearer_auth(&access_jwt)
21
+
.json(&create_payload)
22
+
.send()
23
+
.await
24
+
.expect("Failed to create invite code");
25
+
26
+
let res = client
27
+
.get(format!(
28
+
"{}/xrpc/com.atproto.admin.getInviteCodes",
29
+
base_url().await
30
+
))
31
+
.bearer_auth(&access_jwt)
32
+
.send()
33
+
.await
34
+
.expect("Failed to send request");
35
+
36
+
assert_eq!(res.status(), StatusCode::OK);
37
+
let body: Value = res.json().await.expect("Response was not valid JSON");
38
+
assert!(body["codes"].is_array());
39
+
}
40
+
41
+
#[tokio::test]
42
+
async fn test_admin_get_invite_codes_with_limit() {
43
+
let client = client();
44
+
let (access_jwt, _did) = create_account_and_login(&client).await;
45
+
46
+
for _ in 0..5 {
47
+
let create_payload = json!({
48
+
"useCount": 1
49
+
});
50
+
let _ = client
51
+
.post(format!(
52
+
"{}/xrpc/com.atproto.server.createInviteCode",
53
+
base_url().await
54
+
))
55
+
.bearer_auth(&access_jwt)
56
+
.json(&create_payload)
57
+
.send()
58
+
.await;
59
+
}
60
+
61
+
let res = client
62
+
.get(format!(
63
+
"{}/xrpc/com.atproto.admin.getInviteCodes",
64
+
base_url().await
65
+
))
66
+
.bearer_auth(&access_jwt)
67
+
.query(&[("limit", "2")])
68
+
.send()
69
+
.await
70
+
.expect("Failed to send request");
71
+
72
+
assert_eq!(res.status(), StatusCode::OK);
73
+
let body: Value = res.json().await.expect("Response was not valid JSON");
74
+
let codes = body["codes"].as_array().unwrap();
75
+
assert!(codes.len() <= 2);
76
+
}
77
+
78
+
#[tokio::test]
79
+
async fn test_admin_get_invite_codes_no_auth() {
80
+
let client = client();
81
+
82
+
let res = client
83
+
.get(format!(
84
+
"{}/xrpc/com.atproto.admin.getInviteCodes",
85
+
base_url().await
86
+
))
87
+
.send()
88
+
.await
89
+
.expect("Failed to send request");
90
+
91
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
92
+
}
93
+
94
+
#[tokio::test]
95
+
async fn test_disable_account_invites_success() {
96
+
let client = client();
97
+
let (access_jwt, did) = create_account_and_login(&client).await;
98
+
99
+
let payload = json!({
100
+
"account": did
101
+
});
102
+
103
+
let res = client
104
+
.post(format!(
105
+
"{}/xrpc/com.atproto.admin.disableAccountInvites",
106
+
base_url().await
107
+
))
108
+
.bearer_auth(&access_jwt)
109
+
.json(&payload)
110
+
.send()
111
+
.await
112
+
.expect("Failed to send request");
113
+
114
+
assert_eq!(res.status(), StatusCode::OK);
115
+
116
+
let create_payload = json!({
117
+
"useCount": 1
118
+
});
119
+
let res = client
120
+
.post(format!(
121
+
"{}/xrpc/com.atproto.server.createInviteCode",
122
+
base_url().await
123
+
))
124
+
.bearer_auth(&access_jwt)
125
+
.json(&create_payload)
126
+
.send()
127
+
.await
128
+
.expect("Failed to send request");
129
+
130
+
assert_eq!(res.status(), StatusCode::FORBIDDEN);
131
+
let body: Value = res.json().await.expect("Response was not valid JSON");
132
+
assert_eq!(body["error"], "InvitesDisabled");
133
+
}
134
+
135
+
#[tokio::test]
136
+
async fn test_enable_account_invites_success() {
137
+
let client = client();
138
+
let (access_jwt, did) = create_account_and_login(&client).await;
139
+
140
+
let disable_payload = json!({
141
+
"account": did
142
+
});
143
+
let _ = client
144
+
.post(format!(
145
+
"{}/xrpc/com.atproto.admin.disableAccountInvites",
146
+
base_url().await
147
+
))
148
+
.bearer_auth(&access_jwt)
149
+
.json(&disable_payload)
150
+
.send()
151
+
.await;
152
+
153
+
let enable_payload = json!({
154
+
"account": did
155
+
});
156
+
let res = client
157
+
.post(format!(
158
+
"{}/xrpc/com.atproto.admin.enableAccountInvites",
159
+
base_url().await
160
+
))
161
+
.bearer_auth(&access_jwt)
162
+
.json(&enable_payload)
163
+
.send()
164
+
.await
165
+
.expect("Failed to send request");
166
+
167
+
assert_eq!(res.status(), StatusCode::OK);
168
+
169
+
let create_payload = json!({
170
+
"useCount": 1
171
+
});
172
+
let res = client
173
+
.post(format!(
174
+
"{}/xrpc/com.atproto.server.createInviteCode",
175
+
base_url().await
176
+
))
177
+
.bearer_auth(&access_jwt)
178
+
.json(&create_payload)
179
+
.send()
180
+
.await
181
+
.expect("Failed to send request");
182
+
183
+
assert_eq!(res.status(), StatusCode::OK);
184
+
}
185
+
186
+
#[tokio::test]
187
+
async fn test_disable_account_invites_no_auth() {
188
+
let client = client();
189
+
let payload = json!({
190
+
"account": "did:plc:test"
191
+
});
192
+
193
+
let res = client
194
+
.post(format!(
195
+
"{}/xrpc/com.atproto.admin.disableAccountInvites",
196
+
base_url().await
197
+
))
198
+
.json(&payload)
199
+
.send()
200
+
.await
201
+
.expect("Failed to send request");
202
+
203
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
204
+
}
205
+
206
+
#[tokio::test]
207
+
async fn test_disable_account_invites_not_found() {
208
+
let client = client();
209
+
let (access_jwt, _did) = create_account_and_login(&client).await;
210
+
211
+
let payload = json!({
212
+
"account": "did:plc:nonexistent"
213
+
});
214
+
215
+
let res = client
216
+
.post(format!(
217
+
"{}/xrpc/com.atproto.admin.disableAccountInvites",
218
+
base_url().await
219
+
))
220
+
.bearer_auth(&access_jwt)
221
+
.json(&payload)
222
+
.send()
223
+
.await
224
+
.expect("Failed to send request");
225
+
226
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
227
+
}
228
+
229
+
#[tokio::test]
230
+
async fn test_disable_invite_codes_by_code() {
231
+
let client = client();
232
+
let (access_jwt, _did) = create_account_and_login(&client).await;
233
+
234
+
let create_payload = json!({
235
+
"useCount": 5
236
+
});
237
+
let create_res = client
238
+
.post(format!(
239
+
"{}/xrpc/com.atproto.server.createInviteCode",
240
+
base_url().await
241
+
))
242
+
.bearer_auth(&access_jwt)
243
+
.json(&create_payload)
244
+
.send()
245
+
.await
246
+
.expect("Failed to create invite code");
247
+
248
+
let create_body: Value = create_res.json().await.unwrap();
249
+
let code = create_body["code"].as_str().unwrap();
250
+
251
+
let disable_payload = json!({
252
+
"codes": [code]
253
+
});
254
+
let res = client
255
+
.post(format!(
256
+
"{}/xrpc/com.atproto.admin.disableInviteCodes",
257
+
base_url().await
258
+
))
259
+
.bearer_auth(&access_jwt)
260
+
.json(&disable_payload)
261
+
.send()
262
+
.await
263
+
.expect("Failed to send request");
264
+
265
+
assert_eq!(res.status(), StatusCode::OK);
266
+
267
+
let list_res = client
268
+
.get(format!(
269
+
"{}/xrpc/com.atproto.server.getAccountInviteCodes",
270
+
base_url().await
271
+
))
272
+
.bearer_auth(&access_jwt)
273
+
.send()
274
+
.await
275
+
.expect("Failed to get invite codes");
276
+
277
+
let list_body: Value = list_res.json().await.unwrap();
278
+
let codes = list_body["codes"].as_array().unwrap();
279
+
let disabled_code = codes.iter().find(|c| c["code"].as_str().unwrap() == code);
280
+
assert!(disabled_code.is_some());
281
+
assert_eq!(disabled_code.unwrap()["disabled"], true);
282
+
}
283
+
284
+
#[tokio::test]
285
+
async fn test_disable_invite_codes_by_account() {
286
+
let client = client();
287
+
let (access_jwt, did) = create_account_and_login(&client).await;
288
+
289
+
for _ in 0..3 {
290
+
let create_payload = json!({
291
+
"useCount": 1
292
+
});
293
+
let _ = client
294
+
.post(format!(
295
+
"{}/xrpc/com.atproto.server.createInviteCode",
296
+
base_url().await
297
+
))
298
+
.bearer_auth(&access_jwt)
299
+
.json(&create_payload)
300
+
.send()
301
+
.await;
302
+
}
303
+
304
+
let disable_payload = json!({
305
+
"accounts": [did]
306
+
});
307
+
let res = client
308
+
.post(format!(
309
+
"{}/xrpc/com.atproto.admin.disableInviteCodes",
310
+
base_url().await
311
+
))
312
+
.bearer_auth(&access_jwt)
313
+
.json(&disable_payload)
314
+
.send()
315
+
.await
316
+
.expect("Failed to send request");
317
+
318
+
assert_eq!(res.status(), StatusCode::OK);
319
+
320
+
let list_res = client
321
+
.get(format!(
322
+
"{}/xrpc/com.atproto.server.getAccountInviteCodes",
323
+
base_url().await
324
+
))
325
+
.bearer_auth(&access_jwt)
326
+
.send()
327
+
.await
328
+
.expect("Failed to get invite codes");
329
+
330
+
let list_body: Value = list_res.json().await.unwrap();
331
+
let codes = list_body["codes"].as_array().unwrap();
332
+
for code in codes {
333
+
assert_eq!(code["disabled"], true);
334
+
}
335
+
}
336
+
337
+
#[tokio::test]
338
+
async fn test_disable_invite_codes_no_auth() {
339
+
let client = client();
340
+
let payload = json!({
341
+
"codes": ["some-code"]
342
+
});
343
+
344
+
let res = client
345
+
.post(format!(
346
+
"{}/xrpc/com.atproto.admin.disableInviteCodes",
347
+
base_url().await
348
+
))
349
+
.json(&payload)
350
+
.send()
351
+
.await
352
+
.expect("Failed to send request");
353
+
354
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
355
+
}
356
+
357
+
#[tokio::test]
358
+
async fn test_admin_enable_account_invites_not_found() {
359
+
let client = client();
360
+
let (access_jwt, _did) = create_account_and_login(&client).await;
361
+
362
+
let payload = json!({
363
+
"account": "did:plc:nonexistent"
364
+
});
365
+
366
+
let res = client
367
+
.post(format!(
368
+
"{}/xrpc/com.atproto.admin.enableAccountInvites",
369
+
base_url().await
370
+
))
371
+
.bearer_auth(&access_jwt)
372
+
.json(&payload)
373
+
.send()
374
+
.await
375
+
.expect("Failed to send request");
376
+
377
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
378
+
}
+302
tests/admin_moderation.rs
+302
tests/admin_moderation.rs
···
1
+
mod common;
2
+
use common::*;
3
+
4
+
use reqwest::StatusCode;
5
+
use serde_json::{Value, json};
6
+
7
+
#[tokio::test]
8
+
async fn test_get_subject_status_user_success() {
9
+
let client = client();
10
+
let (access_jwt, did) = create_account_and_login(&client).await;
11
+
12
+
let res = client
13
+
.get(format!(
14
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
15
+
base_url().await
16
+
))
17
+
.bearer_auth(&access_jwt)
18
+
.query(&[("did", did.as_str())])
19
+
.send()
20
+
.await
21
+
.expect("Failed to send request");
22
+
23
+
assert_eq!(res.status(), StatusCode::OK);
24
+
let body: Value = res.json().await.expect("Response was not valid JSON");
25
+
assert!(body["subject"].is_object());
26
+
assert_eq!(body["subject"]["$type"], "com.atproto.admin.defs#repoRef");
27
+
assert_eq!(body["subject"]["did"], did);
28
+
}
29
+
30
+
#[tokio::test]
31
+
async fn test_get_subject_status_not_found() {
32
+
let client = client();
33
+
let (access_jwt, _did) = create_account_and_login(&client).await;
34
+
35
+
let res = client
36
+
.get(format!(
37
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
38
+
base_url().await
39
+
))
40
+
.bearer_auth(&access_jwt)
41
+
.query(&[("did", "did:plc:nonexistent")])
42
+
.send()
43
+
.await
44
+
.expect("Failed to send request");
45
+
46
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
47
+
let body: Value = res.json().await.expect("Response was not valid JSON");
48
+
assert_eq!(body["error"], "SubjectNotFound");
49
+
}
50
+
51
+
#[tokio::test]
52
+
async fn test_get_subject_status_no_param() {
53
+
let client = client();
54
+
let (access_jwt, _did) = create_account_and_login(&client).await;
55
+
56
+
let res = client
57
+
.get(format!(
58
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
59
+
base_url().await
60
+
))
61
+
.bearer_auth(&access_jwt)
62
+
.send()
63
+
.await
64
+
.expect("Failed to send request");
65
+
66
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
67
+
let body: Value = res.json().await.expect("Response was not valid JSON");
68
+
assert_eq!(body["error"], "InvalidRequest");
69
+
}
70
+
71
+
#[tokio::test]
72
+
async fn test_get_subject_status_no_auth() {
73
+
let client = client();
74
+
75
+
let res = client
76
+
.get(format!(
77
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
78
+
base_url().await
79
+
))
80
+
.query(&[("did", "did:plc:test")])
81
+
.send()
82
+
.await
83
+
.expect("Failed to send request");
84
+
85
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
86
+
}
87
+
88
+
#[tokio::test]
89
+
async fn test_update_subject_status_takedown_user() {
90
+
let client = client();
91
+
let (access_jwt, did) = create_account_and_login(&client).await;
92
+
93
+
let payload = json!({
94
+
"subject": {
95
+
"$type": "com.atproto.admin.defs#repoRef",
96
+
"did": did
97
+
},
98
+
"takedown": {
99
+
"apply": true,
100
+
"ref": "mod-action-123"
101
+
}
102
+
});
103
+
104
+
let res = client
105
+
.post(format!(
106
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
107
+
base_url().await
108
+
))
109
+
.bearer_auth(&access_jwt)
110
+
.json(&payload)
111
+
.send()
112
+
.await
113
+
.expect("Failed to send request");
114
+
115
+
assert_eq!(res.status(), StatusCode::OK);
116
+
let body: Value = res.json().await.expect("Response was not valid JSON");
117
+
assert!(body["takedown"].is_object());
118
+
assert_eq!(body["takedown"]["applied"], true);
119
+
assert_eq!(body["takedown"]["ref"], "mod-action-123");
120
+
121
+
let status_res = client
122
+
.get(format!(
123
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
124
+
base_url().await
125
+
))
126
+
.bearer_auth(&access_jwt)
127
+
.query(&[("did", did.as_str())])
128
+
.send()
129
+
.await
130
+
.expect("Failed to send request");
131
+
132
+
let status_body: Value = status_res.json().await.unwrap();
133
+
assert!(status_body["takedown"].is_object());
134
+
assert_eq!(status_body["takedown"]["applied"], true);
135
+
assert_eq!(status_body["takedown"]["ref"], "mod-action-123");
136
+
}
137
+
138
+
#[tokio::test]
139
+
async fn test_update_subject_status_remove_takedown() {
140
+
let client = client();
141
+
let (access_jwt, did) = create_account_and_login(&client).await;
142
+
143
+
let takedown_payload = json!({
144
+
"subject": {
145
+
"$type": "com.atproto.admin.defs#repoRef",
146
+
"did": did
147
+
},
148
+
"takedown": {
149
+
"apply": true,
150
+
"ref": "mod-action-456"
151
+
}
152
+
});
153
+
154
+
let _ = client
155
+
.post(format!(
156
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
157
+
base_url().await
158
+
))
159
+
.bearer_auth(&access_jwt)
160
+
.json(&takedown_payload)
161
+
.send()
162
+
.await;
163
+
164
+
let remove_payload = json!({
165
+
"subject": {
166
+
"$type": "com.atproto.admin.defs#repoRef",
167
+
"did": did
168
+
},
169
+
"takedown": {
170
+
"apply": false
171
+
}
172
+
});
173
+
174
+
let res = client
175
+
.post(format!(
176
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
177
+
base_url().await
178
+
))
179
+
.bearer_auth(&access_jwt)
180
+
.json(&remove_payload)
181
+
.send()
182
+
.await
183
+
.expect("Failed to send request");
184
+
185
+
assert_eq!(res.status(), StatusCode::OK);
186
+
187
+
let status_res = client
188
+
.get(format!(
189
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
190
+
base_url().await
191
+
))
192
+
.bearer_auth(&access_jwt)
193
+
.query(&[("did", did.as_str())])
194
+
.send()
195
+
.await
196
+
.expect("Failed to send request");
197
+
198
+
let status_body: Value = status_res.json().await.unwrap();
199
+
assert!(status_body["takedown"].is_null() || !status_body["takedown"]["applied"].as_bool().unwrap_or(false));
200
+
}
201
+
202
+
#[tokio::test]
203
+
async fn test_update_subject_status_deactivate_user() {
204
+
let client = client();
205
+
let (access_jwt, did) = create_account_and_login(&client).await;
206
+
207
+
let payload = json!({
208
+
"subject": {
209
+
"$type": "com.atproto.admin.defs#repoRef",
210
+
"did": did
211
+
},
212
+
"deactivated": {
213
+
"apply": true
214
+
}
215
+
});
216
+
217
+
let res = client
218
+
.post(format!(
219
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
220
+
base_url().await
221
+
))
222
+
.bearer_auth(&access_jwt)
223
+
.json(&payload)
224
+
.send()
225
+
.await
226
+
.expect("Failed to send request");
227
+
228
+
assert_eq!(res.status(), StatusCode::OK);
229
+
230
+
let status_res = client
231
+
.get(format!(
232
+
"{}/xrpc/com.atproto.admin.getSubjectStatus",
233
+
base_url().await
234
+
))
235
+
.bearer_auth(&access_jwt)
236
+
.query(&[("did", did.as_str())])
237
+
.send()
238
+
.await
239
+
.expect("Failed to send request");
240
+
241
+
let status_body: Value = status_res.json().await.unwrap();
242
+
assert!(status_body["deactivated"].is_object());
243
+
assert_eq!(status_body["deactivated"]["applied"], true);
244
+
}
245
+
246
+
#[tokio::test]
247
+
async fn test_update_subject_status_invalid_type() {
248
+
let client = client();
249
+
let (access_jwt, _did) = create_account_and_login(&client).await;
250
+
251
+
let payload = json!({
252
+
"subject": {
253
+
"$type": "invalid.type",
254
+
"did": "did:plc:test"
255
+
},
256
+
"takedown": {
257
+
"apply": true
258
+
}
259
+
});
260
+
261
+
let res = client
262
+
.post(format!(
263
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
264
+
base_url().await
265
+
))
266
+
.bearer_auth(&access_jwt)
267
+
.json(&payload)
268
+
.send()
269
+
.await
270
+
.expect("Failed to send request");
271
+
272
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
273
+
let body: Value = res.json().await.expect("Response was not valid JSON");
274
+
assert_eq!(body["error"], "InvalidRequest");
275
+
}
276
+
277
+
#[tokio::test]
278
+
async fn test_update_subject_status_no_auth() {
279
+
let client = client();
280
+
281
+
let payload = json!({
282
+
"subject": {
283
+
"$type": "com.atproto.admin.defs#repoRef",
284
+
"did": "did:plc:test"
285
+
},
286
+
"takedown": {
287
+
"apply": true
288
+
}
289
+
});
290
+
291
+
let res = client
292
+
.post(format!(
293
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
294
+
base_url().await
295
+
))
296
+
.json(&payload)
297
+
.send()
298
+
.await
299
+
.expect("Failed to send request");
300
+
301
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
302
+
}
+231
tests/helpers/mod.rs
+231
tests/helpers/mod.rs
···
1
+
use chrono::Utc;
2
+
use reqwest::StatusCode;
3
+
use serde_json::{Value, json};
4
+
5
+
pub use crate::common::*;
6
+
7
+
pub async fn setup_new_user(handle_prefix: &str) -> (String, String) {
8
+
let client = client();
9
+
let ts = Utc::now().timestamp_millis();
10
+
let handle = format!("{}-{}.test", handle_prefix, ts);
11
+
let email = format!("{}-{}@test.com", handle_prefix, ts);
12
+
let password = "e2e-password-123";
13
+
14
+
let create_account_payload = json!({
15
+
"handle": handle,
16
+
"email": email,
17
+
"password": password
18
+
});
19
+
let create_res = client
20
+
.post(format!(
21
+
"{}/xrpc/com.atproto.server.createAccount",
22
+
base_url().await
23
+
))
24
+
.json(&create_account_payload)
25
+
.send()
26
+
.await
27
+
.expect("setup_new_user: Failed to send createAccount");
28
+
29
+
if create_res.status() != reqwest::StatusCode::OK {
30
+
panic!(
31
+
"setup_new_user: Failed to create account: {:?}",
32
+
create_res.text().await
33
+
);
34
+
}
35
+
36
+
let create_body: Value = create_res
37
+
.json()
38
+
.await
39
+
.expect("setup_new_user: createAccount response was not JSON");
40
+
41
+
let new_did = create_body["did"]
42
+
.as_str()
43
+
.expect("setup_new_user: Response had no DID")
44
+
.to_string();
45
+
let new_jwt = create_body["accessJwt"]
46
+
.as_str()
47
+
.expect("setup_new_user: Response had no accessJwt")
48
+
.to_string();
49
+
50
+
(new_did, new_jwt)
51
+
}
52
+
53
+
pub async fn create_post(
54
+
client: &reqwest::Client,
55
+
did: &str,
56
+
jwt: &str,
57
+
text: &str,
58
+
) -> (String, String) {
59
+
let collection = "app.bsky.feed.post";
60
+
let rkey = format!("e2e_social_{}", Utc::now().timestamp_millis());
61
+
let now = Utc::now().to_rfc3339();
62
+
63
+
let create_payload = json!({
64
+
"repo": did,
65
+
"collection": collection,
66
+
"rkey": rkey,
67
+
"record": {
68
+
"$type": collection,
69
+
"text": text,
70
+
"createdAt": now
71
+
}
72
+
});
73
+
74
+
let create_res = client
75
+
.post(format!(
76
+
"{}/xrpc/com.atproto.repo.putRecord",
77
+
base_url().await
78
+
))
79
+
.bearer_auth(jwt)
80
+
.json(&create_payload)
81
+
.send()
82
+
.await
83
+
.expect("Failed to send create post request");
84
+
85
+
assert_eq!(
86
+
create_res.status(),
87
+
reqwest::StatusCode::OK,
88
+
"Failed to create post record"
89
+
);
90
+
let create_body: Value = create_res
91
+
.json()
92
+
.await
93
+
.expect("create post response was not JSON");
94
+
let uri = create_body["uri"].as_str().unwrap().to_string();
95
+
let cid = create_body["cid"].as_str().unwrap().to_string();
96
+
(uri, cid)
97
+
}
98
+
99
+
pub async fn create_follow(
100
+
client: &reqwest::Client,
101
+
follower_did: &str,
102
+
follower_jwt: &str,
103
+
followee_did: &str,
104
+
) -> (String, String) {
105
+
let collection = "app.bsky.graph.follow";
106
+
let rkey = format!("e2e_follow_{}", Utc::now().timestamp_millis());
107
+
let now = Utc::now().to_rfc3339();
108
+
109
+
let create_payload = json!({
110
+
"repo": follower_did,
111
+
"collection": collection,
112
+
"rkey": rkey,
113
+
"record": {
114
+
"$type": collection,
115
+
"subject": followee_did,
116
+
"createdAt": now
117
+
}
118
+
});
119
+
120
+
let create_res = client
121
+
.post(format!(
122
+
"{}/xrpc/com.atproto.repo.putRecord",
123
+
base_url().await
124
+
))
125
+
.bearer_auth(follower_jwt)
126
+
.json(&create_payload)
127
+
.send()
128
+
.await
129
+
.expect("Failed to send create follow request");
130
+
131
+
assert_eq!(
132
+
create_res.status(),
133
+
reqwest::StatusCode::OK,
134
+
"Failed to create follow record"
135
+
);
136
+
let create_body: Value = create_res
137
+
.json()
138
+
.await
139
+
.expect("create follow response was not JSON");
140
+
let uri = create_body["uri"].as_str().unwrap().to_string();
141
+
let cid = create_body["cid"].as_str().unwrap().to_string();
142
+
(uri, cid)
143
+
}
144
+
145
+
pub async fn create_like(
146
+
client: &reqwest::Client,
147
+
liker_did: &str,
148
+
liker_jwt: &str,
149
+
subject_uri: &str,
150
+
subject_cid: &str,
151
+
) -> (String, String) {
152
+
let collection = "app.bsky.feed.like";
153
+
let rkey = format!("e2e_like_{}", Utc::now().timestamp_millis());
154
+
let now = Utc::now().to_rfc3339();
155
+
156
+
let payload = json!({
157
+
"repo": liker_did,
158
+
"collection": collection,
159
+
"rkey": rkey,
160
+
"record": {
161
+
"$type": collection,
162
+
"subject": {
163
+
"uri": subject_uri,
164
+
"cid": subject_cid
165
+
},
166
+
"createdAt": now
167
+
}
168
+
});
169
+
170
+
let res = client
171
+
.post(format!(
172
+
"{}/xrpc/com.atproto.repo.putRecord",
173
+
base_url().await
174
+
))
175
+
.bearer_auth(liker_jwt)
176
+
.json(&payload)
177
+
.send()
178
+
.await
179
+
.expect("Failed to create like");
180
+
181
+
assert_eq!(res.status(), StatusCode::OK, "Failed to create like");
182
+
let body: Value = res.json().await.expect("Like response not JSON");
183
+
(
184
+
body["uri"].as_str().unwrap().to_string(),
185
+
body["cid"].as_str().unwrap().to_string(),
186
+
)
187
+
}
188
+
189
+
pub async fn create_repost(
190
+
client: &reqwest::Client,
191
+
reposter_did: &str,
192
+
reposter_jwt: &str,
193
+
subject_uri: &str,
194
+
subject_cid: &str,
195
+
) -> (String, String) {
196
+
let collection = "app.bsky.feed.repost";
197
+
let rkey = format!("e2e_repost_{}", Utc::now().timestamp_millis());
198
+
let now = Utc::now().to_rfc3339();
199
+
200
+
let payload = json!({
201
+
"repo": reposter_did,
202
+
"collection": collection,
203
+
"rkey": rkey,
204
+
"record": {
205
+
"$type": collection,
206
+
"subject": {
207
+
"uri": subject_uri,
208
+
"cid": subject_cid
209
+
},
210
+
"createdAt": now
211
+
}
212
+
});
213
+
214
+
let res = client
215
+
.post(format!(
216
+
"{}/xrpc/com.atproto.repo.putRecord",
217
+
base_url().await
218
+
))
219
+
.bearer_auth(reposter_jwt)
220
+
.json(&payload)
221
+
.send()
222
+
.await
223
+
.expect("Failed to create repost");
224
+
225
+
assert_eq!(res.status(), StatusCode::OK, "Failed to create repost");
226
+
let body: Value = res.json().await.expect("Repost response not JSON");
227
+
(
228
+
body["uri"].as_str().unwrap().to_string(),
229
+
body["cid"].as_str().unwrap().to_string(),
230
+
)
231
+
}
+288
tests/invite.rs
+288
tests/invite.rs
···
1
+
mod common;
2
+
use common::*;
3
+
4
+
use reqwest::StatusCode;
5
+
use serde_json::{Value, json};
6
+
7
+
#[tokio::test]
8
+
async fn test_create_invite_code_success() {
9
+
let client = client();
10
+
let (access_jwt, _did) = create_account_and_login(&client).await;
11
+
12
+
let payload = json!({
13
+
"useCount": 5
14
+
});
15
+
16
+
let res = client
17
+
.post(format!(
18
+
"{}/xrpc/com.atproto.server.createInviteCode",
19
+
base_url().await
20
+
))
21
+
.bearer_auth(&access_jwt)
22
+
.json(&payload)
23
+
.send()
24
+
.await
25
+
.expect("Failed to send request");
26
+
27
+
assert_eq!(res.status(), StatusCode::OK);
28
+
let body: Value = res.json().await.expect("Response was not valid JSON");
29
+
assert!(body["code"].is_string());
30
+
let code = body["code"].as_str().unwrap();
31
+
assert!(!code.is_empty());
32
+
assert!(code.contains('-'), "Code should be a UUID format");
33
+
}
34
+
35
+
#[tokio::test]
36
+
async fn test_create_invite_code_no_auth() {
37
+
let client = client();
38
+
let payload = json!({
39
+
"useCount": 5
40
+
});
41
+
42
+
let res = client
43
+
.post(format!(
44
+
"{}/xrpc/com.atproto.server.createInviteCode",
45
+
base_url().await
46
+
))
47
+
.json(&payload)
48
+
.send()
49
+
.await
50
+
.expect("Failed to send request");
51
+
52
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
53
+
let body: Value = res.json().await.expect("Response was not valid JSON");
54
+
assert_eq!(body["error"], "AuthenticationRequired");
55
+
}
56
+
57
+
#[tokio::test]
58
+
async fn test_create_invite_code_invalid_use_count() {
59
+
let client = client();
60
+
let (access_jwt, _did) = create_account_and_login(&client).await;
61
+
62
+
let payload = json!({
63
+
"useCount": 0
64
+
});
65
+
66
+
let res = client
67
+
.post(format!(
68
+
"{}/xrpc/com.atproto.server.createInviteCode",
69
+
base_url().await
70
+
))
71
+
.bearer_auth(&access_jwt)
72
+
.json(&payload)
73
+
.send()
74
+
.await
75
+
.expect("Failed to send request");
76
+
77
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
78
+
let body: Value = res.json().await.expect("Response was not valid JSON");
79
+
assert_eq!(body["error"], "InvalidRequest");
80
+
}
81
+
82
+
#[tokio::test]
83
+
async fn test_create_invite_code_for_another_account() {
84
+
let client = client();
85
+
let (access_jwt1, _did1) = create_account_and_login(&client).await;
86
+
let (_access_jwt2, did2) = create_account_and_login(&client).await;
87
+
88
+
let payload = json!({
89
+
"useCount": 3,
90
+
"forAccount": did2
91
+
});
92
+
93
+
let res = client
94
+
.post(format!(
95
+
"{}/xrpc/com.atproto.server.createInviteCode",
96
+
base_url().await
97
+
))
98
+
.bearer_auth(&access_jwt1)
99
+
.json(&payload)
100
+
.send()
101
+
.await
102
+
.expect("Failed to send request");
103
+
104
+
assert_eq!(res.status(), StatusCode::OK);
105
+
let body: Value = res.json().await.expect("Response was not valid JSON");
106
+
assert!(body["code"].is_string());
107
+
}
108
+
109
+
#[tokio::test]
110
+
async fn test_create_invite_codes_success() {
111
+
let client = client();
112
+
let (access_jwt, _did) = create_account_and_login(&client).await;
113
+
114
+
let payload = json!({
115
+
"useCount": 2,
116
+
"codeCount": 3
117
+
});
118
+
119
+
let res = client
120
+
.post(format!(
121
+
"{}/xrpc/com.atproto.server.createInviteCodes",
122
+
base_url().await
123
+
))
124
+
.bearer_auth(&access_jwt)
125
+
.json(&payload)
126
+
.send()
127
+
.await
128
+
.expect("Failed to send request");
129
+
130
+
assert_eq!(res.status(), StatusCode::OK);
131
+
let body: Value = res.json().await.expect("Response was not valid JSON");
132
+
assert!(body["codes"].is_array());
133
+
let codes = body["codes"].as_array().unwrap();
134
+
assert_eq!(codes.len(), 1);
135
+
assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3);
136
+
}
137
+
138
+
#[tokio::test]
139
+
async fn test_create_invite_codes_for_multiple_accounts() {
140
+
let client = client();
141
+
let (access_jwt1, did1) = create_account_and_login(&client).await;
142
+
let (_access_jwt2, did2) = create_account_and_login(&client).await;
143
+
144
+
let payload = json!({
145
+
"useCount": 1,
146
+
"codeCount": 2,
147
+
"forAccounts": [did1, did2]
148
+
});
149
+
150
+
let res = client
151
+
.post(format!(
152
+
"{}/xrpc/com.atproto.server.createInviteCodes",
153
+
base_url().await
154
+
))
155
+
.bearer_auth(&access_jwt1)
156
+
.json(&payload)
157
+
.send()
158
+
.await
159
+
.expect("Failed to send request");
160
+
161
+
assert_eq!(res.status(), StatusCode::OK);
162
+
let body: Value = res.json().await.expect("Response was not valid JSON");
163
+
let codes = body["codes"].as_array().unwrap();
164
+
assert_eq!(codes.len(), 2);
165
+
166
+
for code_obj in codes {
167
+
assert!(code_obj["account"].is_string());
168
+
assert_eq!(code_obj["codes"].as_array().unwrap().len(), 2);
169
+
}
170
+
}
171
+
172
+
#[tokio::test]
173
+
async fn test_create_invite_codes_no_auth() {
174
+
let client = client();
175
+
let payload = json!({
176
+
"useCount": 2
177
+
});
178
+
179
+
let res = client
180
+
.post(format!(
181
+
"{}/xrpc/com.atproto.server.createInviteCodes",
182
+
base_url().await
183
+
))
184
+
.json(&payload)
185
+
.send()
186
+
.await
187
+
.expect("Failed to send request");
188
+
189
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
190
+
}
191
+
192
+
#[tokio::test]
193
+
async fn test_get_account_invite_codes_success() {
194
+
let client = client();
195
+
let (access_jwt, _did) = create_account_and_login(&client).await;
196
+
197
+
let create_payload = json!({
198
+
"useCount": 5
199
+
});
200
+
let _ = client
201
+
.post(format!(
202
+
"{}/xrpc/com.atproto.server.createInviteCode",
203
+
base_url().await
204
+
))
205
+
.bearer_auth(&access_jwt)
206
+
.json(&create_payload)
207
+
.send()
208
+
.await
209
+
.expect("Failed to create invite code");
210
+
211
+
let res = client
212
+
.get(format!(
213
+
"{}/xrpc/com.atproto.server.getAccountInviteCodes",
214
+
base_url().await
215
+
))
216
+
.bearer_auth(&access_jwt)
217
+
.send()
218
+
.await
219
+
.expect("Failed to send request");
220
+
221
+
assert_eq!(res.status(), StatusCode::OK);
222
+
let body: Value = res.json().await.expect("Response was not valid JSON");
223
+
assert!(body["codes"].is_array());
224
+
let codes = body["codes"].as_array().unwrap();
225
+
assert!(!codes.is_empty());
226
+
227
+
let code = &codes[0];
228
+
assert!(code["code"].is_string());
229
+
assert!(code["available"].is_number());
230
+
assert!(code["disabled"].is_boolean());
231
+
assert!(code["createdAt"].is_string());
232
+
assert!(code["uses"].is_array());
233
+
}
234
+
235
+
#[tokio::test]
236
+
async fn test_get_account_invite_codes_no_auth() {
237
+
let client = client();
238
+
239
+
let res = client
240
+
.get(format!(
241
+
"{}/xrpc/com.atproto.server.getAccountInviteCodes",
242
+
base_url().await
243
+
))
244
+
.send()
245
+
.await
246
+
.expect("Failed to send request");
247
+
248
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
249
+
}
250
+
251
+
#[tokio::test]
252
+
async fn test_get_account_invite_codes_include_used_filter() {
253
+
let client = client();
254
+
let (access_jwt, _did) = create_account_and_login(&client).await;
255
+
256
+
let create_payload = json!({
257
+
"useCount": 5
258
+
});
259
+
let _ = client
260
+
.post(format!(
261
+
"{}/xrpc/com.atproto.server.createInviteCode",
262
+
base_url().await
263
+
))
264
+
.bearer_auth(&access_jwt)
265
+
.json(&create_payload)
266
+
.send()
267
+
.await
268
+
.expect("Failed to create invite code");
269
+
270
+
let res = client
271
+
.get(format!(
272
+
"{}/xrpc/com.atproto.server.getAccountInviteCodes",
273
+
base_url().await
274
+
))
275
+
.bearer_auth(&access_jwt)
276
+
.query(&[("includeUsed", "false")])
277
+
.send()
278
+
.await
279
+
.expect("Failed to send request");
280
+
281
+
assert_eq!(res.status(), StatusCode::OK);
282
+
let body: Value = res.json().await.expect("Response was not valid JSON");
283
+
assert!(body["codes"].is_array());
284
+
285
+
for code in body["codes"].as_array().unwrap() {
286
+
assert!(code["available"].as_i64().unwrap() > 0);
287
+
}
288
+
}
+887
tests/lifecycle_record.rs
+887
tests/lifecycle_record.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use common::*;
5
+
use helpers::*;
6
+
7
+
use chrono::Utc;
8
+
use reqwest::{StatusCode, header};
9
+
use serde_json::{Value, json};
10
+
use std::time::Duration;
11
+
12
+
#[tokio::test]
13
+
async fn test_post_crud_lifecycle() {
14
+
let client = client();
15
+
let (did, jwt) = setup_new_user("lifecycle-crud").await;
16
+
let collection = "app.bsky.feed.post";
17
+
18
+
let rkey = format!("e2e_lifecycle_{}", Utc::now().timestamp_millis());
19
+
let now = Utc::now().to_rfc3339();
20
+
21
+
let original_text = "Hello from the lifecycle test!";
22
+
let create_payload = json!({
23
+
"repo": did,
24
+
"collection": collection,
25
+
"rkey": rkey,
26
+
"record": {
27
+
"$type": collection,
28
+
"text": original_text,
29
+
"createdAt": now
30
+
}
31
+
});
32
+
33
+
let create_res = client
34
+
.post(format!(
35
+
"{}/xrpc/com.atproto.repo.putRecord",
36
+
base_url().await
37
+
))
38
+
.bearer_auth(&jwt)
39
+
.json(&create_payload)
40
+
.send()
41
+
.await
42
+
.expect("Failed to send create request");
43
+
44
+
if create_res.status() != reqwest::StatusCode::OK {
45
+
let status = create_res.status();
46
+
let body = create_res
47
+
.text()
48
+
.await
49
+
.unwrap_or_else(|_| "Could not get body".to_string());
50
+
panic!(
51
+
"Failed to create record. Status: {}, Body: {}",
52
+
status, body
53
+
);
54
+
}
55
+
56
+
let create_body: Value = create_res
57
+
.json()
58
+
.await
59
+
.expect("create response was not JSON");
60
+
let uri = create_body["uri"].as_str().unwrap();
61
+
62
+
let params = [
63
+
("repo", did.as_str()),
64
+
("collection", collection),
65
+
("rkey", &rkey),
66
+
];
67
+
let get_res = client
68
+
.get(format!(
69
+
"{}/xrpc/com.atproto.repo.getRecord",
70
+
base_url().await
71
+
))
72
+
.query(¶ms)
73
+
.send()
74
+
.await
75
+
.expect("Failed to send get request");
76
+
77
+
assert_eq!(
78
+
get_res.status(),
79
+
reqwest::StatusCode::OK,
80
+
"Failed to get record after create"
81
+
);
82
+
let get_body: Value = get_res.json().await.expect("get response was not JSON");
83
+
assert_eq!(get_body["uri"], uri);
84
+
assert_eq!(get_body["value"]["text"], original_text);
85
+
86
+
let updated_text = "This post has been updated.";
87
+
let update_payload = json!({
88
+
"repo": did,
89
+
"collection": collection,
90
+
"rkey": rkey,
91
+
"record": {
92
+
"$type": collection,
93
+
"text": updated_text,
94
+
"createdAt": now
95
+
}
96
+
});
97
+
98
+
let update_res = client
99
+
.post(format!(
100
+
"{}/xrpc/com.atproto.repo.putRecord",
101
+
base_url().await
102
+
))
103
+
.bearer_auth(&jwt)
104
+
.json(&update_payload)
105
+
.send()
106
+
.await
107
+
.expect("Failed to send update request");
108
+
109
+
assert_eq!(
110
+
update_res.status(),
111
+
reqwest::StatusCode::OK,
112
+
"Failed to update record"
113
+
);
114
+
115
+
let get_updated_res = client
116
+
.get(format!(
117
+
"{}/xrpc/com.atproto.repo.getRecord",
118
+
base_url().await
119
+
))
120
+
.query(¶ms)
121
+
.send()
122
+
.await
123
+
.expect("Failed to send get-after-update request");
124
+
125
+
assert_eq!(
126
+
get_updated_res.status(),
127
+
reqwest::StatusCode::OK,
128
+
"Failed to get record after update"
129
+
);
130
+
let get_updated_body: Value = get_updated_res
131
+
.json()
132
+
.await
133
+
.expect("get-updated response was not JSON");
134
+
assert_eq!(
135
+
get_updated_body["value"]["text"], updated_text,
136
+
"Text was not updated"
137
+
);
138
+
139
+
let delete_payload = json!({
140
+
"repo": did,
141
+
"collection": collection,
142
+
"rkey": rkey
143
+
});
144
+
145
+
let delete_res = client
146
+
.post(format!(
147
+
"{}/xrpc/com.atproto.repo.deleteRecord",
148
+
base_url().await
149
+
))
150
+
.bearer_auth(&jwt)
151
+
.json(&delete_payload)
152
+
.send()
153
+
.await
154
+
.expect("Failed to send delete request");
155
+
156
+
assert_eq!(
157
+
delete_res.status(),
158
+
reqwest::StatusCode::OK,
159
+
"Failed to delete record"
160
+
);
161
+
162
+
let get_deleted_res = client
163
+
.get(format!(
164
+
"{}/xrpc/com.atproto.repo.getRecord",
165
+
base_url().await
166
+
))
167
+
.query(¶ms)
168
+
.send()
169
+
.await
170
+
.expect("Failed to send get-after-delete request");
171
+
172
+
assert_eq!(
173
+
get_deleted_res.status(),
174
+
reqwest::StatusCode::NOT_FOUND,
175
+
"Record was found, but it should be deleted"
176
+
);
177
+
}
178
+
179
+
#[tokio::test]
180
+
async fn test_record_update_conflict_lifecycle() {
181
+
let client = client();
182
+
let (user_did, user_jwt) = setup_new_user("user-conflict").await;
183
+
184
+
let profile_payload = json!({
185
+
"repo": user_did,
186
+
"collection": "app.bsky.actor.profile",
187
+
"rkey": "self",
188
+
"record": {
189
+
"$type": "app.bsky.actor.profile",
190
+
"displayName": "Original Name"
191
+
}
192
+
});
193
+
let create_res = client
194
+
.post(format!(
195
+
"{}/xrpc/com.atproto.repo.putRecord",
196
+
base_url().await
197
+
))
198
+
.bearer_auth(&user_jwt)
199
+
.json(&profile_payload)
200
+
.send()
201
+
.await
202
+
.expect("create profile failed");
203
+
204
+
if create_res.status() != reqwest::StatusCode::OK {
205
+
return;
206
+
}
207
+
208
+
let get_res = client
209
+
.get(format!(
210
+
"{}/xrpc/com.atproto.repo.getRecord",
211
+
base_url().await
212
+
))
213
+
.query(&[
214
+
("repo", &user_did),
215
+
("collection", &"app.bsky.actor.profile".to_string()),
216
+
("rkey", &"self".to_string()),
217
+
])
218
+
.send()
219
+
.await
220
+
.expect("getRecord failed");
221
+
let get_body: Value = get_res.json().await.expect("getRecord not json");
222
+
let cid_v1 = get_body["cid"]
223
+
.as_str()
224
+
.expect("Profile v1 had no CID")
225
+
.to_string();
226
+
227
+
let update_payload_v2 = json!({
228
+
"repo": user_did,
229
+
"collection": "app.bsky.actor.profile",
230
+
"rkey": "self",
231
+
"record": {
232
+
"$type": "app.bsky.actor.profile",
233
+
"displayName": "Updated Name (v2)"
234
+
},
235
+
"swapRecord": cid_v1
236
+
});
237
+
let update_res_v2 = client
238
+
.post(format!(
239
+
"{}/xrpc/com.atproto.repo.putRecord",
240
+
base_url().await
241
+
))
242
+
.bearer_auth(&user_jwt)
243
+
.json(&update_payload_v2)
244
+
.send()
245
+
.await
246
+
.expect("putRecord v2 failed");
247
+
assert_eq!(
248
+
update_res_v2.status(),
249
+
reqwest::StatusCode::OK,
250
+
"v2 update failed"
251
+
);
252
+
let update_body_v2: Value = update_res_v2.json().await.expect("v2 body not json");
253
+
let cid_v2 = update_body_v2["cid"]
254
+
.as_str()
255
+
.expect("v2 response had no CID")
256
+
.to_string();
257
+
258
+
let update_payload_v3_stale = json!({
259
+
"repo": user_did,
260
+
"collection": "app.bsky.actor.profile",
261
+
"rkey": "self",
262
+
"record": {
263
+
"$type": "app.bsky.actor.profile",
264
+
"displayName": "Stale Update (v3)"
265
+
},
266
+
"swapRecord": cid_v1
267
+
});
268
+
let update_res_v3_stale = client
269
+
.post(format!(
270
+
"{}/xrpc/com.atproto.repo.putRecord",
271
+
base_url().await
272
+
))
273
+
.bearer_auth(&user_jwt)
274
+
.json(&update_payload_v3_stale)
275
+
.send()
276
+
.await
277
+
.expect("putRecord v3 (stale) failed");
278
+
279
+
assert_eq!(
280
+
update_res_v3_stale.status(),
281
+
reqwest::StatusCode::CONFLICT,
282
+
"Stale update did not cause a 409 Conflict"
283
+
);
284
+
285
+
let update_payload_v3_good = json!({
286
+
"repo": user_did,
287
+
"collection": "app.bsky.actor.profile",
288
+
"rkey": "self",
289
+
"record": {
290
+
"$type": "app.bsky.actor.profile",
291
+
"displayName": "Good Update (v3)"
292
+
},
293
+
"swapRecord": cid_v2
294
+
});
295
+
let update_res_v3_good = client
296
+
.post(format!(
297
+
"{}/xrpc/com.atproto.repo.putRecord",
298
+
base_url().await
299
+
))
300
+
.bearer_auth(&user_jwt)
301
+
.json(&update_payload_v3_good)
302
+
.send()
303
+
.await
304
+
.expect("putRecord v3 (good) failed");
305
+
306
+
assert_eq!(
307
+
update_res_v3_good.status(),
308
+
reqwest::StatusCode::OK,
309
+
"v3 (good) update failed"
310
+
);
311
+
}
312
+
313
+
#[tokio::test]
314
+
async fn test_profile_lifecycle() {
315
+
let client = client();
316
+
let (did, jwt) = setup_new_user("profile-lifecycle").await;
317
+
318
+
let profile_payload = json!({
319
+
"repo": did,
320
+
"collection": "app.bsky.actor.profile",
321
+
"rkey": "self",
322
+
"record": {
323
+
"$type": "app.bsky.actor.profile",
324
+
"displayName": "Test User",
325
+
"description": "A test profile for lifecycle testing"
326
+
}
327
+
});
328
+
329
+
let create_res = client
330
+
.post(format!(
331
+
"{}/xrpc/com.atproto.repo.putRecord",
332
+
base_url().await
333
+
))
334
+
.bearer_auth(&jwt)
335
+
.json(&profile_payload)
336
+
.send()
337
+
.await
338
+
.expect("Failed to create profile");
339
+
340
+
assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile");
341
+
let create_body: Value = create_res.json().await.unwrap();
342
+
let initial_cid = create_body["cid"].as_str().unwrap().to_string();
343
+
344
+
let get_res = client
345
+
.get(format!(
346
+
"{}/xrpc/com.atproto.repo.getRecord",
347
+
base_url().await
348
+
))
349
+
.query(&[
350
+
("repo", did.as_str()),
351
+
("collection", "app.bsky.actor.profile"),
352
+
("rkey", "self"),
353
+
])
354
+
.send()
355
+
.await
356
+
.expect("Failed to get profile");
357
+
358
+
assert_eq!(get_res.status(), StatusCode::OK);
359
+
let get_body: Value = get_res.json().await.unwrap();
360
+
assert_eq!(get_body["value"]["displayName"], "Test User");
361
+
assert_eq!(get_body["value"]["description"], "A test profile for lifecycle testing");
362
+
363
+
let update_payload = json!({
364
+
"repo": did,
365
+
"collection": "app.bsky.actor.profile",
366
+
"rkey": "self",
367
+
"record": {
368
+
"$type": "app.bsky.actor.profile",
369
+
"displayName": "Updated User",
370
+
"description": "Profile has been updated"
371
+
},
372
+
"swapRecord": initial_cid
373
+
});
374
+
375
+
let update_res = client
376
+
.post(format!(
377
+
"{}/xrpc/com.atproto.repo.putRecord",
378
+
base_url().await
379
+
))
380
+
.bearer_auth(&jwt)
381
+
.json(&update_payload)
382
+
.send()
383
+
.await
384
+
.expect("Failed to update profile");
385
+
386
+
assert_eq!(update_res.status(), StatusCode::OK, "Failed to update profile");
387
+
388
+
let get_updated_res = client
389
+
.get(format!(
390
+
"{}/xrpc/com.atproto.repo.getRecord",
391
+
base_url().await
392
+
))
393
+
.query(&[
394
+
("repo", did.as_str()),
395
+
("collection", "app.bsky.actor.profile"),
396
+
("rkey", "self"),
397
+
])
398
+
.send()
399
+
.await
400
+
.expect("Failed to get updated profile");
401
+
402
+
let updated_body: Value = get_updated_res.json().await.unwrap();
403
+
assert_eq!(updated_body["value"]["displayName"], "Updated User");
404
+
}
405
+
406
+
#[tokio::test]
407
+
async fn test_reply_thread_lifecycle() {
408
+
let client = client();
409
+
410
+
let (alice_did, alice_jwt) = setup_new_user("alice-thread").await;
411
+
let (bob_did, bob_jwt) = setup_new_user("bob-thread").await;
412
+
413
+
let (root_uri, root_cid) = create_post(&client, &alice_did, &alice_jwt, "This is the root post").await;
414
+
415
+
tokio::time::sleep(Duration::from_millis(100)).await;
416
+
417
+
let reply_collection = "app.bsky.feed.post";
418
+
let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis());
419
+
let now = Utc::now().to_rfc3339();
420
+
421
+
let reply_payload = json!({
422
+
"repo": bob_did,
423
+
"collection": reply_collection,
424
+
"rkey": reply_rkey,
425
+
"record": {
426
+
"$type": reply_collection,
427
+
"text": "This is Bob's reply to Alice",
428
+
"createdAt": now,
429
+
"reply": {
430
+
"root": {
431
+
"uri": root_uri,
432
+
"cid": root_cid
433
+
},
434
+
"parent": {
435
+
"uri": root_uri,
436
+
"cid": root_cid
437
+
}
438
+
}
439
+
}
440
+
});
441
+
442
+
let reply_res = client
443
+
.post(format!(
444
+
"{}/xrpc/com.atproto.repo.putRecord",
445
+
base_url().await
446
+
))
447
+
.bearer_auth(&bob_jwt)
448
+
.json(&reply_payload)
449
+
.send()
450
+
.await
451
+
.expect("Failed to create reply");
452
+
453
+
assert_eq!(reply_res.status(), StatusCode::OK, "Failed to create reply");
454
+
let reply_body: Value = reply_res.json().await.unwrap();
455
+
let reply_uri = reply_body["uri"].as_str().unwrap();
456
+
let reply_cid = reply_body["cid"].as_str().unwrap();
457
+
458
+
let get_reply_res = client
459
+
.get(format!(
460
+
"{}/xrpc/com.atproto.repo.getRecord",
461
+
base_url().await
462
+
))
463
+
.query(&[
464
+
("repo", bob_did.as_str()),
465
+
("collection", reply_collection),
466
+
("rkey", reply_rkey.as_str()),
467
+
])
468
+
.send()
469
+
.await
470
+
.expect("Failed to get reply");
471
+
472
+
assert_eq!(get_reply_res.status(), StatusCode::OK);
473
+
let reply_record: Value = get_reply_res.json().await.unwrap();
474
+
assert_eq!(reply_record["value"]["reply"]["root"]["uri"], root_uri);
475
+
assert_eq!(reply_record["value"]["reply"]["parent"]["uri"], root_uri);
476
+
477
+
tokio::time::sleep(Duration::from_millis(100)).await;
478
+
479
+
let nested_reply_rkey = format!("e2e_nested_reply_{}", Utc::now().timestamp_millis());
480
+
let nested_payload = json!({
481
+
"repo": alice_did,
482
+
"collection": reply_collection,
483
+
"rkey": nested_reply_rkey,
484
+
"record": {
485
+
"$type": reply_collection,
486
+
"text": "Alice replies to Bob's reply",
487
+
"createdAt": Utc::now().to_rfc3339(),
488
+
"reply": {
489
+
"root": {
490
+
"uri": root_uri,
491
+
"cid": root_cid
492
+
},
493
+
"parent": {
494
+
"uri": reply_uri,
495
+
"cid": reply_cid
496
+
}
497
+
}
498
+
}
499
+
});
500
+
501
+
let nested_res = client
502
+
.post(format!(
503
+
"{}/xrpc/com.atproto.repo.putRecord",
504
+
base_url().await
505
+
))
506
+
.bearer_auth(&alice_jwt)
507
+
.json(&nested_payload)
508
+
.send()
509
+
.await
510
+
.expect("Failed to create nested reply");
511
+
512
+
assert_eq!(nested_res.status(), StatusCode::OK, "Failed to create nested reply");
513
+
}
514
+
515
+
#[tokio::test]
516
+
async fn test_blob_in_record_lifecycle() {
517
+
let client = client();
518
+
let (did, jwt) = setup_new_user("blob-record").await;
519
+
520
+
let blob_data = b"This is test blob data for a profile avatar";
521
+
let upload_res = client
522
+
.post(format!(
523
+
"{}/xrpc/com.atproto.repo.uploadBlob",
524
+
base_url().await
525
+
))
526
+
.header(header::CONTENT_TYPE, "text/plain")
527
+
.bearer_auth(&jwt)
528
+
.body(blob_data.to_vec())
529
+
.send()
530
+
.await
531
+
.expect("Failed to upload blob");
532
+
533
+
assert_eq!(upload_res.status(), StatusCode::OK);
534
+
let upload_body: Value = upload_res.json().await.unwrap();
535
+
let blob_ref = upload_body["blob"].clone();
536
+
537
+
let profile_payload = json!({
538
+
"repo": did,
539
+
"collection": "app.bsky.actor.profile",
540
+
"rkey": "self",
541
+
"record": {
542
+
"$type": "app.bsky.actor.profile",
543
+
"displayName": "User With Avatar",
544
+
"avatar": blob_ref
545
+
}
546
+
});
547
+
548
+
let create_res = client
549
+
.post(format!(
550
+
"{}/xrpc/com.atproto.repo.putRecord",
551
+
base_url().await
552
+
))
553
+
.bearer_auth(&jwt)
554
+
.json(&profile_payload)
555
+
.send()
556
+
.await
557
+
.expect("Failed to create profile with blob");
558
+
559
+
assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile with blob");
560
+
561
+
let get_res = client
562
+
.get(format!(
563
+
"{}/xrpc/com.atproto.repo.getRecord",
564
+
base_url().await
565
+
))
566
+
.query(&[
567
+
("repo", did.as_str()),
568
+
("collection", "app.bsky.actor.profile"),
569
+
("rkey", "self"),
570
+
])
571
+
.send()
572
+
.await
573
+
.expect("Failed to get profile");
574
+
575
+
assert_eq!(get_res.status(), StatusCode::OK);
576
+
let profile: Value = get_res.json().await.unwrap();
577
+
assert!(profile["value"]["avatar"]["ref"]["$link"].is_string());
578
+
}
579
+
580
+
#[tokio::test]
581
+
async fn test_authorization_cannot_modify_other_repo() {
582
+
let client = client();
583
+
584
+
let (alice_did, _alice_jwt) = setup_new_user("alice-auth").await;
585
+
let (_bob_did, bob_jwt) = setup_new_user("bob-auth").await;
586
+
587
+
let post_payload = json!({
588
+
"repo": alice_did,
589
+
"collection": "app.bsky.feed.post",
590
+
"rkey": "unauthorized-post",
591
+
"record": {
592
+
"$type": "app.bsky.feed.post",
593
+
"text": "Bob trying to post as Alice",
594
+
"createdAt": Utc::now().to_rfc3339()
595
+
}
596
+
});
597
+
598
+
let res = client
599
+
.post(format!(
600
+
"{}/xrpc/com.atproto.repo.putRecord",
601
+
base_url().await
602
+
))
603
+
.bearer_auth(&bob_jwt)
604
+
.json(&post_payload)
605
+
.send()
606
+
.await
607
+
.expect("Failed to send request");
608
+
609
+
assert!(
610
+
res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
611
+
"Expected 403 or 401 when writing to another user's repo, got {}",
612
+
res.status()
613
+
);
614
+
}
615
+
616
+
#[tokio::test]
617
+
async fn test_authorization_cannot_delete_other_record() {
618
+
let client = client();
619
+
620
+
let (alice_did, alice_jwt) = setup_new_user("alice-del-auth").await;
621
+
let (_bob_did, bob_jwt) = setup_new_user("bob-del-auth").await;
622
+
623
+
let (post_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's post").await;
624
+
let post_rkey = post_uri.split('/').last().unwrap();
625
+
626
+
let delete_payload = json!({
627
+
"repo": alice_did,
628
+
"collection": "app.bsky.feed.post",
629
+
"rkey": post_rkey
630
+
});
631
+
632
+
let res = client
633
+
.post(format!(
634
+
"{}/xrpc/com.atproto.repo.deleteRecord",
635
+
base_url().await
636
+
))
637
+
.bearer_auth(&bob_jwt)
638
+
.json(&delete_payload)
639
+
.send()
640
+
.await
641
+
.expect("Failed to send request");
642
+
643
+
assert!(
644
+
res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
645
+
"Expected 403 or 401 when deleting another user's record, got {}",
646
+
res.status()
647
+
);
648
+
649
+
let get_res = client
650
+
.get(format!(
651
+
"{}/xrpc/com.atproto.repo.getRecord",
652
+
base_url().await
653
+
))
654
+
.query(&[
655
+
("repo", alice_did.as_str()),
656
+
("collection", "app.bsky.feed.post"),
657
+
("rkey", post_rkey),
658
+
])
659
+
.send()
660
+
.await
661
+
.expect("Failed to verify record exists");
662
+
663
+
assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist");
664
+
}
665
+
666
+
#[tokio::test]
667
+
async fn test_list_records_pagination() {
668
+
let client = client();
669
+
let (did, jwt) = setup_new_user("list-pagination").await;
670
+
671
+
for i in 0..5 {
672
+
tokio::time::sleep(Duration::from_millis(50)).await;
673
+
create_post(&client, &did, &jwt, &format!("Post number {}", i)).await;
674
+
}
675
+
676
+
let list_res = client
677
+
.get(format!(
678
+
"{}/xrpc/com.atproto.repo.listRecords",
679
+
base_url().await
680
+
))
681
+
.query(&[
682
+
("repo", did.as_str()),
683
+
("collection", "app.bsky.feed.post"),
684
+
("limit", "2"),
685
+
])
686
+
.send()
687
+
.await
688
+
.expect("Failed to list records");
689
+
690
+
assert_eq!(list_res.status(), StatusCode::OK);
691
+
let list_body: Value = list_res.json().await.unwrap();
692
+
let records = list_body["records"].as_array().unwrap();
693
+
assert_eq!(records.len(), 2, "Should return 2 records with limit=2");
694
+
695
+
if let Some(cursor) = list_body["cursor"].as_str() {
696
+
let list_page2_res = client
697
+
.get(format!(
698
+
"{}/xrpc/com.atproto.repo.listRecords",
699
+
base_url().await
700
+
))
701
+
.query(&[
702
+
("repo", did.as_str()),
703
+
("collection", "app.bsky.feed.post"),
704
+
("limit", "2"),
705
+
("cursor", cursor),
706
+
])
707
+
.send()
708
+
.await
709
+
.expect("Failed to list records page 2");
710
+
711
+
assert_eq!(list_page2_res.status(), StatusCode::OK);
712
+
let page2_body: Value = list_page2_res.json().await.unwrap();
713
+
let page2_records = page2_body["records"].as_array().unwrap();
714
+
assert_eq!(page2_records.len(), 2, "Page 2 should have 2 more records");
715
+
}
716
+
}
717
+
718
+
#[tokio::test]
719
+
async fn test_apply_writes_batch_lifecycle() {
720
+
let client = client();
721
+
let (did, jwt) = setup_new_user("apply-writes-batch").await;
722
+
723
+
let now = Utc::now().to_rfc3339();
724
+
let writes_payload = json!({
725
+
"repo": did,
726
+
"writes": [
727
+
{
728
+
"$type": "com.atproto.repo.applyWrites#create",
729
+
"collection": "app.bsky.feed.post",
730
+
"rkey": "batch-post-1",
731
+
"value": {
732
+
"$type": "app.bsky.feed.post",
733
+
"text": "First batch post",
734
+
"createdAt": now
735
+
}
736
+
},
737
+
{
738
+
"$type": "com.atproto.repo.applyWrites#create",
739
+
"collection": "app.bsky.feed.post",
740
+
"rkey": "batch-post-2",
741
+
"value": {
742
+
"$type": "app.bsky.feed.post",
743
+
"text": "Second batch post",
744
+
"createdAt": now
745
+
}
746
+
},
747
+
{
748
+
"$type": "com.atproto.repo.applyWrites#create",
749
+
"collection": "app.bsky.actor.profile",
750
+
"rkey": "self",
751
+
"value": {
752
+
"$type": "app.bsky.actor.profile",
753
+
"displayName": "Batch User"
754
+
}
755
+
}
756
+
]
757
+
});
758
+
759
+
let apply_res = client
760
+
.post(format!(
761
+
"{}/xrpc/com.atproto.repo.applyWrites",
762
+
base_url().await
763
+
))
764
+
.bearer_auth(&jwt)
765
+
.json(&writes_payload)
766
+
.send()
767
+
.await
768
+
.expect("Failed to apply writes");
769
+
770
+
assert_eq!(apply_res.status(), StatusCode::OK);
771
+
772
+
let get_post1 = client
773
+
.get(format!(
774
+
"{}/xrpc/com.atproto.repo.getRecord",
775
+
base_url().await
776
+
))
777
+
.query(&[
778
+
("repo", did.as_str()),
779
+
("collection", "app.bsky.feed.post"),
780
+
("rkey", "batch-post-1"),
781
+
])
782
+
.send()
783
+
.await
784
+
.expect("Failed to get post 1");
785
+
assert_eq!(get_post1.status(), StatusCode::OK);
786
+
let post1_body: Value = get_post1.json().await.unwrap();
787
+
assert_eq!(post1_body["value"]["text"], "First batch post");
788
+
789
+
let get_post2 = client
790
+
.get(format!(
791
+
"{}/xrpc/com.atproto.repo.getRecord",
792
+
base_url().await
793
+
))
794
+
.query(&[
795
+
("repo", did.as_str()),
796
+
("collection", "app.bsky.feed.post"),
797
+
("rkey", "batch-post-2"),
798
+
])
799
+
.send()
800
+
.await
801
+
.expect("Failed to get post 2");
802
+
assert_eq!(get_post2.status(), StatusCode::OK);
803
+
804
+
let get_profile = client
805
+
.get(format!(
806
+
"{}/xrpc/com.atproto.repo.getRecord",
807
+
base_url().await
808
+
))
809
+
.query(&[
810
+
("repo", did.as_str()),
811
+
("collection", "app.bsky.actor.profile"),
812
+
("rkey", "self"),
813
+
])
814
+
.send()
815
+
.await
816
+
.expect("Failed to get profile");
817
+
assert_eq!(get_profile.status(), StatusCode::OK);
818
+
let profile_body: Value = get_profile.json().await.unwrap();
819
+
assert_eq!(profile_body["value"]["displayName"], "Batch User");
820
+
821
+
let update_writes = json!({
822
+
"repo": did,
823
+
"writes": [
824
+
{
825
+
"$type": "com.atproto.repo.applyWrites#update",
826
+
"collection": "app.bsky.actor.profile",
827
+
"rkey": "self",
828
+
"value": {
829
+
"$type": "app.bsky.actor.profile",
830
+
"displayName": "Updated Batch User"
831
+
}
832
+
},
833
+
{
834
+
"$type": "com.atproto.repo.applyWrites#delete",
835
+
"collection": "app.bsky.feed.post",
836
+
"rkey": "batch-post-1"
837
+
}
838
+
]
839
+
});
840
+
841
+
let update_res = client
842
+
.post(format!(
843
+
"{}/xrpc/com.atproto.repo.applyWrites",
844
+
base_url().await
845
+
))
846
+
.bearer_auth(&jwt)
847
+
.json(&update_writes)
848
+
.send()
849
+
.await
850
+
.expect("Failed to apply update writes");
851
+
assert_eq!(update_res.status(), StatusCode::OK);
852
+
853
+
let get_updated_profile = client
854
+
.get(format!(
855
+
"{}/xrpc/com.atproto.repo.getRecord",
856
+
base_url().await
857
+
))
858
+
.query(&[
859
+
("repo", did.as_str()),
860
+
("collection", "app.bsky.actor.profile"),
861
+
("rkey", "self"),
862
+
])
863
+
.send()
864
+
.await
865
+
.expect("Failed to get updated profile");
866
+
let updated_profile: Value = get_updated_profile.json().await.unwrap();
867
+
assert_eq!(updated_profile["value"]["displayName"], "Updated Batch User");
868
+
869
+
let get_deleted_post = client
870
+
.get(format!(
871
+
"{}/xrpc/com.atproto.repo.getRecord",
872
+
base_url().await
873
+
))
874
+
.query(&[
875
+
("repo", did.as_str()),
876
+
("collection", "app.bsky.feed.post"),
877
+
("rkey", "batch-post-1"),
878
+
])
879
+
.send()
880
+
.await
881
+
.expect("Failed to check deleted post");
882
+
assert_eq!(
883
+
get_deleted_post.status(),
884
+
StatusCode::NOT_FOUND,
885
+
"Batch-deleted post should be gone"
886
+
);
887
+
}
+306
tests/lifecycle_session.rs
+306
tests/lifecycle_session.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use common::*;
5
+
use helpers::*;
6
+
7
+
use chrono::Utc;
8
+
use reqwest::StatusCode;
9
+
use serde_json::{Value, json};
10
+
11
+
#[tokio::test]
12
+
async fn test_session_lifecycle_wrong_password() {
13
+
let client = client();
14
+
let (_, _) = setup_new_user("session-wrong-pw").await;
15
+
16
+
let login_payload = json!({
17
+
"identifier": format!("session-wrong-pw-{}.test", Utc::now().timestamp_millis()),
18
+
"password": "wrong-password"
19
+
});
20
+
21
+
let res = client
22
+
.post(format!(
23
+
"{}/xrpc/com.atproto.server.createSession",
24
+
base_url().await
25
+
))
26
+
.json(&login_payload)
27
+
.send()
28
+
.await
29
+
.expect("Failed to send request");
30
+
31
+
assert!(
32
+
res.status() == StatusCode::UNAUTHORIZED || res.status() == StatusCode::BAD_REQUEST,
33
+
"Expected 401 or 400 for wrong password, got {}",
34
+
res.status()
35
+
);
36
+
}
37
+
38
+
#[tokio::test]
39
+
async fn test_session_lifecycle_multiple_sessions() {
40
+
let client = client();
41
+
let ts = Utc::now().timestamp_millis();
42
+
let handle = format!("multi-session-{}.test", ts);
43
+
let email = format!("multi-session-{}@test.com", ts);
44
+
let password = "multi-session-pw";
45
+
46
+
let create_payload = json!({
47
+
"handle": handle,
48
+
"email": email,
49
+
"password": password
50
+
});
51
+
let create_res = client
52
+
.post(format!(
53
+
"{}/xrpc/com.atproto.server.createAccount",
54
+
base_url().await
55
+
))
56
+
.json(&create_payload)
57
+
.send()
58
+
.await
59
+
.expect("Failed to create account");
60
+
assert_eq!(create_res.status(), StatusCode::OK);
61
+
62
+
let login_payload = json!({
63
+
"identifier": handle,
64
+
"password": password
65
+
});
66
+
67
+
let session1_res = client
68
+
.post(format!(
69
+
"{}/xrpc/com.atproto.server.createSession",
70
+
base_url().await
71
+
))
72
+
.json(&login_payload)
73
+
.send()
74
+
.await
75
+
.expect("Failed session 1");
76
+
assert_eq!(session1_res.status(), StatusCode::OK);
77
+
let session1: Value = session1_res.json().await.unwrap();
78
+
let jwt1 = session1["accessJwt"].as_str().unwrap();
79
+
80
+
let session2_res = client
81
+
.post(format!(
82
+
"{}/xrpc/com.atproto.server.createSession",
83
+
base_url().await
84
+
))
85
+
.json(&login_payload)
86
+
.send()
87
+
.await
88
+
.expect("Failed session 2");
89
+
assert_eq!(session2_res.status(), StatusCode::OK);
90
+
let session2: Value = session2_res.json().await.unwrap();
91
+
let jwt2 = session2["accessJwt"].as_str().unwrap();
92
+
93
+
assert_ne!(jwt1, jwt2, "Sessions should have different tokens");
94
+
95
+
let get1 = client
96
+
.get(format!(
97
+
"{}/xrpc/com.atproto.server.getSession",
98
+
base_url().await
99
+
))
100
+
.bearer_auth(jwt1)
101
+
.send()
102
+
.await
103
+
.expect("Failed getSession 1");
104
+
assert_eq!(get1.status(), StatusCode::OK);
105
+
106
+
let get2 = client
107
+
.get(format!(
108
+
"{}/xrpc/com.atproto.server.getSession",
109
+
base_url().await
110
+
))
111
+
.bearer_auth(jwt2)
112
+
.send()
113
+
.await
114
+
.expect("Failed getSession 2");
115
+
assert_eq!(get2.status(), StatusCode::OK);
116
+
}
117
+
118
+
#[tokio::test]
119
+
async fn test_session_lifecycle_refresh_invalidates_old() {
120
+
let client = client();
121
+
let ts = Utc::now().timestamp_millis();
122
+
let handle = format!("refresh-inv-{}.test", ts);
123
+
let email = format!("refresh-inv-{}@test.com", ts);
124
+
let password = "refresh-inv-pw";
125
+
126
+
let create_payload = json!({
127
+
"handle": handle,
128
+
"email": email,
129
+
"password": password
130
+
});
131
+
client
132
+
.post(format!(
133
+
"{}/xrpc/com.atproto.server.createAccount",
134
+
base_url().await
135
+
))
136
+
.json(&create_payload)
137
+
.send()
138
+
.await
139
+
.expect("Failed to create account");
140
+
141
+
let login_payload = json!({
142
+
"identifier": handle,
143
+
"password": password
144
+
});
145
+
let login_res = client
146
+
.post(format!(
147
+
"{}/xrpc/com.atproto.server.createSession",
148
+
base_url().await
149
+
))
150
+
.json(&login_payload)
151
+
.send()
152
+
.await
153
+
.expect("Failed login");
154
+
let login_body: Value = login_res.json().await.unwrap();
155
+
let refresh_jwt = login_body["refreshJwt"].as_str().unwrap().to_string();
156
+
157
+
let refresh_res = client
158
+
.post(format!(
159
+
"{}/xrpc/com.atproto.server.refreshSession",
160
+
base_url().await
161
+
))
162
+
.bearer_auth(&refresh_jwt)
163
+
.send()
164
+
.await
165
+
.expect("Failed first refresh");
166
+
assert_eq!(refresh_res.status(), StatusCode::OK);
167
+
let refresh_body: Value = refresh_res.json().await.unwrap();
168
+
let new_refresh_jwt = refresh_body["refreshJwt"].as_str().unwrap();
169
+
170
+
assert_ne!(refresh_jwt, new_refresh_jwt, "Refresh tokens should differ");
171
+
172
+
let reuse_res = client
173
+
.post(format!(
174
+
"{}/xrpc/com.atproto.server.refreshSession",
175
+
base_url().await
176
+
))
177
+
.bearer_auth(&refresh_jwt)
178
+
.send()
179
+
.await
180
+
.expect("Failed reuse attempt");
181
+
182
+
assert!(
183
+
reuse_res.status() == StatusCode::UNAUTHORIZED || reuse_res.status() == StatusCode::BAD_REQUEST,
184
+
"Old refresh token should be invalid after use"
185
+
);
186
+
}
187
+
188
+
#[tokio::test]
189
+
async fn test_app_password_lifecycle() {
190
+
let client = client();
191
+
let ts = Utc::now().timestamp_millis();
192
+
let handle = format!("apppass-{}.test", ts);
193
+
let email = format!("apppass-{}@test.com", ts);
194
+
let password = "apppass-password";
195
+
196
+
let create_res = client
197
+
.post(format!(
198
+
"{}/xrpc/com.atproto.server.createAccount",
199
+
base_url().await
200
+
))
201
+
.json(&json!({
202
+
"handle": handle,
203
+
"email": email,
204
+
"password": password
205
+
}))
206
+
.send()
207
+
.await
208
+
.expect("Failed to create account");
209
+
210
+
assert_eq!(create_res.status(), StatusCode::OK);
211
+
let account: Value = create_res.json().await.unwrap();
212
+
let jwt = account["accessJwt"].as_str().unwrap();
213
+
214
+
let create_app_pass_res = client
215
+
.post(format!(
216
+
"{}/xrpc/com.atproto.server.createAppPassword",
217
+
base_url().await
218
+
))
219
+
.bearer_auth(jwt)
220
+
.json(&json!({ "name": "Test App" }))
221
+
.send()
222
+
.await
223
+
.expect("Failed to create app password");
224
+
225
+
assert_eq!(create_app_pass_res.status(), StatusCode::OK);
226
+
let app_pass: Value = create_app_pass_res.json().await.unwrap();
227
+
let app_password = app_pass["password"].as_str().unwrap().to_string();
228
+
assert_eq!(app_pass["name"], "Test App");
229
+
230
+
let list_res = client
231
+
.get(format!(
232
+
"{}/xrpc/com.atproto.server.listAppPasswords",
233
+
base_url().await
234
+
))
235
+
.bearer_auth(jwt)
236
+
.send()
237
+
.await
238
+
.expect("Failed to list app passwords");
239
+
240
+
assert_eq!(list_res.status(), StatusCode::OK);
241
+
let list_body: Value = list_res.json().await.unwrap();
242
+
let passwords = list_body["passwords"].as_array().unwrap();
243
+
assert_eq!(passwords.len(), 1);
244
+
assert_eq!(passwords[0]["name"], "Test App");
245
+
246
+
let login_res = client
247
+
.post(format!(
248
+
"{}/xrpc/com.atproto.server.createSession",
249
+
base_url().await
250
+
))
251
+
.json(&json!({
252
+
"identifier": handle,
253
+
"password": app_password
254
+
}))
255
+
.send()
256
+
.await
257
+
.expect("Failed to login with app password");
258
+
259
+
assert_eq!(login_res.status(), StatusCode::OK, "App password login should work");
260
+
261
+
let revoke_res = client
262
+
.post(format!(
263
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
264
+
base_url().await
265
+
))
266
+
.bearer_auth(jwt)
267
+
.json(&json!({ "name": "Test App" }))
268
+
.send()
269
+
.await
270
+
.expect("Failed to revoke app password");
271
+
272
+
assert_eq!(revoke_res.status(), StatusCode::OK);
273
+
274
+
let login_after_revoke = client
275
+
.post(format!(
276
+
"{}/xrpc/com.atproto.server.createSession",
277
+
base_url().await
278
+
))
279
+
.json(&json!({
280
+
"identifier": handle,
281
+
"password": app_password
282
+
}))
283
+
.send()
284
+
.await
285
+
.expect("Failed to attempt login after revoke");
286
+
287
+
assert!(
288
+
login_after_revoke.status() == StatusCode::UNAUTHORIZED
289
+
|| login_after_revoke.status() == StatusCode::BAD_REQUEST,
290
+
"Revoked app password should not work"
291
+
);
292
+
293
+
let list_after_revoke = client
294
+
.get(format!(
295
+
"{}/xrpc/com.atproto.server.listAppPasswords",
296
+
base_url().await
297
+
))
298
+
.bearer_auth(jwt)
299
+
.send()
300
+
.await
301
+
.expect("Failed to list after revoke");
302
+
303
+
let list_after: Value = list_after_revoke.json().await.unwrap();
304
+
let passwords_after = list_after["passwords"].as_array().unwrap();
305
+
assert_eq!(passwords_after.len(), 0, "No app passwords should remain");
306
+
}
-793
tests/repo.rs
-793
tests/repo.rs
···
1
-
mod common;
2
-
use common::*;
3
-
4
-
use chrono::Utc;
5
-
use reqwest::{StatusCode, header};
6
-
use serde_json::{Value, json};
7
-
8
-
#[tokio::test]
9
-
async fn test_get_record_not_found() {
10
-
let client = client();
11
-
let (_, did) = create_account_and_login(&client).await;
12
-
13
-
let params = [
14
-
("repo", did.as_str()),
15
-
("collection", "app.bsky.feed.post"),
16
-
("rkey", "nonexistent"),
17
-
];
18
-
19
-
let res = client
20
-
.get(format!(
21
-
"{}/xrpc/com.atproto.repo.getRecord",
22
-
base_url().await
23
-
))
24
-
.query(¶ms)
25
-
.send()
26
-
.await
27
-
.expect("Failed to send request");
28
-
29
-
assert_eq!(res.status(), StatusCode::NOT_FOUND);
30
-
}
31
-
32
-
#[tokio::test]
33
-
async fn test_upload_blob_no_auth() {
34
-
let client = client();
35
-
let res = client
36
-
.post(format!(
37
-
"{}/xrpc/com.atproto.repo.uploadBlob",
38
-
base_url().await
39
-
))
40
-
.header(header::CONTENT_TYPE, "text/plain")
41
-
.body("no auth")
42
-
.send()
43
-
.await
44
-
.expect("Failed to send request");
45
-
46
-
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
47
-
let body: Value = res.json().await.expect("Response was not valid JSON");
48
-
assert_eq!(body["error"], "AuthenticationRequired");
49
-
}
50
-
51
-
#[tokio::test]
52
-
async fn test_upload_blob_success() {
53
-
let client = client();
54
-
let (token, _) = create_account_and_login(&client).await;
55
-
let res = client
56
-
.post(format!(
57
-
"{}/xrpc/com.atproto.repo.uploadBlob",
58
-
base_url().await
59
-
))
60
-
.header(header::CONTENT_TYPE, "text/plain")
61
-
.bearer_auth(token)
62
-
.body("This is our blob data")
63
-
.send()
64
-
.await
65
-
.expect("Failed to send request");
66
-
67
-
assert_eq!(res.status(), StatusCode::OK);
68
-
let body: Value = res.json().await.expect("Response was not valid JSON");
69
-
assert!(body["blob"]["ref"]["$link"].as_str().is_some());
70
-
}
71
-
72
-
#[tokio::test]
73
-
async fn test_put_record_no_auth() {
74
-
let client = client();
75
-
let payload = json!({
76
-
"repo": "did:plc:123",
77
-
"collection": "app.bsky.feed.post",
78
-
"rkey": "fake",
79
-
"record": {}
80
-
});
81
-
82
-
let res = client
83
-
.post(format!(
84
-
"{}/xrpc/com.atproto.repo.putRecord",
85
-
base_url().await
86
-
))
87
-
.json(&payload)
88
-
.send()
89
-
.await
90
-
.expect("Failed to send request");
91
-
92
-
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
93
-
let body: Value = res.json().await.expect("Response was not valid JSON");
94
-
assert_eq!(body["error"], "AuthenticationRequired");
95
-
}
96
-
97
-
#[tokio::test]
98
-
async fn test_put_record_success() {
99
-
let client = client();
100
-
let (token, did) = create_account_and_login(&client).await;
101
-
let now = Utc::now().to_rfc3339();
102
-
let payload = json!({
103
-
"repo": did,
104
-
"collection": "app.bsky.feed.post",
105
-
"rkey": "e2e_test_post",
106
-
"record": {
107
-
"$type": "app.bsky.feed.post",
108
-
"text": "Hello from the e2e test script!",
109
-
"createdAt": now
110
-
}
111
-
});
112
-
113
-
let res = client
114
-
.post(format!(
115
-
"{}/xrpc/com.atproto.repo.putRecord",
116
-
base_url().await
117
-
))
118
-
.bearer_auth(token)
119
-
.json(&payload)
120
-
.send()
121
-
.await
122
-
.expect("Failed to send request");
123
-
124
-
assert_eq!(res.status(), StatusCode::OK);
125
-
let body: Value = res.json().await.expect("Response was not valid JSON");
126
-
assert!(body.get("uri").is_some());
127
-
assert!(body.get("cid").is_some());
128
-
}
129
-
130
-
#[tokio::test]
131
-
async fn test_get_record_missing_params() {
132
-
let client = client();
133
-
let params = [("repo", "did:plc:12345")];
134
-
135
-
let res = client
136
-
.get(format!(
137
-
"{}/xrpc/com.atproto.repo.getRecord",
138
-
base_url().await
139
-
))
140
-
.query(¶ms)
141
-
.send()
142
-
.await
143
-
.expect("Failed to send request");
144
-
145
-
assert_eq!(
146
-
res.status(),
147
-
StatusCode::BAD_REQUEST,
148
-
"Expected 400 for missing params"
149
-
);
150
-
}
151
-
152
-
#[tokio::test]
153
-
async fn test_upload_blob_bad_token() {
154
-
let client = client();
155
-
let res = client
156
-
.post(format!(
157
-
"{}/xrpc/com.atproto.repo.uploadBlob",
158
-
base_url().await
159
-
))
160
-
.header(header::CONTENT_TYPE, "text/plain")
161
-
.bearer_auth(BAD_AUTH_TOKEN)
162
-
.body("This is our blob data")
163
-
.send()
164
-
.await
165
-
.expect("Failed to send request");
166
-
167
-
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
168
-
let body: Value = res.json().await.expect("Response was not valid JSON");
169
-
assert_eq!(body["error"], "AuthenticationFailed");
170
-
}
171
-
172
-
#[tokio::test]
173
-
async fn test_put_record_mismatched_repo() {
174
-
let client = client();
175
-
let (token, _) = create_account_and_login(&client).await;
176
-
let now = Utc::now().to_rfc3339();
177
-
let payload = json!({
178
-
"repo": "did:plc:OTHER-USER",
179
-
"collection": "app.bsky.feed.post",
180
-
"rkey": "e2e_test_post",
181
-
"record": {
182
-
"$type": "app.bsky.feed.post",
183
-
"text": "Hello from the e2e test script!",
184
-
"createdAt": now
185
-
}
186
-
});
187
-
188
-
let res = client
189
-
.post(format!(
190
-
"{}/xrpc/com.atproto.repo.putRecord",
191
-
base_url().await
192
-
))
193
-
.bearer_auth(token)
194
-
.json(&payload)
195
-
.send()
196
-
.await
197
-
.expect("Failed to send request");
198
-
199
-
assert!(
200
-
res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
201
-
"Expected 403 or 401 for mismatched repo and auth, got {}",
202
-
res.status()
203
-
);
204
-
}
205
-
206
-
#[tokio::test]
207
-
async fn test_put_record_invalid_schema() {
208
-
let client = client();
209
-
let (token, did) = create_account_and_login(&client).await;
210
-
let now = Utc::now().to_rfc3339();
211
-
let payload = json!({
212
-
"repo": did,
213
-
"collection": "app.bsky.feed.post",
214
-
"rkey": "e2e_test_invalid",
215
-
"record": {
216
-
"$type": "app.bsky.feed.post",
217
-
"createdAt": now
218
-
}
219
-
});
220
-
221
-
let res = client
222
-
.post(format!(
223
-
"{}/xrpc/com.atproto.repo.putRecord",
224
-
base_url().await
225
-
))
226
-
.bearer_auth(token)
227
-
.json(&payload)
228
-
.send()
229
-
.await
230
-
.expect("Failed to send request");
231
-
232
-
assert_eq!(
233
-
res.status(),
234
-
StatusCode::BAD_REQUEST,
235
-
"Expected 400 for invalid record schema"
236
-
);
237
-
}
238
-
239
-
#[tokio::test]
240
-
async fn test_upload_blob_unsupported_mime_type() {
241
-
let client = client();
242
-
let (token, _) = create_account_and_login(&client).await;
243
-
let res = client
244
-
.post(format!(
245
-
"{}/xrpc/com.atproto.repo.uploadBlob",
246
-
base_url().await
247
-
))
248
-
.header(header::CONTENT_TYPE, "application/xml")
249
-
.bearer_auth(token)
250
-
.body("<xml>not an image</xml>")
251
-
.send()
252
-
.await
253
-
.expect("Failed to send request");
254
-
255
-
// Changed expectation to OK for now, bc we don't validate mime type strictly yet.
256
-
assert_eq!(res.status(), StatusCode::OK);
257
-
}
258
-
259
-
#[tokio::test]
260
-
async fn test_list_records() {
261
-
let client = client();
262
-
let (_, did) = create_account_and_login(&client).await;
263
-
let params = [
264
-
("repo", did.as_str()),
265
-
("collection", "app.bsky.feed.post"),
266
-
("limit", "10"),
267
-
];
268
-
let res = client
269
-
.get(format!(
270
-
"{}/xrpc/com.atproto.repo.listRecords",
271
-
base_url().await
272
-
))
273
-
.query(¶ms)
274
-
.send()
275
-
.await
276
-
.expect("Failed to send request");
277
-
278
-
assert_eq!(res.status(), StatusCode::OK);
279
-
}
280
-
281
-
#[tokio::test]
282
-
async fn test_describe_repo() {
283
-
let client = client();
284
-
let (_, did) = create_account_and_login(&client).await;
285
-
let params = [("repo", did.as_str())];
286
-
let res = client
287
-
.get(format!(
288
-
"{}/xrpc/com.atproto.repo.describeRepo",
289
-
base_url().await
290
-
))
291
-
.query(¶ms)
292
-
.send()
293
-
.await
294
-
.expect("Failed to send request");
295
-
296
-
assert_eq!(res.status(), StatusCode::OK);
297
-
}
298
-
299
-
#[tokio::test]
300
-
async fn test_create_record_success_with_generated_rkey() {
301
-
let client = client();
302
-
let (token, did) = create_account_and_login(&client).await;
303
-
let payload = json!({
304
-
"repo": did,
305
-
"collection": "app.bsky.feed.post",
306
-
"record": {
307
-
"$type": "app.bsky.feed.post",
308
-
"text": "Hello, world!",
309
-
"createdAt": "2025-12-02T12:00:00Z"
310
-
}
311
-
});
312
-
313
-
let res = client
314
-
.post(format!(
315
-
"{}/xrpc/com.atproto.repo.createRecord",
316
-
base_url().await
317
-
))
318
-
.json(&payload)
319
-
.bearer_auth(token)
320
-
.send()
321
-
.await
322
-
.expect("Failed to send request");
323
-
324
-
assert_eq!(res.status(), StatusCode::OK);
325
-
let body: Value = res.json().await.expect("Response was not valid JSON");
326
-
let uri = body["uri"].as_str().unwrap();
327
-
assert!(uri.starts_with(&format!("at://{}/app.bsky.feed.post/", did)));
328
-
assert!(body.get("cid").is_some());
329
-
}
330
-
331
-
#[tokio::test]
332
-
async fn test_create_record_success_with_provided_rkey() {
333
-
let client = client();
334
-
let (token, did) = create_account_and_login(&client).await;
335
-
let rkey = format!("custom-rkey-{}", Utc::now().timestamp_millis());
336
-
let payload = json!({
337
-
"repo": did,
338
-
"collection": "app.bsky.feed.post",
339
-
"rkey": rkey,
340
-
"record": {
341
-
"$type": "app.bsky.feed.post",
342
-
"text": "Hello, world!",
343
-
"createdAt": "2025-12-02T12:00:00Z"
344
-
}
345
-
});
346
-
347
-
let res = client
348
-
.post(format!(
349
-
"{}/xrpc/com.atproto.repo.createRecord",
350
-
base_url().await
351
-
))
352
-
.json(&payload)
353
-
.bearer_auth(token)
354
-
.send()
355
-
.await
356
-
.expect("Failed to send request");
357
-
358
-
assert_eq!(res.status(), StatusCode::OK);
359
-
let body: Value = res.json().await.expect("Response was not valid JSON");
360
-
assert_eq!(
361
-
body["uri"],
362
-
format!("at://{}/app.bsky.feed.post/{}", did, rkey)
363
-
);
364
-
assert!(body.get("cid").is_some());
365
-
}
366
-
367
-
#[tokio::test]
368
-
async fn test_delete_record() {
369
-
let client = client();
370
-
let (token, did) = create_account_and_login(&client).await;
371
-
let rkey = format!("post_to_delete_{}", Utc::now().timestamp_millis());
372
-
373
-
let create_payload = json!({
374
-
"repo": did,
375
-
"collection": "app.bsky.feed.post",
376
-
"rkey": rkey,
377
-
"record": {
378
-
"$type": "app.bsky.feed.post",
379
-
"text": "This post will be deleted",
380
-
"createdAt": Utc::now().to_rfc3339()
381
-
}
382
-
});
383
-
let create_res = client
384
-
.post(format!(
385
-
"{}/xrpc/com.atproto.repo.putRecord",
386
-
base_url().await
387
-
))
388
-
.bearer_auth(&token)
389
-
.json(&create_payload)
390
-
.send()
391
-
.await
392
-
.expect("Failed to create record");
393
-
assert_eq!(create_res.status(), StatusCode::OK);
394
-
395
-
let delete_payload = json!({
396
-
"repo": did,
397
-
"collection": "app.bsky.feed.post",
398
-
"rkey": rkey
399
-
});
400
-
let delete_res = client
401
-
.post(format!(
402
-
"{}/xrpc/com.atproto.repo.deleteRecord",
403
-
base_url().await
404
-
))
405
-
.bearer_auth(&token)
406
-
.json(&delete_payload)
407
-
.send()
408
-
.await
409
-
.expect("Failed to send request");
410
-
411
-
assert_eq!(delete_res.status(), StatusCode::OK);
412
-
413
-
let get_res = client
414
-
.get(format!(
415
-
"{}/xrpc/com.atproto.repo.getRecord",
416
-
base_url().await
417
-
))
418
-
.query(&[
419
-
("repo", did.as_str()),
420
-
("collection", "app.bsky.feed.post"),
421
-
("rkey", rkey.as_str()),
422
-
])
423
-
.send()
424
-
.await
425
-
.expect("Failed to verify deletion");
426
-
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
427
-
}
428
-
429
-
#[tokio::test]
430
-
async fn test_apply_writes_create() {
431
-
let client = client();
432
-
let (token, did) = create_account_and_login(&client).await;
433
-
let now = Utc::now().to_rfc3339();
434
-
435
-
let payload = json!({
436
-
"repo": did,
437
-
"writes": [
438
-
{
439
-
"$type": "com.atproto.repo.applyWrites#create",
440
-
"collection": "app.bsky.feed.post",
441
-
"value": {
442
-
"$type": "app.bsky.feed.post",
443
-
"text": "Batch created post 1",
444
-
"createdAt": now
445
-
}
446
-
},
447
-
{
448
-
"$type": "com.atproto.repo.applyWrites#create",
449
-
"collection": "app.bsky.feed.post",
450
-
"value": {
451
-
"$type": "app.bsky.feed.post",
452
-
"text": "Batch created post 2",
453
-
"createdAt": now
454
-
}
455
-
}
456
-
]
457
-
});
458
-
459
-
let res = client
460
-
.post(format!(
461
-
"{}/xrpc/com.atproto.repo.applyWrites",
462
-
base_url().await
463
-
))
464
-
.bearer_auth(&token)
465
-
.json(&payload)
466
-
.send()
467
-
.await
468
-
.expect("Failed to send request");
469
-
470
-
assert_eq!(res.status(), StatusCode::OK);
471
-
let body: Value = res.json().await.expect("Response was not valid JSON");
472
-
assert!(body["commit"]["cid"].is_string());
473
-
assert!(body["results"].is_array());
474
-
let results = body["results"].as_array().unwrap();
475
-
assert_eq!(results.len(), 2);
476
-
assert!(results[0]["uri"].is_string());
477
-
assert!(results[0]["cid"].is_string());
478
-
}
479
-
480
-
#[tokio::test]
481
-
async fn test_apply_writes_update() {
482
-
let client = client();
483
-
let (token, did) = create_account_and_login(&client).await;
484
-
let now = Utc::now().to_rfc3339();
485
-
let rkey = format!("batch_update_{}", Utc::now().timestamp_millis());
486
-
487
-
let create_payload = json!({
488
-
"repo": did,
489
-
"collection": "app.bsky.feed.post",
490
-
"rkey": rkey,
491
-
"record": {
492
-
"$type": "app.bsky.feed.post",
493
-
"text": "Original post",
494
-
"createdAt": now
495
-
}
496
-
});
497
-
let res = client
498
-
.post(format!(
499
-
"{}/xrpc/com.atproto.repo.putRecord",
500
-
base_url().await
501
-
))
502
-
.bearer_auth(&token)
503
-
.json(&create_payload)
504
-
.send()
505
-
.await
506
-
.expect("Failed to create");
507
-
assert_eq!(res.status(), StatusCode::OK);
508
-
509
-
let update_payload = json!({
510
-
"repo": did,
511
-
"writes": [
512
-
{
513
-
"$type": "com.atproto.repo.applyWrites#update",
514
-
"collection": "app.bsky.feed.post",
515
-
"rkey": rkey,
516
-
"value": {
517
-
"$type": "app.bsky.feed.post",
518
-
"text": "Updated post via applyWrites",
519
-
"createdAt": now
520
-
}
521
-
}
522
-
]
523
-
});
524
-
525
-
let res = client
526
-
.post(format!(
527
-
"{}/xrpc/com.atproto.repo.applyWrites",
528
-
base_url().await
529
-
))
530
-
.bearer_auth(&token)
531
-
.json(&update_payload)
532
-
.send()
533
-
.await
534
-
.expect("Failed to send request");
535
-
536
-
assert_eq!(res.status(), StatusCode::OK);
537
-
let body: Value = res.json().await.expect("Response was not valid JSON");
538
-
let results = body["results"].as_array().unwrap();
539
-
assert_eq!(results.len(), 1);
540
-
assert!(results[0]["uri"].is_string());
541
-
}
542
-
543
-
#[tokio::test]
544
-
async fn test_apply_writes_delete() {
545
-
let client = client();
546
-
let (token, did) = create_account_and_login(&client).await;
547
-
let now = Utc::now().to_rfc3339();
548
-
let rkey = format!("batch_delete_{}", Utc::now().timestamp_millis());
549
-
550
-
let create_payload = json!({
551
-
"repo": did,
552
-
"collection": "app.bsky.feed.post",
553
-
"rkey": rkey,
554
-
"record": {
555
-
"$type": "app.bsky.feed.post",
556
-
"text": "Post to delete",
557
-
"createdAt": now
558
-
}
559
-
});
560
-
let res = client
561
-
.post(format!(
562
-
"{}/xrpc/com.atproto.repo.putRecord",
563
-
base_url().await
564
-
))
565
-
.bearer_auth(&token)
566
-
.json(&create_payload)
567
-
.send()
568
-
.await
569
-
.expect("Failed to create");
570
-
assert_eq!(res.status(), StatusCode::OK);
571
-
572
-
let delete_payload = json!({
573
-
"repo": did,
574
-
"writes": [
575
-
{
576
-
"$type": "com.atproto.repo.applyWrites#delete",
577
-
"collection": "app.bsky.feed.post",
578
-
"rkey": rkey
579
-
}
580
-
]
581
-
});
582
-
583
-
let res = client
584
-
.post(format!(
585
-
"{}/xrpc/com.atproto.repo.applyWrites",
586
-
base_url().await
587
-
))
588
-
.bearer_auth(&token)
589
-
.json(&delete_payload)
590
-
.send()
591
-
.await
592
-
.expect("Failed to send request");
593
-
594
-
assert_eq!(res.status(), StatusCode::OK);
595
-
596
-
let get_res = client
597
-
.get(format!(
598
-
"{}/xrpc/com.atproto.repo.getRecord",
599
-
base_url().await
600
-
))
601
-
.query(&[
602
-
("repo", did.as_str()),
603
-
("collection", "app.bsky.feed.post"),
604
-
("rkey", rkey.as_str()),
605
-
])
606
-
.send()
607
-
.await
608
-
.expect("Failed to verify");
609
-
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
610
-
}
611
-
612
-
#[tokio::test]
613
-
async fn test_apply_writes_mixed_operations() {
614
-
let client = client();
615
-
let (token, did) = create_account_and_login(&client).await;
616
-
let now = Utc::now().to_rfc3339();
617
-
let rkey_to_delete = format!("mixed_del_{}", Utc::now().timestamp_millis());
618
-
let rkey_to_update = format!("mixed_upd_{}", Utc::now().timestamp_millis());
619
-
620
-
let setup_payload = json!({
621
-
"repo": did,
622
-
"writes": [
623
-
{
624
-
"$type": "com.atproto.repo.applyWrites#create",
625
-
"collection": "app.bsky.feed.post",
626
-
"rkey": rkey_to_delete,
627
-
"value": {
628
-
"$type": "app.bsky.feed.post",
629
-
"text": "To be deleted",
630
-
"createdAt": now
631
-
}
632
-
},
633
-
{
634
-
"$type": "com.atproto.repo.applyWrites#create",
635
-
"collection": "app.bsky.feed.post",
636
-
"rkey": rkey_to_update,
637
-
"value": {
638
-
"$type": "app.bsky.feed.post",
639
-
"text": "To be updated",
640
-
"createdAt": now
641
-
}
642
-
}
643
-
]
644
-
});
645
-
let res = client
646
-
.post(format!(
647
-
"{}/xrpc/com.atproto.repo.applyWrites",
648
-
base_url().await
649
-
))
650
-
.bearer_auth(&token)
651
-
.json(&setup_payload)
652
-
.send()
653
-
.await
654
-
.expect("Failed to setup");
655
-
assert_eq!(res.status(), StatusCode::OK);
656
-
657
-
let mixed_payload = json!({
658
-
"repo": did,
659
-
"writes": [
660
-
{
661
-
"$type": "com.atproto.repo.applyWrites#create",
662
-
"collection": "app.bsky.feed.post",
663
-
"value": {
664
-
"$type": "app.bsky.feed.post",
665
-
"text": "New post",
666
-
"createdAt": now
667
-
}
668
-
},
669
-
{
670
-
"$type": "com.atproto.repo.applyWrites#update",
671
-
"collection": "app.bsky.feed.post",
672
-
"rkey": rkey_to_update,
673
-
"value": {
674
-
"$type": "app.bsky.feed.post",
675
-
"text": "Updated text",
676
-
"createdAt": now
677
-
}
678
-
},
679
-
{
680
-
"$type": "com.atproto.repo.applyWrites#delete",
681
-
"collection": "app.bsky.feed.post",
682
-
"rkey": rkey_to_delete
683
-
}
684
-
]
685
-
});
686
-
687
-
let res = client
688
-
.post(format!(
689
-
"{}/xrpc/com.atproto.repo.applyWrites",
690
-
base_url().await
691
-
))
692
-
.bearer_auth(&token)
693
-
.json(&mixed_payload)
694
-
.send()
695
-
.await
696
-
.expect("Failed to send request");
697
-
698
-
assert_eq!(res.status(), StatusCode::OK);
699
-
let body: Value = res.json().await.expect("Response was not valid JSON");
700
-
let results = body["results"].as_array().unwrap();
701
-
assert_eq!(results.len(), 3);
702
-
}
703
-
704
-
#[tokio::test]
705
-
async fn test_apply_writes_no_auth() {
706
-
let client = client();
707
-
708
-
let payload = json!({
709
-
"repo": "did:plc:test",
710
-
"writes": [
711
-
{
712
-
"$type": "com.atproto.repo.applyWrites#create",
713
-
"collection": "app.bsky.feed.post",
714
-
"value": {
715
-
"$type": "app.bsky.feed.post",
716
-
"text": "Test",
717
-
"createdAt": "2025-01-01T00:00:00Z"
718
-
}
719
-
}
720
-
]
721
-
});
722
-
723
-
let res = client
724
-
.post(format!(
725
-
"{}/xrpc/com.atproto.repo.applyWrites",
726
-
base_url().await
727
-
))
728
-
.json(&payload)
729
-
.send()
730
-
.await
731
-
.expect("Failed to send request");
732
-
733
-
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
734
-
}
735
-
736
-
#[tokio::test]
737
-
async fn test_apply_writes_empty_writes() {
738
-
let client = client();
739
-
let (token, did) = create_account_and_login(&client).await;
740
-
741
-
let payload = json!({
742
-
"repo": did,
743
-
"writes": []
744
-
});
745
-
746
-
let res = client
747
-
.post(format!(
748
-
"{}/xrpc/com.atproto.repo.applyWrites",
749
-
base_url().await
750
-
))
751
-
.bearer_auth(&token)
752
-
.json(&payload)
753
-
.send()
754
-
.await
755
-
.expect("Failed to send request");
756
-
757
-
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
758
-
}
759
-
760
-
#[tokio::test]
761
-
async fn test_list_missing_blobs() {
762
-
let client = client();
763
-
let (access_jwt, _) = create_account_and_login(&client).await;
764
-
765
-
let res = client
766
-
.get(format!(
767
-
"{}/xrpc/com.atproto.repo.listMissingBlobs",
768
-
base_url().await
769
-
))
770
-
.bearer_auth(&access_jwt)
771
-
.send()
772
-
.await
773
-
.expect("Failed to send request");
774
-
775
-
assert_eq!(res.status(), StatusCode::OK);
776
-
let body: Value = res.json().await.expect("Response was not valid JSON");
777
-
assert!(body["blobs"].is_array());
778
-
}
779
-
780
-
#[tokio::test]
781
-
async fn test_list_missing_blobs_no_auth() {
782
-
let client = client();
783
-
let res = client
784
-
.get(format!(
785
-
"{}/xrpc/com.atproto.repo.listMissingBlobs",
786
-
base_url().await
787
-
))
788
-
.send()
789
-
.await
790
-
.expect("Failed to send request");
791
-
792
-
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
793
-
}
+337
tests/repo_batch.rs
+337
tests/repo_batch.rs
···
1
+
mod common;
2
+
use common::*;
3
+
4
+
use chrono::Utc;
5
+
use reqwest::StatusCode;
6
+
use serde_json::{Value, json};
7
+
8
+
#[tokio::test]
9
+
async fn test_apply_writes_create() {
10
+
let client = client();
11
+
let (token, did) = create_account_and_login(&client).await;
12
+
let now = Utc::now().to_rfc3339();
13
+
14
+
let payload = json!({
15
+
"repo": did,
16
+
"writes": [
17
+
{
18
+
"$type": "com.atproto.repo.applyWrites#create",
19
+
"collection": "app.bsky.feed.post",
20
+
"value": {
21
+
"$type": "app.bsky.feed.post",
22
+
"text": "Batch created post 1",
23
+
"createdAt": now
24
+
}
25
+
},
26
+
{
27
+
"$type": "com.atproto.repo.applyWrites#create",
28
+
"collection": "app.bsky.feed.post",
29
+
"value": {
30
+
"$type": "app.bsky.feed.post",
31
+
"text": "Batch created post 2",
32
+
"createdAt": now
33
+
}
34
+
}
35
+
]
36
+
});
37
+
38
+
let res = client
39
+
.post(format!(
40
+
"{}/xrpc/com.atproto.repo.applyWrites",
41
+
base_url().await
42
+
))
43
+
.bearer_auth(&token)
44
+
.json(&payload)
45
+
.send()
46
+
.await
47
+
.expect("Failed to send request");
48
+
49
+
assert_eq!(res.status(), StatusCode::OK);
50
+
let body: Value = res.json().await.expect("Response was not valid JSON");
51
+
assert!(body["commit"]["cid"].is_string());
52
+
assert!(body["results"].is_array());
53
+
let results = body["results"].as_array().unwrap();
54
+
assert_eq!(results.len(), 2);
55
+
assert!(results[0]["uri"].is_string());
56
+
assert!(results[0]["cid"].is_string());
57
+
}
58
+
59
+
#[tokio::test]
60
+
async fn test_apply_writes_update() {
61
+
let client = client();
62
+
let (token, did) = create_account_and_login(&client).await;
63
+
let now = Utc::now().to_rfc3339();
64
+
let rkey = format!("batch_update_{}", Utc::now().timestamp_millis());
65
+
66
+
let create_payload = json!({
67
+
"repo": did,
68
+
"collection": "app.bsky.feed.post",
69
+
"rkey": rkey,
70
+
"record": {
71
+
"$type": "app.bsky.feed.post",
72
+
"text": "Original post",
73
+
"createdAt": now
74
+
}
75
+
});
76
+
let res = client
77
+
.post(format!(
78
+
"{}/xrpc/com.atproto.repo.putRecord",
79
+
base_url().await
80
+
))
81
+
.bearer_auth(&token)
82
+
.json(&create_payload)
83
+
.send()
84
+
.await
85
+
.expect("Failed to create");
86
+
assert_eq!(res.status(), StatusCode::OK);
87
+
88
+
let update_payload = json!({
89
+
"repo": did,
90
+
"writes": [
91
+
{
92
+
"$type": "com.atproto.repo.applyWrites#update",
93
+
"collection": "app.bsky.feed.post",
94
+
"rkey": rkey,
95
+
"value": {
96
+
"$type": "app.bsky.feed.post",
97
+
"text": "Updated post via applyWrites",
98
+
"createdAt": now
99
+
}
100
+
}
101
+
]
102
+
});
103
+
104
+
let res = client
105
+
.post(format!(
106
+
"{}/xrpc/com.atproto.repo.applyWrites",
107
+
base_url().await
108
+
))
109
+
.bearer_auth(&token)
110
+
.json(&update_payload)
111
+
.send()
112
+
.await
113
+
.expect("Failed to send request");
114
+
115
+
assert_eq!(res.status(), StatusCode::OK);
116
+
let body: Value = res.json().await.expect("Response was not valid JSON");
117
+
let results = body["results"].as_array().unwrap();
118
+
assert_eq!(results.len(), 1);
119
+
assert!(results[0]["uri"].is_string());
120
+
}
121
+
122
+
#[tokio::test]
123
+
async fn test_apply_writes_delete() {
124
+
let client = client();
125
+
let (token, did) = create_account_and_login(&client).await;
126
+
let now = Utc::now().to_rfc3339();
127
+
let rkey = format!("batch_delete_{}", Utc::now().timestamp_millis());
128
+
129
+
let create_payload = json!({
130
+
"repo": did,
131
+
"collection": "app.bsky.feed.post",
132
+
"rkey": rkey,
133
+
"record": {
134
+
"$type": "app.bsky.feed.post",
135
+
"text": "Post to delete",
136
+
"createdAt": now
137
+
}
138
+
});
139
+
let res = client
140
+
.post(format!(
141
+
"{}/xrpc/com.atproto.repo.putRecord",
142
+
base_url().await
143
+
))
144
+
.bearer_auth(&token)
145
+
.json(&create_payload)
146
+
.send()
147
+
.await
148
+
.expect("Failed to create");
149
+
assert_eq!(res.status(), StatusCode::OK);
150
+
151
+
let delete_payload = json!({
152
+
"repo": did,
153
+
"writes": [
154
+
{
155
+
"$type": "com.atproto.repo.applyWrites#delete",
156
+
"collection": "app.bsky.feed.post",
157
+
"rkey": rkey
158
+
}
159
+
]
160
+
});
161
+
162
+
let res = client
163
+
.post(format!(
164
+
"{}/xrpc/com.atproto.repo.applyWrites",
165
+
base_url().await
166
+
))
167
+
.bearer_auth(&token)
168
+
.json(&delete_payload)
169
+
.send()
170
+
.await
171
+
.expect("Failed to send request");
172
+
173
+
assert_eq!(res.status(), StatusCode::OK);
174
+
175
+
let get_res = client
176
+
.get(format!(
177
+
"{}/xrpc/com.atproto.repo.getRecord",
178
+
base_url().await
179
+
))
180
+
.query(&[
181
+
("repo", did.as_str()),
182
+
("collection", "app.bsky.feed.post"),
183
+
("rkey", rkey.as_str()),
184
+
])
185
+
.send()
186
+
.await
187
+
.expect("Failed to verify");
188
+
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
189
+
}
190
+
191
+
#[tokio::test]
192
+
async fn test_apply_writes_mixed_operations() {
193
+
let client = client();
194
+
let (token, did) = create_account_and_login(&client).await;
195
+
let now = Utc::now().to_rfc3339();
196
+
let rkey_to_delete = format!("mixed_del_{}", Utc::now().timestamp_millis());
197
+
let rkey_to_update = format!("mixed_upd_{}", Utc::now().timestamp_millis());
198
+
199
+
let setup_payload = json!({
200
+
"repo": did,
201
+
"writes": [
202
+
{
203
+
"$type": "com.atproto.repo.applyWrites#create",
204
+
"collection": "app.bsky.feed.post",
205
+
"rkey": rkey_to_delete,
206
+
"value": {
207
+
"$type": "app.bsky.feed.post",
208
+
"text": "To be deleted",
209
+
"createdAt": now
210
+
}
211
+
},
212
+
{
213
+
"$type": "com.atproto.repo.applyWrites#create",
214
+
"collection": "app.bsky.feed.post",
215
+
"rkey": rkey_to_update,
216
+
"value": {
217
+
"$type": "app.bsky.feed.post",
218
+
"text": "To be updated",
219
+
"createdAt": now
220
+
}
221
+
}
222
+
]
223
+
});
224
+
let res = client
225
+
.post(format!(
226
+
"{}/xrpc/com.atproto.repo.applyWrites",
227
+
base_url().await
228
+
))
229
+
.bearer_auth(&token)
230
+
.json(&setup_payload)
231
+
.send()
232
+
.await
233
+
.expect("Failed to setup");
234
+
assert_eq!(res.status(), StatusCode::OK);
235
+
236
+
let mixed_payload = json!({
237
+
"repo": did,
238
+
"writes": [
239
+
{
240
+
"$type": "com.atproto.repo.applyWrites#create",
241
+
"collection": "app.bsky.feed.post",
242
+
"value": {
243
+
"$type": "app.bsky.feed.post",
244
+
"text": "New post",
245
+
"createdAt": now
246
+
}
247
+
},
248
+
{
249
+
"$type": "com.atproto.repo.applyWrites#update",
250
+
"collection": "app.bsky.feed.post",
251
+
"rkey": rkey_to_update,
252
+
"value": {
253
+
"$type": "app.bsky.feed.post",
254
+
"text": "Updated text",
255
+
"createdAt": now
256
+
}
257
+
},
258
+
{
259
+
"$type": "com.atproto.repo.applyWrites#delete",
260
+
"collection": "app.bsky.feed.post",
261
+
"rkey": rkey_to_delete
262
+
}
263
+
]
264
+
});
265
+
266
+
let res = client
267
+
.post(format!(
268
+
"{}/xrpc/com.atproto.repo.applyWrites",
269
+
base_url().await
270
+
))
271
+
.bearer_auth(&token)
272
+
.json(&mixed_payload)
273
+
.send()
274
+
.await
275
+
.expect("Failed to send request");
276
+
277
+
assert_eq!(res.status(), StatusCode::OK);
278
+
let body: Value = res.json().await.expect("Response was not valid JSON");
279
+
let results = body["results"].as_array().unwrap();
280
+
assert_eq!(results.len(), 3);
281
+
}
282
+
283
+
#[tokio::test]
284
+
async fn test_apply_writes_no_auth() {
285
+
let client = client();
286
+
287
+
let payload = json!({
288
+
"repo": "did:plc:test",
289
+
"writes": [
290
+
{
291
+
"$type": "com.atproto.repo.applyWrites#create",
292
+
"collection": "app.bsky.feed.post",
293
+
"value": {
294
+
"$type": "app.bsky.feed.post",
295
+
"text": "Test",
296
+
"createdAt": "2025-01-01T00:00:00Z"
297
+
}
298
+
}
299
+
]
300
+
});
301
+
302
+
let res = client
303
+
.post(format!(
304
+
"{}/xrpc/com.atproto.repo.applyWrites",
305
+
base_url().await
306
+
))
307
+
.json(&payload)
308
+
.send()
309
+
.await
310
+
.expect("Failed to send request");
311
+
312
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
313
+
}
314
+
315
+
#[tokio::test]
316
+
async fn test_apply_writes_empty_writes() {
317
+
let client = client();
318
+
let (token, did) = create_account_and_login(&client).await;
319
+
320
+
let payload = json!({
321
+
"repo": did,
322
+
"writes": []
323
+
});
324
+
325
+
let res = client
326
+
.post(format!(
327
+
"{}/xrpc/com.atproto.repo.applyWrites",
328
+
base_url().await
329
+
))
330
+
.bearer_auth(&token)
331
+
.json(&payload)
332
+
.send()
333
+
.await
334
+
.expect("Failed to send request");
335
+
336
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
337
+
}
+119
tests/repo_blob.rs
+119
tests/repo_blob.rs
···
1
+
mod common;
2
+
use common::*;
3
+
4
+
use reqwest::{StatusCode, header};
5
+
use serde_json::Value;
6
+
7
+
#[tokio::test]
8
+
async fn test_upload_blob_no_auth() {
9
+
let client = client();
10
+
let res = client
11
+
.post(format!(
12
+
"{}/xrpc/com.atproto.repo.uploadBlob",
13
+
base_url().await
14
+
))
15
+
.header(header::CONTENT_TYPE, "text/plain")
16
+
.body("no auth")
17
+
.send()
18
+
.await
19
+
.expect("Failed to send request");
20
+
21
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
22
+
let body: Value = res.json().await.expect("Response was not valid JSON");
23
+
assert_eq!(body["error"], "AuthenticationRequired");
24
+
}
25
+
26
+
#[tokio::test]
27
+
async fn test_upload_blob_success() {
28
+
let client = client();
29
+
let (token, _) = create_account_and_login(&client).await;
30
+
let res = client
31
+
.post(format!(
32
+
"{}/xrpc/com.atproto.repo.uploadBlob",
33
+
base_url().await
34
+
))
35
+
.header(header::CONTENT_TYPE, "text/plain")
36
+
.bearer_auth(token)
37
+
.body("This is our blob data")
38
+
.send()
39
+
.await
40
+
.expect("Failed to send request");
41
+
42
+
assert_eq!(res.status(), StatusCode::OK);
43
+
let body: Value = res.json().await.expect("Response was not valid JSON");
44
+
assert!(body["blob"]["ref"]["$link"].as_str().is_some());
45
+
}
46
+
47
+
#[tokio::test]
48
+
async fn test_upload_blob_bad_token() {
49
+
let client = client();
50
+
let res = client
51
+
.post(format!(
52
+
"{}/xrpc/com.atproto.repo.uploadBlob",
53
+
base_url().await
54
+
))
55
+
.header(header::CONTENT_TYPE, "text/plain")
56
+
.bearer_auth(BAD_AUTH_TOKEN)
57
+
.body("This is our blob data")
58
+
.send()
59
+
.await
60
+
.expect("Failed to send request");
61
+
62
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
63
+
let body: Value = res.json().await.expect("Response was not valid JSON");
64
+
assert_eq!(body["error"], "AuthenticationFailed");
65
+
}
66
+
67
+
#[tokio::test]
68
+
async fn test_upload_blob_unsupported_mime_type() {
69
+
let client = client();
70
+
let (token, _) = create_account_and_login(&client).await;
71
+
let res = client
72
+
.post(format!(
73
+
"{}/xrpc/com.atproto.repo.uploadBlob",
74
+
base_url().await
75
+
))
76
+
.header(header::CONTENT_TYPE, "application/xml")
77
+
.bearer_auth(token)
78
+
.body("<xml>not an image</xml>")
79
+
.send()
80
+
.await
81
+
.expect("Failed to send request");
82
+
83
+
assert_eq!(res.status(), StatusCode::OK);
84
+
}
85
+
86
+
#[tokio::test]
87
+
async fn test_list_missing_blobs() {
88
+
let client = client();
89
+
let (access_jwt, _) = create_account_and_login(&client).await;
90
+
91
+
let res = client
92
+
.get(format!(
93
+
"{}/xrpc/com.atproto.repo.listMissingBlobs",
94
+
base_url().await
95
+
))
96
+
.bearer_auth(&access_jwt)
97
+
.send()
98
+
.await
99
+
.expect("Failed to send request");
100
+
101
+
assert_eq!(res.status(), StatusCode::OK);
102
+
let body: Value = res.json().await.expect("Response was not valid JSON");
103
+
assert!(body["blobs"].is_array());
104
+
}
105
+
106
+
#[tokio::test]
107
+
async fn test_list_missing_blobs_no_auth() {
108
+
let client = client();
109
+
let res = client
110
+
.get(format!(
111
+
"{}/xrpc/com.atproto.repo.listMissingBlobs",
112
+
base_url().await
113
+
))
114
+
.send()
115
+
.await
116
+
.expect("Failed to send request");
117
+
118
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
119
+
}
+347
tests/repo_record.rs
+347
tests/repo_record.rs
···
1
+
mod common;
2
+
use common::*;
3
+
4
+
use chrono::Utc;
5
+
use reqwest::StatusCode;
6
+
use serde_json::{Value, json};
7
+
8
+
#[tokio::test]
9
+
async fn test_get_record_not_found() {
10
+
let client = client();
11
+
let (_, did) = create_account_and_login(&client).await;
12
+
13
+
let params = [
14
+
("repo", did.as_str()),
15
+
("collection", "app.bsky.feed.post"),
16
+
("rkey", "nonexistent"),
17
+
];
18
+
19
+
let res = client
20
+
.get(format!(
21
+
"{}/xrpc/com.atproto.repo.getRecord",
22
+
base_url().await
23
+
))
24
+
.query(¶ms)
25
+
.send()
26
+
.await
27
+
.expect("Failed to send request");
28
+
29
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
30
+
}
31
+
32
+
#[tokio::test]
33
+
async fn test_put_record_no_auth() {
34
+
let client = client();
35
+
let payload = json!({
36
+
"repo": "did:plc:123",
37
+
"collection": "app.bsky.feed.post",
38
+
"rkey": "fake",
39
+
"record": {}
40
+
});
41
+
42
+
let res = client
43
+
.post(format!(
44
+
"{}/xrpc/com.atproto.repo.putRecord",
45
+
base_url().await
46
+
))
47
+
.json(&payload)
48
+
.send()
49
+
.await
50
+
.expect("Failed to send request");
51
+
52
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
53
+
let body: Value = res.json().await.expect("Response was not valid JSON");
54
+
assert_eq!(body["error"], "AuthenticationRequired");
55
+
}
56
+
57
+
#[tokio::test]
58
+
async fn test_put_record_success() {
59
+
let client = client();
60
+
let (token, did) = create_account_and_login(&client).await;
61
+
let now = Utc::now().to_rfc3339();
62
+
let payload = json!({
63
+
"repo": did,
64
+
"collection": "app.bsky.feed.post",
65
+
"rkey": "e2e_test_post",
66
+
"record": {
67
+
"$type": "app.bsky.feed.post",
68
+
"text": "Hello from the e2e test script!",
69
+
"createdAt": now
70
+
}
71
+
});
72
+
73
+
let res = client
74
+
.post(format!(
75
+
"{}/xrpc/com.atproto.repo.putRecord",
76
+
base_url().await
77
+
))
78
+
.bearer_auth(token)
79
+
.json(&payload)
80
+
.send()
81
+
.await
82
+
.expect("Failed to send request");
83
+
84
+
assert_eq!(res.status(), StatusCode::OK);
85
+
let body: Value = res.json().await.expect("Response was not valid JSON");
86
+
assert!(body.get("uri").is_some());
87
+
assert!(body.get("cid").is_some());
88
+
}
89
+
90
+
#[tokio::test]
91
+
async fn test_get_record_missing_params() {
92
+
let client = client();
93
+
let params = [("repo", "did:plc:12345")];
94
+
95
+
let res = client
96
+
.get(format!(
97
+
"{}/xrpc/com.atproto.repo.getRecord",
98
+
base_url().await
99
+
))
100
+
.query(¶ms)
101
+
.send()
102
+
.await
103
+
.expect("Failed to send request");
104
+
105
+
assert_eq!(
106
+
res.status(),
107
+
StatusCode::BAD_REQUEST,
108
+
"Expected 400 for missing params"
109
+
);
110
+
}
111
+
112
+
#[tokio::test]
113
+
async fn test_put_record_mismatched_repo() {
114
+
let client = client();
115
+
let (token, _) = create_account_and_login(&client).await;
116
+
let now = Utc::now().to_rfc3339();
117
+
let payload = json!({
118
+
"repo": "did:plc:OTHER-USER",
119
+
"collection": "app.bsky.feed.post",
120
+
"rkey": "e2e_test_post",
121
+
"record": {
122
+
"$type": "app.bsky.feed.post",
123
+
"text": "Hello from the e2e test script!",
124
+
"createdAt": now
125
+
}
126
+
});
127
+
128
+
let res = client
129
+
.post(format!(
130
+
"{}/xrpc/com.atproto.repo.putRecord",
131
+
base_url().await
132
+
))
133
+
.bearer_auth(token)
134
+
.json(&payload)
135
+
.send()
136
+
.await
137
+
.expect("Failed to send request");
138
+
139
+
assert!(
140
+
res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED,
141
+
"Expected 403 or 401 for mismatched repo and auth, got {}",
142
+
res.status()
143
+
);
144
+
}
145
+
146
+
#[tokio::test]
147
+
async fn test_put_record_invalid_schema() {
148
+
let client = client();
149
+
let (token, did) = create_account_and_login(&client).await;
150
+
let now = Utc::now().to_rfc3339();
151
+
let payload = json!({
152
+
"repo": did,
153
+
"collection": "app.bsky.feed.post",
154
+
"rkey": "e2e_test_invalid",
155
+
"record": {
156
+
"$type": "app.bsky.feed.post",
157
+
"createdAt": now
158
+
}
159
+
});
160
+
161
+
let res = client
162
+
.post(format!(
163
+
"{}/xrpc/com.atproto.repo.putRecord",
164
+
base_url().await
165
+
))
166
+
.bearer_auth(token)
167
+
.json(&payload)
168
+
.send()
169
+
.await
170
+
.expect("Failed to send request");
171
+
172
+
assert_eq!(
173
+
res.status(),
174
+
StatusCode::BAD_REQUEST,
175
+
"Expected 400 for invalid record schema"
176
+
);
177
+
}
178
+
179
+
#[tokio::test]
180
+
async fn test_list_records() {
181
+
let client = client();
182
+
let (_, did) = create_account_and_login(&client).await;
183
+
let params = [
184
+
("repo", did.as_str()),
185
+
("collection", "app.bsky.feed.post"),
186
+
("limit", "10"),
187
+
];
188
+
let res = client
189
+
.get(format!(
190
+
"{}/xrpc/com.atproto.repo.listRecords",
191
+
base_url().await
192
+
))
193
+
.query(¶ms)
194
+
.send()
195
+
.await
196
+
.expect("Failed to send request");
197
+
198
+
assert_eq!(res.status(), StatusCode::OK);
199
+
}
200
+
201
+
#[tokio::test]
202
+
async fn test_describe_repo() {
203
+
let client = client();
204
+
let (_, did) = create_account_and_login(&client).await;
205
+
let params = [("repo", did.as_str())];
206
+
let res = client
207
+
.get(format!(
208
+
"{}/xrpc/com.atproto.repo.describeRepo",
209
+
base_url().await
210
+
))
211
+
.query(¶ms)
212
+
.send()
213
+
.await
214
+
.expect("Failed to send request");
215
+
216
+
assert_eq!(res.status(), StatusCode::OK);
217
+
}
218
+
219
+
#[tokio::test]
220
+
async fn test_create_record_success_with_generated_rkey() {
221
+
let client = client();
222
+
let (token, did) = create_account_and_login(&client).await;
223
+
let payload = json!({
224
+
"repo": did,
225
+
"collection": "app.bsky.feed.post",
226
+
"record": {
227
+
"$type": "app.bsky.feed.post",
228
+
"text": "Hello, world!",
229
+
"createdAt": "2025-12-02T12:00:00Z"
230
+
}
231
+
});
232
+
233
+
let res = client
234
+
.post(format!(
235
+
"{}/xrpc/com.atproto.repo.createRecord",
236
+
base_url().await
237
+
))
238
+
.json(&payload)
239
+
.bearer_auth(token)
240
+
.send()
241
+
.await
242
+
.expect("Failed to send request");
243
+
244
+
assert_eq!(res.status(), StatusCode::OK);
245
+
let body: Value = res.json().await.expect("Response was not valid JSON");
246
+
let uri = body["uri"].as_str().unwrap();
247
+
assert!(uri.starts_with(&format!("at://{}/app.bsky.feed.post/", did)));
248
+
assert!(body.get("cid").is_some());
249
+
}
250
+
251
+
#[tokio::test]
252
+
async fn test_create_record_success_with_provided_rkey() {
253
+
let client = client();
254
+
let (token, did) = create_account_and_login(&client).await;
255
+
let rkey = format!("custom-rkey-{}", Utc::now().timestamp_millis());
256
+
let payload = json!({
257
+
"repo": did,
258
+
"collection": "app.bsky.feed.post",
259
+
"rkey": rkey,
260
+
"record": {
261
+
"$type": "app.bsky.feed.post",
262
+
"text": "Hello, world!",
263
+
"createdAt": "2025-12-02T12:00:00Z"
264
+
}
265
+
});
266
+
267
+
let res = client
268
+
.post(format!(
269
+
"{}/xrpc/com.atproto.repo.createRecord",
270
+
base_url().await
271
+
))
272
+
.json(&payload)
273
+
.bearer_auth(token)
274
+
.send()
275
+
.await
276
+
.expect("Failed to send request");
277
+
278
+
assert_eq!(res.status(), StatusCode::OK);
279
+
let body: Value = res.json().await.expect("Response was not valid JSON");
280
+
assert_eq!(
281
+
body["uri"],
282
+
format!("at://{}/app.bsky.feed.post/{}", did, rkey)
283
+
);
284
+
assert!(body.get("cid").is_some());
285
+
}
286
+
287
+
#[tokio::test]
288
+
async fn test_delete_record() {
289
+
let client = client();
290
+
let (token, did) = create_account_and_login(&client).await;
291
+
let rkey = format!("post_to_delete_{}", Utc::now().timestamp_millis());
292
+
293
+
let create_payload = json!({
294
+
"repo": did,
295
+
"collection": "app.bsky.feed.post",
296
+
"rkey": rkey,
297
+
"record": {
298
+
"$type": "app.bsky.feed.post",
299
+
"text": "This post will be deleted",
300
+
"createdAt": Utc::now().to_rfc3339()
301
+
}
302
+
});
303
+
let create_res = client
304
+
.post(format!(
305
+
"{}/xrpc/com.atproto.repo.putRecord",
306
+
base_url().await
307
+
))
308
+
.bearer_auth(&token)
309
+
.json(&create_payload)
310
+
.send()
311
+
.await
312
+
.expect("Failed to create record");
313
+
assert_eq!(create_res.status(), StatusCode::OK);
314
+
315
+
let delete_payload = json!({
316
+
"repo": did,
317
+
"collection": "app.bsky.feed.post",
318
+
"rkey": rkey
319
+
});
320
+
let delete_res = client
321
+
.post(format!(
322
+
"{}/xrpc/com.atproto.repo.deleteRecord",
323
+
base_url().await
324
+
))
325
+
.bearer_auth(&token)
326
+
.json(&delete_payload)
327
+
.send()
328
+
.await
329
+
.expect("Failed to send request");
330
+
331
+
assert_eq!(delete_res.status(), StatusCode::OK);
332
+
333
+
let get_res = client
334
+
.get(format!(
335
+
"{}/xrpc/com.atproto.repo.getRecord",
336
+
base_url().await
337
+
))
338
+
.query(&[
339
+
("repo", did.as_str()),
340
+
("collection", "app.bsky.feed.post"),
341
+
("rkey", rkey.as_str()),
342
+
])
343
+
.send()
344
+
.await
345
+
.expect("Failed to verify deletion");
346
+
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
347
+
}
-126
tests/sync.rs
tests/sync_repo.rs
-126
tests/sync.rs
tests/sync_repo.rs
···
1
1
mod common;
2
2
use common::*;
3
3
use reqwest::StatusCode;
4
-
use reqwest::header;
5
4
use serde_json::Value;
6
-
use chrono;
7
5
8
6
#[tokio::test]
9
7
async fn test_get_latest_commit_success() {
···
194
192
assert_eq!(res.status(), StatusCode::NOT_FOUND);
195
193
let body: Value = res.json().await.expect("Response was not valid JSON");
196
194
assert_eq!(body["error"], "RepoNotFound");
197
-
}
198
-
199
-
#[tokio::test]
200
-
async fn test_list_blobs_success() {
201
-
let client = client();
202
-
let (access_jwt, did) = create_account_and_login(&client).await;
203
-
204
-
let blob_res = client
205
-
.post(format!(
206
-
"{}/xrpc/com.atproto.repo.uploadBlob",
207
-
base_url().await
208
-
))
209
-
.header(header::CONTENT_TYPE, "text/plain")
210
-
.bearer_auth(&access_jwt)
211
-
.body("test blob content")
212
-
.send()
213
-
.await
214
-
.expect("Failed to upload blob");
215
-
216
-
assert_eq!(blob_res.status(), StatusCode::OK);
217
-
218
-
let params = [("did", did.as_str())];
219
-
let res = client
220
-
.get(format!(
221
-
"{}/xrpc/com.atproto.sync.listBlobs",
222
-
base_url().await
223
-
))
224
-
.query(¶ms)
225
-
.send()
226
-
.await
227
-
.expect("Failed to send request");
228
-
229
-
assert_eq!(res.status(), StatusCode::OK);
230
-
let body: Value = res.json().await.expect("Response was not valid JSON");
231
-
assert!(body["cids"].is_array());
232
-
let cids = body["cids"].as_array().unwrap();
233
-
assert!(!cids.is_empty());
234
-
}
235
-
236
-
#[tokio::test]
237
-
async fn test_list_blobs_not_found() {
238
-
let client = client();
239
-
let params = [("did", "did:plc:nonexistent12345")];
240
-
let res = client
241
-
.get(format!(
242
-
"{}/xrpc/com.atproto.sync.listBlobs",
243
-
base_url().await
244
-
))
245
-
.query(¶ms)
246
-
.send()
247
-
.await
248
-
.expect("Failed to send request");
249
-
250
-
assert_eq!(res.status(), StatusCode::NOT_FOUND);
251
-
let body: Value = res.json().await.expect("Response was not valid JSON");
252
-
assert_eq!(body["error"], "RepoNotFound");
253
-
}
254
-
255
-
#[tokio::test]
256
-
async fn test_get_blob_success() {
257
-
let client = client();
258
-
let (access_jwt, did) = create_account_and_login(&client).await;
259
-
260
-
let blob_content = "test blob for get_blob";
261
-
let blob_res = client
262
-
.post(format!(
263
-
"{}/xrpc/com.atproto.repo.uploadBlob",
264
-
base_url().await
265
-
))
266
-
.header(header::CONTENT_TYPE, "text/plain")
267
-
.bearer_auth(&access_jwt)
268
-
.body(blob_content)
269
-
.send()
270
-
.await
271
-
.expect("Failed to upload blob");
272
-
273
-
assert_eq!(blob_res.status(), StatusCode::OK);
274
-
let blob_body: Value = blob_res.json().await.expect("Response was not valid JSON");
275
-
let cid = blob_body["blob"]["ref"]["$link"].as_str().expect("No CID");
276
-
277
-
let params = [("did", did.as_str()), ("cid", cid)];
278
-
let res = client
279
-
.get(format!(
280
-
"{}/xrpc/com.atproto.sync.getBlob",
281
-
base_url().await
282
-
))
283
-
.query(¶ms)
284
-
.send()
285
-
.await
286
-
.expect("Failed to send request");
287
-
288
-
assert_eq!(res.status(), StatusCode::OK);
289
-
assert_eq!(
290
-
res.headers()
291
-
.get("content-type")
292
-
.and_then(|h| h.to_str().ok()),
293
-
Some("text/plain")
294
-
);
295
-
let body = res.text().await.expect("Failed to get body");
296
-
assert_eq!(body, blob_content);
297
-
}
298
-
299
-
#[tokio::test]
300
-
async fn test_get_blob_not_found() {
301
-
let client = client();
302
-
let (_, did) = create_account_and_login(&client).await;
303
-
304
-
let params = [
305
-
("did", did.as_str()),
306
-
("cid", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"),
307
-
];
308
-
let res = client
309
-
.get(format!(
310
-
"{}/xrpc/com.atproto.sync.getBlob",
311
-
base_url().await
312
-
))
313
-
.query(¶ms)
314
-
.send()
315
-
.await
316
-
.expect("Failed to send request");
317
-
318
-
assert_eq!(res.status(), StatusCode::NOT_FOUND);
319
-
let body: Value = res.json().await.expect("Response was not valid JSON");
320
-
assert_eq!(body["error"], "BlobNotFound");
321
195
}
322
196
323
197
#[tokio::test]
+129
tests/sync_blob.rs
+129
tests/sync_blob.rs
···
1
+
mod common;
2
+
use common::*;
3
+
use reqwest::StatusCode;
4
+
use reqwest::header;
5
+
use serde_json::Value;
6
+
7
+
#[tokio::test]
8
+
async fn test_list_blobs_success() {
9
+
let client = client();
10
+
let (access_jwt, did) = create_account_and_login(&client).await;
11
+
12
+
let blob_res = client
13
+
.post(format!(
14
+
"{}/xrpc/com.atproto.repo.uploadBlob",
15
+
base_url().await
16
+
))
17
+
.header(header::CONTENT_TYPE, "text/plain")
18
+
.bearer_auth(&access_jwt)
19
+
.body("test blob content")
20
+
.send()
21
+
.await
22
+
.expect("Failed to upload blob");
23
+
24
+
assert_eq!(blob_res.status(), StatusCode::OK);
25
+
26
+
let params = [("did", did.as_str())];
27
+
let res = client
28
+
.get(format!(
29
+
"{}/xrpc/com.atproto.sync.listBlobs",
30
+
base_url().await
31
+
))
32
+
.query(¶ms)
33
+
.send()
34
+
.await
35
+
.expect("Failed to send request");
36
+
37
+
assert_eq!(res.status(), StatusCode::OK);
38
+
let body: Value = res.json().await.expect("Response was not valid JSON");
39
+
assert!(body["cids"].is_array());
40
+
let cids = body["cids"].as_array().unwrap();
41
+
assert!(!cids.is_empty());
42
+
}
43
+
44
+
#[tokio::test]
45
+
async fn test_list_blobs_not_found() {
46
+
let client = client();
47
+
let params = [("did", "did:plc:nonexistent12345")];
48
+
let res = client
49
+
.get(format!(
50
+
"{}/xrpc/com.atproto.sync.listBlobs",
51
+
base_url().await
52
+
))
53
+
.query(¶ms)
54
+
.send()
55
+
.await
56
+
.expect("Failed to send request");
57
+
58
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
59
+
let body: Value = res.json().await.expect("Response was not valid JSON");
60
+
assert_eq!(body["error"], "RepoNotFound");
61
+
}
62
+
63
+
#[tokio::test]
64
+
async fn test_get_blob_success() {
65
+
let client = client();
66
+
let (access_jwt, did) = create_account_and_login(&client).await;
67
+
68
+
let blob_content = "test blob for get_blob";
69
+
let blob_res = client
70
+
.post(format!(
71
+
"{}/xrpc/com.atproto.repo.uploadBlob",
72
+
base_url().await
73
+
))
74
+
.header(header::CONTENT_TYPE, "text/plain")
75
+
.bearer_auth(&access_jwt)
76
+
.body(blob_content)
77
+
.send()
78
+
.await
79
+
.expect("Failed to upload blob");
80
+
81
+
assert_eq!(blob_res.status(), StatusCode::OK);
82
+
let blob_body: Value = blob_res.json().await.expect("Response was not valid JSON");
83
+
let cid = blob_body["blob"]["ref"]["$link"].as_str().expect("No CID");
84
+
85
+
let params = [("did", did.as_str()), ("cid", cid)];
86
+
let res = client
87
+
.get(format!(
88
+
"{}/xrpc/com.atproto.sync.getBlob",
89
+
base_url().await
90
+
))
91
+
.query(¶ms)
92
+
.send()
93
+
.await
94
+
.expect("Failed to send request");
95
+
96
+
assert_eq!(res.status(), StatusCode::OK);
97
+
assert_eq!(
98
+
res.headers()
99
+
.get("content-type")
100
+
.and_then(|h| h.to_str().ok()),
101
+
Some("text/plain")
102
+
);
103
+
let body = res.text().await.expect("Failed to get body");
104
+
assert_eq!(body, blob_content);
105
+
}
106
+
107
+
#[tokio::test]
108
+
async fn test_get_blob_not_found() {
109
+
let client = client();
110
+
let (_, did) = create_account_and_login(&client).await;
111
+
112
+
let params = [
113
+
("did", did.as_str()),
114
+
("cid", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"),
115
+
];
116
+
let res = client
117
+
.get(format!(
118
+
"{}/xrpc/com.atproto.sync.getBlob",
119
+
base_url().await
120
+
))
121
+
.query(¶ms)
122
+
.send()
123
+
.await
124
+
.expect("Failed to send request");
125
+
126
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
127
+
let body: Value = res.json().await.expect("Response was not valid JSON");
128
+
assert_eq!(body["error"], "BlobNotFound");
129
+
}