Better oauth, appview groundwork

This commit is contained in:
lewis
2025-12-17 23:29:48 +02:00
parent dea6c09aa0
commit 2cf87e2cfb
45 changed files with 4586 additions and 906 deletions
+163
View File
@@ -0,0 +1,163 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use reqwest::StatusCode;
use serde_json::Value;
#[tokio::test]
async fn test_search_accounts_as_admin() {
let client = client();
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
let (user_did, _) = setup_new_user("search-target").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts",
base_url().await
))
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"].as_array().expect("accounts should be array");
assert!(!accounts.is_empty(), "Should return some accounts");
let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did));
assert!(found, "Should find the created user in results");
}
#[tokio::test]
async fn test_search_accounts_with_handle_filter() {
let client = client();
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
let ts = chrono::Utc::now().timestamp_millis();
let unique_handle = format!("unique-handle-{}.test", ts);
let create_payload = serde_json::json!({
"handle": unique_handle,
"email": format!("unique-{}@searchtest.com", ts),
"password": "test-password-123"
});
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 res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts?handle={}",
base_url().await,
unique_handle
))
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle");
assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str()));
}
#[tokio::test]
async fn test_search_accounts_pagination() {
let client = client();
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
for i in 0..3 {
let _ = setup_new_user(&format!("search-page-{}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts?limit=2",
base_url().await
))
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2, "Should return exactly 2 accounts");
let cursor = body["cursor"].as_str();
assert!(cursor.is_some(), "Should have cursor for more results");
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts?limit=2&cursor={}",
base_url().await,
cursor.unwrap()
))
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res2.status(), StatusCode::OK);
let body2: Value = res2.json().await.unwrap();
let accounts2 = body2["accounts"].as_array().unwrap();
assert!(!accounts2.is_empty(), "Should return more accounts after cursor");
let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect();
let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect();
for did in &second_page_dids {
assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs");
}
}
#[tokio::test]
async fn test_search_accounts_requires_admin() {
let client = client();
let (_, user_jwt) = setup_new_user("search-nonadmin").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts",
base_url().await
))
.bearer_auth(&user_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_search_accounts_requires_auth() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_search_accounts_returns_expected_fields() {
let client = client();
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
let _ = setup_new_user("search-fields").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.admin.searchAccounts?limit=1",
base_url().await
))
.bearer_auth(&admin_jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"].as_array().unwrap();
assert!(!accounts.is_empty());
let account = &accounts[0];
assert!(account["did"].as_str().is_some(), "Should have did");
assert!(account["handle"].as_str().is_some(), "Should have handle");
assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt");
}
+197
View File
@@ -0,0 +1,197 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
#[tokio::test]
async fn test_change_password_success() {
let client = client();
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("change-pw-{}.test", ts);
let email = format!("change-pw-{}@test.com", ts);
let old_password = "old-password-123";
let new_password = "new-password-456";
let create_payload = json!({
"handle": handle,
"email": email,
"password": old_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 create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let jwt = verify_new_account(&client, did).await;
let change_res = client
.post(format!(
"{}/xrpc/com.bspds.account.changePassword",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": old_password,
"newPassword": new_password
}))
.send()
.await
.expect("Failed to change password");
assert_eq!(change_res.status(), StatusCode::OK);
let login_old = client
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
base_url().await
))
.json(&json!({
"identifier": handle,
"password": old_password
}))
.send()
.await
.expect("Failed to try old password");
assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work");
let login_new = client
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
base_url().await
))
.json(&json!({
"identifier": handle,
"password": new_password
}))
.send()
.await
.expect("Failed to try new password");
assert_eq!(login_new.status(), StatusCode::OK, "New password should work");
}
#[tokio::test]
async fn test_change_password_wrong_current() {
let client = client();
let (_, jwt) = setup_new_user("change-pw-wrong").await;
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.changePassword",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": "wrong-password",
"newPassword": "new-password-123"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let body: Value = res.json().await.unwrap();
assert_eq!(body["error"].as_str(), Some("InvalidPassword"));
}
#[tokio::test]
async fn test_change_password_too_short() {
let client = client();
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("change-pw-short-{}.test", ts);
let email = format!("change-pw-short-{}@test.com", ts);
let password = "correct-password";
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 create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let jwt = verify_new_account(&client, did).await;
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.changePassword",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": password,
"newPassword": "short"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.unwrap();
assert!(body["message"].as_str().unwrap().contains("8 characters"));
}
#[tokio::test]
async fn test_change_password_empty_current() {
let client = client();
let (_, jwt) = setup_new_user("change-pw-empty").await;
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.changePassword",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": "",
"newPassword": "new-password-123"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_change_password_empty_new() {
let client = client();
let (_, jwt) = setup_new_user("change-pw-emptynew").await;
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.changePassword",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": "e2e-password-123",
"newPassword": ""
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_change_password_requires_auth() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.changePassword",
base_url().await
))
.json(&json!({
"currentPassword": "old",
"newPassword": "new-password-123"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
+25 -2
View File
@@ -137,8 +137,12 @@ async fn setup_with_external_infra() -> String {
}
let mock_server = MockServer::start().await;
setup_mock_appview(&mock_server).await;
let mock_uri = mock_server.uri();
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
unsafe {
std::env::set_var("APPVIEW_URL", mock_server.uri());
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
}
MOCK_APPVIEW.set(mock_server).ok();
spawn_app(database_url).await
@@ -186,8 +190,12 @@ async fn setup_with_testcontainers() -> String {
let _ = s3_client.create_bucket().bucket("test-bucket").send().await;
let mock_server = MockServer::start().await;
setup_mock_appview(&mock_server).await;
let mock_uri = mock_server.uri();
let mock_host = mock_uri.strip_prefix("http://").unwrap_or(&mock_uri);
let mock_did = format!("did:web:{}", mock_host.replace(':', "%3A"));
setup_mock_did_document(&mock_server, &mock_did, &mock_uri).await;
unsafe {
std::env::set_var("APPVIEW_URL", mock_server.uri());
std::env::set_var("APPVIEW_DID_APP_BSKY", &mock_did);
}
MOCK_APPVIEW.set(mock_server).ok();
S3_CONTAINER.set(s3_container).ok();
@@ -215,6 +223,21 @@ async fn setup_with_testcontainers() -> String {
);
}
async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_endpoint: &str) {
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": did,
"service": [{
"id": "#atproto_appview",
"type": "AtprotoAppView",
"serviceEndpoint": service_endpoint
}]
})))
.mount(mock_server)
.await;
}
async fn setup_mock_appview(mock_server: &MockServer) {
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.actor.getProfile"))
+75
View File
@@ -0,0 +1,75 @@
mod common;
use common::*;
use reqwest::StatusCode;
use serde_json::Value;
#[tokio::test]
async fn test_frontend_client_metadata_returns_valid_json() {
let client = client();
let res = client
.get(format!(
"{}/oauth/client-metadata.json",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Should return valid JSON");
assert!(body["client_id"].as_str().is_some(), "Should have client_id");
assert!(body["client_name"].as_str().is_some(), "Should have client_name");
assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris");
assert!(body["grant_types"].as_array().is_some(), "Should have grant_types");
assert!(body["response_types"].as_array().is_some(), "Should have response_types");
assert!(body["scope"].as_str().is_some(), "Should have scope");
assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method");
}
#[tokio::test]
async fn test_frontend_client_metadata_correct_values() {
let client = client();
let res = client
.get(format!(
"{}/oauth/client-metadata.json",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let client_id = body["client_id"].as_str().unwrap();
assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json");
let grant_types = body["grant_types"].as_array().unwrap();
let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect();
assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant");
assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant");
let response_types = body["response_types"].as_array().unwrap();
let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect();
assert!(response_strs.contains(&"code"), "Should support code response type");
assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)");
assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application");
assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP");
let scope = body["scope"].as_str().unwrap();
assert!(scope.contains("atproto"), "Scope should include atproto");
}
#[tokio::test]
async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() {
let client = client();
let res = client
.get(format!(
"{}/oauth/client-metadata.json",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let client_uri = body["client_uri"].as_str().unwrap();
let redirect_uris = body["redirect_uris"].as_array().unwrap();
assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI");
let redirect_uri = redirect_uris[0].as_str().unwrap();
assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri");
}
+234
View File
@@ -0,0 +1,234 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
#[tokio::test]
async fn test_list_sessions_returns_current_session() {
let client = client();
let (did, jwt) = setup_new_user("list-sessions").await;
let res = client
.get(format!(
"{}/xrpc/com.bspds.account.listSessions",
base_url().await
))
.bearer_auth(&jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let sessions = body["sessions"].as_array().expect("sessions should be array");
assert!(!sessions.is_empty(), "Should have at least one session");
let current = sessions.iter().find(|s| s["isCurrent"].as_bool() == Some(true));
assert!(current.is_some(), "Should have a current session marked");
let session = current.unwrap();
assert!(session["id"].as_str().is_some(), "Session should have id");
assert!(session["createdAt"].as_str().is_some(), "Session should have createdAt");
assert!(session["expiresAt"].as_str().is_some(), "Session should have expiresAt");
let _ = did;
}
#[tokio::test]
async fn test_list_sessions_multiple_sessions() {
let client = client();
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("multi-list-{}.test", ts);
let email = format!("multi-list-{}@test.com", ts);
let password = "test-password-123";
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 create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let jwt1 = verify_new_account(&client, did).await;
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 to login");
assert_eq!(login_res.status(), StatusCode::OK);
let login_body: Value = login_res.json().await.unwrap();
let jwt2 = login_body["accessJwt"].as_str().unwrap();
let list_res = client
.get(format!(
"{}/xrpc/com.bspds.account.listSessions",
base_url().await
))
.bearer_auth(jwt2)
.send()
.await
.expect("Failed to list sessions");
assert_eq!(list_res.status(), StatusCode::OK);
let list_body: Value = list_res.json().await.unwrap();
let sessions = list_body["sessions"].as_array().unwrap();
assert!(sessions.len() >= 2, "Should have at least 2 sessions, got {}", sessions.len());
let _ = jwt1;
}
#[tokio::test]
async fn test_list_sessions_requires_auth() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.bspds.account.listSessions",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_revoke_session_success() {
let client = client();
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("revoke-sess-{}.test", ts);
let email = format!("revoke-sess-{}@test.com", ts);
let password = "test-password-123";
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 create_body: Value = create_res.json().await.unwrap();
let did = create_body["did"].as_str().unwrap();
let jwt1 = verify_new_account(&client, did).await;
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 to login");
assert_eq!(login_res.status(), StatusCode::OK);
let login_body: Value = login_res.json().await.unwrap();
let jwt2 = login_body["accessJwt"].as_str().unwrap();
let list_res = client
.get(format!(
"{}/xrpc/com.bspds.account.listSessions",
base_url().await
))
.bearer_auth(jwt2)
.send()
.await
.expect("Failed to list sessions");
let list_body: Value = list_res.json().await.unwrap();
let sessions = list_body["sessions"].as_array().unwrap();
let other_session = sessions.iter().find(|s| s["isCurrent"].as_bool() != Some(true));
assert!(other_session.is_some(), "Should have another session to revoke");
let session_id = other_session.unwrap()["id"].as_str().unwrap();
let revoke_res = client
.post(format!(
"{}/xrpc/com.bspds.account.revokeSession",
base_url().await
))
.bearer_auth(jwt2)
.json(&json!({"sessionId": session_id}))
.send()
.await
.expect("Failed to revoke session");
assert_eq!(revoke_res.status(), StatusCode::OK);
let list_after_res = client
.get(format!(
"{}/xrpc/com.bspds.account.listSessions",
base_url().await
))
.bearer_auth(jwt2)
.send()
.await
.expect("Failed to list sessions after revoke");
let list_after_body: Value = list_after_res.json().await.unwrap();
let sessions_after = list_after_body["sessions"].as_array().unwrap();
let revoked_still_exists = sessions_after.iter().any(|s| s["id"].as_str() == Some(session_id));
assert!(!revoked_still_exists, "Revoked session should not appear in list");
let _ = jwt1;
}
#[tokio::test]
async fn test_revoke_session_invalid_id() {
let client = client();
let (_, jwt) = setup_new_user("revoke-invalid").await;
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.revokeSession",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({"sessionId": "not-a-number"}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_revoke_session_not_found() {
let client = client();
let (_, jwt) = setup_new_user("revoke-notfound").await;
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.revokeSession",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({"sessionId": "999999999"}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_revoke_session_requires_auth() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/com.bspds.account.revokeSession",
base_url().await
))
.json(&json!({"sessionId": "1"}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}