Identity endpoint conformance vs ref

This commit is contained in:
lewis
2025-12-29 21:55:49 +02:00
parent cd4780af41
commit 98b4a33447
15 changed files with 367 additions and 140 deletions
+99 -23
View File
@@ -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
&new_handle
};
let full_handle = if new_handle.ends_with(&suffix) {
new_handle.clone()
} else {
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) =
+28 -65
View File
@@ -23,13 +23,11 @@ pub async fn submit_plc_operation(
headers: axum::http::HeaderMap,
Json(input): Json<SubmitPlcOperationInput>,
) -> 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<PlcError>> =
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()
}
+12 -12
View File
@@ -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<String, HandleValidationErr
}
if let Some(first_char) = handle.chars().next()
&& (first_char == '-' || first_char == '_')
&& first_char == '-'
{
return Err(HandleValidationError::StartsWithInvalidChar);
}
if let Some(last_char) = handle.chars().last()
&& (last_char == '-' || last_char == '_')
&& last_char == '-'
{
return Err(HandleValidationError::EndsWithInvalidChar);
}
for c in handle.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
if !c.is_ascii_alphanumeric() && c != '-' {
return Err(HandleValidationError::InvalidCharacters);
}
}
@@ -150,10 +150,6 @@ mod tests {
validate_short_handle("user-name"),
Ok("user-name".to_string())
);
assert_eq!(
validate_short_handle("user_name"),
Ok("user_name".to_string())
);
assert_eq!(
validate_short_handle("UPPERCASE"),
Ok("uppercase".to_string())
@@ -194,7 +190,7 @@ mod tests {
);
assert_eq!(
validate_short_handle("_starts"),
Err(HandleValidationError::StartsWithInvalidChar)
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("ends-"),
@@ -202,7 +198,11 @@ mod tests {
);
assert_eq!(
validate_short_handle("ends_"),
Err(HandleValidationError::EndsWithInvalidChar)
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("user_name"),
Err(HandleValidationError::InvalidCharacters)
);
assert_eq!(
validate_short_handle("test@user"),
+8
View File
@@ -110,6 +110,14 @@ impl DidResolver {
Some(resolved)
}
pub async fn refresh_did(&self, did: &str) -> Option<ResolvedService> {
{
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<ResolvedService> {
let did_doc = if did.starts_with("did:web:") {
self.resolve_did_web(did).await
+4
View File
@@ -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<String> = 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"));
}
+12
View File
@@ -30,6 +30,8 @@ pub struct RateLimiters {
pub app_password: Arc<KeyedRateLimiter>,
pub email_update: Arc<KeyedRateLimiter>,
pub totp_verify: Arc<KeyedRateLimiter>,
pub handle_update: Arc<KeyedRateLimiter>,
pub handle_update_daily: Arc<KeyedRateLimiter>,
}
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()),
)),
}
}
+8
View File
@@ -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();
+1 -1
View File
@@ -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),
+7 -7
View File
@@ -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://"));
+13 -13
View File
@@ -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
+160 -4
View File
@@ -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(&current_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"));
}
+5 -5
View File
@@ -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,
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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))
+5 -5
View File
@@ -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",