From 04a70e590cf4c1ad6d9066de659e49dce4e1dbbc Mon Sep 17 00:00:00 2001 From: lewis Date: Tue, 9 Dec 2025 23:41:53 +0200 Subject: [PATCH] More endpoints, split out some tests to smaller files --- TODO.md | 18 +- .../202512211700_invite_enhancements.sql | 3 + migrations/202512211800_takedown_refs.sql | 5 + src/api/admin/mod.rs | 659 +++++++++++++ src/api/server/invite.rs | 502 ++++++++++ src/api/server/mod.rs | 2 + src/lib.rs | 36 + tests/admin_invite.rs | 378 ++++++++ tests/admin_moderation.rs | 302 ++++++ tests/helpers/mod.rs | 231 +++++ tests/invite.rs | 288 ++++++ tests/lifecycle_record.rs | 887 ++++++++++++++++++ tests/lifecycle_session.rs | 306 ++++++ tests/lifecycle_social.rs | 416 ++++++++ tests/repo.rs | 793 ---------------- tests/repo_batch.rs | 337 +++++++ tests/repo_blob.rs | 119 +++ tests/repo_record.rs | 347 +++++++ tests/sync_blob.rs | 129 +++ tests/{sync.rs => sync_repo.rs} | 126 --- 20 files changed, 4956 insertions(+), 928 deletions(-) create mode 100644 migrations/202512211700_invite_enhancements.sql create mode 100644 migrations/202512211800_takedown_refs.sql create mode 100644 src/api/server/invite.rs create mode 100644 tests/admin_invite.rs create mode 100644 tests/admin_moderation.rs create mode 100644 tests/helpers/mod.rs create mode 100644 tests/invite.rs create mode 100644 tests/lifecycle_record.rs create mode 100644 tests/lifecycle_session.rs create mode 100644 tests/lifecycle_social.rs delete mode 100644 tests/repo.rs create mode 100644 tests/repo_batch.rs create mode 100644 tests/repo_blob.rs create mode 100644 tests/repo_record.rs create mode 100644 tests/sync_blob.rs rename tests/{sync.rs => sync_repo.rs} (76%) diff --git a/TODO.md b/TODO.md index d56b37c..d37acad 100644 --- a/TODO.md +++ b/TODO.md @@ -28,10 +28,10 @@ Lewis' corrected big boy todofile - [x] Implement `com.atproto.server.activateAccount`. - [x] Implement `com.atproto.server.checkAccountStatus`. - [x] Implement `com.atproto.server.createAppPassword`. - - [ ] Implement `com.atproto.server.createInviteCode`. - - [ ] Implement `com.atproto.server.createInviteCodes`. + - [x] Implement `com.atproto.server.createInviteCode`. + - [x] Implement `com.atproto.server.createInviteCodes`. - [x] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`. - - [ ] Implement `com.atproto.server.getAccountInviteCodes`. + - [x] Implement `com.atproto.server.getAccountInviteCodes`. - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). - [x] Implement `com.atproto.server.listAppPasswords`. - [ ] Implement `com.atproto.server.requestAccountDelete`. @@ -91,17 +91,17 @@ Lewis' corrected big boy todofile ## Admin Management (`com.atproto.admin`) - [x] Implement `com.atproto.admin.deleteAccount`. -- [ ] Implement `com.atproto.admin.disableAccountInvites`. -- [ ] Implement `com.atproto.admin.disableInviteCodes`. -- [ ] Implement `com.atproto.admin.enableAccountInvites`. +- [x] Implement `com.atproto.admin.disableAccountInvites`. +- [x] Implement `com.atproto.admin.disableInviteCodes`. +- [x] Implement `com.atproto.admin.enableAccountInvites`. - [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. -- [ ] Implement `com.atproto.admin.getInviteCodes`. -- [ ] Implement `com.atproto.admin.getSubjectStatus`. +- [x] Implement `com.atproto.admin.getInviteCodes`. +- [x] Implement `com.atproto.admin.getSubjectStatus`. - [ ] Implement `com.atproto.admin.sendEmail`. - [x] Implement `com.atproto.admin.updateAccountEmail`. - [x] Implement `com.atproto.admin.updateAccountHandle`. - [x] Implement `com.atproto.admin.updateAccountPassword`. -- [ ] Implement `com.atproto.admin.updateSubjectStatus`. +- [x] Implement `com.atproto.admin.updateSubjectStatus`. ## Moderation (`com.atproto.moderation`) - [x] Implement `com.atproto.moderation.createReport`. diff --git a/migrations/202512211700_invite_enhancements.sql b/migrations/202512211700_invite_enhancements.sql new file mode 100644 index 0000000..2a9c417 --- /dev/null +++ b/migrations/202512211700_invite_enhancements.sql @@ -0,0 +1,3 @@ +ALTER TABLE invite_codes ADD COLUMN disabled BOOLEAN DEFAULT FALSE; + +ALTER TABLE users ADD COLUMN invites_disabled BOOLEAN DEFAULT FALSE; diff --git a/migrations/202512211800_takedown_refs.sql b/migrations/202512211800_takedown_refs.sql new file mode 100644 index 0000000..aaeea67 --- /dev/null +++ b/migrations/202512211800_takedown_refs.sql @@ -0,0 +1,5 @@ +ALTER TABLE users ADD COLUMN takedown_ref TEXT; + +ALTER TABLE records ADD COLUMN takedown_ref TEXT; + +ALTER TABLE blobs ADD COLUMN takedown_ref TEXT; diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 6287b1f..9d0fa54 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -9,6 +9,665 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::error; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisableInviteCodesInput { + pub codes: Option>, + pub accounts: Option>, +} + +pub async fn disable_invite_codes( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + if let Some(codes) = &input.codes { + for code in codes { + let _ = sqlx::query!("UPDATE invite_codes SET disabled = TRUE WHERE code = $1", code) + .execute(&state.db) + .await; + } + } + + if let Some(accounts) = &input.accounts { + for account in accounts { + let user = sqlx::query!("SELECT id FROM users WHERE did = $1", account) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(user_row)) = user { + let _ = sqlx::query!( + "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1", + user_row.id + ) + .execute(&state.db) + .await; + } + } + } + + (StatusCode::OK, Json(json!({}))).into_response() +} + +#[derive(Deserialize)] +pub struct GetSubjectStatusParams { + pub did: Option, + pub uri: Option, + pub blob: Option, +} + +#[derive(Serialize)] +pub struct SubjectStatus { + pub subject: serde_json::Value, + pub takedown: Option, + pub deactivated: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusAttr { + pub applied: bool, + pub r#ref: Option, +} + +pub async fn get_subject_status( + State(state): State, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + if params.did.is_none() && params.uri.is_none() && params.blob.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Must provide did, uri, or blob"})), + ) + .into_response(); + } + + if let Some(did) = ¶ms.did { + let user = sqlx::query!( + "SELECT did, deactivated_at, takedown_ref FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await; + + match user { + Ok(Some(row)) => { + let deactivated = row.deactivated_at.map(|_| StatusAttr { + applied: true, + r#ref: None, + }); + let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr { + applied: true, + r#ref: Some(r.clone()), + }); + + return ( + StatusCode::OK, + Json(SubjectStatus { + subject: json!({ + "$type": "com.atproto.admin.defs#repoRef", + "did": row.did + }), + takedown, + deactivated, + }), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in get_subject_status: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } + + if let Some(uri) = ¶ms.uri { + let record = sqlx::query!( + "SELECT r.id, r.takedown_ref FROM records r WHERE r.record_cid = $1", + uri + ) + .fetch_optional(&state.db) + .await; + + match record { + Ok(Some(row)) => { + let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr { + applied: true, + r#ref: Some(r.clone()), + }); + + return ( + StatusCode::OK, + Json(SubjectStatus { + subject: json!({ + "$type": "com.atproto.repo.strongRef", + "uri": uri, + "cid": uri + }), + takedown, + deactivated: None, + }), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in get_subject_status: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } + + if let Some(blob_cid) = ¶ms.blob { + let blob = sqlx::query!("SELECT cid, takedown_ref FROM blobs WHERE cid = $1", blob_cid) + .fetch_optional(&state.db) + .await; + + match blob { + Ok(Some(row)) => { + let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr { + applied: true, + r#ref: Some(r.clone()), + }); + + return ( + StatusCode::OK, + Json(SubjectStatus { + subject: json!({ + "$type": "com.atproto.admin.defs#repoBlobRef", + "did": "", + "cid": row.cid + }), + takedown, + deactivated: None, + }), + ) + .into_response(); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in get_subject_status: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } + + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})), + ) + .into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSubjectStatusInput { + pub subject: serde_json::Value, + pub takedown: Option, + pub deactivated: Option, +} + +#[derive(Deserialize)] +pub struct StatusAttrInput { + pub apply: bool, + pub r#ref: Option, +} + +pub async fn update_subject_status( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let subject_type = input.subject.get("$type").and_then(|t| t.as_str()); + + match subject_type { + Some("com.atproto.admin.defs#repoRef") => { + let did = input.subject.get("did").and_then(|d| d.as_str()); + if let Some(did) = did { + if let Some(takedown) = &input.takedown { + let takedown_ref = if takedown.apply { + takedown.r#ref.clone() + } else { + None + }; + let _ = sqlx::query!( + "UPDATE users SET takedown_ref = $1 WHERE did = $2", + takedown_ref, + did + ) + .execute(&state.db) + .await; + } + + if let Some(deactivated) = &input.deactivated { + if deactivated.apply { + let _ = sqlx::query!( + "UPDATE users SET deactivated_at = NOW() WHERE did = $1", + did + ) + .execute(&state.db) + .await; + } else { + let _ = sqlx::query!( + "UPDATE users SET deactivated_at = NULL WHERE did = $1", + did + ) + .execute(&state.db) + .await; + } + } + + return ( + StatusCode::OK, + Json(json!({ + "subject": input.subject, + "takedown": input.takedown.as_ref().map(|t| json!({ + "applied": t.apply, + "ref": t.r#ref + })), + "deactivated": input.deactivated.as_ref().map(|d| json!({ + "applied": d.apply + })) + })), + ) + .into_response(); + } + } + Some("com.atproto.repo.strongRef") => { + let uri = input.subject.get("uri").and_then(|u| u.as_str()); + if let Some(uri) = uri { + if let Some(takedown) = &input.takedown { + let takedown_ref = if takedown.apply { + takedown.r#ref.clone() + } else { + None + }; + let _ = sqlx::query!( + "UPDATE records SET takedown_ref = $1 WHERE record_cid = $2", + takedown_ref, + uri + ) + .execute(&state.db) + .await; + } + + return ( + StatusCode::OK, + Json(json!({ + "subject": input.subject, + "takedown": input.takedown.as_ref().map(|t| json!({ + "applied": t.apply, + "ref": t.r#ref + })) + })), + ) + .into_response(); + } + } + Some("com.atproto.admin.defs#repoBlobRef") => { + let cid = input.subject.get("cid").and_then(|c| c.as_str()); + if let Some(cid) = cid { + if let Some(takedown) = &input.takedown { + let takedown_ref = if takedown.apply { + takedown.r#ref.clone() + } else { + None + }; + let _ = sqlx::query!( + "UPDATE blobs SET takedown_ref = $1 WHERE cid = $2", + takedown_ref, + cid + ) + .execute(&state.db) + .await; + } + + return ( + StatusCode::OK, + Json(json!({ + "subject": input.subject, + "takedown": input.takedown.as_ref().map(|t| json!({ + "applied": t.apply, + "ref": t.r#ref + })) + })), + ) + .into_response(); + } + } + _ => {} + } + + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})), + ) + .into_response() +} + +#[derive(Deserialize)] +pub struct GetInviteCodesParams { + pub sort: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteCodeInfo { + pub code: String, + pub available: i32, + pub disabled: bool, + pub for_account: String, + pub created_by: String, + pub created_at: String, + pub uses: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteCodeUseInfo { + pub used_by: String, + pub used_at: String, +} + +#[derive(Serialize)] +pub struct GetInviteCodesOutput { + pub cursor: Option, + pub codes: Vec, +} + +pub async fn get_invite_codes( + State(state): State, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let limit = params.limit.unwrap_or(100).min(500); + let sort = params.sort.as_deref().unwrap_or("recent"); + + let order_clause = match sort { + "usage" => "available_uses DESC", + _ => "created_at DESC", + }; + + let codes_result = if let Some(cursor) = ¶ms.cursor { + sqlx::query_as::<_, (String, i32, Option, uuid::Uuid, chrono::DateTime)>(&format!( + r#" + SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at + FROM invite_codes ic + WHERE ic.created_at < (SELECT created_at FROM invite_codes WHERE code = $1) + ORDER BY {} + LIMIT $2 + "#, + order_clause + )) + .bind(cursor) + .bind(limit) + .fetch_all(&state.db) + .await + } else { + sqlx::query_as::<_, (String, i32, Option, uuid::Uuid, chrono::DateTime)>(&format!( + r#" + SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at + FROM invite_codes ic + ORDER BY {} + LIMIT $1 + "#, + order_clause + )) + .bind(limit) + .fetch_all(&state.db) + .await + }; + + let codes_rows = match codes_result { + Ok(rows) => rows, + Err(e) => { + error!("DB error fetching invite codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let mut codes = Vec::new(); + for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows { + let creator_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .unwrap_or_else(|| "unknown".to_string()); + + let uses_result = sqlx::query!( + r#" + SELECT u.did, icu.used_at + FROM invite_code_uses icu + JOIN users u ON icu.used_by_user = u.id + WHERE icu.code = $1 + ORDER BY icu.used_at DESC + "#, + code + ) + .fetch_all(&state.db) + .await; + + let uses = match uses_result { + Ok(use_rows) => use_rows + .iter() + .map(|u| InviteCodeUseInfo { + used_by: u.did.clone(), + used_at: u.used_at.to_rfc3339(), + }) + .collect(), + Err(_) => Vec::new(), + }; + + codes.push(InviteCodeInfo { + code: code.clone(), + available: *available_uses, + disabled: disabled.unwrap_or(false), + for_account: creator_did.clone(), + created_by: creator_did, + created_at: created_at.to_rfc3339(), + uses, + }); + } + + let next_cursor = if codes_rows.len() == limit as usize { + codes_rows.last().map(|(code, _, _, _, _)| code.clone()) + } else { + None + }; + + ( + StatusCode::OK, + Json(GetInviteCodesOutput { + cursor: next_cursor, + codes, + }), + ) + .into_response() +} + +#[derive(Deserialize)] +pub struct DisableAccountInvitesInput { + pub account: String, +} + +pub async fn disable_account_invites( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let account = input.account.trim(); + if account.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "account is required"})), + ) + .into_response(); + } + + let result = sqlx::query!("UPDATE users SET invites_disabled = TRUE WHERE did = $1", account) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() == 0 { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error disabling account invites: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +pub struct EnableAccountInvitesInput { + pub account: String, +} + +pub async fn enable_account_invites( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let account = input.account.trim(); + if account.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "account is required"})), + ) + .into_response(); + } + + let result = sqlx::query!("UPDATE users SET invites_disabled = FALSE WHERE did = $1", account) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() == 0 { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error enabling account invites: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + #[derive(Deserialize)] pub struct GetAccountInfoParams { pub did: String, diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs new file mode 100644 index 0000000..7d58333 --- /dev/null +++ b/src/api/server/invite.rs @@ -0,0 +1,502 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::error; +use uuid::Uuid; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateInviteCodeInput { + pub use_count: i32, + pub for_account: Option, +} + +#[derive(Serialize)] +pub struct CreateInviteCodeOutput { + pub code: String, +} + +pub async fn create_invite_code( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + if input.use_count < 1 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "useCount must be at least 1"})), + ) + .into_response(); + } + + let token = auth_header + .unwrap() + .to_str() + .unwrap_or("") + .replace("Bearer ", ""); + + let session = sqlx::query!( + r#" + SELECT s.did, k.key_bytes, u.id as user_id + FROM sessions s + JOIN users u ON s.did = u.did + JOIN user_keys k ON u.id = k.user_id + WHERE s.access_jwt = $1 + "#, + token + ) + .fetch_optional(&state.db) + .await; + + let (did, key_bytes, user_id) = match session { + Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in create_invite_code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), + ) + .into_response(); + } + + let creator_user_id = if let Some(for_account) = &input.for_account { + let target = sqlx::query!("SELECT id FROM users WHERE did = $1", for_account) + .fetch_optional(&state.db) + .await; + + match target { + Ok(Some(row)) => row.id, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Target account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error looking up target account: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } else { + user_id + }; + + let user_invites_disabled = sqlx::query_scalar!( + "SELECT invites_disabled FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .flatten() + .unwrap_or(false); + + if user_invites_disabled { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "InvitesDisabled", "message": "Invites are disabled for this account"})), + ) + .into_response(); + } + + let code = Uuid::new_v4().to_string(); + + let result = sqlx::query!( + "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", + code, + input.use_count, + creator_user_id + ) + .execute(&state.db) + .await; + + match result { + Ok(_) => (StatusCode::OK, Json(CreateInviteCodeOutput { code })).into_response(), + Err(e) => { + error!("DB error creating invite code: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateInviteCodesInput { + pub code_count: Option, + pub use_count: i32, + pub for_accounts: Option>, +} + +#[derive(Serialize)] +pub struct CreateInviteCodesOutput { + pub codes: Vec, +} + +#[derive(Serialize)] +pub struct AccountCodes { + pub account: String, + pub codes: Vec, +} + +pub async fn create_invite_codes( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + if input.use_count < 1 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "useCount must be at least 1"})), + ) + .into_response(); + } + + let token = auth_header + .unwrap() + .to_str() + .unwrap_or("") + .replace("Bearer ", ""); + + let session = sqlx::query!( + r#" + SELECT s.did, k.key_bytes, u.id as user_id + FROM sessions s + JOIN users u ON s.did = u.did + JOIN user_keys k ON u.id = k.user_id + WHERE s.access_jwt = $1 + "#, + token + ) + .fetch_optional(&state.db) + .await; + + let (_did, key_bytes, user_id) = match session { + Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in create_invite_codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), + ) + .into_response(); + } + + let code_count = input.code_count.unwrap_or(1).max(1); + let for_accounts = input.for_accounts.unwrap_or_default(); + + let mut result_codes = Vec::new(); + + if for_accounts.is_empty() { + let mut codes = Vec::new(); + for _ in 0..code_count { + let code = Uuid::new_v4().to_string(); + + let insert = sqlx::query!( + "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", + code, + input.use_count, + user_id + ) + .execute(&state.db) + .await; + + if let Err(e) = insert { + error!("DB error creating invite code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + codes.push(code); + } + + result_codes.push(AccountCodes { + account: "admin".to_string(), + codes, + }); + } else { + for account_did in for_accounts { + let target = sqlx::query!("SELECT id FROM users WHERE did = $1", account_did) + .fetch_optional(&state.db) + .await; + + let target_user_id = match target { + Ok(Some(row)) => row.id, + Ok(None) => { + continue; + } + Err(e) => { + error!("DB error looking up target account: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let mut codes = Vec::new(); + for _ in 0..code_count { + let code = Uuid::new_v4().to_string(); + + let insert = sqlx::query!( + "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", + code, + input.use_count, + target_user_id + ) + .execute(&state.db) + .await; + + if let Err(e) = insert { + error!("DB error creating invite code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + codes.push(code); + } + + result_codes.push(AccountCodes { + account: account_did, + codes, + }); + } + } + + (StatusCode::OK, Json(CreateInviteCodesOutput { codes: result_codes })).into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAccountInviteCodesParams { + pub include_used: Option, + pub create_available: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteCode { + pub code: String, + pub available: i32, + pub disabled: bool, + pub for_account: String, + pub created_by: String, + pub created_at: String, + pub uses: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InviteCodeUse { + pub used_by: String, + pub used_at: String, +} + +#[derive(Serialize)] +pub struct GetAccountInviteCodesOutput { + pub codes: Vec, +} + +pub async fn get_account_invite_codes( + State(state): State, + headers: axum::http::HeaderMap, + axum::extract::Query(params): axum::extract::Query, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let token = auth_header + .unwrap() + .to_str() + .unwrap_or("") + .replace("Bearer ", ""); + + let session = sqlx::query!( + r#" + SELECT s.did, k.key_bytes, u.id as user_id + FROM sessions s + JOIN users u ON s.did = u.did + JOIN user_keys k ON u.id = k.user_id + WHERE s.access_jwt = $1 + "#, + token + ) + .fetch_optional(&state.db) + .await; + + let (did, key_bytes, user_id) = match session { + Ok(Some(row)) => (row.did, row.key_bytes, row.user_id), + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in get_account_invite_codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), + ) + .into_response(); + } + + let include_used = params.include_used.unwrap_or(true); + + let codes_result = sqlx::query!( + r#" + SELECT code, available_uses, created_at, disabled + FROM invite_codes + WHERE created_by_user = $1 + ORDER BY created_at DESC + "#, + user_id + ) + .fetch_all(&state.db) + .await; + + let codes_rows = match codes_result { + Ok(rows) => { + if include_used { + rows + } else { + rows.into_iter().filter(|r| r.available_uses > 0).collect() + } + } + Err(e) => { + error!("DB error fetching invite codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let mut codes = Vec::new(); + for row in codes_rows { + let uses_result = sqlx::query!( + r#" + SELECT u.did, icu.used_at + FROM invite_code_uses icu + JOIN users u ON icu.used_by_user = u.id + WHERE icu.code = $1 + ORDER BY icu.used_at DESC + "#, + row.code + ) + .fetch_all(&state.db) + .await; + + let uses = match uses_result { + Ok(use_rows) => use_rows + .iter() + .map(|u| InviteCodeUse { + used_by: u.did.clone(), + used_at: u.used_at.to_rfc3339(), + }) + .collect(), + Err(_) => Vec::new(), + }; + + codes.push(InviteCode { + code: row.code, + available: row.available_uses, + disabled: row.disabled.unwrap_or(false), + for_account: did.clone(), + created_by: did.clone(), + created_at: row.created_at.to_rfc3339(), + uses, + }); + } + + (StatusCode::OK, Json(GetAccountInviteCodesOutput { codes })).into_response() +} diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index fbc93e1..04c5727 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -1,6 +1,8 @@ +pub mod invite; pub mod meta; pub mod session; +pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use meta::{describe_server, health}; pub use session::{ activate_account, check_account_status, create_app_password, create_session, diff --git a/src/lib.rs b/src/lib.rs index e4ee112..ba3393f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -182,6 +182,42 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.revokeAppPassword", post(api::server::revoke_app_password), ) + .route( + "/xrpc/com.atproto.server.createInviteCode", + post(api::server::create_invite_code), + ) + .route( + "/xrpc/com.atproto.server.createInviteCodes", + post(api::server::create_invite_codes), + ) + .route( + "/xrpc/com.atproto.server.getAccountInviteCodes", + get(api::server::get_account_invite_codes), + ) + .route( + "/xrpc/com.atproto.admin.getInviteCodes", + get(api::admin::get_invite_codes), + ) + .route( + "/xrpc/com.atproto.admin.disableAccountInvites", + post(api::admin::disable_account_invites), + ) + .route( + "/xrpc/com.atproto.admin.enableAccountInvites", + post(api::admin::enable_account_invites), + ) + .route( + "/xrpc/com.atproto.admin.disableInviteCodes", + post(api::admin::disable_invite_codes), + ) + .route( + "/xrpc/com.atproto.admin.getSubjectStatus", + get(api::admin::get_subject_status), + ) + .route( + "/xrpc/com.atproto.admin.updateSubjectStatus", + post(api::admin::update_subject_status), + ) // I know I know, I'm not supposed to implement appview endpoints. Leave me be .route( "/xrpc/app.bsky.feed.getTimeline", diff --git a/tests/admin_invite.rs b/tests/admin_invite.rs new file mode 100644 index 0000000..6354f80 --- /dev/null +++ b/tests/admin_invite.rs @@ -0,0 +1,378 @@ +mod common; +use common::*; + +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_admin_get_invite_codes_success() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let create_payload = json!({ + "useCount": 3 + }); + let _ = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to create invite code"); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["codes"].is_array()); +} + +#[tokio::test] +async fn test_admin_get_invite_codes_with_limit() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + for _ in 0..5 { + let create_payload = json!({ + "useCount": 1 + }); + let _ = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await; + } + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("limit", "2")]) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + let codes = body["codes"].as_array().unwrap(); + assert!(codes.len() <= 2); +} + +#[tokio::test] +async fn test_admin_get_invite_codes_no_auth() { + let client = client(); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getInviteCodes", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_disable_account_invites_success() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let payload = json!({ + "account": did + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableAccountInvites", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let create_payload = json!({ + "useCount": 1 + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::FORBIDDEN); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "InvitesDisabled"); +} + +#[tokio::test] +async fn test_enable_account_invites_success() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let disable_payload = json!({ + "account": did + }); + let _ = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableAccountInvites", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&disable_payload) + .send() + .await; + + let enable_payload = json!({ + "account": did + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.enableAccountInvites", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&enable_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let create_payload = json!({ + "useCount": 1 + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_disable_account_invites_no_auth() { + let client = client(); + let payload = json!({ + "account": "did:plc:test" + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableAccountInvites", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_disable_account_invites_not_found() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let payload = json!({ + "account": "did:plc:nonexistent" + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableAccountInvites", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_disable_invite_codes_by_code() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let create_payload = json!({ + "useCount": 5 + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to create invite code"); + + let create_body: Value = create_res.json().await.unwrap(); + let code = create_body["code"].as_str().unwrap(); + + let disable_payload = json!({ + "codes": [code] + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&disable_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let list_res = client + .get(format!( + "{}/xrpc/com.atproto.server.getAccountInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to get invite codes"); + + let list_body: Value = list_res.json().await.unwrap(); + let codes = list_body["codes"].as_array().unwrap(); + let disabled_code = codes.iter().find(|c| c["code"].as_str().unwrap() == code); + assert!(disabled_code.is_some()); + assert_eq!(disabled_code.unwrap()["disabled"], true); +} + +#[tokio::test] +async fn test_disable_invite_codes_by_account() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + for _ in 0..3 { + let create_payload = json!({ + "useCount": 1 + }); + let _ = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await; + } + + let disable_payload = json!({ + "accounts": [did] + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&disable_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let list_res = client + .get(format!( + "{}/xrpc/com.atproto.server.getAccountInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to get invite codes"); + + let list_body: Value = list_res.json().await.unwrap(); + let codes = list_body["codes"].as_array().unwrap(); + for code in codes { + assert_eq!(code["disabled"], true); + } +} + +#[tokio::test] +async fn test_disable_invite_codes_no_auth() { + let client = client(); + let payload = json!({ + "codes": ["some-code"] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.disableInviteCodes", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_admin_enable_account_invites_not_found() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let payload = json!({ + "account": "did:plc:nonexistent" + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.enableAccountInvites", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} diff --git a/tests/admin_moderation.rs b/tests/admin_moderation.rs new file mode 100644 index 0000000..67182e3 --- /dev/null +++ b/tests/admin_moderation.rs @@ -0,0 +1,302 @@ +mod common; +use common::*; + +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_get_subject_status_user_success() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("did", did.as_str())]) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["subject"].is_object()); + assert_eq!(body["subject"]["$type"], "com.atproto.admin.defs#repoRef"); + assert_eq!(body["subject"]["did"], did); +} + +#[tokio::test] +async fn test_get_subject_status_not_found() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("did", "did:plc:nonexistent")]) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "SubjectNotFound"); +} + +#[tokio::test] +async fn test_get_subject_status_no_param() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "InvalidRequest"); +} + +#[tokio::test] +async fn test_get_subject_status_no_auth() { + let client = client(); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .query(&[("did", "did:plc:test")]) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_update_subject_status_takedown_user() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let payload = json!({ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": did + }, + "takedown": { + "apply": true, + "ref": "mod-action-123" + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["takedown"].is_object()); + assert_eq!(body["takedown"]["applied"], true); + assert_eq!(body["takedown"]["ref"], "mod-action-123"); + + let status_res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("did", did.as_str())]) + .send() + .await + .expect("Failed to send request"); + + let status_body: Value = status_res.json().await.unwrap(); + assert!(status_body["takedown"].is_object()); + assert_eq!(status_body["takedown"]["applied"], true); + assert_eq!(status_body["takedown"]["ref"], "mod-action-123"); +} + +#[tokio::test] +async fn test_update_subject_status_remove_takedown() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let takedown_payload = json!({ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": did + }, + "takedown": { + "apply": true, + "ref": "mod-action-456" + } + }); + + let _ = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&takedown_payload) + .send() + .await; + + let remove_payload = json!({ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": did + }, + "takedown": { + "apply": false + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&remove_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let status_res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("did", did.as_str())]) + .send() + .await + .expect("Failed to send request"); + + let status_body: Value = status_res.json().await.unwrap(); + assert!(status_body["takedown"].is_null() || !status_body["takedown"]["applied"].as_bool().unwrap_or(false)); +} + +#[tokio::test] +async fn test_update_subject_status_deactivate_user() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let payload = json!({ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": did + }, + "deactivated": { + "apply": true + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let status_res = client + .get(format!( + "{}/xrpc/com.atproto.admin.getSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("did", did.as_str())]) + .send() + .await + .expect("Failed to send request"); + + let status_body: Value = status_res.json().await.unwrap(); + assert!(status_body["deactivated"].is_object()); + assert_eq!(status_body["deactivated"]["applied"], true); +} + +#[tokio::test] +async fn test_update_subject_status_invalid_type() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let payload = json!({ + "subject": { + "$type": "invalid.type", + "did": "did:plc:test" + }, + "takedown": { + "apply": true + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "InvalidRequest"); +} + +#[tokio::test] +async fn test_update_subject_status_no_auth() { + let client = client(); + + let payload = json!({ + "subject": { + "$type": "com.atproto.admin.defs#repoRef", + "did": "did:plc:test" + }, + "takedown": { + "apply": true + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateSubjectStatus", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs new file mode 100644 index 0000000..20c454e --- /dev/null +++ b/tests/helpers/mod.rs @@ -0,0 +1,231 @@ +use chrono::Utc; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +pub use crate::common::*; + +pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("{}-{}.test", handle_prefix, ts); + let email = format!("{}-{}@test.com", handle_prefix, ts); + let password = "e2e-password-123"; + + let create_account_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_account_payload) + .send() + .await + .expect("setup_new_user: Failed to send createAccount"); + + if create_res.status() != reqwest::StatusCode::OK { + panic!( + "setup_new_user: Failed to create account: {:?}", + create_res.text().await + ); + } + + let create_body: Value = create_res + .json() + .await + .expect("setup_new_user: createAccount response was not JSON"); + + let new_did = create_body["did"] + .as_str() + .expect("setup_new_user: Response had no DID") + .to_string(); + let new_jwt = create_body["accessJwt"] + .as_str() + .expect("setup_new_user: Response had no accessJwt") + .to_string(); + + (new_did, new_jwt) +} + +pub async fn create_post( + client: &reqwest::Client, + did: &str, + jwt: &str, + text: &str, +) -> (String, String) { + let collection = "app.bsky.feed.post"; + let rkey = format!("e2e_social_{}", Utc::now().timestamp_millis()); + let now = Utc::now().to_rfc3339(); + + let create_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": text, + "createdAt": now + } + }); + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to send create post request"); + + assert_eq!( + create_res.status(), + reqwest::StatusCode::OK, + "Failed to create post record" + ); + let create_body: Value = create_res + .json() + .await + .expect("create post response was not JSON"); + let uri = create_body["uri"].as_str().unwrap().to_string(); + let cid = create_body["cid"].as_str().unwrap().to_string(); + (uri, cid) +} + +pub async fn create_follow( + client: &reqwest::Client, + follower_did: &str, + follower_jwt: &str, + followee_did: &str, +) -> (String, String) { + let collection = "app.bsky.graph.follow"; + let rkey = format!("e2e_follow_{}", Utc::now().timestamp_millis()); + let now = Utc::now().to_rfc3339(); + + let create_payload = json!({ + "repo": follower_did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "subject": followee_did, + "createdAt": now + } + }); + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(follower_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to send create follow request"); + + assert_eq!( + create_res.status(), + reqwest::StatusCode::OK, + "Failed to create follow record" + ); + let create_body: Value = create_res + .json() + .await + .expect("create follow response was not JSON"); + let uri = create_body["uri"].as_str().unwrap().to_string(); + let cid = create_body["cid"].as_str().unwrap().to_string(); + (uri, cid) +} + +pub async fn create_like( + client: &reqwest::Client, + liker_did: &str, + liker_jwt: &str, + subject_uri: &str, + subject_cid: &str, +) -> (String, String) { + let collection = "app.bsky.feed.like"; + let rkey = format!("e2e_like_{}", Utc::now().timestamp_millis()); + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": liker_did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "subject": { + "uri": subject_uri, + "cid": subject_cid + }, + "createdAt": now + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(liker_jwt) + .json(&payload) + .send() + .await + .expect("Failed to create like"); + + assert_eq!(res.status(), StatusCode::OK, "Failed to create like"); + let body: Value = res.json().await.expect("Like response not JSON"); + ( + body["uri"].as_str().unwrap().to_string(), + body["cid"].as_str().unwrap().to_string(), + ) +} + +pub async fn create_repost( + client: &reqwest::Client, + reposter_did: &str, + reposter_jwt: &str, + subject_uri: &str, + subject_cid: &str, +) -> (String, String) { + let collection = "app.bsky.feed.repost"; + let rkey = format!("e2e_repost_{}", Utc::now().timestamp_millis()); + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": reposter_did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "subject": { + "uri": subject_uri, + "cid": subject_cid + }, + "createdAt": now + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(reposter_jwt) + .json(&payload) + .send() + .await + .expect("Failed to create repost"); + + assert_eq!(res.status(), StatusCode::OK, "Failed to create repost"); + let body: Value = res.json().await.expect("Repost response not JSON"); + ( + body["uri"].as_str().unwrap().to_string(), + body["cid"].as_str().unwrap().to_string(), + ) +} diff --git a/tests/invite.rs b/tests/invite.rs new file mode 100644 index 0000000..412a1bc --- /dev/null +++ b/tests/invite.rs @@ -0,0 +1,288 @@ +mod common; +use common::*; + +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_create_invite_code_success() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let payload = json!({ + "useCount": 5 + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["code"].is_string()); + let code = body["code"].as_str().unwrap(); + assert!(!code.is_empty()); + assert!(code.contains('-'), "Code should be a UUID format"); +} + +#[tokio::test] +async fn test_create_invite_code_no_auth() { + let client = client(); + let payload = json!({ + "useCount": 5 + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "AuthenticationRequired"); +} + +#[tokio::test] +async fn test_create_invite_code_invalid_use_count() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let payload = json!({ + "useCount": 0 + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "InvalidRequest"); +} + +#[tokio::test] +async fn test_create_invite_code_for_another_account() { + let client = client(); + let (access_jwt1, _did1) = create_account_and_login(&client).await; + let (_access_jwt2, did2) = create_account_and_login(&client).await; + + let payload = json!({ + "useCount": 3, + "forAccount": did2 + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt1) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["code"].is_string()); +} + +#[tokio::test] +async fn test_create_invite_codes_success() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let payload = json!({ + "useCount": 2, + "codeCount": 3 + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["codes"].is_array()); + let codes = body["codes"].as_array().unwrap(); + assert_eq!(codes.len(), 1); + assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn test_create_invite_codes_for_multiple_accounts() { + let client = client(); + let (access_jwt1, did1) = create_account_and_login(&client).await; + let (_access_jwt2, did2) = create_account_and_login(&client).await; + + let payload = json!({ + "useCount": 1, + "codeCount": 2, + "forAccounts": [did1, did2] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt1) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + let codes = body["codes"].as_array().unwrap(); + assert_eq!(codes.len(), 2); + + for code_obj in codes { + assert!(code_obj["account"].is_string()); + assert_eq!(code_obj["codes"].as_array().unwrap().len(), 2); + } +} + +#[tokio::test] +async fn test_create_invite_codes_no_auth() { + let client = client(); + let payload = json!({ + "useCount": 2 + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCodes", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_get_account_invite_codes_success() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let create_payload = json!({ + "useCount": 5 + }); + let _ = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to create invite code"); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.getAccountInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["codes"].is_array()); + let codes = body["codes"].as_array().unwrap(); + assert!(!codes.is_empty()); + + let code = &codes[0]; + assert!(code["code"].is_string()); + assert!(code["available"].is_number()); + assert!(code["disabled"].is_boolean()); + assert!(code["createdAt"].is_string()); + assert!(code["uses"].is_array()); +} + +#[tokio::test] +async fn test_get_account_invite_codes_no_auth() { + let client = client(); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.getAccountInviteCodes", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_get_account_invite_codes_include_used_filter() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + + let create_payload = json!({ + "useCount": 5 + }); + let _ = client + .post(format!( + "{}/xrpc/com.atproto.server.createInviteCode", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to create invite code"); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.getAccountInviteCodes", + base_url().await + )) + .bearer_auth(&access_jwt) + .query(&[("includeUsed", "false")]) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["codes"].is_array()); + + for code in body["codes"].as_array().unwrap() { + assert!(code["available"].as_i64().unwrap() > 0); + } +} diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs new file mode 100644 index 0000000..e810098 --- /dev/null +++ b/tests/lifecycle_record.rs @@ -0,0 +1,887 @@ +mod common; +mod helpers; + +use common::*; +use helpers::*; + +use chrono::Utc; +use reqwest::{StatusCode, header}; +use serde_json::{Value, json}; +use std::time::Duration; + +#[tokio::test] +async fn test_post_crud_lifecycle() { + let client = client(); + let (did, jwt) = setup_new_user("lifecycle-crud").await; + let collection = "app.bsky.feed.post"; + + let rkey = format!("e2e_lifecycle_{}", Utc::now().timestamp_millis()); + let now = Utc::now().to_rfc3339(); + + let original_text = "Hello from the lifecycle test!"; + let create_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": original_text, + "createdAt": now + } + }); + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to send create request"); + + if create_res.status() != reqwest::StatusCode::OK { + let status = create_res.status(); + let body = create_res + .text() + .await + .unwrap_or_else(|_| "Could not get body".to_string()); + panic!( + "Failed to create record. Status: {}, Body: {}", + status, body + ); + } + + let create_body: Value = create_res + .json() + .await + .expect("create response was not JSON"); + let uri = create_body["uri"].as_str().unwrap(); + + let params = [ + ("repo", did.as_str()), + ("collection", collection), + ("rkey", &rkey), + ]; + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send get request"); + + assert_eq!( + get_res.status(), + reqwest::StatusCode::OK, + "Failed to get record after create" + ); + let get_body: Value = get_res.json().await.expect("get response was not JSON"); + assert_eq!(get_body["uri"], uri); + assert_eq!(get_body["value"]["text"], original_text); + + let updated_text = "This post has been updated."; + let update_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": updated_text, + "createdAt": now + } + }); + + let update_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&update_payload) + .send() + .await + .expect("Failed to send update request"); + + assert_eq!( + update_res.status(), + reqwest::StatusCode::OK, + "Failed to update record" + ); + + let get_updated_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send get-after-update request"); + + assert_eq!( + get_updated_res.status(), + reqwest::StatusCode::OK, + "Failed to get record after update" + ); + let get_updated_body: Value = get_updated_res + .json() + .await + .expect("get-updated response was not JSON"); + assert_eq!( + get_updated_body["value"]["text"], updated_text, + "Text was not updated" + ); + + let delete_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey + }); + + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + + assert_eq!( + delete_res.status(), + reqwest::StatusCode::OK, + "Failed to delete record" + ); + + let get_deleted_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send get-after-delete request"); + + assert_eq!( + get_deleted_res.status(), + reqwest::StatusCode::NOT_FOUND, + "Record was found, but it should be deleted" + ); +} + +#[tokio::test] +async fn test_record_update_conflict_lifecycle() { + let client = client(); + let (user_did, user_jwt) = setup_new_user("user-conflict").await; + + let profile_payload = json!({ + "repo": user_did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Original Name" + } + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&user_jwt) + .json(&profile_payload) + .send() + .await + .expect("create profile failed"); + + if create_res.status() != reqwest::StatusCode::OK { + return; + } + + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", &user_did), + ("collection", &"app.bsky.actor.profile".to_string()), + ("rkey", &"self".to_string()), + ]) + .send() + .await + .expect("getRecord failed"); + let get_body: Value = get_res.json().await.expect("getRecord not json"); + let cid_v1 = get_body["cid"] + .as_str() + .expect("Profile v1 had no CID") + .to_string(); + + let update_payload_v2 = json!({ + "repo": user_did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Updated Name (v2)" + }, + "swapRecord": cid_v1 + }); + let update_res_v2 = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&user_jwt) + .json(&update_payload_v2) + .send() + .await + .expect("putRecord v2 failed"); + assert_eq!( + update_res_v2.status(), + reqwest::StatusCode::OK, + "v2 update failed" + ); + let update_body_v2: Value = update_res_v2.json().await.expect("v2 body not json"); + let cid_v2 = update_body_v2["cid"] + .as_str() + .expect("v2 response had no CID") + .to_string(); + + let update_payload_v3_stale = json!({ + "repo": user_did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Stale Update (v3)" + }, + "swapRecord": cid_v1 + }); + let update_res_v3_stale = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&user_jwt) + .json(&update_payload_v3_stale) + .send() + .await + .expect("putRecord v3 (stale) failed"); + + assert_eq!( + update_res_v3_stale.status(), + reqwest::StatusCode::CONFLICT, + "Stale update did not cause a 409 Conflict" + ); + + let update_payload_v3_good = json!({ + "repo": user_did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Good Update (v3)" + }, + "swapRecord": cid_v2 + }); + let update_res_v3_good = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&user_jwt) + .json(&update_payload_v3_good) + .send() + .await + .expect("putRecord v3 (good) failed"); + + assert_eq!( + update_res_v3_good.status(), + reqwest::StatusCode::OK, + "v3 (good) update failed" + ); +} + +#[tokio::test] +async fn test_profile_lifecycle() { + let client = client(); + let (did, jwt) = setup_new_user("profile-lifecycle").await; + + let profile_payload = json!({ + "repo": did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Test User", + "description": "A test profile for lifecycle testing" + } + }); + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&profile_payload) + .send() + .await + .expect("Failed to create profile"); + + assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile"); + let create_body: Value = create_res.json().await.unwrap(); + let initial_cid = create_body["cid"].as_str().unwrap().to_string(); + + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get profile"); + + assert_eq!(get_res.status(), StatusCode::OK); + let get_body: Value = get_res.json().await.unwrap(); + assert_eq!(get_body["value"]["displayName"], "Test User"); + assert_eq!(get_body["value"]["description"], "A test profile for lifecycle testing"); + + let update_payload = json!({ + "repo": did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Updated User", + "description": "Profile has been updated" + }, + "swapRecord": initial_cid + }); + + let update_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&update_payload) + .send() + .await + .expect("Failed to update profile"); + + assert_eq!(update_res.status(), StatusCode::OK, "Failed to update profile"); + + let get_updated_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get updated profile"); + + let updated_body: Value = get_updated_res.json().await.unwrap(); + assert_eq!(updated_body["value"]["displayName"], "Updated User"); +} + +#[tokio::test] +async fn test_reply_thread_lifecycle() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-thread").await; + let (bob_did, bob_jwt) = setup_new_user("bob-thread").await; + + let (root_uri, root_cid) = create_post(&client, &alice_did, &alice_jwt, "This is the root post").await; + + tokio::time::sleep(Duration::from_millis(100)).await; + + let reply_collection = "app.bsky.feed.post"; + let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis()); + let now = Utc::now().to_rfc3339(); + + let reply_payload = json!({ + "repo": bob_did, + "collection": reply_collection, + "rkey": reply_rkey, + "record": { + "$type": reply_collection, + "text": "This is Bob's reply to Alice", + "createdAt": now, + "reply": { + "root": { + "uri": root_uri, + "cid": root_cid + }, + "parent": { + "uri": root_uri, + "cid": root_cid + } + } + } + }); + + let reply_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&reply_payload) + .send() + .await + .expect("Failed to create reply"); + + assert_eq!(reply_res.status(), StatusCode::OK, "Failed to create reply"); + let reply_body: Value = reply_res.json().await.unwrap(); + let reply_uri = reply_body["uri"].as_str().unwrap(); + let reply_cid = reply_body["cid"].as_str().unwrap(); + + let get_reply_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", reply_collection), + ("rkey", reply_rkey.as_str()), + ]) + .send() + .await + .expect("Failed to get reply"); + + assert_eq!(get_reply_res.status(), StatusCode::OK); + let reply_record: Value = get_reply_res.json().await.unwrap(); + assert_eq!(reply_record["value"]["reply"]["root"]["uri"], root_uri); + assert_eq!(reply_record["value"]["reply"]["parent"]["uri"], root_uri); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let nested_reply_rkey = format!("e2e_nested_reply_{}", Utc::now().timestamp_millis()); + let nested_payload = json!({ + "repo": alice_did, + "collection": reply_collection, + "rkey": nested_reply_rkey, + "record": { + "$type": reply_collection, + "text": "Alice replies to Bob's reply", + "createdAt": Utc::now().to_rfc3339(), + "reply": { + "root": { + "uri": root_uri, + "cid": root_cid + }, + "parent": { + "uri": reply_uri, + "cid": reply_cid + } + } + } + }); + + let nested_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&alice_jwt) + .json(&nested_payload) + .send() + .await + .expect("Failed to create nested reply"); + + assert_eq!(nested_res.status(), StatusCode::OK, "Failed to create nested reply"); +} + +#[tokio::test] +async fn test_blob_in_record_lifecycle() { + let client = client(); + let (did, jwt) = setup_new_user("blob-record").await; + + let blob_data = b"This is test blob data for a profile avatar"; + let upload_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "text/plain") + .bearer_auth(&jwt) + .body(blob_data.to_vec()) + .send() + .await + .expect("Failed to upload blob"); + + assert_eq!(upload_res.status(), StatusCode::OK); + let upload_body: Value = upload_res.json().await.unwrap(); + let blob_ref = upload_body["blob"].clone(); + + let profile_payload = json!({ + "repo": did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "User With Avatar", + "avatar": blob_ref + } + }); + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&profile_payload) + .send() + .await + .expect("Failed to create profile with blob"); + + assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile with blob"); + + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get profile"); + + assert_eq!(get_res.status(), StatusCode::OK); + let profile: Value = get_res.json().await.unwrap(); + assert!(profile["value"]["avatar"]["ref"]["$link"].is_string()); +} + +#[tokio::test] +async fn test_authorization_cannot_modify_other_repo() { + let client = client(); + + let (alice_did, _alice_jwt) = setup_new_user("alice-auth").await; + let (_bob_did, bob_jwt) = setup_new_user("bob-auth").await; + + let post_payload = json!({ + "repo": alice_did, + "collection": "app.bsky.feed.post", + "rkey": "unauthorized-post", + "record": { + "$type": "app.bsky.feed.post", + "text": "Bob trying to post as Alice", + "createdAt": Utc::now().to_rfc3339() + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&post_payload) + .send() + .await + .expect("Failed to send request"); + + assert!( + res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED, + "Expected 403 or 401 when writing to another user's repo, got {}", + res.status() + ); +} + +#[tokio::test] +async fn test_authorization_cannot_delete_other_record() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-del-auth").await; + let (_bob_did, bob_jwt) = setup_new_user("bob-del-auth").await; + + let (post_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's post").await; + let post_rkey = post_uri.split('/').last().unwrap(); + + let delete_payload = json!({ + "repo": alice_did, + "collection": "app.bsky.feed.post", + "rkey": post_rkey + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to send request"); + + assert!( + res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED, + "Expected 403 or 401 when deleting another user's record, got {}", + res.status() + ); + + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", alice_did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", post_rkey), + ]) + .send() + .await + .expect("Failed to verify record exists"); + + assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist"); +} + +#[tokio::test] +async fn test_list_records_pagination() { + let client = client(); + let (did, jwt) = setup_new_user("list-pagination").await; + + for i in 0..5 { + tokio::time::sleep(Duration::from_millis(50)).await; + create_post(&client, &did, &jwt, &format!("Post number {}", i)).await; + } + + let list_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("limit", "2"), + ]) + .send() + .await + .expect("Failed to list records"); + + assert_eq!(list_res.status(), StatusCode::OK); + let list_body: Value = list_res.json().await.unwrap(); + let records = list_body["records"].as_array().unwrap(); + assert_eq!(records.len(), 2, "Should return 2 records with limit=2"); + + if let Some(cursor) = list_body["cursor"].as_str() { + let list_page2_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("limit", "2"), + ("cursor", cursor), + ]) + .send() + .await + .expect("Failed to list records page 2"); + + assert_eq!(list_page2_res.status(), StatusCode::OK); + let page2_body: Value = list_page2_res.json().await.unwrap(); + let page2_records = page2_body["records"].as_array().unwrap(); + assert_eq!(page2_records.len(), 2, "Page 2 should have 2 more records"); + } +} + +#[tokio::test] +async fn test_apply_writes_batch_lifecycle() { + let client = client(); + let (did, jwt) = setup_new_user("apply-writes-batch").await; + + let now = Utc::now().to_rfc3339(); + let writes_payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "rkey": "batch-post-1", + "value": { + "$type": "app.bsky.feed.post", + "text": "First batch post", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "rkey": "batch-post-2", + "value": { + "$type": "app.bsky.feed.post", + "text": "Second batch post", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.actor.profile", + "rkey": "self", + "value": { + "$type": "app.bsky.actor.profile", + "displayName": "Batch User" + } + } + ] + }); + + let apply_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&jwt) + .json(&writes_payload) + .send() + .await + .expect("Failed to apply writes"); + + assert_eq!(apply_res.status(), StatusCode::OK); + + let get_post1 = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-post-1"), + ]) + .send() + .await + .expect("Failed to get post 1"); + assert_eq!(get_post1.status(), StatusCode::OK); + let post1_body: Value = get_post1.json().await.unwrap(); + assert_eq!(post1_body["value"]["text"], "First batch post"); + + let get_post2 = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-post-2"), + ]) + .send() + .await + .expect("Failed to get post 2"); + assert_eq!(get_post2.status(), StatusCode::OK); + + let get_profile = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get profile"); + assert_eq!(get_profile.status(), StatusCode::OK); + let profile_body: Value = get_profile.json().await.unwrap(); + assert_eq!(profile_body["value"]["displayName"], "Batch User"); + + let update_writes = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#update", + "collection": "app.bsky.actor.profile", + "rkey": "self", + "value": { + "$type": "app.bsky.actor.profile", + "displayName": "Updated Batch User" + } + }, + { + "$type": "com.atproto.repo.applyWrites#delete", + "collection": "app.bsky.feed.post", + "rkey": "batch-post-1" + } + ] + }); + + let update_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&jwt) + .json(&update_writes) + .send() + .await + .expect("Failed to apply update writes"); + assert_eq!(update_res.status(), StatusCode::OK); + + let get_updated_profile = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get updated profile"); + let updated_profile: Value = get_updated_profile.json().await.unwrap(); + assert_eq!(updated_profile["value"]["displayName"], "Updated Batch User"); + + let get_deleted_post = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-post-1"), + ]) + .send() + .await + .expect("Failed to check deleted post"); + assert_eq!( + get_deleted_post.status(), + StatusCode::NOT_FOUND, + "Batch-deleted post should be gone" + ); +} diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs new file mode 100644 index 0000000..344be68 --- /dev/null +++ b/tests/lifecycle_session.rs @@ -0,0 +1,306 @@ +mod common; +mod helpers; + +use common::*; +use helpers::*; + +use chrono::Utc; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_session_lifecycle_wrong_password() { + let client = client(); + let (_, _) = setup_new_user("session-wrong-pw").await; + + let login_payload = json!({ + "identifier": format!("session-wrong-pw-{}.test", Utc::now().timestamp_millis()), + "password": "wrong-password" + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&login_payload) + .send() + .await + .expect("Failed to send request"); + + assert!( + res.status() == StatusCode::UNAUTHORIZED || res.status() == StatusCode::BAD_REQUEST, + "Expected 401 or 400 for wrong password, got {}", + res.status() + ); +} + +#[tokio::test] +async fn test_session_lifecycle_multiple_sessions() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("multi-session-{}.test", ts); + let email = format!("multi-session-{}@test.com", ts); + let password = "multi-session-pw"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + + let login_payload = json!({ + "identifier": handle, + "password": password + }); + + let session1_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&login_payload) + .send() + .await + .expect("Failed session 1"); + assert_eq!(session1_res.status(), StatusCode::OK); + let session1: Value = session1_res.json().await.unwrap(); + let jwt1 = session1["accessJwt"].as_str().unwrap(); + + let session2_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&login_payload) + .send() + .await + .expect("Failed session 2"); + assert_eq!(session2_res.status(), StatusCode::OK); + let session2: Value = session2_res.json().await.unwrap(); + let jwt2 = session2["accessJwt"].as_str().unwrap(); + + assert_ne!(jwt1, jwt2, "Sessions should have different tokens"); + + let get1 = client + .get(format!( + "{}/xrpc/com.atproto.server.getSession", + base_url().await + )) + .bearer_auth(jwt1) + .send() + .await + .expect("Failed getSession 1"); + assert_eq!(get1.status(), StatusCode::OK); + + let get2 = client + .get(format!( + "{}/xrpc/com.atproto.server.getSession", + base_url().await + )) + .bearer_auth(jwt2) + .send() + .await + .expect("Failed getSession 2"); + assert_eq!(get2.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_session_lifecycle_refresh_invalidates_old() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("refresh-inv-{}.test", ts); + let email = format!("refresh-inv-{}@test.com", ts); + let password = "refresh-inv-pw"; + + let create_payload = json!({ + "handle": handle, + "email": email, + "password": password + }); + client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&create_payload) + .send() + .await + .expect("Failed to create account"); + + let login_payload = json!({ + "identifier": handle, + "password": password + }); + let login_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&login_payload) + .send() + .await + .expect("Failed login"); + let login_body: Value = login_res.json().await.unwrap(); + let refresh_jwt = login_body["refreshJwt"].as_str().unwrap().to_string(); + + let refresh_res = client + .post(format!( + "{}/xrpc/com.atproto.server.refreshSession", + base_url().await + )) + .bearer_auth(&refresh_jwt) + .send() + .await + .expect("Failed first refresh"); + assert_eq!(refresh_res.status(), StatusCode::OK); + let refresh_body: Value = refresh_res.json().await.unwrap(); + let new_refresh_jwt = refresh_body["refreshJwt"].as_str().unwrap(); + + assert_ne!(refresh_jwt, new_refresh_jwt, "Refresh tokens should differ"); + + let reuse_res = client + .post(format!( + "{}/xrpc/com.atproto.server.refreshSession", + base_url().await + )) + .bearer_auth(&refresh_jwt) + .send() + .await + .expect("Failed reuse attempt"); + + assert!( + reuse_res.status() == StatusCode::UNAUTHORIZED || reuse_res.status() == StatusCode::BAD_REQUEST, + "Old refresh token should be invalid after use" + ); +} + +#[tokio::test] +async fn test_app_password_lifecycle() { + let client = client(); + let ts = Utc::now().timestamp_millis(); + let handle = format!("apppass-{}.test", ts); + let email = format!("apppass-{}@test.com", ts); + let password = "apppass-password"; + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .expect("Failed to create account"); + + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let jwt = account["accessJwt"].as_str().unwrap(); + + let create_app_pass_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAppPassword", + base_url().await + )) + .bearer_auth(jwt) + .json(&json!({ "name": "Test App" })) + .send() + .await + .expect("Failed to create app password"); + + assert_eq!(create_app_pass_res.status(), StatusCode::OK); + let app_pass: Value = create_app_pass_res.json().await.unwrap(); + let app_password = app_pass["password"].as_str().unwrap().to_string(); + assert_eq!(app_pass["name"], "Test App"); + + let list_res = client + .get(format!( + "{}/xrpc/com.atproto.server.listAppPasswords", + base_url().await + )) + .bearer_auth(jwt) + .send() + .await + .expect("Failed to list app passwords"); + + assert_eq!(list_res.status(), StatusCode::OK); + let list_body: Value = list_res.json().await.unwrap(); + let passwords = list_body["passwords"].as_array().unwrap(); + assert_eq!(passwords.len(), 1); + assert_eq!(passwords[0]["name"], "Test App"); + + let login_res = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&json!({ + "identifier": handle, + "password": app_password + })) + .send() + .await + .expect("Failed to login with app password"); + + assert_eq!(login_res.status(), StatusCode::OK, "App password login should work"); + + let revoke_res = client + .post(format!( + "{}/xrpc/com.atproto.server.revokeAppPassword", + base_url().await + )) + .bearer_auth(jwt) + .json(&json!({ "name": "Test App" })) + .send() + .await + .expect("Failed to revoke app password"); + + assert_eq!(revoke_res.status(), StatusCode::OK); + + let login_after_revoke = client + .post(format!( + "{}/xrpc/com.atproto.server.createSession", + base_url().await + )) + .json(&json!({ + "identifier": handle, + "password": app_password + })) + .send() + .await + .expect("Failed to attempt login after revoke"); + + assert!( + login_after_revoke.status() == StatusCode::UNAUTHORIZED + || login_after_revoke.status() == StatusCode::BAD_REQUEST, + "Revoked app password should not work" + ); + + let list_after_revoke = client + .get(format!( + "{}/xrpc/com.atproto.server.listAppPasswords", + base_url().await + )) + .bearer_auth(jwt) + .send() + .await + .expect("Failed to list after revoke"); + + let list_after: Value = list_after_revoke.json().await.unwrap(); + let passwords_after = list_after["passwords"].as_array().unwrap(); + assert_eq!(passwords_after.len(), 0, "No app passwords should remain"); +} diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs new file mode 100644 index 0000000..eac84d7 --- /dev/null +++ b/tests/lifecycle_social.rs @@ -0,0 +1,416 @@ +mod common; +mod helpers; + +use common::*; +use helpers::*; + +use reqwest::StatusCode; +use serde_json::{Value, json}; +use std::time::Duration; + +#[tokio::test] +async fn test_social_flow_lifecycle() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-social").await; + let (bob_did, bob_jwt) = setup_new_user("bob-social").await; + + let (post1_uri, _) = create_post(&client, &alice_did, &alice_jwt, "Alice's first post!").await; + + create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let timeline_res_1 = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&bob_jwt) + .send() + .await + .expect("Failed to get timeline (1)"); + + assert_eq!( + timeline_res_1.status(), + reqwest::StatusCode::OK, + "Failed to get timeline (1)" + ); + let timeline_body_1: Value = timeline_res_1.json().await.expect("Timeline (1) not JSON"); + let feed_1 = timeline_body_1["feed"].as_array().unwrap(); + assert_eq!(feed_1.len(), 1, "Timeline should have 1 post"); + assert_eq!( + feed_1[0]["post"]["uri"], post1_uri, + "Post URI mismatch in timeline (1)" + ); + + let (post2_uri, _) = create_post( + &client, + &alice_did, + &alice_jwt, + "Alice's second post, so exciting!", + ) + .await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let timeline_res_2 = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&bob_jwt) + .send() + .await + .expect("Failed to get timeline (2)"); + + assert_eq!( + timeline_res_2.status(), + reqwest::StatusCode::OK, + "Failed to get timeline (2)" + ); + let timeline_body_2: Value = timeline_res_2.json().await.expect("Timeline (2) not JSON"); + let feed_2 = timeline_body_2["feed"].as_array().unwrap(); + assert_eq!(feed_2.len(), 2, "Timeline should have 2 posts"); + assert_eq!( + feed_2[0]["post"]["uri"], post2_uri, + "Post 2 should be first" + ); + assert_eq!( + feed_2[1]["post"]["uri"], post1_uri, + "Post 1 should be second" + ); + + let delete_payload = json!({ + "repo": alice_did, + "collection": "app.bsky.feed.post", + "rkey": post1_uri.split('/').last().unwrap() + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&alice_jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to send delete request"); + assert_eq!( + delete_res.status(), + reqwest::StatusCode::OK, + "Failed to delete record" + ); + + tokio::time::sleep(Duration::from_secs(1)).await; + + let timeline_res_3 = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&bob_jwt) + .send() + .await + .expect("Failed to get timeline (3)"); + + assert_eq!( + timeline_res_3.status(), + reqwest::StatusCode::OK, + "Failed to get timeline (3)" + ); + let timeline_body_3: Value = timeline_res_3.json().await.expect("Timeline (3) not JSON"); + let feed_3 = timeline_body_3["feed"].as_array().unwrap(); + assert_eq!(feed_3.len(), 1, "Timeline should have 1 post after delete"); + assert_eq!( + feed_3[0]["post"]["uri"], post2_uri, + "Only post 2 should remain" + ); +} + +#[tokio::test] +async fn test_like_lifecycle() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-like").await; + let (bob_did, bob_jwt) = setup_new_user("bob-like").await; + + let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Like this post!").await; + + let (like_uri, _) = create_like(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; + + let like_rkey = like_uri.split('/').last().unwrap(); + let get_like_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", "app.bsky.feed.like"), + ("rkey", like_rkey), + ]) + .send() + .await + .expect("Failed to get like"); + + assert_eq!(get_like_res.status(), StatusCode::OK); + let like_body: Value = get_like_res.json().await.unwrap(); + assert_eq!(like_body["value"]["subject"]["uri"], post_uri); + + let delete_payload = json!({ + "repo": bob_did, + "collection": "app.bsky.feed.like", + "rkey": like_rkey + }); + + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete like"); + + assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete like"); + + let get_deleted_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", "app.bsky.feed.like"), + ("rkey", like_rkey), + ]) + .send() + .await + .expect("Failed to check deleted like"); + + assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Like should be deleted"); +} + +#[tokio::test] +async fn test_repost_lifecycle() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-repost").await; + let (bob_did, bob_jwt) = setup_new_user("bob-repost").await; + + let (post_uri, post_cid) = create_post(&client, &alice_did, &alice_jwt, "Repost this!").await; + + let (repost_uri, _) = create_repost(&client, &bob_did, &bob_jwt, &post_uri, &post_cid).await; + + let repost_rkey = repost_uri.split('/').last().unwrap(); + let get_repost_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", "app.bsky.feed.repost"), + ("rkey", repost_rkey), + ]) + .send() + .await + .expect("Failed to get repost"); + + assert_eq!(get_repost_res.status(), StatusCode::OK); + let repost_body: Value = get_repost_res.json().await.unwrap(); + assert_eq!(repost_body["value"]["subject"]["uri"], post_uri); + + let delete_payload = json!({ + "repo": bob_did, + "collection": "app.bsky.feed.repost", + "rkey": repost_rkey + }); + + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete repost"); + + assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete repost"); +} + +#[tokio::test] +async fn test_unfollow_lifecycle() { + let client = client(); + + let (alice_did, _alice_jwt) = setup_new_user("alice-unfollow").await; + let (bob_did, bob_jwt) = setup_new_user("bob-unfollow").await; + + let (follow_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; + + let follow_rkey = follow_uri.split('/').last().unwrap(); + let get_follow_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", "app.bsky.graph.follow"), + ("rkey", follow_rkey), + ]) + .send() + .await + .expect("Failed to get follow"); + + assert_eq!(get_follow_res.status(), StatusCode::OK); + + let unfollow_payload = json!({ + "repo": bob_did, + "collection": "app.bsky.graph.follow", + "rkey": follow_rkey + }); + + let unfollow_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&unfollow_payload) + .send() + .await + .expect("Failed to unfollow"); + + assert_eq!(unfollow_res.status(), StatusCode::OK, "Failed to unfollow"); + + let get_deleted_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", bob_did.as_str()), + ("collection", "app.bsky.graph.follow"), + ("rkey", follow_rkey), + ]) + .send() + .await + .expect("Failed to check deleted follow"); + + assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Follow should be deleted"); +} + +#[tokio::test] +async fn test_timeline_after_unfollow() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-tl-unfollow").await; + let (bob_did, bob_jwt) = setup_new_user("bob-tl-unfollow").await; + + let (follow_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; + + create_post(&client, &alice_did, &alice_jwt, "Post while following").await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let timeline_res = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&bob_jwt) + .send() + .await + .expect("Failed to get timeline"); + + assert_eq!(timeline_res.status(), StatusCode::OK); + let timeline_body: Value = timeline_res.json().await.unwrap(); + let feed = timeline_body["feed"].as_array().unwrap(); + assert_eq!(feed.len(), 1, "Should see 1 post from Alice"); + + let follow_rkey = follow_uri.split('/').last().unwrap(); + let unfollow_payload = json!({ + "repo": bob_did, + "collection": "app.bsky.graph.follow", + "rkey": follow_rkey + }); + client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&bob_jwt) + .json(&unfollow_payload) + .send() + .await + .expect("Failed to unfollow"); + + tokio::time::sleep(Duration::from_secs(1)).await; + + let timeline_after_res = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&bob_jwt) + .send() + .await + .expect("Failed to get timeline after unfollow"); + + assert_eq!(timeline_after_res.status(), StatusCode::OK); + let timeline_after: Value = timeline_after_res.json().await.unwrap(); + let feed_after = timeline_after["feed"].as_array().unwrap(); + assert_eq!(feed_after.len(), 0, "Should see 0 posts after unfollowing"); +} + +#[tokio::test] +async fn test_mutual_follow_lifecycle() { + let client = client(); + + let (alice_did, alice_jwt) = setup_new_user("alice-mutual").await; + let (bob_did, bob_jwt) = setup_new_user("bob-mutual").await; + + create_follow(&client, &alice_did, &alice_jwt, &bob_did).await; + create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; + + create_post(&client, &alice_did, &alice_jwt, "Alice's post for mutual").await; + create_post(&client, &bob_did, &bob_jwt, "Bob's post for mutual").await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let alice_timeline_res = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&alice_jwt) + .send() + .await + .expect("Failed to get Alice's timeline"); + + assert_eq!(alice_timeline_res.status(), StatusCode::OK); + let alice_tl: Value = alice_timeline_res.json().await.unwrap(); + let alice_feed = alice_tl["feed"].as_array().unwrap(); + assert_eq!(alice_feed.len(), 1, "Alice should see Bob's 1 post"); + + let bob_timeline_res = client + .get(format!( + "{}/xrpc/app.bsky.feed.getTimeline", + base_url().await + )) + .bearer_auth(&bob_jwt) + .send() + .await + .expect("Failed to get Bob's timeline"); + + assert_eq!(bob_timeline_res.status(), StatusCode::OK); + let bob_tl: Value = bob_timeline_res.json().await.unwrap(); + let bob_feed = bob_tl["feed"].as_array().unwrap(); + assert_eq!(bob_feed.len(), 1, "Bob should see Alice's 1 post"); +} diff --git a/tests/repo.rs b/tests/repo.rs deleted file mode 100644 index 81c2aff..0000000 --- a/tests/repo.rs +++ /dev/null @@ -1,793 +0,0 @@ -mod common; -use common::*; - -use chrono::Utc; -use reqwest::{StatusCode, header}; -use serde_json::{Value, json}; - -#[tokio::test] -async fn test_get_record_not_found() { - let client = client(); - let (_, did) = create_account_and_login(&client).await; - - let params = [ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ("rkey", "nonexistent"), - ]; - - let res = client - .get(format!( - "{}/xrpc/com.atproto.repo.getRecord", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_upload_blob_no_auth() { - let client = client(); - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.uploadBlob", - base_url().await - )) - .header(header::CONTENT_TYPE, "text/plain") - .body("no auth") - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert_eq!(body["error"], "AuthenticationRequired"); -} - -#[tokio::test] -async fn test_upload_blob_success() { - let client = client(); - let (token, _) = create_account_and_login(&client).await; - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.uploadBlob", - base_url().await - )) - .header(header::CONTENT_TYPE, "text/plain") - .bearer_auth(token) - .body("This is our blob data") - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert!(body["blob"]["ref"]["$link"].as_str().is_some()); -} - -#[tokio::test] -async fn test_put_record_no_auth() { - let client = client(); - let payload = json!({ - "repo": "did:plc:123", - "collection": "app.bsky.feed.post", - "rkey": "fake", - "record": {} - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert_eq!(body["error"], "AuthenticationRequired"); -} - -#[tokio::test] -async fn test_put_record_success() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - let payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": "e2e_test_post", - "record": { - "$type": "app.bsky.feed.post", - "text": "Hello from the e2e test script!", - "createdAt": now - } - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .bearer_auth(token) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert!(body.get("uri").is_some()); - assert!(body.get("cid").is_some()); -} - -#[tokio::test] -async fn test_get_record_missing_params() { - let client = client(); - let params = [("repo", "did:plc:12345")]; - - let res = client - .get(format!( - "{}/xrpc/com.atproto.repo.getRecord", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!( - res.status(), - StatusCode::BAD_REQUEST, - "Expected 400 for missing params" - ); -} - -#[tokio::test] -async fn test_upload_blob_bad_token() { - let client = client(); - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.uploadBlob", - base_url().await - )) - .header(header::CONTENT_TYPE, "text/plain") - .bearer_auth(BAD_AUTH_TOKEN) - .body("This is our blob data") - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert_eq!(body["error"], "AuthenticationFailed"); -} - -#[tokio::test] -async fn test_put_record_mismatched_repo() { - let client = client(); - let (token, _) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - let payload = json!({ - "repo": "did:plc:OTHER-USER", - "collection": "app.bsky.feed.post", - "rkey": "e2e_test_post", - "record": { - "$type": "app.bsky.feed.post", - "text": "Hello from the e2e test script!", - "createdAt": now - } - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .bearer_auth(token) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert!( - res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED, - "Expected 403 or 401 for mismatched repo and auth, got {}", - res.status() - ); -} - -#[tokio::test] -async fn test_put_record_invalid_schema() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - let payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": "e2e_test_invalid", - "record": { - "$type": "app.bsky.feed.post", - "createdAt": now - } - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .bearer_auth(token) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!( - res.status(), - StatusCode::BAD_REQUEST, - "Expected 400 for invalid record schema" - ); -} - -#[tokio::test] -async fn test_upload_blob_unsupported_mime_type() { - let client = client(); - let (token, _) = create_account_and_login(&client).await; - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.uploadBlob", - base_url().await - )) - .header(header::CONTENT_TYPE, "application/xml") - .bearer_auth(token) - .body("not an image") - .send() - .await - .expect("Failed to send request"); - - // Changed expectation to OK for now, bc we don't validate mime type strictly yet. - assert_eq!(res.status(), StatusCode::OK); -} - -#[tokio::test] -async fn test_list_records() { - let client = client(); - let (_, did) = create_account_and_login(&client).await; - let params = [ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ("limit", "10"), - ]; - let res = client - .get(format!( - "{}/xrpc/com.atproto.repo.listRecords", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); -} - -#[tokio::test] -async fn test_describe_repo() { - let client = client(); - let (_, did) = create_account_and_login(&client).await; - let params = [("repo", did.as_str())]; - let res = client - .get(format!( - "{}/xrpc/com.atproto.repo.describeRepo", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); -} - -#[tokio::test] -async fn test_create_record_success_with_generated_rkey() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "record": { - "$type": "app.bsky.feed.post", - "text": "Hello, world!", - "createdAt": "2025-12-02T12:00:00Z" - } - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.createRecord", - base_url().await - )) - .json(&payload) - .bearer_auth(token) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - let uri = body["uri"].as_str().unwrap(); - assert!(uri.starts_with(&format!("at://{}/app.bsky.feed.post/", did))); - assert!(body.get("cid").is_some()); -} - -#[tokio::test] -async fn test_create_record_success_with_provided_rkey() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let rkey = format!("custom-rkey-{}", Utc::now().timestamp_millis()); - let payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": rkey, - "record": { - "$type": "app.bsky.feed.post", - "text": "Hello, world!", - "createdAt": "2025-12-02T12:00:00Z" - } - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.createRecord", - base_url().await - )) - .json(&payload) - .bearer_auth(token) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert_eq!( - body["uri"], - format!("at://{}/app.bsky.feed.post/{}", did, rkey) - ); - assert!(body.get("cid").is_some()); -} - -#[tokio::test] -async fn test_delete_record() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let rkey = format!("post_to_delete_{}", Utc::now().timestamp_millis()); - - let create_payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": rkey, - "record": { - "$type": "app.bsky.feed.post", - "text": "This post will be deleted", - "createdAt": Utc::now().to_rfc3339() - } - }); - let create_res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .bearer_auth(&token) - .json(&create_payload) - .send() - .await - .expect("Failed to create record"); - assert_eq!(create_res.status(), StatusCode::OK); - - let delete_payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": rkey - }); - let delete_res = client - .post(format!( - "{}/xrpc/com.atproto.repo.deleteRecord", - base_url().await - )) - .bearer_auth(&token) - .json(&delete_payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(delete_res.status(), StatusCode::OK); - - let get_res = client - .get(format!( - "{}/xrpc/com.atproto.repo.getRecord", - base_url().await - )) - .query(&[ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ("rkey", rkey.as_str()), - ]) - .send() - .await - .expect("Failed to verify deletion"); - assert_eq!(get_res.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_apply_writes_create() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - - let payload = json!({ - "repo": did, - "writes": [ - { - "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", - "value": { - "$type": "app.bsky.feed.post", - "text": "Batch created post 1", - "createdAt": now - } - }, - { - "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", - "value": { - "$type": "app.bsky.feed.post", - "text": "Batch created post 2", - "createdAt": now - } - } - ] - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .bearer_auth(&token) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert!(body["commit"]["cid"].is_string()); - assert!(body["results"].is_array()); - let results = body["results"].as_array().unwrap(); - assert_eq!(results.len(), 2); - assert!(results[0]["uri"].is_string()); - assert!(results[0]["cid"].is_string()); -} - -#[tokio::test] -async fn test_apply_writes_update() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - let rkey = format!("batch_update_{}", Utc::now().timestamp_millis()); - - let create_payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": rkey, - "record": { - "$type": "app.bsky.feed.post", - "text": "Original post", - "createdAt": now - } - }); - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .bearer_auth(&token) - .json(&create_payload) - .send() - .await - .expect("Failed to create"); - assert_eq!(res.status(), StatusCode::OK); - - let update_payload = json!({ - "repo": did, - "writes": [ - { - "$type": "com.atproto.repo.applyWrites#update", - "collection": "app.bsky.feed.post", - "rkey": rkey, - "value": { - "$type": "app.bsky.feed.post", - "text": "Updated post via applyWrites", - "createdAt": now - } - } - ] - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .bearer_auth(&token) - .json(&update_payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - let results = body["results"].as_array().unwrap(); - assert_eq!(results.len(), 1); - assert!(results[0]["uri"].is_string()); -} - -#[tokio::test] -async fn test_apply_writes_delete() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - let rkey = format!("batch_delete_{}", Utc::now().timestamp_millis()); - - let create_payload = json!({ - "repo": did, - "collection": "app.bsky.feed.post", - "rkey": rkey, - "record": { - "$type": "app.bsky.feed.post", - "text": "Post to delete", - "createdAt": now - } - }); - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.putRecord", - base_url().await - )) - .bearer_auth(&token) - .json(&create_payload) - .send() - .await - .expect("Failed to create"); - assert_eq!(res.status(), StatusCode::OK); - - let delete_payload = json!({ - "repo": did, - "writes": [ - { - "$type": "com.atproto.repo.applyWrites#delete", - "collection": "app.bsky.feed.post", - "rkey": rkey - } - ] - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .bearer_auth(&token) - .json(&delete_payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - - let get_res = client - .get(format!( - "{}/xrpc/com.atproto.repo.getRecord", - base_url().await - )) - .query(&[ - ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), - ("rkey", rkey.as_str()), - ]) - .send() - .await - .expect("Failed to verify"); - assert_eq!(get_res.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn test_apply_writes_mixed_operations() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - let now = Utc::now().to_rfc3339(); - let rkey_to_delete = format!("mixed_del_{}", Utc::now().timestamp_millis()); - let rkey_to_update = format!("mixed_upd_{}", Utc::now().timestamp_millis()); - - let setup_payload = json!({ - "repo": did, - "writes": [ - { - "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", - "rkey": rkey_to_delete, - "value": { - "$type": "app.bsky.feed.post", - "text": "To be deleted", - "createdAt": now - } - }, - { - "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", - "rkey": rkey_to_update, - "value": { - "$type": "app.bsky.feed.post", - "text": "To be updated", - "createdAt": now - } - } - ] - }); - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .bearer_auth(&token) - .json(&setup_payload) - .send() - .await - .expect("Failed to setup"); - assert_eq!(res.status(), StatusCode::OK); - - let mixed_payload = json!({ - "repo": did, - "writes": [ - { - "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", - "value": { - "$type": "app.bsky.feed.post", - "text": "New post", - "createdAt": now - } - }, - { - "$type": "com.atproto.repo.applyWrites#update", - "collection": "app.bsky.feed.post", - "rkey": rkey_to_update, - "value": { - "$type": "app.bsky.feed.post", - "text": "Updated text", - "createdAt": now - } - }, - { - "$type": "com.atproto.repo.applyWrites#delete", - "collection": "app.bsky.feed.post", - "rkey": rkey_to_delete - } - ] - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .bearer_auth(&token) - .json(&mixed_payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - let results = body["results"].as_array().unwrap(); - assert_eq!(results.len(), 3); -} - -#[tokio::test] -async fn test_apply_writes_no_auth() { - let client = client(); - - let payload = json!({ - "repo": "did:plc:test", - "writes": [ - { - "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", - "value": { - "$type": "app.bsky.feed.post", - "text": "Test", - "createdAt": "2025-01-01T00:00:00Z" - } - } - ] - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn test_apply_writes_empty_writes() { - let client = client(); - let (token, did) = create_account_and_login(&client).await; - - let payload = json!({ - "repo": did, - "writes": [] - }); - - let res = client - .post(format!( - "{}/xrpc/com.atproto.repo.applyWrites", - base_url().await - )) - .bearer_auth(&token) - .json(&payload) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn test_list_missing_blobs() { - let client = client(); - let (access_jwt, _) = create_account_and_login(&client).await; - - let res = client - .get(format!( - "{}/xrpc/com.atproto.repo.listMissingBlobs", - base_url().await - )) - .bearer_auth(&access_jwt) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert!(body["blobs"].is_array()); -} - -#[tokio::test] -async fn test_list_missing_blobs_no_auth() { - let client = client(); - let res = client - .get(format!( - "{}/xrpc/com.atproto.repo.listMissingBlobs", - base_url().await - )) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); -} diff --git a/tests/repo_batch.rs b/tests/repo_batch.rs new file mode 100644 index 0000000..547dbf2 --- /dev/null +++ b/tests/repo_batch.rs @@ -0,0 +1,337 @@ +mod common; +use common::*; + +use chrono::Utc; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_apply_writes_create() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + + let payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "value": { + "$type": "app.bsky.feed.post", + "text": "Batch created post 1", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "value": { + "$type": "app.bsky.feed.post", + "text": "Batch created post 2", + "createdAt": now + } + } + ] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["commit"]["cid"].is_string()); + assert!(body["results"].is_array()); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 2); + assert!(results[0]["uri"].is_string()); + assert!(results[0]["cid"].is_string()); +} + +#[tokio::test] +async fn test_apply_writes_update() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + let rkey = format!("batch_update_{}", Utc::now().timestamp_millis()); + + let create_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": rkey, + "record": { + "$type": "app.bsky.feed.post", + "text": "Original post", + "createdAt": now + } + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&create_payload) + .send() + .await + .expect("Failed to create"); + assert_eq!(res.status(), StatusCode::OK); + + let update_payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#update", + "collection": "app.bsky.feed.post", + "rkey": rkey, + "value": { + "$type": "app.bsky.feed.post", + "text": "Updated post via applyWrites", + "createdAt": now + } + } + ] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&update_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0]["uri"].is_string()); +} + +#[tokio::test] +async fn test_apply_writes_delete() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + let rkey = format!("batch_delete_{}", Utc::now().timestamp_millis()); + + let create_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": rkey, + "record": { + "$type": "app.bsky.feed.post", + "text": "Post to delete", + "createdAt": now + } + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&create_payload) + .send() + .await + .expect("Failed to create"); + assert_eq!(res.status(), StatusCode::OK); + + let delete_payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#delete", + "collection": "app.bsky.feed.post", + "rkey": rkey + } + ] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&delete_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", rkey.as_str()), + ]) + .send() + .await + .expect("Failed to verify"); + assert_eq!(get_res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_apply_writes_mixed_operations() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + let rkey_to_delete = format!("mixed_del_{}", Utc::now().timestamp_millis()); + let rkey_to_update = format!("mixed_upd_{}", Utc::now().timestamp_millis()); + + let setup_payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "rkey": rkey_to_delete, + "value": { + "$type": "app.bsky.feed.post", + "text": "To be deleted", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "rkey": rkey_to_update, + "value": { + "$type": "app.bsky.feed.post", + "text": "To be updated", + "createdAt": now + } + } + ] + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&setup_payload) + .send() + .await + .expect("Failed to setup"); + assert_eq!(res.status(), StatusCode::OK); + + let mixed_payload = json!({ + "repo": did, + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "value": { + "$type": "app.bsky.feed.post", + "text": "New post", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#update", + "collection": "app.bsky.feed.post", + "rkey": rkey_to_update, + "value": { + "$type": "app.bsky.feed.post", + "text": "Updated text", + "createdAt": now + } + }, + { + "$type": "com.atproto.repo.applyWrites#delete", + "collection": "app.bsky.feed.post", + "rkey": rkey_to_delete + } + ] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&mixed_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + let results = body["results"].as_array().unwrap(); + assert_eq!(results.len(), 3); +} + +#[tokio::test] +async fn test_apply_writes_no_auth() { + let client = client(); + + let payload = json!({ + "repo": "did:plc:test", + "writes": [ + { + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "value": { + "$type": "app.bsky.feed.post", + "text": "Test", + "createdAt": "2025-01-01T00:00:00Z" + } + } + ] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_apply_writes_empty_writes() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let payload = json!({ + "repo": did, + "writes": [] + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); +} diff --git a/tests/repo_blob.rs b/tests/repo_blob.rs new file mode 100644 index 0000000..927c194 --- /dev/null +++ b/tests/repo_blob.rs @@ -0,0 +1,119 @@ +mod common; +use common::*; + +use reqwest::{StatusCode, header}; +use serde_json::Value; + +#[tokio::test] +async fn test_upload_blob_no_auth() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "text/plain") + .body("no auth") + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "AuthenticationRequired"); +} + +#[tokio::test] +async fn test_upload_blob_success() { + let client = client(); + let (token, _) = create_account_and_login(&client).await; + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "text/plain") + .bearer_auth(token) + .body("This is our blob data") + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["blob"]["ref"]["$link"].as_str().is_some()); +} + +#[tokio::test] +async fn test_upload_blob_bad_token() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "text/plain") + .bearer_auth(BAD_AUTH_TOKEN) + .body("This is our blob data") + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "AuthenticationFailed"); +} + +#[tokio::test] +async fn test_upload_blob_unsupported_mime_type() { + let client = client(); + let (token, _) = create_account_and_login(&client).await; + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "application/xml") + .bearer_auth(token) + .body("not an image") + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_list_missing_blobs() { + let client = client(); + let (access_jwt, _) = create_account_and_login(&client).await; + + let res = client + .get(format!( + "{}/xrpc/com.atproto.repo.listMissingBlobs", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["blobs"].is_array()); +} + +#[tokio::test] +async fn test_list_missing_blobs_no_auth() { + let client = client(); + let res = client + .get(format!( + "{}/xrpc/com.atproto.repo.listMissingBlobs", + base_url().await + )) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/repo_record.rs b/tests/repo_record.rs new file mode 100644 index 0000000..1c61872 --- /dev/null +++ b/tests/repo_record.rs @@ -0,0 +1,347 @@ +mod common; +use common::*; + +use chrono::Utc; +use reqwest::StatusCode; +use serde_json::{Value, json}; + +#[tokio::test] +async fn test_get_record_not_found() { + let client = client(); + let (_, did) = create_account_and_login(&client).await; + + let params = [ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "nonexistent"), + ]; + + let res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_put_record_no_auth() { + let client = client(); + let payload = json!({ + "repo": "did:plc:123", + "collection": "app.bsky.feed.post", + "rkey": "fake", + "record": {} + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "AuthenticationRequired"); +} + +#[tokio::test] +async fn test_put_record_success() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "e2e_test_post", + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello from the e2e test script!", + "createdAt": now + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body.get("uri").is_some()); + assert!(body.get("cid").is_some()); +} + +#[tokio::test] +async fn test_get_record_missing_params() { + let client = client(); + let params = [("repo", "did:plc:12345")]; + + let res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Expected 400 for missing params" + ); +} + +#[tokio::test] +async fn test_put_record_mismatched_repo() { + let client = client(); + let (token, _) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + let payload = json!({ + "repo": "did:plc:OTHER-USER", + "collection": "app.bsky.feed.post", + "rkey": "e2e_test_post", + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello from the e2e test script!", + "createdAt": now + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert!( + res.status() == StatusCode::FORBIDDEN || res.status() == StatusCode::UNAUTHORIZED, + "Expected 403 or 401 for mismatched repo and auth, got {}", + res.status() + ); +} + +#[tokio::test] +async fn test_put_record_invalid_schema() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let now = Utc::now().to_rfc3339(); + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": "e2e_test_invalid", + "record": { + "$type": "app.bsky.feed.post", + "createdAt": now + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Expected 400 for invalid record schema" + ); +} + +#[tokio::test] +async fn test_list_records() { + let client = client(); + let (_, did) = create_account_and_login(&client).await; + let params = [ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("limit", "10"), + ]; + let res = client + .get(format!( + "{}/xrpc/com.atproto.repo.listRecords", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_describe_repo() { + let client = client(); + let (_, did) = create_account_and_login(&client).await; + let params = [("repo", did.as_str())]; + let res = client + .get(format!( + "{}/xrpc/com.atproto.repo.describeRepo", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_create_record_success_with_generated_rkey() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello, world!", + "createdAt": "2025-12-02T12:00:00Z" + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .json(&payload) + .bearer_auth(token) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + let uri = body["uri"].as_str().unwrap(); + assert!(uri.starts_with(&format!("at://{}/app.bsky.feed.post/", did))); + assert!(body.get("cid").is_some()); +} + +#[tokio::test] +async fn test_create_record_success_with_provided_rkey() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let rkey = format!("custom-rkey-{}", Utc::now().timestamp_millis()); + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": rkey, + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello, world!", + "createdAt": "2025-12-02T12:00:00Z" + } + }); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .json(&payload) + .bearer_auth(token) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!( + body["uri"], + format!("at://{}/app.bsky.feed.post/{}", did, rkey) + ); + assert!(body.get("cid").is_some()); +} + +#[tokio::test] +async fn test_delete_record() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + let rkey = format!("post_to_delete_{}", Utc::now().timestamp_millis()); + + let create_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": rkey, + "record": { + "$type": "app.bsky.feed.post", + "text": "This post will be deleted", + "createdAt": Utc::now().to_rfc3339() + } + }); + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&create_payload) + .send() + .await + .expect("Failed to create record"); + assert_eq!(create_res.status(), StatusCode::OK); + + let delete_payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "rkey": rkey + }); + let delete_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&delete_payload) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(delete_res.status(), StatusCode::OK); + + let get_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", rkey.as_str()), + ]) + .send() + .await + .expect("Failed to verify deletion"); + assert_eq!(get_res.status(), StatusCode::NOT_FOUND); +} diff --git a/tests/sync_blob.rs b/tests/sync_blob.rs new file mode 100644 index 0000000..7223c85 --- /dev/null +++ b/tests/sync_blob.rs @@ -0,0 +1,129 @@ +mod common; +use common::*; +use reqwest::StatusCode; +use reqwest::header; +use serde_json::Value; + +#[tokio::test] +async fn test_list_blobs_success() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let blob_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "text/plain") + .bearer_auth(&access_jwt) + .body("test blob content") + .send() + .await + .expect("Failed to upload blob"); + + assert_eq!(blob_res.status(), StatusCode::OK); + + let params = [("did", did.as_str())]; + let res = client + .get(format!( + "{}/xrpc/com.atproto.sync.listBlobs", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert!(body["cids"].is_array()); + let cids = body["cids"].as_array().unwrap(); + assert!(!cids.is_empty()); +} + +#[tokio::test] +async fn test_list_blobs_not_found() { + let client = client(); + let params = [("did", "did:plc:nonexistent12345")]; + let res = client + .get(format!( + "{}/xrpc/com.atproto.sync.listBlobs", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "RepoNotFound"); +} + +#[tokio::test] +async fn test_get_blob_success() { + let client = client(); + let (access_jwt, did) = create_account_and_login(&client).await; + + let blob_content = "test blob for get_blob"; + let blob_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.uploadBlob", + base_url().await + )) + .header(header::CONTENT_TYPE, "text/plain") + .bearer_auth(&access_jwt) + .body(blob_content) + .send() + .await + .expect("Failed to upload blob"); + + assert_eq!(blob_res.status(), StatusCode::OK); + let blob_body: Value = blob_res.json().await.expect("Response was not valid JSON"); + let cid = blob_body["blob"]["ref"]["$link"].as_str().expect("No CID"); + + let params = [("did", did.as_str()), ("cid", cid)]; + let res = client + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get("content-type") + .and_then(|h| h.to_str().ok()), + Some("text/plain") + ); + let body = res.text().await.expect("Failed to get body"); + assert_eq!(body, blob_content); +} + +#[tokio::test] +async fn test_get_blob_not_found() { + let client = client(); + let (_, did) = create_account_and_login(&client).await; + + let params = [ + ("did", did.as_str()), + ("cid", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"), + ]; + let res = client + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob", + base_url().await + )) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body: Value = res.json().await.expect("Response was not valid JSON"); + assert_eq!(body["error"], "BlobNotFound"); +} diff --git a/tests/sync.rs b/tests/sync_repo.rs similarity index 76% rename from tests/sync.rs rename to tests/sync_repo.rs index ff9353f..4e7a4dd 100644 --- a/tests/sync.rs +++ b/tests/sync_repo.rs @@ -1,9 +1,7 @@ mod common; use common::*; use reqwest::StatusCode; -use reqwest::header; use serde_json::Value; -use chrono; #[tokio::test] async fn test_get_latest_commit_success() { @@ -196,130 +194,6 @@ async fn test_get_repo_status_not_found() { assert_eq!(body["error"], "RepoNotFound"); } -#[tokio::test] -async fn test_list_blobs_success() { - let client = client(); - let (access_jwt, did) = create_account_and_login(&client).await; - - let blob_res = client - .post(format!( - "{}/xrpc/com.atproto.repo.uploadBlob", - base_url().await - )) - .header(header::CONTENT_TYPE, "text/plain") - .bearer_auth(&access_jwt) - .body("test blob content") - .send() - .await - .expect("Failed to upload blob"); - - assert_eq!(blob_res.status(), StatusCode::OK); - - let params = [("did", did.as_str())]; - let res = client - .get(format!( - "{}/xrpc/com.atproto.sync.listBlobs", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert!(body["cids"].is_array()); - let cids = body["cids"].as_array().unwrap(); - assert!(!cids.is_empty()); -} - -#[tokio::test] -async fn test_list_blobs_not_found() { - let client = client(); - let params = [("did", "did:plc:nonexistent12345")]; - let res = client - .get(format!( - "{}/xrpc/com.atproto.sync.listBlobs", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::NOT_FOUND); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert_eq!(body["error"], "RepoNotFound"); -} - -#[tokio::test] -async fn test_get_blob_success() { - let client = client(); - let (access_jwt, did) = create_account_and_login(&client).await; - - let blob_content = "test blob for get_blob"; - let blob_res = client - .post(format!( - "{}/xrpc/com.atproto.repo.uploadBlob", - base_url().await - )) - .header(header::CONTENT_TYPE, "text/plain") - .bearer_auth(&access_jwt) - .body(blob_content) - .send() - .await - .expect("Failed to upload blob"); - - assert_eq!(blob_res.status(), StatusCode::OK); - let blob_body: Value = blob_res.json().await.expect("Response was not valid JSON"); - let cid = blob_body["blob"]["ref"]["$link"].as_str().expect("No CID"); - - let params = [("did", did.as_str()), ("cid", cid)]; - let res = client - .get(format!( - "{}/xrpc/com.atproto.sync.getBlob", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::OK); - assert_eq!( - res.headers() - .get("content-type") - .and_then(|h| h.to_str().ok()), - Some("text/plain") - ); - let body = res.text().await.expect("Failed to get body"); - assert_eq!(body, blob_content); -} - -#[tokio::test] -async fn test_get_blob_not_found() { - let client = client(); - let (_, did) = create_account_and_login(&client).await; - - let params = [ - ("did", did.as_str()), - ("cid", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"), - ]; - let res = client - .get(format!( - "{}/xrpc/com.atproto.sync.getBlob", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(res.status(), StatusCode::NOT_FOUND); - let body: Value = res.json().await.expect("Response was not valid JSON"); - assert_eq!(body["error"], "BlobNotFound"); -} - #[tokio::test] async fn test_notify_of_update() { let client = client();