this repo has no description

More endpoints, split out some tests to smaller files

lewis 3bf9f1bd cdaf5842

+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
··· 1 + ALTER TABLE invite_codes ADD COLUMN disabled BOOLEAN DEFAULT FALSE; 2 + 3 + ALTER TABLE users ADD COLUMN invites_disabled BOOLEAN DEFAULT FALSE;
+5
migrations/202512211800_takedown_refs.sql
··· 1 + ALTER TABLE users ADD COLUMN takedown_ref TEXT; 2 + 3 + ALTER TABLE records ADD COLUMN takedown_ref TEXT; 4 + 5 + ALTER TABLE blobs ADD COLUMN takedown_ref TEXT;
+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) = &params.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) = &params.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) = &params.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) = &params.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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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(&params) 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(&params) 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(&params) 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
··· 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 + }
+416
tests/lifecycle_social.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use common::*; 5 + use helpers::*; 6 + 7 + use reqwest::StatusCode; 8 + use serde_json::{Value, json}; 9 + use std::time::Duration; 10 + 11 + #[tokio::test] 12 + async fn test_social_flow_lifecycle() { 13 + let client = client(); 14 + 15 + let (alice_did, alice_jwt) = setup_new_user("alice-social").await; 16 + let (bob_did, bob_jwt) = setup_new_user("bob-social").await; 17 + 18 + let (post1_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's first post!").await; 19 + 20 + create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; 21 + 22 + tokio::time::sleep(Duration::from_secs(1)).await; 23 + 24 + let timeline_res_1 = client 25 + .get(format!( 26 + "{}/xrpc/app.bsky.feed.getTimeline", 27 + base_url().await 28 + )) 29 + .bearer_auth(&bob_jwt) 30 + .send() 31 + .await 32 + .expect("Failed to get timeline (1)"); 33 + 34 + assert_eq!( 35 + timeline_res_1.status(), 36 + reqwest::StatusCode::OK, 37 + "Failed to get timeline (1)" 38 + ); 39 + let timeline_body_1: Value = timeline_res_1.json().await.expect("Timeline (1) not JSON"); 40 + let feed_1 = timeline_body_1["feed"].as_array().unwrap(); 41 + assert_eq!(feed_1.len(), 1, "Timeline should have 1 post"); 42 + assert_eq!( 43 + feed_1[0]["post"]["uri"], post1_uri, 44 + "Post URI mismatch in timeline (1)" 45 + ); 46 + 47 + let (post2_uri, _) = create_post( 48 + &client, 49 + &alice_did, 50 + &alice_jwt, 51 + "Alice's second post, so exciting!", 52 + ) 53 + .await; 54 + 55 + tokio::time::sleep(Duration::from_secs(1)).await; 56 + 57 + let timeline_res_2 = client 58 + .get(format!( 59 + "{}/xrpc/app.bsky.feed.getTimeline", 60 + base_url().await 61 + )) 62 + .bearer_auth(&bob_jwt) 63 + .send() 64 + .await 65 + .expect("Failed to get timeline (2)"); 66 + 67 + assert_eq!( 68 + timeline_res_2.status(), 69 + reqwest::StatusCode::OK, 70 + "Failed to get timeline (2)" 71 + ); 72 + let timeline_body_2: Value = timeline_res_2.json().await.expect("Timeline (2) not JSON"); 73 + let feed_2 = timeline_body_2["feed"].as_array().unwrap(); 74 + assert_eq!(feed_2.len(), 2, "Timeline should have 2 posts"); 75 + assert_eq!( 76 + feed_2[0]["post"]["uri"], post2_uri, 77 + "Post 2 should be first" 78 + ); 79 + assert_eq!( 80 + feed_2[1]["post"]["uri"], post1_uri, 81 + "Post 1 should be second" 82 + ); 83 + 84 + let delete_payload = json!({ 85 + "repo": alice_did, 86 + "collection": "app.bsky.feed.post", 87 + "rkey": post1_uri.split('/').last().unwrap() 88 + }); 89 + let delete_res = client 90 + .post(format!( 91 + "{}/xrpc/com.atproto.repo.deleteRecord", 92 + base_url().await 93 + )) 94 + .bearer_auth(&alice_jwt) 95 + .json(&delete_payload) 96 + .send() 97 + .await 98 + .expect("Failed to send delete request"); 99 + assert_eq!( 100 + delete_res.status(), 101 + reqwest::StatusCode::OK, 102 + "Failed to delete record" 103 + ); 104 + 105 + tokio::time::sleep(Duration::from_secs(1)).await; 106 + 107 + let timeline_res_3 = client 108 + .get(format!( 109 + "{}/xrpc/app.bsky.feed.getTimeline", 110 + base_url().await 111 + )) 112 + .bearer_auth(&bob_jwt) 113 + .send() 114 + .await 115 + .expect("Failed to get timeline (3)"); 116 + 117 + assert_eq!( 118 + timeline_res_3.status(), 119 + reqwest::StatusCode::OK, 120 + "Failed to get timeline (3)" 121 + ); 122 + let timeline_body_3: Value = timeline_res_3.json().await.expect("Timeline (3) not JSON"); 123 + let feed_3 = timeline_body_3["feed"].as_array().unwrap(); 124 + assert_eq!(feed_3.len(), 1, "Timeline should have 1 post after delete"); 125 + assert_eq!( 126 + feed_3[0]["post"]["uri"], post2_uri, 127 + "Only post 2 should remain" 128 + ); 129 + } 130 + 131 + #[tokio::test] 132 + async fn test_like_lifecycle() { 133 + let client = client(); 134 + 135 + let (alice_did, alice_jwt) = setup_new_user("alice-like").await; 136 + let (bob_did, bob_jwt) = setup_new_user("bob-like").await; 137 + 138 + let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Like this post!").await; 139 + 140 + let (like_uri, _) = create_like(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; 141 + 142 + let like_rkey = like_uri.split('/').last().unwrap(); 143 + let get_like_res = client 144 + .get(format!( 145 + "{}/xrpc/com.atproto.repo.getRecord", 146 + base_url().await 147 + )) 148 + .query(&[ 149 + ("repo", bob_did.as_str()), 150 + ("collection", "app.bsky.feed.like"), 151 + ("rkey", like_rkey), 152 + ]) 153 + .send() 154 + .await 155 + .expect("Failed to get like"); 156 + 157 + assert_eq!(get_like_res.status(), StatusCode::OK); 158 + let like_body: Value = get_like_res.json().await.unwrap(); 159 + assert_eq!(like_body["value"]["subject"]["uri"], post_uri); 160 + 161 + let delete_payload = json!({ 162 + "repo": bob_did, 163 + "collection": "app.bsky.feed.like", 164 + "rkey": like_rkey 165 + }); 166 + 167 + let delete_res = client 168 + .post(format!( 169 + "{}/xrpc/com.atproto.repo.deleteRecord", 170 + base_url().await 171 + )) 172 + .bearer_auth(&bob_jwt) 173 + .json(&delete_payload) 174 + .send() 175 + .await 176 + .expect("Failed to delete like"); 177 + 178 + assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete like"); 179 + 180 + let get_deleted_res = client 181 + .get(format!( 182 + "{}/xrpc/com.atproto.repo.getRecord", 183 + base_url().await 184 + )) 185 + .query(&[ 186 + ("repo", bob_did.as_str()), 187 + ("collection", "app.bsky.feed.like"), 188 + ("rkey", like_rkey), 189 + ]) 190 + .send() 191 + .await 192 + .expect("Failed to check deleted like"); 193 + 194 + assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Like should be deleted"); 195 + } 196 + 197 + #[tokio::test] 198 + async fn test_repost_lifecycle() { 199 + let client = client(); 200 + 201 + let (alice_did, alice_jwt) = setup_new_user("alice-repost").await; 202 + let (bob_did, bob_jwt) = setup_new_user("bob-repost").await; 203 + 204 + let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Repost this!").await; 205 + 206 + let (repost_uri, _) = create_repost(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; 207 + 208 + let repost_rkey = repost_uri.split('/').last().unwrap(); 209 + let get_repost_res = client 210 + .get(format!( 211 + "{}/xrpc/com.atproto.repo.getRecord", 212 + base_url().await 213 + )) 214 + .query(&[ 215 + ("repo", bob_did.as_str()), 216 + ("collection", "app.bsky.feed.repost"), 217 + ("rkey", repost_rkey), 218 + ]) 219 + .send() 220 + .await 221 + .expect("Failed to get repost"); 222 + 223 + assert_eq!(get_repost_res.status(), StatusCode::OK); 224 + let repost_body: Value = get_repost_res.json().await.unwrap(); 225 + assert_eq!(repost_body["value"]["subject"]["uri"], post_uri); 226 + 227 + let delete_payload = json!({ 228 + "repo": bob_did, 229 + "collection": "app.bsky.feed.repost", 230 + "rkey": repost_rkey 231 + }); 232 + 233 + let delete_res = client 234 + .post(format!( 235 + "{}/xrpc/com.atproto.repo.deleteRecord", 236 + base_url().await 237 + )) 238 + .bearer_auth(&bob_jwt) 239 + .json(&delete_payload) 240 + .send() 241 + .await 242 + .expect("Failed to delete repost"); 243 + 244 + assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete repost"); 245 + } 246 + 247 + #[tokio::test] 248 + async fn test_unfollow_lifecycle() { 249 + let client = client(); 250 + 251 + let (alice_did, _alice_jwt) = setup_new_user("alice-unfollow").await; 252 + let (bob_did, bob_jwt) = setup_new_user("bob-unfollow").await; 253 + 254 + let (follow_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; 255 + 256 + let follow_rkey = follow_uri.split('/').last().unwrap(); 257 + let get_follow_res = client 258 + .get(format!( 259 + "{}/xrpc/com.atproto.repo.getRecord", 260 + base_url().await 261 + )) 262 + .query(&[ 263 + ("repo", bob_did.as_str()), 264 + ("collection", "app.bsky.graph.follow"), 265 + ("rkey", follow_rkey), 266 + ]) 267 + .send() 268 + .await 269 + .expect("Failed to get follow"); 270 + 271 + assert_eq!(get_follow_res.status(), StatusCode::OK); 272 + 273 + let unfollow_payload = json!({ 274 + "repo": bob_did, 275 + "collection": "app.bsky.graph.follow", 276 + "rkey": follow_rkey 277 + }); 278 + 279 + let unfollow_res = client 280 + .post(format!( 281 + "{}/xrpc/com.atproto.repo.deleteRecord", 282 + base_url().await 283 + )) 284 + .bearer_auth(&bob_jwt) 285 + .json(&unfollow_payload) 286 + .send() 287 + .await 288 + .expect("Failed to unfollow"); 289 + 290 + assert_eq!(unfollow_res.status(), StatusCode::OK, "Failed to unfollow"); 291 + 292 + let get_deleted_res = client 293 + .get(format!( 294 + "{}/xrpc/com.atproto.repo.getRecord", 295 + base_url().await 296 + )) 297 + .query(&[ 298 + ("repo", bob_did.as_str()), 299 + ("collection", "app.bsky.graph.follow"), 300 + ("rkey", follow_rkey), 301 + ]) 302 + .send() 303 + .await 304 + .expect("Failed to check deleted follow"); 305 + 306 + assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Follow should be deleted"); 307 + } 308 + 309 + #[tokio::test] 310 + async fn test_timeline_after_unfollow() { 311 + let client = client(); 312 + 313 + let (alice_did, alice_jwt) = setup_new_user("alice-tl-unfollow").await; 314 + let (bob_did, bob_jwt) = setup_new_user("bob-tl-unfollow").await; 315 + 316 + let (follow_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; 317 + 318 + create_post(&client, &alice_did, &alice_jwt, "Post while following").await; 319 + 320 + tokio::time::sleep(Duration::from_secs(1)).await; 321 + 322 + let timeline_res = client 323 + .get(format!( 324 + "{}/xrpc/app.bsky.feed.getTimeline", 325 + base_url().await 326 + )) 327 + .bearer_auth(&bob_jwt) 328 + .send() 329 + .await 330 + .expect("Failed to get timeline"); 331 + 332 + assert_eq!(timeline_res.status(), StatusCode::OK); 333 + let timeline_body: Value = timeline_res.json().await.unwrap(); 334 + let feed = timeline_body["feed"].as_array().unwrap(); 335 + assert_eq!(feed.len(), 1, "Should see 1 post from Alice"); 336 + 337 + let follow_rkey = follow_uri.split('/').last().unwrap(); 338 + let unfollow_payload = json!({ 339 + "repo": bob_did, 340 + "collection": "app.bsky.graph.follow", 341 + "rkey": follow_rkey 342 + }); 343 + client 344 + .post(format!( 345 + "{}/xrpc/com.atproto.repo.deleteRecord", 346 + base_url().await 347 + )) 348 + .bearer_auth(&bob_jwt) 349 + .json(&unfollow_payload) 350 + .send() 351 + .await 352 + .expect("Failed to unfollow"); 353 + 354 + tokio::time::sleep(Duration::from_secs(1)).await; 355 + 356 + let timeline_after_res = client 357 + .get(format!( 358 + "{}/xrpc/app.bsky.feed.getTimeline", 359 + base_url().await 360 + )) 361 + .bearer_auth(&bob_jwt) 362 + .send() 363 + .await 364 + .expect("Failed to get timeline after unfollow"); 365 + 366 + assert_eq!(timeline_after_res.status(), StatusCode::OK); 367 + let timeline_after: Value = timeline_after_res.json().await.unwrap(); 368 + let feed_after = timeline_after["feed"].as_array().unwrap(); 369 + assert_eq!(feed_after.len(), 0, "Should see 0 posts after unfollowing"); 370 + } 371 + 372 + #[tokio::test] 373 + async fn test_mutual_follow_lifecycle() { 374 + let client = client(); 375 + 376 + let (alice_did, alice_jwt) = setup_new_user("alice-mutual").await; 377 + let (bob_did, bob_jwt) = setup_new_user("bob-mutual").await; 378 + 379 + create_follow(&client, &alice_did, &alice_jwt, &bob_did).await; 380 + create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; 381 + 382 + create_post(&client, &alice_did, &alice_jwt, "Alice's post for mutual").await; 383 + create_post(&client, &bob_did, &bob_jwt, "Bob's post for mutual").await; 384 + 385 + tokio::time::sleep(Duration::from_secs(1)).await; 386 + 387 + let alice_timeline_res = client 388 + .get(format!( 389 + "{}/xrpc/app.bsky.feed.getTimeline", 390 + base_url().await 391 + )) 392 + .bearer_auth(&alice_jwt) 393 + .send() 394 + .await 395 + .expect("Failed to get Alice's timeline"); 396 + 397 + assert_eq!(alice_timeline_res.status(), StatusCode::OK); 398 + let alice_tl: Value = alice_timeline_res.json().await.unwrap(); 399 + let alice_feed = alice_tl["feed"].as_array().unwrap(); 400 + assert_eq!(alice_feed.len(), 1, "Alice should see Bob's 1 post"); 401 + 402 + let bob_timeline_res = client 403 + .get(format!( 404 + "{}/xrpc/app.bsky.feed.getTimeline", 405 + base_url().await 406 + )) 407 + .bearer_auth(&bob_jwt) 408 + .send() 409 + .await 410 + .expect("Failed to get Bob's timeline"); 411 + 412 + assert_eq!(bob_timeline_res.status(), StatusCode::OK); 413 + let bob_tl: Value = bob_timeline_res.json().await.unwrap(); 414 + let bob_feed = bob_tl["feed"].as_array().unwrap(); 415 + assert_eq!(bob_feed.len(), 1, "Bob should see Alice's 1 post"); 416 + }
-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(&params) 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(&params) 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(&params) 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(&params) 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
··· 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
··· 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
··· 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(&params) 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(&params) 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(&params) 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(&params) 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
··· 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(&params) 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(&params) 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(&params) 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(&params) 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
··· 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(&params) 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(&params) 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(&params) 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(&params) 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 + }