Files
tranquil-pds/tests/oauth_lifecycle.rs
T
2025-12-22 21:09:09 +02:00

1056 lines
36 KiB
Rust

mod common;
mod helpers;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use common::{base_url, client};
use helpers::verify_new_account;
use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let hash = hasher.finalize();
let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
(code_verifier, code_challenge)
}
async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
let mock_server = MockServer::start().await;
let client_id = mock_server.uri();
let metadata = json!({
"client_id": client_id,
"client_name": "Test OAuth Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": false
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
mock_server
}
struct OAuthSession {
access_token: String,
refresh_token: String,
did: String,
client_id: String,
}
async fn create_user_and_oauth_session(
handle_prefix: &str,
redirect_uri: &str,
) -> (OAuthSession, MockServer) {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("{}-{}", handle_prefix, ts);
let email = format!("{}-{}@example.com", handle_prefix, ts);
let password = format!("{}Pass123!", handle_prefix);
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.expect("Account creation failed");
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap().to_string();
let _ = verify_new_account(&http_client, &user_did).await;
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_res = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("scope", "atproto"),
])
.send()
.await
.expect("PAR failed");
assert!(
par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
"PAR should succeed with 200 or 201, got {}",
par_res.status()
);
let par_body: Value = par_res.json().await.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": &password,
"remember_device": false
}))
.send()
.await
.expect("Authorize failed");
assert_eq!(
auth_res.status(),
StatusCode::OK,
"Authorize should return OK"
);
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"]
.as_str()
.expect("Expected redirect_uri")
.to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
.send().await.expect("Consent request failed");
assert_eq!(
consent_res.status(),
StatusCode::OK,
"Consent should succeed"
);
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"]
.as_str()
.expect("Expected redirect_uri from consent")
.to_string();
}
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.expect("Token request failed");
assert_eq!(token_res.status(), StatusCode::OK);
let token_body: Value = token_res.json().await.unwrap();
let session = OAuthSession {
access_token: token_body["access_token"].as_str().unwrap().to_string(),
refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(),
did: user_did,
client_id,
};
(session, mock_client)
}
#[tokio::test]
async fn test_oauth_token_can_create_and_read_records() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-records", "https://example.com/callback").await;
let collection = "app.bsky.feed.post";
let post_text = "Hello from OAuth! This post was created with an OAuth access token.";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"record": {
"$type": collection,
"text": post_text,
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.expect("createRecord failed");
assert_eq!(
create_res.status(),
StatusCode::OK,
"Should create record with OAuth token"
);
let create_body: Value = create_res.json().await.unwrap();
let uri = create_body["uri"].as_str().unwrap();
let rkey = uri.split('/').last().unwrap();
let get_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
.bearer_auth(&session.access_token)
.query(&[
("repo", session.did.as_str()),
("collection", collection),
("rkey", rkey),
])
.send()
.await
.expect("getRecord failed");
assert_eq!(
get_res.status(),
StatusCode::OK,
"Should read record with OAuth token"
);
let get_body: Value = get_res.json().await.unwrap();
assert_eq!(get_body["value"]["text"], post_text);
}
#[tokio::test]
async fn test_oauth_token_can_upload_blob() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-blob", "https://example.com/callback").await;
let blob_data = b"This is test blob data uploaded via OAuth";
let upload_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url))
.bearer_auth(&session.access_token)
.header("Content-Type", "text/plain")
.body(blob_data.to_vec())
.send()
.await
.expect("uploadBlob failed");
assert_eq!(
upload_res.status(),
StatusCode::OK,
"Should upload blob with OAuth token"
);
let upload_body: Value = upload_res.json().await.unwrap();
assert!(upload_body["blob"]["ref"]["$link"].is_string());
assert_eq!(upload_body["blob"]["mimeType"], "text/plain");
}
#[tokio::test]
async fn test_oauth_token_can_describe_repo() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-describe", "https://example.com/callback").await;
let describe_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.describeRepo", url))
.bearer_auth(&session.access_token)
.query(&[("repo", session.did.as_str())])
.send()
.await
.expect("describeRepo failed");
assert_eq!(
describe_res.status(),
StatusCode::OK,
"Should describe repo with OAuth token"
);
let describe_body: Value = describe_res.json().await.unwrap();
assert_eq!(describe_body["did"], session.did);
assert!(describe_body["handle"].is_string());
}
#[tokio::test]
async fn test_oauth_full_post_lifecycle_create_edit_delete() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-lifecycle", "https://example.com/callback").await;
let collection = "app.bsky.feed.post";
let original_text = "Original post content";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"record": {
"$type": collection,
"text": original_text,
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let create_body: Value = create_res.json().await.unwrap();
let uri = create_body["uri"].as_str().unwrap();
let rkey = uri.split('/').last().unwrap();
let updated_text = "Updated post content via OAuth putRecord";
let put_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"rkey": rkey,
"record": {
"$type": collection,
"text": updated_text,
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
put_res.status(),
StatusCode::OK,
"Should update record with OAuth token"
);
let get_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
.bearer_auth(&session.access_token)
.query(&[
("repo", session.did.as_str()),
("collection", collection),
("rkey", rkey),
])
.send()
.await
.unwrap();
let get_body: Value = get_res.json().await.unwrap();
assert_eq!(
get_body["value"]["text"], updated_text,
"Record should have updated text"
);
let delete_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"rkey": rkey
}))
.send()
.await
.unwrap();
assert_eq!(
delete_res.status(),
StatusCode::OK,
"Should delete record with OAuth token"
);
let get_deleted_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
.bearer_auth(&session.access_token)
.query(&[
("repo", session.did.as_str()),
("collection", collection),
("rkey", rkey),
])
.send()
.await
.unwrap();
assert!(
get_deleted_res.status() == StatusCode::BAD_REQUEST
|| get_deleted_res.status() == StatusCode::NOT_FOUND,
"Deleted record should not be found, got {}",
get_deleted_res.status()
);
}
#[tokio::test]
async fn test_oauth_batch_operations_apply_writes() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-batch", "https://example.com/callback").await;
let collection = "app.bsky.feed.post";
let now = Utc::now().to_rfc3339();
let apply_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"writes": [
{
"$type": "com.atproto.repo.applyWrites#create",
"collection": collection,
"rkey": "batch1",
"value": {
"$type": collection,
"text": "Batch post 1",
"createdAt": now
}
},
{
"$type": "com.atproto.repo.applyWrites#create",
"collection": collection,
"rkey": "batch2",
"value": {
"$type": collection,
"text": "Batch post 2",
"createdAt": now
}
},
{
"$type": "com.atproto.repo.applyWrites#create",
"collection": collection,
"rkey": "batch3",
"value": {
"$type": collection,
"text": "Batch post 3",
"createdAt": now
}
}
]
}))
.send()
.await
.unwrap();
assert_eq!(
apply_res.status(),
StatusCode::OK,
"Should apply batch writes with OAuth token"
);
let list_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(&session.access_token)
.query(&[("repo", session.did.as_str()), ("collection", collection)])
.send()
.await
.unwrap();
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!(
records.len() >= 3,
"Should have at least 3 records from batch"
);
}
#[tokio::test]
async fn test_oauth_token_refresh_maintains_access() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-refresh-access", "https://example.com/callback").await;
let collection = "app.bsky.feed.post";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Post before refresh",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
create_res.status(),
StatusCode::OK,
"Original token should work"
);
let refresh_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &session.refresh_token),
("client_id", &session.client_id),
])
.send()
.await
.unwrap();
assert_eq!(refresh_res.status(), StatusCode::OK);
let refresh_body: Value = refresh_res.json().await.unwrap();
let new_access_token = refresh_body["access_token"].as_str().unwrap();
assert_ne!(
new_access_token, session.access_token,
"New token should be different"
);
let create_res2 = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(new_access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Post after refresh with new token",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
create_res2.status(),
StatusCode::OK,
"New token should work for creating records"
);
let list_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(new_access_token)
.query(&[("repo", session.did.as_str()), ("collection", collection)])
.send()
.await
.unwrap();
assert_eq!(
list_res.status(),
StatusCode::OK,
"New token should work for listing records"
);
let list_body: Value = list_res.json().await.unwrap();
let records = list_body["records"].as_array().unwrap();
assert_eq!(records.len(), 2, "Should have both posts");
}
#[tokio::test]
async fn test_oauth_revoked_token_cannot_access_resources() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-revoke-access", "https://example.com/callback").await;
let collection = "app.bsky.feed.post";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Post before revocation",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
create_res.status(),
StatusCode::OK,
"Token should work before revocation"
);
let revoke_res = http_client
.post(format!("{}/oauth/revoke", url))
.form(&[("token", session.refresh_token.as_str())])
.send()
.await
.unwrap();
assert_eq!(
revoke_res.status(),
StatusCode::OK,
"Revocation should succeed"
);
let refresh_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &session.refresh_token),
("client_id", &session.client_id),
])
.send()
.await
.unwrap();
assert_eq!(
refresh_res.status(),
StatusCode::BAD_REQUEST,
"Revoked refresh token should not work"
);
}
#[tokio::test]
async fn test_oauth_multiple_clients_same_user() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("multi-client-{}", ts);
let email = format!("multi-client-{}@example.com", ts);
let password = "MultiClient123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let _ = verify_new_account(&http_client, user_did).await;
let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await;
let client1_id = mock_client1.uri();
let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await;
let client2_id = mock_client2.uri();
let (verifier1, challenge1) = generate_pkce();
let par_res1 = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client1_id),
("redirect_uri", "https://client1.example.com/callback"),
("code_challenge", &challenge1),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap();
let par_body1: Value = par_res1.json().await.unwrap();
let request_uri1 = par_body1["request_uri"].as_str().unwrap();
let auth_res1 = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri1,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
assert_eq!(auth_res1.status(), StatusCode::OK);
let auth_body1: Value = auth_res1.json().await.unwrap();
let mut location1 = auth_body1["redirect_uri"].as_str().unwrap().to_string();
if location1.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto"], "remember": false}))
.send().await.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code1 = location1
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_res1 = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code1),
("redirect_uri", "https://client1.example.com/callback"),
("code_verifier", &verifier1),
("client_id", &client1_id),
])
.send()
.await
.unwrap();
let token_body1: Value = token_res1.json().await.unwrap();
let token1 = token_body1["access_token"].as_str().unwrap();
let (verifier2, challenge2) = generate_pkce();
let par_res2 = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client2_id),
("redirect_uri", "https://client2.example.com/callback"),
("code_challenge", &challenge2),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap();
let par_body2: Value = par_res2.json().await.unwrap();
let request_uri2 = par_body2["request_uri"].as_str().unwrap();
let auth_res2 = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri2,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
assert_eq!(auth_res2.status(), StatusCode::OK);
let auth_body2: Value = auth_res2.json().await.unwrap();
let mut location2 = auth_body2["redirect_uri"].as_str().unwrap().to_string();
if location2.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto"], "remember": false}))
.send().await.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code2 = location2
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_res2 = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code2),
("redirect_uri", "https://client2.example.com/callback"),
("code_verifier", &verifier2),
("client_id", &client2_id),
])
.send()
.await
.unwrap();
let token_body2: Value = token_res2.json().await.unwrap();
let token2 = token_body2["access_token"].as_str().unwrap();
assert_ne!(
token1, token2,
"Different clients should get different tokens"
);
let collection = "app.bsky.feed.post";
let create_res1 = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(token1)
.json(&json!({
"repo": user_did,
"collection": collection,
"record": {
"$type": collection,
"text": "Post from client 1",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
create_res1.status(),
StatusCode::OK,
"Client 1 token should work"
);
let create_res2 = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(token2)
.json(&json!({
"repo": user_did,
"collection": collection,
"record": {
"$type": collection,
"text": "Post from client 2",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
create_res2.status(),
StatusCode::OK,
"Client 2 token should work"
);
let list_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(token1)
.query(&[("repo", user_did), ("collection", collection)])
.send()
.await
.unwrap();
let list_body: Value = list_res.json().await.unwrap();
let records = list_body["records"].as_array().unwrap();
assert_eq!(
records.len(),
2,
"Both posts should be visible to either client"
);
}
#[tokio::test]
async fn test_oauth_social_interactions_follow_like_repost() {
let url = base_url().await;
let http_client = client();
let (alice, _mock_alice) =
create_user_and_oauth_session("alice-social", "https://alice-app.example.com/callback")
.await;
let (bob, _mock_bob) =
create_user_and_oauth_session("bob-social", "https://bob-app.example.com/callback").await;
let post_collection = "app.bsky.feed.post";
let post_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&alice.access_token)
.json(&json!({
"repo": alice.did,
"collection": post_collection,
"record": {
"$type": post_collection,
"text": "Hello from Alice! Looking for friends.",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(post_res.status(), StatusCode::OK);
let post_body: Value = post_res.json().await.unwrap();
let post_uri = post_body["uri"].as_str().unwrap();
let post_cid = post_body["cid"].as_str().unwrap();
let follow_collection = "app.bsky.graph.follow";
let follow_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&bob.access_token)
.json(&json!({
"repo": bob.did,
"collection": follow_collection,
"record": {
"$type": follow_collection,
"subject": alice.did,
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
follow_res.status(),
StatusCode::OK,
"Bob should be able to follow Alice via OAuth"
);
let like_collection = "app.bsky.feed.like";
let like_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&bob.access_token)
.json(&json!({
"repo": bob.did,
"collection": like_collection,
"record": {
"$type": like_collection,
"subject": {
"uri": post_uri,
"cid": post_cid
},
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
like_res.status(),
StatusCode::OK,
"Bob should be able to like Alice's post via OAuth"
);
let repost_collection = "app.bsky.feed.repost";
let repost_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&bob.access_token)
.json(&json!({
"repo": bob.did,
"collection": repost_collection,
"record": {
"$type": repost_collection,
"subject": {
"uri": post_uri,
"cid": post_cid
},
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
repost_res.status(),
StatusCode::OK,
"Bob should be able to repost Alice's post via OAuth"
);
let bob_follows = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(&bob.access_token)
.query(&[
("repo", bob.did.as_str()),
("collection", follow_collection),
])
.send()
.await
.unwrap();
let follows_body: Value = bob_follows.json().await.unwrap();
let follows = follows_body["records"].as_array().unwrap();
assert_eq!(follows.len(), 1, "Bob should have 1 follow");
assert_eq!(follows[0]["value"]["subject"], alice.did);
let bob_likes = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(&bob.access_token)
.query(&[("repo", bob.did.as_str()), ("collection", like_collection)])
.send()
.await
.unwrap();
let likes_body: Value = bob_likes.json().await.unwrap();
let likes = likes_body["records"].as_array().unwrap();
assert_eq!(likes.len(), 1, "Bob should have 1 like");
}
#[tokio::test]
async fn test_oauth_cannot_modify_other_users_repo() {
let url = base_url().await;
let http_client = client();
let (alice, _mock_alice) =
create_user_and_oauth_session("alice-boundary", "https://alice.example.com/callback").await;
let (bob, _mock_bob) =
create_user_and_oauth_session("bob-boundary", "https://bob.example.com/callback").await;
let collection = "app.bsky.feed.post";
let malicious_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&bob.access_token)
.json(&json!({
"repo": alice.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Bob trying to post as Alice!",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_ne!(
malicious_res.status(),
StatusCode::OK,
"Bob should NOT be able to create records in Alice's repo"
);
let alice_posts = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(&alice.access_token)
.query(&[("repo", alice.did.as_str()), ("collection", collection)])
.send()
.await
.unwrap();
let posts_body: Value = alice_posts.json().await.unwrap();
let posts = posts_body["records"].as_array().unwrap();
assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob");
}
#[tokio::test]
async fn test_oauth_session_isolation_between_users() {
let url = base_url().await;
let http_client = client();
let (alice, _mock_alice) =
create_user_and_oauth_session("alice-isolation", "https://alice.example.com/callback")
.await;
let (bob, _mock_bob) =
create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await;
let collection = "app.bsky.feed.post";
let alice_post = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&alice.access_token)
.json(&json!({
"repo": alice.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Alice's private thoughts",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(alice_post.status(), StatusCode::OK);
let bob_post = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&bob.access_token)
.json(&json!({
"repo": bob.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Bob's different thoughts",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(bob_post.status(), StatusCode::OK);
let alice_list = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(&alice.access_token)
.query(&[("repo", alice.did.as_str()), ("collection", collection)])
.send()
.await
.unwrap();
let alice_records: Value = alice_list.json().await.unwrap();
let alice_posts = alice_records["records"].as_array().unwrap();
assert_eq!(alice_posts.len(), 1);
assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts");
let bob_list = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(&bob.access_token)
.query(&[("repo", bob.did.as_str()), ("collection", collection)])
.send()
.await
.unwrap();
let bob_records: Value = bob_list.json().await.unwrap();
let bob_posts = bob_records["records"].as_array().unwrap();
assert_eq!(bob_posts.len(), 1);
assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts");
}
#[tokio::test]
async fn test_oauth_token_works_with_sync_endpoints() {
let url = base_url().await;
let http_client = client();
let (session, _mock) =
create_user_and_oauth_session("oauth-sync", "https://example.com/callback").await;
let collection = "app.bsky.feed.post";
http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.did,
"collection": collection,
"record": {
"$type": collection,
"text": "Post to sync",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
let latest_commit = http_client
.get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url))
.query(&[("did", session.did.as_str())])
.send()
.await
.unwrap();
assert_eq!(latest_commit.status(), StatusCode::OK);
let commit_body: Value = latest_commit.json().await.unwrap();
assert!(commit_body["cid"].is_string());
assert!(commit_body["rev"].is_string());
let repo_status = http_client
.get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url))
.query(&[("did", session.did.as_str())])
.send()
.await
.unwrap();
assert_eq!(repo_status.status(), StatusCode::OK);
let status_body: Value = repo_status.json().await.unwrap();
assert_eq!(status_body["did"], session.did);
assert!(status_body["active"].as_bool().unwrap());
}