diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index 4c67062..4e5c7ca 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -511,7 +511,14 @@ pub async fn get_recommended_did_credentials( let rotation_keys = if auth_user.did.starts_with("did:web:") { vec![] } else { - vec![did_key.clone()] + let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") { + Ok(key) => key, + Err(_) => { + warn!("PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"); + did_key.clone() + } + }; + vec![server_rotation_key] }; ( StatusCode::OK, @@ -559,20 +566,45 @@ pub async fn update_handle( return e; } let did = auth_user.did; - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) + if !state + .check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did) .await { - Ok(Some(id)) => id, + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({"error": "RateLimitExceeded", "message": "Too many handle updates. Try again later."})), + ) + .into_response(); + } + if !state + .check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did) + .await + { + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({"error": "RateLimitExceeded", "message": "Daily handle update limit exceeded."})), + ) + .into_response(); + } + let user_row = match sqlx::query!( + "SELECT id, handle FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, _ => return ApiError::InternalError.into_response(), }; - let new_handle = input.handle.trim(); + let user_id = user_row.id; + let current_handle = user_row.handle; + let new_handle = input.handle.trim().to_ascii_lowercase(); if new_handle.is_empty() { return ApiError::InvalidRequest("handle is required".into()).into_response(); } if !new_handle .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') { return ( StatusCode::BAD_REQUEST, @@ -582,7 +614,23 @@ pub async fn update_handle( ) .into_response(); } - if crate::moderation::has_explicit_slur(new_handle) { + for segment in new_handle.split('.') { + if segment.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": "Handle contains empty segment"})), + ) + .into_response(); + } + if segment.starts_with('-') || segment.ends_with('-') { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": "Handle segment cannot start or end with hyphen"})), + ) + .into_response(); + } + } + if crate::moderation::has_explicit_slur(&new_handle) { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})), @@ -591,13 +639,27 @@ pub async fn update_handle( } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let suffix = format!(".{}", hostname); - let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); + let is_service_domain = crate::handle::is_service_domain_handle(&new_handle, &hostname); let handle = if is_service_domain { let short_part = if new_handle.ends_with(&suffix) { - new_handle.strip_suffix(&suffix).unwrap_or(new_handle) + new_handle.strip_suffix(&suffix).unwrap_or(&new_handle) + } else { + &new_handle + }; + let full_handle = if new_handle.ends_with(&suffix) { + new_handle.clone() } else { - new_handle + format!("{}.{}", new_handle, hostname) }; + if full_handle == current_handle { + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) + .await + { + warn!("Failed to sequence identity event for handle update: {}", e); + } + return (StatusCode::OK, Json(json!({}))).into_response(); + } if short_part.contains('.') { return ( StatusCode::BAD_REQUEST, @@ -608,13 +670,32 @@ pub async fn update_handle( ) .into_response(); } - if new_handle.ends_with(&suffix) { - new_handle.to_string() - } else { - format!("{}.{}", new_handle, hostname) + if short_part.len() < 3 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": "Handle too short"})), + ) + .into_response(); } + if short_part.len() > 18 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": "Handle too long"})), + ) + .into_response(); + } + full_handle } else { - match crate::handle::verify_handle_ownership(new_handle, &did).await { + if new_handle == current_handle { + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&new_handle)) + .await + { + warn!("Failed to sequence identity event for handle update: {}", e); + } + return (StatusCode::OK, Json(json!({}))).into_response(); + } + match crate::handle::verify_handle_ownership(&new_handle, &did).await { Ok(()) => {} Err(crate::handle::HandleResolutionError::NotFound) => { return ( @@ -649,13 +730,8 @@ pub async fn update_handle( .into_response(); } } - new_handle.to_string() + new_handle.clone() }; - let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) - .fetch_optional(&state.db) - .await - .ok() - .flatten(); let existing = sqlx::query!( "SELECT id FROM users WHERE handle = $1 AND id != $2", handle, @@ -679,8 +755,8 @@ pub async fn update_handle( .await; match result { Ok(_) => { - if let Some(old) = old_handle { - let _ = state.cache.delete(&format!("handle:{}", old)).await; + if !current_handle.is_empty() { + let _ = state.cache.delete(&format!("handle:{}", current_handle)).await; } let _ = state.cache.delete(&format!("handle:{}", handle)).await; if let Err(e) = diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index b483336..f0eec58 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -23,13 +23,11 @@ pub async fn submit_plc_operation( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - info!("[MIGRATION] submitPlcOperation called"); let bearer = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, None => { - info!("[MIGRATION] submitPlcOperation: No bearer token"); return ApiError::AuthenticationRequired.into_response(); } }; @@ -37,20 +35,14 @@ pub async fn submit_plc_operation( match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { Ok(user) => user, Err(e) => { - info!("[MIGRATION] submitPlcOperation: Auth failed: {:?}", e); return ApiError::from(e).into_response(); } }; - info!( - "[MIGRATION] submitPlcOperation: Authenticated user did={}", - auth_user.did - ); if let Err(e) = crate::auth::scope_check::check_identity_scope( auth_user.is_oauth, auth_user.scope.as_deref(), crate::oauth::scopes::IdentityAttr::Wildcard, ) { - info!("[MIGRATION] submitPlcOperation: Scope check failed"); return e; } let did = &auth_user.did; @@ -67,7 +59,7 @@ pub async fn submit_plc_operation( let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let public_url = format!("https://{}", hostname); let user = match sqlx::query!( - "SELECT id, handle, deactivated_at FROM users WHERE did = $1", + "SELECT id, handle FROM users WHERE did = $1", did ) .fetch_optional(&state.db) @@ -82,7 +74,6 @@ pub async fn submit_plc_operation( .into_response(); } }; - let is_migration = user.deactivated_at.is_some(); let key_row = match sqlx::query!( "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user.id @@ -123,10 +114,9 @@ pub async fn submit_plc_operation( } }; let user_did_key = signing_key_to_did_key(&signing_key); - if !is_migration && let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) - { - let server_rotation_key = - std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); + let server_rotation_key = + std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); + if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { let has_server_key = rotation_keys .iter() .any(|k| k.as_str() == Some(&server_rotation_key)); @@ -167,21 +157,20 @@ pub async fn submit_plc_operation( .into_response(); } } - if !is_migration { - if let Some(verification_methods) = - op.get("verificationMethods").and_then(|v| v.as_object()) - && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) - && atproto_key != user_did_key - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect signing key in verificationMethods" - })), - ) - .into_response(); - } + if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) + && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) + && atproto_key != user_did_key + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect signing key in verificationMethods" + })), + ) + .into_response(); + } + if !user.handle.is_empty() { if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { let expected_handle = format!("at://{}", user.handle); let first_aka = also_known_as.first().and_then(|v| v.as_str()); @@ -200,11 +189,6 @@ pub async fn submit_plc_operation( let plc_client = PlcClient::new(None); let operation_clone = input.operation.clone(); let did_clone = did.clone(); - info!( - "[MIGRATION] submitPlcOperation: Sending operation to PLC directory for did={}", - did - ); - let plc_start = std::time::Instant::now(); let result: Result<(), CircuitBreakerError> = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { plc_client @@ -213,17 +197,9 @@ pub async fn submit_plc_operation( }) .await; match result { - Ok(()) => { - info!( - "[MIGRATION] submitPlcOperation: PLC directory accepted operation in {:?}", - plc_start.elapsed() - ); - } + Ok(()) => {} Err(CircuitBreakerError::CircuitOpen(e)) => { - warn!( - "[MIGRATION] submitPlcOperation: PLC directory circuit breaker open: {}", - e - ); + warn!("PLC directory circuit breaker open: {}", e); return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ @@ -234,10 +210,7 @@ pub async fn submit_plc_operation( .into_response(); } Err(CircuitBreakerError::OperationFailed(e)) => { - error!( - "[MIGRATION] submitPlcOperation: PLC operation failed: {:?}", - e - ); + error!("PLC operation failed: {:?}", e); return ( StatusCode::BAD_GATEWAY, Json(json!({ @@ -248,10 +221,6 @@ pub async fn submit_plc_operation( .into_response(); } } - info!( - "[MIGRATION] submitPlcOperation: Sequencing identity event for did={}", - did - ); match sqlx::query!( "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity') RETURNING seq", did @@ -260,27 +229,21 @@ pub async fn submit_plc_operation( .await { Ok(row) => { - info!( - "[MIGRATION] submitPlcOperation: Identity event sequenced with seq={}", - row.seq - ); if let Err(e) = sqlx::query(&format!("NOTIFY repo_updates, '{}'", row.seq)) .execute(&state.db) .await { - warn!( - "[MIGRATION] submitPlcOperation: Failed to notify identity event: {:?}", - e - ); + warn!("Failed to notify identity event: {:?}", e); } } Err(e) => { - warn!( - "[MIGRATION] submitPlcOperation: Failed to sequence identity event: {:?}", - e - ); + warn!("Failed to sequence identity event: {:?}", e); } } - info!("[MIGRATION] submitPlcOperation: SUCCESS for did={}", did); + let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; + if state.did_resolver.refresh_did(did).await.is_none() { + warn!(did = %did, "Failed to refresh DID cache after PLC update"); + } + info!(did = %did, "PLC operation submitted successfully"); (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/validation.rs b/src/api/validation.rs index 8d73839..d85a9b9 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -35,12 +35,12 @@ impl std::fmt::Display for HandleValidationError { ), Self::InvalidCharacters => write!( f, - "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed" + "Handle contains invalid characters. Only alphanumeric characters and hyphens are allowed" ), Self::StartsWithInvalidChar => { - write!(f, "Handle cannot start with a hyphen or underscore") + write!(f, "Handle cannot start with a hyphen") } - Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), + Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen"), Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), Self::BannedWord => write!(f, "Inappropriate language in handle"), } @@ -67,19 +67,19 @@ pub fn validate_short_handle(handle: &str) -> Result Option { + { + let mut cache = self.did_cache.write().await; + cache.remove(did); + } + self.resolve_did(did).await + } + async fn resolve_did_internal(&self, did: &str) -> Option { let did_doc = if did.starts_with("did:web:") { self.resolve_did_web(did).await diff --git a/src/handle/mod.rs b/src/handle/mod.rs index a1c90a0..b6c1a16 100644 --- a/src/handle/mod.rs +++ b/src/handle/mod.rs @@ -93,6 +93,9 @@ pub async fn verify_handle_ownership( } pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { + if !handle.contains('.') { + return true; + } let service_domains: Vec = std::env::var("PDS_SERVICE_HANDLE_DOMAINS") .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) .unwrap_or_else(|_| vec![hostname.to_string()]); @@ -115,6 +118,7 @@ mod tests { fn test_is_service_domain_handle() { assert!(is_service_domain_handle("user.example.com", "example.com")); assert!(is_service_domain_handle("example.com", "example.com")); + assert!(is_service_domain_handle("myhandle", "example.com")); assert!(!is_service_domain_handle("user.other.com", "example.com")); assert!(!is_service_domain_handle("myhandle.xyz", "example.com")); } diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 6430055..240ef7b 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -30,6 +30,8 @@ pub struct RateLimiters { pub app_password: Arc, pub email_update: Arc, pub totp_verify: Arc, + pub handle_update: Arc, + pub handle_update_daily: Arc, } impl Default for RateLimiters { @@ -79,6 +81,16 @@ impl RateLimiters { .unwrap() .allow_burst(NonZeroU32::new(5).unwrap()), )), + handle_update: Arc::new(RateLimiter::keyed( + Quota::with_period(std::time::Duration::from_secs(30)) + .unwrap() + .allow_burst(NonZeroU32::new(10).unwrap()), + )), + handle_update_daily: Arc::new(RateLimiter::keyed( + Quota::with_period(std::time::Duration::from_secs(1728)) + .unwrap() + .allow_burst(NonZeroU32::new(50).unwrap()), + )), } } diff --git a/src/state.rs b/src/state.rs index 8020334..01b66c4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -37,6 +37,8 @@ pub enum RateLimitKind { AppPassword, EmailUpdate, TotpVerify, + HandleUpdate, + HandleUpdateDaily, } impl RateLimitKind { @@ -54,6 +56,8 @@ impl RateLimitKind { Self::AppPassword => "app_password", Self::EmailUpdate => "email_update", Self::TotpVerify => "totp_verify", + Self::HandleUpdate => "handle_update", + Self::HandleUpdateDaily => "handle_update_daily", } } @@ -71,6 +75,8 @@ impl RateLimitKind { Self::AppPassword => (10, 60_000), Self::EmailUpdate => (5, 3_600_000), Self::TotpVerify => (5, 300_000), + Self::HandleUpdate => (10, 300_000), + Self::HandleUpdateDaily => (50, 86_400_000), } } } @@ -191,6 +197,8 @@ impl AppState { RateLimitKind::AppPassword => &self.rate_limiters.app_password, RateLimitKind::EmailUpdate => &self.rate_limiters.email_update, RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, + RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, + RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, }; let ok = limiter.check_key(&client_ip.to_string()).is_ok(); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dee51c0..d72eb97 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -430,7 +430,7 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) -> if attempt > 0 { tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await; } - let handle = format!("user_{}", uuid::Uuid::new_v4()); + let handle = format!("user-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), diff --git a/tests/did_web.rs b/tests/did_web.rs index 79f7e32..26fa03c 100644 --- a/tests/did_web.rs +++ b/tests/did_web.rs @@ -11,7 +11,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] async fn test_create_self_hosted_did_web() { let client = client(); - let handle = format!("selfweb_{}", uuid::Uuid::new_v4()); + let handle = format!("selfweb-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), @@ -98,7 +98,7 @@ async fn test_external_did_web_no_local_doc() { let mock_uri = mock_server.uri(); let mock_addr = mock_uri.trim_start_matches("http://"); let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); - let handle = format!("extweb_{}", uuid::Uuid::new_v4()); + let handle = format!("extweb-{}", uuid::Uuid::new_v4()); let pds_endpoint = base_url().await.replace("http://", "https://"); let reserve_res = client @@ -180,7 +180,7 @@ async fn test_external_did_web_no_local_doc() { #[tokio::test] async fn test_plc_operations_blocked_for_did_web() { let client = client(); - let handle = format!("plcblock_{}", uuid::Uuid::new_v4()); + let handle = format!("plcblock-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), @@ -245,7 +245,7 @@ async fn test_plc_operations_blocked_for_did_web() { #[tokio::test] async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() { let client = client(); - let handle = format!("creds_{}", uuid::Uuid::new_v4()); + let handle = format!("creds-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), @@ -294,7 +294,7 @@ async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() { #[tokio::test] async fn test_did_plc_still_works_with_did_type_param() { let client = client(); - let handle = format!("plctype_{}", uuid::Uuid::new_v4()); + let handle = format!("plctype-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), @@ -323,7 +323,7 @@ async fn test_did_plc_still_works_with_did_type_param() { #[tokio::test] async fn test_external_did_web_requires_did_field() { let client = client(); - let handle = format!("nodid_{}", uuid::Uuid::new_v4()); + let handle = format!("nodid-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), @@ -392,7 +392,7 @@ async fn test_did_web_byod_flow() { mock_addr.replace(":", "%3A"), unique_id ); - let handle = format!("byod_{}", uuid::Uuid::new_v4()); + let handle = format!("byod-{}", uuid::Uuid::new_v4()); let pds_endpoint = base_url().await.replace("http://", "https://"); let pds_did = format!("did:web:{}", pds_endpoint.trim_start_matches("https://")); diff --git a/tests/email_update.rs b/tests/email_update.rs index 6ae0c0c..8651002 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -67,7 +67,7 @@ async fn test_email_update_flow_success() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("emailup_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); @@ -108,10 +108,10 @@ async fn test_email_update_flow_success() { async fn test_request_email_update_taken_email() { let client = common::client(); let base_url = common::base_url().await; - let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4()); + let handle1 = format!("emailup-taken1-{}", uuid::Uuid::new_v4()); let email1 = format!("{}@example.com", handle1); let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; - let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4()); + let handle2 = format!("emailup-taken2-{}", uuid::Uuid::new_v4()); let email2 = format!("{}@example.com", handle2); let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; let res = client @@ -133,7 +133,7 @@ async fn test_request_email_update_taken_email() { async fn test_confirm_email_invalid_token() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-inv-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); @@ -168,7 +168,7 @@ async fn test_confirm_email_wrong_email() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-wrong-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("new_{}@example.com", handle); @@ -205,7 +205,7 @@ async fn test_confirm_email_wrong_email() { async fn test_update_email_requires_token() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-direct-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("direct_{}@example.com", handle); @@ -225,7 +225,7 @@ async fn test_update_email_requires_token() { async fn test_update_email_same_email_noop() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("emailup_same_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-same-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let res = client @@ -246,7 +246,7 @@ async fn test_update_email_same_email_noop() { async fn test_update_email_requires_token_after_pending() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("emailup_token_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-token-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("pending_{}@example.com", handle); @@ -278,7 +278,7 @@ async fn test_update_email_with_valid_token() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-valid-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("valid_{}@example.com", handle); @@ -316,7 +316,7 @@ async fn test_update_email_with_valid_token() { async fn test_update_email_invalid_token() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-badtok-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("badtok_{}@example.com", handle); @@ -350,10 +350,10 @@ async fn test_update_email_invalid_token() { async fn test_update_email_already_taken() { let client = common::client(); let base_url = common::base_url().await; - let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4()); + let handle1 = format!("emailup-dup1-{}", uuid::Uuid::new_v4()); let email1 = format!("{}@example.com", handle1); let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; - let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4()); + let handle2 = format!("emailup-dup2-{}", uuid::Uuid::new_v4()); let email2 = format!("{}@example.com", handle2); let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; let res = client @@ -394,7 +394,7 @@ async fn test_update_email_no_auth() { async fn test_update_email_invalid_format() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4()); + let handle = format!("emailup-fmt-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let res = client diff --git a/tests/identity.rs b/tests/identity.rs index e431af3..d883547 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -8,7 +8,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] async fn test_resolve_handle_success() { let client = client(); - let short_handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); + let short_handle = format!("resolvetest-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": short_handle, "email": format!("{}@example.com", short_handle), @@ -98,7 +98,7 @@ async fn test_create_did_web_account_and_resolve() { let mock_uri = mock_server.uri(); let mock_addr = mock_uri.trim_start_matches("http://"); let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); - let handle = format!("webuser_{}", uuid::Uuid::new_v4()); + let handle = format!("webuser-{}", uuid::Uuid::new_v4()); let pds_endpoint = base_url().await.replace("http://", "https://"); let reserve_res = client @@ -183,7 +183,7 @@ async fn test_create_did_web_account_and_resolve() { #[tokio::test] async fn test_create_account_duplicate_handle() { let client = client(); - let handle = format!("dupe_{}", uuid::Uuid::new_v4()); + let handle = format!("dupe-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let payload = json!({ "handle": handle, @@ -220,7 +220,7 @@ async fn test_did_web_lifecycle() { let mock_server = MockServer::start().await; let mock_uri = mock_server.uri(); let mock_addr = mock_uri.trim_start_matches("http://"); - let handle = format!("lifecycle_{}", uuid::Uuid::new_v4()); + let handle = format!("lifecycle-{}", uuid::Uuid::new_v4()); let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle); let email = format!("{}@test.com", handle); let pds_endpoint = base_url().await.replace("http://", "https://"); @@ -378,3 +378,159 @@ async fn test_get_recommended_did_credentials_no_auth() { let body: Value = res.json().await.expect("Response was not valid JSON"); assert_eq!(body["error"], "AuthenticationRequired"); } + +#[tokio::test] +async fn test_update_handle_to_same() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + let session = client + .get(format!( + "{}/xrpc/com.atproto.server.getSession", + base_url().await + )) + .bearer_auth(&access_jwt) + .send() + .await + .expect("Failed to get session"); + let session_body: Value = session.json().await.expect("Invalid JSON"); + let current_handle = session_body["handle"].as_str().expect("No handle").to_string(); + let short_handle = current_handle.split('.').next().unwrap_or(¤t_handle); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&json!({ "handle": short_handle })) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_update_handle_no_auth() { + let client = client(); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .json(&json!({ "handle": "newhandle" })) + .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_update_handle_invalid_characters() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&json!({ "handle": "invalid@handle!" })) + .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"], "InvalidHandle"); +} + +#[tokio::test] +async fn test_update_handle_empty() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&json!({ "handle": "" })) + .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_handle_taken() { + let client = client(); + let (access_jwt1, _did1) = create_account_and_login(&client).await; + let (access_jwt2, _did2) = create_account_and_login(&client).await; + let short_handle = format!("taken{}", &uuid::Uuid::new_v4().to_string()[..8]); + let update1 = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt1) + .json(&json!({ "handle": short_handle })) + .send() + .await + .expect("Failed to update handle"); + assert_eq!(update1.status(), StatusCode::OK); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt2) + .json(&json!({ "handle": short_handle })) + .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"], "HandleTaken"); +} + +#[tokio::test] +async fn test_update_handle_too_short() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&json!({ "handle": "ab" })) + .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"], "InvalidHandle"); + assert!(body["message"].as_str().unwrap().contains("short")); +} + +#[tokio::test] +async fn test_update_handle_too_long() { + let client = client(); + let (access_jwt, _did) = create_account_and_login(&client).await; + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base_url().await + )) + .bearer_auth(&access_jwt) + .json(&json!({ "handle": "thishandleiswaytoolongforservicedomain" })) + .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"], "InvalidHandle"); + assert!(body["message"].as_str().unwrap().contains("long")); +} diff --git a/tests/password_reset.rs b/tests/password_reset.rs index 64368c5..d72fb09 100644 --- a/tests/password_reset.rs +++ b/tests/password_reset.rs @@ -19,7 +19,7 @@ async fn test_request_password_reset_creates_code() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("pwreset_{}", uuid::Uuid::new_v4()); + let handle = format!("pwreset-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let payload = json!({ "handle": handle, @@ -81,7 +81,7 @@ async fn test_reset_password_with_valid_token() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("pwreset2_{}", uuid::Uuid::new_v4()); + let handle = format!("pwreset2-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let old_password = "Oldpass123!"; let new_password = "Newpass456!"; @@ -197,7 +197,7 @@ async fn test_reset_password_with_expired_token() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("pwreset3_{}", uuid::Uuid::new_v4()); + let handle = format!("pwreset3-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let payload = json!({ "handle": handle, @@ -261,7 +261,7 @@ async fn test_reset_password_invalidates_sessions() { let client = common::client(); let base_url = common::base_url().await; let pool = get_pool().await; - let handle = format!("pwreset4_{}", uuid::Uuid::new_v4()); + let handle = format!("pwreset4-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let payload = json!({ "handle": handle, @@ -351,7 +351,7 @@ async fn test_reset_password_creates_notification() { let pool = get_pool().await; let client = common::client(); let base_url = common::base_url().await; - let handle = format!("pwreset5_{}", uuid::Uuid::new_v4()); + let handle = format!("pwreset5-{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); let payload = json!({ "handle": handle, diff --git a/tests/rate_limit.rs b/tests/rate_limit.rs index 4f9c350..4163380 100644 --- a/tests/rate_limit.rs +++ b/tests/rate_limit.rs @@ -9,7 +9,7 @@ async fn test_login_rate_limiting() { let client = client(); let url = format!("{}/xrpc/com.atproto.server.createSession", base_url().await); let payload = json!({ - "identifier": "nonexistent_user_for_rate_limit_test", + "identifier": "nonexistent-user-for-rate-limit-test", "password": "wrongpassword" }); let mut rate_limited_count = 0; @@ -53,7 +53,7 @@ async fn test_password_reset_rate_limiting() { let mut success_count = 0; for i in 0..8 { let payload = json!({ - "email": format!("ratelimit_test_{}@example.com", i) + "email": format!("ratelimit-test_{}@example.com", i) }); let res = client .post(&url) @@ -91,8 +91,8 @@ async fn test_account_creation_rate_limiting() { for i in 0..15 { let unique_id = uuid::Uuid::new_v4(); let payload = json!({ - "handle": format!("ratelimit_{}_{}", i, unique_id), - "email": format!("ratelimit_{}_{}@example.com", i, unique_id), + "handle": format!("ratelimit-{}_{}", i, unique_id), + "email": format!("ratelimit-{}_{}@example.com", i, unique_id), "password": "Testpass123!" }); let res = client diff --git a/tests/server.rs b/tests/server.rs index 8b7d088..0dfe0c0 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -26,7 +26,7 @@ async fn test_server_basics() { async fn test_account_and_session_lifecycle() { let client = client(); let base = base_url().await; - let handle = format!("user_{}", uuid::Uuid::new_v4()); + let handle = format!("user-{}", uuid::Uuid::new_v4()); let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Testpass123!" }); let create_res = client .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) diff --git a/tests/signing_key.rs b/tests/signing_key.rs index 94a2401..34ec8a2 100644 --- a/tests/signing_key.rs +++ b/tests/signing_key.rs @@ -174,7 +174,7 @@ async fn test_create_account_with_reserved_signing_key() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let signing_key = body["signingKey"].as_str().unwrap(); - let handle = format!("reserved_key_user_{}", uuid::Uuid::new_v4()); + let handle = format!("reserved-key-user-{}", uuid::Uuid::new_v4()); let res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", @@ -212,7 +212,7 @@ async fn test_create_account_with_reserved_signing_key() { async fn test_create_account_with_invalid_signing_key() { let client = common::client(); let base_url = common::base_url().await; - let handle = format!("bad_key_user_{}", uuid::Uuid::new_v4()); + let handle = format!("bad-key-user-{}", uuid::Uuid::new_v4()); let res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", @@ -248,7 +248,7 @@ async fn test_create_account_cannot_reuse_signing_key() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let signing_key = body["signingKey"].as_str().unwrap(); - let handle1 = format!("reuse_key_user1_{}", uuid::Uuid::new_v4()); + let handle1 = format!("reuse-key-user1-{}", uuid::Uuid::new_v4()); let res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", @@ -264,7 +264,7 @@ async fn test_create_account_cannot_reuse_signing_key() { .await .expect("Failed to create first account"); assert_eq!(res.status(), StatusCode::OK); - let handle2 = format!("reuse_key_user2_{}", uuid::Uuid::new_v4()); + let handle2 = format!("reuse-key-user2-{}", uuid::Uuid::new_v4()); let res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", @@ -301,7 +301,7 @@ async fn test_reserved_key_tokens_work() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.unwrap(); let signing_key = body["signingKey"].as_str().unwrap(); - let handle = format!("token_test_user_{}", uuid::Uuid::new_v4()); + let handle = format!("token-test-user-{}", uuid::Uuid::new_v4()); let res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount",