mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-09 05:40:09 +00:00
More endpoints, split out some tests to smaller files
This commit is contained in:
18
TODO.md
18
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`.
|
||||
|
||||
3
migrations/202512211700_invite_enhancements.sql
Normal file
3
migrations/202512211700_invite_enhancements.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE invite_codes ADD COLUMN disabled BOOLEAN DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE users ADD COLUMN invites_disabled BOOLEAN DEFAULT FALSE;
|
||||
5
migrations/202512211800_takedown_refs.sql
Normal file
5
migrations/202512211800_takedown_refs.sql
Normal file
@@ -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;
|
||||
@@ -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<Vec<String>>,
|
||||
pub accounts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn disable_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<DisableInviteCodesInput>,
|
||||
) -> 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<String>,
|
||||
pub uri: Option<String>,
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SubjectStatus {
|
||||
pub subject: serde_json::Value,
|
||||
pub takedown: Option<StatusAttr>,
|
||||
pub deactivated: Option<StatusAttr>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusAttr {
|
||||
pub applied: bool,
|
||||
pub r#ref: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_subject_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetSubjectStatusParams>,
|
||||
) -> 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<StatusAttrInput>,
|
||||
pub deactivated: Option<StatusAttrInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatusAttrInput {
|
||||
pub apply: bool,
|
||||
pub r#ref: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_subject_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<UpdateSubjectStatusInput>,
|
||||
) -> 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<String>,
|
||||
pub limit: Option<i64>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[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<InviteCodeUseInfo>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub codes: Vec<InviteCodeInfo>,
|
||||
}
|
||||
|
||||
pub async fn get_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetInviteCodesParams>,
|
||||
) -> 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<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&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<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&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<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<DisableAccountInvitesInput>,
|
||||
) -> 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<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<EnableAccountInvitesInput>,
|
||||
) -> 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,
|
||||
|
||||
502
src/api/server/invite.rs
Normal file
502
src/api/server/invite.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateInviteCodeOutput {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn create_invite_code(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<CreateInviteCodeInput>,
|
||||
) -> 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<i32>,
|
||||
pub use_count: i32,
|
||||
pub for_accounts: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateInviteCodesOutput {
|
||||
pub codes: Vec<AccountCodes>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AccountCodes {
|
||||
pub account: String,
|
||||
pub codes: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn create_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<CreateInviteCodesInput>,
|
||||
) -> 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<bool>,
|
||||
pub create_available: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<InviteCodeUse>,
|
||||
}
|
||||
|
||||
#[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<InviteCode>,
|
||||
}
|
||||
|
||||
pub async fn get_account_invite_codes(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>,
|
||||
) -> 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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
36
src/lib.rs
36
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",
|
||||
|
||||
378
tests/admin_invite.rs
Normal file
378
tests/admin_invite.rs
Normal file
@@ -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);
|
||||
}
|
||||
302
tests/admin_moderation.rs
Normal file
302
tests/admin_moderation.rs
Normal file
@@ -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);
|
||||
}
|
||||
231
tests/helpers/mod.rs
Normal file
231
tests/helpers/mod.rs
Normal file
@@ -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(),
|
||||
)
|
||||
}
|
||||
288
tests/invite.rs
Normal file
288
tests/invite.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
887
tests/lifecycle_record.rs
Normal file
887
tests/lifecycle_record.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
306
tests/lifecycle_session.rs
Normal file
306
tests/lifecycle_session.rs
Normal file
@@ -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");
|
||||
}
|
||||
416
tests/lifecycle_social.rs
Normal file
416
tests/lifecycle_social.rs
Normal file
@@ -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");
|
||||
}
|
||||
793
tests/repo.rs
793
tests/repo.rs
@@ -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("<xml>not an image</xml>")
|
||||
.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);
|
||||
}
|
||||
337
tests/repo_batch.rs
Normal file
337
tests/repo_batch.rs
Normal file
@@ -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);
|
||||
}
|
||||
119
tests/repo_blob.rs
Normal file
119
tests/repo_blob.rs
Normal file
@@ -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("<xml>not an image</xml>")
|
||||
.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);
|
||||
}
|
||||
347
tests/repo_record.rs
Normal file
347
tests/repo_record.rs
Normal file
@@ -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);
|
||||
}
|
||||
129
tests/sync_blob.rs
Normal file
129
tests/sync_blob.rs
Normal file
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user