#![allow(
clippy::expect_used,
reason = "Fixture setup and response assertions require successful fallible construction."
)]
use super::*;
use crate::plugins::test_helpers;
use async_trait::async_trait;
use better_auth_core::{AccountConfig, CreateUser};
use chrono::Duration;
use std::collections::HashMap;
use std::sync::Arc;
use better_auth_core::utils::cookie_utils::related_cookie_name;
struct NoopDeleteSender;
#[async_trait]
impl SendDeleteAccountVerification for NoopDeleteSender {
async fn send(
&self,
_user: &FieldValue,
_url: &str,
_token: &str,
_request: Option<&AuthRequest>,
) -> AuthResult<()> {
Ok(())
}
}
#[async_trait]
impl better_auth_core::email::SendVerificationEmail for NoopDeleteSender {
async fn send(&self, _: &better_auth_core::FieldValue, _: &str, _: &str) -> AuthResult<()> {
Ok(())
}
}
type TestSchema = better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
#[tokio::test]
async fn test_change_email_success() {
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let (mut ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
ctx.extensions.insert(
crate::plugins::email_verification::EmailVerificationConfig {
send_verification_email: Some(Arc::new(NoopDeleteSender)),
..Default::default()
},
);
let body = serde_json::json!({ "newEmail": "new@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(session.token.typed().unwrap()),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
}
#[tokio::test]
async fn test_change_email_same_email() {
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "test@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(session.token.typed().unwrap()),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 400);
}
#[tokio::test]
async fn test_change_email_unauthenticated() {
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "new@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
None,
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 401);
}
#[tokio::test]
async fn test_change_email_immediate_when_update_without_verification() {
let plugin = UserManagementPlugin::new()
.change_email_enabled(true)
.update_without_verification(true);
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(false),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "new@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(session.token.typed().unwrap()),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let updated_user = ctx
.database
.get_user_by_id(user.id.typed().unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(
updated_user.email.typed().unwrap().as_deref(),
Some("new@example.com")
);
assert!(!updated_user.email_verified.is_truthy().unwrap());
let identifier = format!("change_email:{}:new@example.com", user.id.typed().unwrap());
let verification = ctx
.database
.get_verification_by_identifier(&identifier)
.await
.unwrap();
assert!(
verification.is_none(),
"no verification token should be created when update_without_verification=true"
);
}
#[tokio::test]
async fn test_delete_user_immediate() {
let plugin = UserManagementPlugin::new().delete_user_enabled(true);
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(session.token.typed().unwrap()),
Some(b"{}".to_vec()),
HashMap::new(),
);
let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let deleted_user = ctx
.database
.get_user_by_id(user.id.typed().unwrap())
.await
.unwrap();
assert!(deleted_user.is_none());
}
#[tokio::test]
async fn test_delete_user_immediate_clears_account_cookie_when_enabled() {
let plugin = UserManagementPlugin::new().delete_user_enabled(true);
let config = test_helpers::create_test_config().account(AccountConfig {
store_account_cookie: Some(true),
..Default::default()
});
let ctx = test_helpers::create_test_context_with_config(config).await;
let (_user, session) = test_helpers::create_user_and_session(
&ctx,
CreateUser::new()
.with_email("account-cookie@test.com")
.with_name("Account Cookie")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(session.token.typed().unwrap()),
Some(b"{}".to_vec()),
HashMap::new(),
);
let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
assert!(
response
.headers
.get_all("Set-Cookie")
.any(|cookie| cookie.starts_with(&account_cookie_name)),
"delete-user should clear the account_data cookie when store_account_cookie is enabled"
);
}
#[tokio::test]
async fn test_delete_user_with_verification() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.send_delete_account_verification(Arc::new(NoopDeleteSender));
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(session.token.typed().unwrap()),
Some(b"{}".to_vec()),
HashMap::new(),
);
let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let still_exists = ctx
.database
.get_user_by_id(user.id.typed().unwrap())
.await
.unwrap();
assert!(still_exists.is_some());
let token = "delete-token-123";
ctx.database
.create_verification(better_auth_core::CreateVerification {
identifier: (format!("delete-account-{}", token)).into(),
value: (user.id.clone()).into(),
expires_at: (chrono::Utc::now() + Duration::hours(24)).into(),
..Default::default()
})
.await
.unwrap();
let mut query = HashMap::new();
query.insert("token".to_string(), token.to_string());
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/delete-user/callback",
Some(session.token.typed().unwrap()),
None,
query,
);
let response = plugin
.handle_delete_user_callback(&req, &ctx)
.await
.unwrap();
assert_eq!(response.status, 200);
let deleted = ctx
.database
.get_user_by_id(user.id.typed().unwrap())
.await
.unwrap();
assert!(deleted.is_none());
}
#[tokio::test]
async fn test_delete_user_callback_clears_account_cookie_when_enabled() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.send_delete_account_verification(Arc::new(NoopDeleteSender));
let config = test_helpers::create_test_config().account(AccountConfig {
store_account_cookie: Some(true),
..Default::default()
});
let ctx = test_helpers::create_test_context_with_config(config).await;
let (user, session) = test_helpers::create_user_and_session(
&ctx,
CreateUser::new()
.with_email("callback-cookie@test.com")
.with_name("Callback Cookie")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let token = "delete-cookie-token";
ctx.database
.create_verification(better_auth_core::CreateVerification {
identifier: (format!("delete-account-{token}")).into(),
value: (user.id.clone()).into(),
expires_at: (chrono::Utc::now() + Duration::hours(24)).into(),
..Default::default()
})
.await
.unwrap();
let mut query = HashMap::new();
query.insert("token".to_string(), token.to_string());
query.insert(
"callbackURL".to_string(),
"https://example.com/goodbye".to_string(),
);
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/delete-user/callback",
Some(session.token.typed().unwrap()),
None,
query,
);
let response = plugin
.handle_delete_user_callback(&req, &ctx)
.await
.unwrap();
assert_eq!(response.status, 302);
let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
assert!(
response
.headers
.get_all("Set-Cookie")
.any(|cookie| cookie.starts_with(&account_cookie_name)),
"delete-user callback should clear the account_data cookie when store_account_cookie is enabled"
);
}
#[tokio::test]
async fn test_delete_user_unauthenticated() {
let plugin = UserManagementPlugin::new().delete_user_enabled(true);
let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
None,
Some(b"{}".to_vec()),
HashMap::new(),
);
let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 401);
}
#[tokio::test]
async fn test_delete_user_verify_invalid_token() {
let plugin = UserManagementPlugin::new().delete_user_enabled(true);
let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let mut query = HashMap::new();
query.insert("token".to_string(), "invalid-token".to_string());
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/delete-user/callback",
Some(_session.token.typed().unwrap()),
None,
query,
);
let err = plugin
.handle_delete_user_callback(&req, &ctx)
.await
.unwrap_err();
assert_eq!(err.status_code().unwrap(), 404);
}
#[tokio::test]
async fn test_delete_user_before_hook_abort() {
use std::sync::atomic::{AtomicBool, Ordering};
struct AbortHook;
#[async_trait]
impl BeforeDeleteUser for AbortHook {
async fn before_delete(
&self,
_user: &FieldValue,
_request: Option<&AuthRequest>,
) -> AuthResult<()> {
Err(AuthError::forbidden("Deletion blocked by policy"))
}
}
let called = Arc::new(AtomicBool::new(false));
let called_clone = called.clone();
struct AfterHook(Arc<AtomicBool>);
#[async_trait]
impl AfterDeleteUser for AfterHook {
async fn after_delete(
&self,
_user: &FieldValue,
_request: Option<&AuthRequest>,
) -> AuthResult<()> {
self.0.store(true, Ordering::SeqCst);
Ok(())
}
}
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.before_delete(Arc::new(AbortHook))
.after_delete(Arc::new(AfterHook(called_clone)));
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(session.token.typed().unwrap()),
Some(b"{}".to_vec()),
HashMap::new(),
);
let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 403);
let still_exists = ctx
.database
.get_user_by_id(user.id.typed().unwrap())
.await
.unwrap();
assert!(still_exists.is_some());
assert!(!called.load(Ordering::SeqCst));
}
#[tokio::test]
async fn test_plugin_routes_conditional() {
let plugin = UserManagementPlugin::new();
assert_eq!(
<UserManagementPlugin as AuthPlugin<TestSchema>>::routes(&plugin).len(),
1
);
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let routes = <UserManagementPlugin as AuthPlugin<TestSchema>>::routes(&plugin);
assert_eq!(routes.len(), 1);
assert!(routes.iter().any(|r| r.path == "/change-email"));
let plugin = UserManagementPlugin::new().delete_user_enabled(true);
let routes = <UserManagementPlugin as AuthPlugin<TestSchema>>::routes(&plugin);
assert_eq!(routes.len(), 3);
assert!(routes.iter().any(|r| r.path == "/delete-user"));
assert!(routes.iter().any(|r| r.path == "/delete-user/callback"));
let plugin = UserManagementPlugin::new()
.change_email_enabled(true)
.delete_user_enabled(true);
assert_eq!(
<UserManagementPlugin as AuthPlugin<TestSchema>>::routes(&plugin).len(),
3
);
}
#[tokio::test]
async fn test_on_request_disabled_change_email_rejects_after_authentication() {
let plugin = UserManagementPlugin::new(); let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "x@y.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(session.token.typed().unwrap()),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let error = plugin.on_request(&req, &ctx).await.unwrap_err();
let response = error.to_auth_response().expect("fixture response succeeds");
assert_eq!(
response.api_error_status().unwrap(),
better_auth_core::SchemaValue::Typed(400)
);
assert_eq!(
response.api_error_body().unwrap().json().unwrap().unwrap(),
serde_json::json!({"code":"CHANGE_EMAIL_DISABLED","message":"Change email is disabled"})
);
}