#![allow(
clippy::expect_used,
reason = "Fixture setup and response assertions require successful fallible construction."
)]
use super::token::create_email_verification_token;
use super::*;
use crate::plugins::test_helpers;
use async_trait::async_trait;
use better_auth_core::wire::UserView;
use better_auth_core::{AuthResult, CreateUser, UpdateUser};
use chrono::{Duration, Utc};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use better_auth_core::{AuthPlugin, HttpMethod};
#[test]
fn test_default_config() {
let config = EmailVerificationConfig::default();
assert_eq!(config.verification_token_expiry(), Duration::hours(1));
assert!(config.send_email_notifications);
assert!(!config.require_verification_for_signin);
assert!(!config.send_on_sign_in);
assert!(!config.auto_sign_in_after_verification);
assert!(config.send_verification_email.is_none());
assert!(config.before_email_verification.is_none());
assert!(config.after_email_verification.is_none());
}
#[test]
fn test_builder_verification_token_expiry() {
let plugin = EmailVerificationPlugin::new().verification_token_expiry(Duration::minutes(30));
assert_eq!(
plugin.config.verification_token_expiry(),
Duration::minutes(30)
);
}
#[test]
fn test_builder_send_on_sign_in() {
let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
assert!(plugin.config.send_on_sign_in);
}
#[test]
fn test_builder_auto_sign_in_after_verification() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
assert!(plugin.config.auto_sign_in_after_verification);
}
#[test]
fn test_builder_send_email_notifications() {
let plugin = EmailVerificationPlugin::new().send_email_notifications(false);
assert!(!plugin.config.send_email_notifications);
}
#[test]
fn test_builder_require_verification_for_signin() {
let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
assert!(plugin.config.require_verification_for_signin);
}
#[test]
fn test_builder_chaining() {
let plugin = EmailVerificationPlugin::new()
.verification_token_expiry(Duration::hours(2))
.send_on_sign_in(true)
.auto_sign_in_after_verification(true)
.send_email_notifications(false)
.require_verification_for_signin(true);
assert_eq!(
plugin.config.verification_token_expiry(),
Duration::hours(2)
);
assert!(plugin.config.send_on_sign_in);
assert!(plugin.config.auto_sign_in_after_verification);
assert!(!plugin.config.send_email_notifications);
assert!(plugin.config.require_verification_for_signin);
}
struct DummySender;
#[async_trait]
impl SendVerificationEmail for DummySender {
async fn send(
&self,
_user: &better_auth_core::FieldValue,
_url: &str,
_token: &str,
) -> AuthResult<()> {
Ok(())
}
}
#[test]
fn test_builder_custom_send_verification_email() {
let plugin =
EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(DummySender));
assert!(plugin.config.send_verification_email.is_some());
}
#[test]
fn test_builder_before_email_verification_hook() {
let hook: EmailVerificationHook =
Arc::new(|_user: &better_auth_core::FieldValue| Box::pin(async { Ok(()) }));
let plugin = EmailVerificationPlugin::new().before_email_verification(hook);
assert!(plugin.config.before_email_verification.is_some());
}
#[test]
fn test_builder_after_email_verification_hook() {
let hook: EmailVerificationHook =
Arc::new(|_user: &better_auth_core::FieldValue| Box::pin(async { Ok(()) }));
let plugin = EmailVerificationPlugin::new().after_email_verification(hook);
assert!(plugin.config.after_email_verification.is_some());
}
fn make_test_user(email: &str, verified: bool) -> UserView {
UserView {
additional_fields: Default::default(),
visible_fields: None,
field_order: Default::default(),
id: "test-id".into(),
name: Some("Test".into()).into(),
email: Some(email.into()).into(),
email_verified: verified.into(),
image: Default::default(),
created_at: Utc::now().into(),
updated_at: Utc::now().into(),
is_anonymous: None.into(),
phone_number: None.into(),
phone_number_verified: None.into(),
username: None.into(),
display_username: None.into(),
two_factor_enabled: Some(false).into(),
role: None.into(),
banned: false.into(),
ban_reason: None.into(),
ban_expires: None.into(),
metadata: better_auth_core::FieldValue::Null,
}
}
fn jwt_token(
ctx: &better_auth_core::AuthContext<impl better_auth_core::AuthSchema>,
email: &str,
update_to: Option<&str>,
request_type: Option<&str>,
) -> String {
create_email_verification_token(
&ctx.config.secret,
email,
update_to,
Duration::hours(1),
request_type,
)
.unwrap()
}
#[test]
fn test_should_send_on_sign_in() {
let plugin = EmailVerificationPlugin::new();
assert!(!plugin.should_send_on_sign_in());
let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
assert!(plugin.should_send_on_sign_in());
}
#[test]
fn test_is_verification_required() {
let plugin = EmailVerificationPlugin::new();
assert!(!plugin.is_verification_required());
let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
assert!(plugin.is_verification_required());
}
#[tokio::test]
async fn test_is_user_verified_or_not_required() {
let plugin = EmailVerificationPlugin::new();
let user = make_test_user("a@b.com", false);
assert!(plugin.is_user_verified_or_not_required(&user));
let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
assert!(!plugin.is_user_verified_or_not_required(&user));
let verified_user = make_test_user("a@b.com", true);
assert!(plugin.is_user_verified_or_not_required(&verified_user));
}
#[test]
fn test_to_user_preserves_fields() {
let user = UserView {
additional_fields: Default::default(),
visible_fields: None,
field_order: Default::default(),
id: "test-id".into(),
name: Some("Test User".into()).into(),
email: Some("test@example.com".into()).into(),
email_verified: true.into(),
image: Some("https://img.example.com/a.png".into()).into(),
created_at: Utc::now().into(),
updated_at: Utc::now().into(),
is_anonymous: None.into(),
phone_number: None.into(),
phone_number_verified: None.into(),
username: Some("testuser".into()).into(),
display_username: Some("TestUser".into()).into(),
two_factor_enabled: Some(true).into(),
role: Some("admin".into()).into(),
banned: true.into(),
ban_reason: Some("spam".into()).into(),
ban_expires: None.into(),
metadata: better_auth_core::FieldValue::Null,
};
let converted = UserView::from(&user);
assert_eq!(converted.id, "test-id");
assert_eq!(
converted.name.typed().unwrap().as_deref(),
Some("Test User")
);
assert_eq!(
converted.email.typed().unwrap().as_deref(),
Some("test@example.com")
);
assert!(converted.email_verified.is_truthy().unwrap());
assert_eq!(
converted.image.typed().unwrap().as_deref(),
Some("https://img.example.com/a.png")
);
assert_eq!(
converted.username.typed().unwrap().as_deref(),
Some("testuser")
);
assert_eq!(
converted.display_username.typed().unwrap().as_deref(),
Some("TestUser")
);
assert_eq!(converted.two_factor_enabled, Some(true));
assert_eq!(converted.role.typed().unwrap().as_deref(), Some("admin"));
assert!(converted.banned.is_truthy().unwrap());
assert_eq!(
converted.ban_reason.typed().unwrap().as_deref(),
Some("spam")
);
}
#[test]
fn test_plugin_name() {
let plugin = EmailVerificationPlugin::new();
assert_eq!(
AuthPlugin::<
better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
>::name(&plugin,),
"email-verification"
);
}
#[test]
fn test_plugin_routes() {
let plugin = EmailVerificationPlugin::new();
let routes = AuthPlugin::<
better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
>::routes(&plugin);
assert_eq!(routes.len(), 2);
assert!(
routes
.iter()
.any(|r| r.path == "/send-verification-email" && r.method == HttpMethod::Post)
);
assert!(
routes
.iter()
.any(|r| r.path == "/verify-email" && r.method == HttpMethod::Get)
);
}
#[tokio::test]
async fn test_on_request_unknown_route_returns_none() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/unknown", None, None, HashMap::new());
let result = plugin.on_request(&req, &ctx).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_send_verification_on_sign_in_disabled() {
let plugin = EmailVerificationPlugin::new().send_on_sign_in(false);
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("unverified@test.com")
.with_name("Test"),
)
.await
.unwrap();
plugin
.send_verification_on_sign_in(&user, None, &ctx)
.await
.unwrap();
}
#[tokio::test]
async fn test_send_verification_on_sign_in_verified_user() {
let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("verified@test.com")
.with_name("Test"),
)
.await
.unwrap();
let update = UpdateUser {
email_verified: Some(true),
..Default::default()
};
let verified = ctx
.database
.update_user(user.id.typed().unwrap(), update)
.await
.unwrap();
plugin
.send_verification_on_sign_in(&verified, None, &ctx)
.await
.unwrap();
}
#[tokio::test]
async fn test_send_verification_on_sign_in_creates_token() {
let call_count = Arc::new(AtomicU32::new(0));
let counter = call_count.clone();
struct CountingSender(Arc<AtomicU32>);
#[async_trait]
impl SendVerificationEmail for CountingSender {
async fn send(
&self,
_user: &better_auth_core::FieldValue,
_url: &str,
_token: &str,
) -> AuthResult<()> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
let plugin = EmailVerificationPlugin::new()
.send_on_sign_in(true)
.send_email_notifications(false) .custom_send_verification_email(Arc::new(CountingSender(counter)));
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("unverified@test.com")
.with_name("Test"),
)
.await
.unwrap();
plugin
.send_verification_on_sign_in(&user, None, &ctx)
.await
.unwrap();
assert_eq!(call_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_signup_then_explicit_verification_uses_custom_sender_once() {
struct CapturingSender(Arc<std::sync::Mutex<Vec<String>>>);
#[async_trait]
impl SendVerificationEmail for CapturingSender {
async fn send(
&self,
_user: &better_auth_core::FieldValue,
_url: &str,
token: &str,
) -> AuthResult<()> {
self.0.lock().unwrap().push(token.to_owned());
Ok(())
}
}
let tokens = Arc::new(std::sync::Mutex::new(Vec::new()));
let plugin = Arc::new(
EmailVerificationPlugin::new()
.send_email_notifications(false)
.custom_send_verification_email(Arc::new(CapturingSender(tokens.clone()))),
);
let signup = crate::plugins::EmailPasswordPlugin::new()
.enable_signup(true)
.with_email_verification(plugin.clone());
let ctx = test_helpers::create_test_context().await;
let request = test_helpers::create_auth_json_request_no_query(
HttpMethod::Post,
"/sign-up/email",
None,
Some(serde_json::json!({
"email": "newuser@test.com", "password": "Password123!", "name": "New"
})),
);
let response = signup.on_request(&request, &ctx).await.unwrap().unwrap();
assert_eq!(response.status, 200);
let request = test_helpers::create_auth_json_request_no_query(
HttpMethod::Post,
"/send-verification-email",
None,
Some(serde_json::json!({ "email": "newuser@test.com" })),
);
let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
assert_eq!(response.status, 200);
let token = {
let sent = tokens.lock().unwrap();
assert_eq!(sent.len(), 1);
sent[0].clone()
};
let request = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
None,
None,
HashMap::from([("token".into(), token)]),
);
let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
assert_eq!(response.status, 200);
let user = ctx
.database
.get_user_by_email("newuser@test.com")
.await
.unwrap()
.unwrap();
assert!(user.email_verified.is_truthy().unwrap());
let request = test_helpers::create_auth_json_request_no_query(
HttpMethod::Post,
"/send-verification-email",
None,
Some(serde_json::json!({ "email": "newuser@test.com" })),
);
let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
assert_eq!(response.status, 200);
assert_eq!(tokens.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn test_verify_email_basic_flow() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("verify@test.com")
.with_name("Verify Me"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "verify@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value.clone());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(body["status"], true);
assert!(body["user"].is_null());
let updated = ctx
.database
.get_user_by_email("verify@test.com")
.await
.unwrap()
.unwrap();
assert!(updated.email_verified.is_truthy().unwrap());
}
#[tokio::test]
async fn test_verify_email_calls_before_and_after_hooks() {
let before_count = Arc::new(AtomicU32::new(0));
let after_count = Arc::new(AtomicU32::new(0));
let bc = before_count.clone();
let ac = after_count.clone();
let before_hook: EmailVerificationHook =
Arc::new(move |_user: &better_auth_core::FieldValue| {
let c = bc.clone();
Box::pin(async move {
c.fetch_add(1, Ordering::Relaxed);
Ok(())
})
});
let after_hook: EmailVerificationHook =
Arc::new(move |_user: &better_auth_core::FieldValue| {
let c = ac.clone();
Box::pin(async move {
c.fetch_add(1, Ordering::Relaxed);
Ok(())
})
});
let plugin = EmailVerificationPlugin::new()
.before_email_verification(before_hook)
.after_email_verification(after_hook);
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("hooks@test.com")
.with_name("Hooks"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "hooks@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
assert_eq!(before_count.load(Ordering::Relaxed), 1);
assert_eq!(after_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_change_email_verification_after_hook_observes_updated_user() {
let captured = Arc::new(std::sync::Mutex::new(Vec::<(Option<String>, bool)>::new()));
let hook_state = captured.clone();
let after_hook: EmailVerificationHook = Arc::new(move |user: &better_auth_core::FieldValue| {
let hook_state = hook_state.clone();
let user =
UserView::try_from(user.as_object().unwrap().snapshot_fields().unwrap()).unwrap();
let email = user.email.clone();
let verified = user.email_verified.clone();
Box::pin(async move {
hook_state
.lock()
.unwrap()
.push((email.typed()?.clone(), *verified.typed()?));
Ok(())
})
});
let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("change-email@test.com")
.with_name("ChangeEmail"),
)
.await
.unwrap();
let token_value = jwt_token(
&ctx,
"change-email@test.com",
Some("updated-email@test.com"),
Some("change-email-verification"),
);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
assert_eq!(
*captured.lock().unwrap(),
vec![(Some("updated-email@test.com".to_string()), true)]
);
let updated_user = ctx
.database
.get_user_by_email("updated-email@test.com")
.await
.unwrap()
.unwrap();
assert!(updated_user.email_verified.is_truthy().unwrap());
}
#[tokio::test]
async fn test_change_email_verification_does_not_fire_after_hook_when_update_fails() {
let after_count = Arc::new(AtomicU32::new(0));
let counter = after_count.clone();
let after_hook: EmailVerificationHook =
Arc::new(move |_user: &better_auth_core::FieldValue| {
let counter = counter.clone();
Box::pin(async move {
counter.fetch_add(1, Ordering::Relaxed);
Ok(())
})
});
let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
let ctx = test_helpers::create_test_context().await;
let _source_user = ctx
.database
.create_user(
CreateUser::new()
.with_email("duplicate-source@test.com")
.with_name("DuplicateSource"),
)
.await
.unwrap();
let _existing_user = ctx
.database
.create_user(
CreateUser::new()
.with_email("duplicate-target@test.com")
.with_name("DuplicateTarget"),
)
.await
.unwrap();
let token_value = jwt_token(
&ctx,
"duplicate-source@test.com",
Some("duplicate-target@test.com"),
Some("change-email-verification"),
);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_ne!(err.status_code().unwrap(), 200);
assert_eq!(after_count.load(Ordering::Relaxed), 0);
let source_user = ctx
.database
.get_user_by_email("duplicate-source@test.com")
.await
.unwrap()
.unwrap();
assert_eq!(
source_user.email.typed().unwrap().as_deref(),
Some("duplicate-source@test.com")
);
assert!(!source_user.email_verified.is_truthy().unwrap());
}
#[tokio::test]
async fn test_verify_email_before_hook_error_aborts() {
let before_hook: EmailVerificationHook = Arc::new(|_user: &better_auth_core::FieldValue| {
Box::pin(async { Err(AuthError::forbidden("hook rejected")) })
});
let plugin = EmailVerificationPlugin::new().before_email_verification(before_hook);
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("hook-err@test.com")
.with_name("HookErr"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "hook-err@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value.clone());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 403);
let u = ctx
.database
.get_user_by_email("hook-err@test.com")
.await
.unwrap()
.unwrap();
assert!(!u.email_verified.is_truthy().unwrap());
}
#[tokio::test]
async fn test_verify_email_auto_sign_in_creates_session() {
let captured = Arc::new(std::sync::Mutex::new(String::new()));
struct CapturingSender(Arc<std::sync::Mutex<String>>);
#[async_trait]
impl SendVerificationEmail for CapturingSender {
async fn send(
&self,
_user: &better_auth_core::FieldValue,
_url: &str,
token: &str,
) -> AuthResult<()> {
*self.0.lock().unwrap() = token.to_string();
Ok(())
}
}
let plugin = EmailVerificationPlugin::new()
.auto_sign_in_after_verification(true)
.custom_send_verification_email(Arc::new(CapturingSender(captured.clone())));
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("autosign@test.com")
.with_name("AutoSign"),
)
.await
.unwrap();
let body = serde_json::json!({ "email": "autosign@test.com" });
let mut headers = HashMap::new();
headers.insert("content-type".to_string(), "application/json".to_string());
let send_req = AuthRequest::from_parts(
HttpMethod::Post,
"/send-verification-email".to_string(),
headers,
Some(body.to_string().into_bytes()),
None,
);
let send_response = plugin
.handle_send_verification_email(&send_req, &ctx)
.await
.unwrap();
assert_eq!(send_response.status, 200);
let mut query = HashMap::new();
query.insert("token".to_string(), captured.lock().unwrap().clone());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
let response = test_helpers::finalize_response(&ctx, &req, response);
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(body["status"], true);
assert!(body["user"].is_null());
assert!(response.headers.contains_key("Set-Cookie"));
let cookie_header = &response.headers["Set-Cookie"];
assert!(cookie_header.contains("better-auth.session"));
}
#[tokio::test]
async fn test_verify_email_no_auto_sign_in_no_session() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("noautosign@test.com")
.with_name("NoAutoSign"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "noautosign@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
let response = test_helpers::finalize_response(&ctx, &req, response);
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(body["status"], true);
assert!(body.get("session").is_none());
assert!(!response.headers.contains_key("Set-Cookie"));
}
#[tokio::test]
async fn test_verify_email_auto_sign_in_redirect_includes_cookie() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect@test.com")
.with_name("Redirect"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
query.insert("callbackURL".to_string(), "/verified".to_string());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
let response = test_helpers::finalize_response(&ctx, &req, response);
assert_eq!(response.status, 302);
assert_eq!(response.headers["Location"], "/verified");
assert!(response.headers.contains_key("Set-Cookie"));
assert!(response.headers["Set-Cookie"].contains("better-auth.session"));
}
#[tokio::test]
async fn test_verify_email_redirect_without_auto_sign_in_no_cookie() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("redir-nocookie@test.com")
.with_name("Redir"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redir-nocookie@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
query.insert("callbackURL".to_string(), "/verified".to_string());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
let response = test_helpers::finalize_response(&ctx, &req, response);
assert_eq!(response.status, 302);
assert!(!response.headers.contains_key("Set-Cookie"));
}
#[tokio::test]
async fn test_verify_email_invalid_token() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let mut query = HashMap::new();
query.insert("token".to_string(), "bogus-token".to_string());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(
err.error_payload().unwrap(),
(
better_auth_core::SchemaValue::Typed(401),
Some("INVALID_TOKEN".into()),
"Invalid token".into()
)
);
}
#[tokio::test]
async fn test_verify_email_errors_preserve_status_codes_redirect_queries_and_fragments() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let _ = test_helpers::create_user(
&ctx,
CreateUser::new()
.with_email("owner@verify-errors.test")
.with_name("Owner"),
)
.await;
let (_, other_session) = test_helpers::create_user_and_session(
&ctx,
CreateUser::new()
.with_email("other@verify-errors.test")
.with_name("Other"),
Duration::hours(1),
)
.await;
let expired = create_email_verification_token(
ctx.config.signing_secret(),
"owner@verify-errors.test",
None,
Duration::zero(),
None,
)
.unwrap();
let missing_user = jwt_token(&ctx, "missing@verify-errors.test", None, None);
let changed_email = jwt_token(
&ctx,
"owner@verify-errors.test",
Some("updated@verify-errors.test"),
Some("change-email-verification"),
);
for (token, session, code, message) in [
(expired.as_str(), None, "TOKEN_EXPIRED", "Token expired"),
("bogus-token", None, "INVALID_TOKEN", "Invalid token"),
("!.!.!", None, "INVALID_TOKEN", "Invalid token"),
(
missing_user.as_str(),
None,
"USER_NOT_FOUND",
"User not found",
),
(
changed_email.as_str(),
Some(other_session.token.typed().unwrap().as_str()),
"INVALID_USER",
"Invalid user",
),
] {
for callback in [None, Some(""), Some("/verified?source=mail#done")] {
let mut query = HashMap::from([("token".to_owned(), token.to_owned())]);
if let Some(callback) = callback {
query.insert("callbackURL".to_owned(), callback.to_owned());
}
let request = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
session,
None,
query,
);
let response = match plugin.handle_verify_email(&request, &ctx).await {
Ok(response) => response,
Err(error) => error.to_auth_response().expect("fixture response succeeds"),
}
.into_http_response()
.unwrap();
assert!(response.headers.get_all("set-cookie").next().is_none());
if callback == Some("/verified?source=mail#done") {
assert_eq!(response.status, 302);
assert_eq!(
response.headers.get("location"),
Some(&format!("/verified?source=mail&error={code}#done"))
);
} else {
assert_eq!(response.status, 401);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&response.body.bytes().unwrap())
.unwrap(),
serde_json::json!({"code": code, "message": message})
);
}
}
}
}
#[tokio::test]
async fn test_verify_email_missing_token() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
None,
None,
HashMap::new(),
);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 400);
}
#[tokio::test]
async fn test_verify_email_already_verified_returns_ok() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("already@test.com")
.with_name("Already"),
)
.await
.unwrap();
ctx.database
.update_user(
user.id.typed().unwrap(),
UpdateUser {
email_verified: Some(true),
..Default::default()
},
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "already@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(body["status"], true);
}
#[tokio::test]
async fn test_send_verification_email_already_verified_returns_error() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("verified@test.com")
.with_name("Verified"),
)
.await
.unwrap();
ctx.database
.update_user(
user.id.typed().unwrap(),
UpdateUser {
email_verified: Some(true),
..Default::default()
},
)
.await
.unwrap();
let body = serde_json::json!({ "email": "verified@test.com" });
let mut headers = HashMap::new();
headers.insert("content-type".to_string(), "application/json".to_string());
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/send-verification-email".to_string(),
headers,
Some(body.to_string().into_bytes()),
None,
);
let err = plugin
.handle_send_verification_email(&req, &ctx)
.await
.unwrap_err();
assert_eq!(err.status_code().unwrap(), 400);
}
#[tokio::test]
async fn test_send_verification_email_user_not_found() {
struct NoopSender;
#[async_trait]
impl SendVerificationEmail for NoopSender {
async fn send(
&self,
_user: &better_auth_core::FieldValue,
_url: &str,
_token: &str,
) -> AuthResult<()> {
Ok(())
}
}
let plugin =
EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(NoopSender));
let ctx = test_helpers::create_test_context().await;
let body = serde_json::json!({ "email": "nobody@test.com" });
let mut headers = HashMap::new();
headers.insert("content-type".to_string(), "application/json".to_string());
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/send-verification-email".to_string(),
headers,
Some(body.to_string().into_bytes()),
None,
);
let response = plugin
.handle_send_verification_email(&req, &ctx)
.await
.unwrap();
assert_eq!(response.status, 200);
}
#[test]
fn test_create_session_cookie_format() {
use better_auth_core::utils::cookie_utils::create_session_cookie;
let ctx = test_helpers::create_test_context_blocking();
let cookie_str = create_session_cookie("my-token-123", &ctx.config).unwrap();
assert!(cookie_str.contains("better-auth.session_token=my-token-123"));
assert!(cookie_str.contains("Path=/"));
assert!(cookie_str.contains("HttpOnly"));
assert!(cookie_str.contains("SameSite=Lax"));
}
#[test]
fn test_create_session_cookie_special_characters_in_token() {
use better_auth_core::utils::cookie_utils::create_session_cookie;
let ctx = test_helpers::create_test_context_blocking();
let token = "token+with/special=chars&more";
let cookie_str = create_session_cookie(token, &ctx.config).unwrap();
assert!(cookie_str.contains("better-auth.session_token="));
}
#[tokio::test]
async fn test_verify_email_rejects_untrusted_callback_url() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-guard@test.com")
.with_name("Redirect Guard"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-guard@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
query.insert(
"callbackURL".to_string(),
"https://evil.com/phish".to_string(),
);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let error = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
test_helpers::assert_coded_api_error(
&error,
"FORBIDDEN",
403,
"INVALID_CALLBACK_URL",
"Invalid callbackURL",
);
let response = test_helpers::finalize_response(
&ctx,
&req,
error.to_auth_response().expect("fixture response succeeds"),
);
assert_eq!(response.status, 403);
assert_eq!(
response.body.bytes().unwrap().as_ref(),
br#"{"message":"Invalid callbackURL","code":"INVALID_CALLBACK_URL"}"#,
);
assert!(response.headers.get("set-cookie").is_none());
assert!(req.new_session().unwrap().is_none());
let stored = ctx
.database
.get_user_by_email("redirect-guard@test.com")
.await
.unwrap()
.unwrap();
assert!(!stored.email_verified.is_truthy().unwrap());
assert!(
ctx.database
.get_user_sessions(stored.id.typed().unwrap())
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn test_verify_email_allows_relative_callback_url() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-rel@test.com")
.with_name("Redirect Rel"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-rel@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
query.insert("callbackURL".to_string(), "/dashboard".to_string());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert_eq!(response.headers["Location"], "/dashboard");
}
#[tokio::test]
async fn test_verify_email_allows_trusted_origin_callback_url() {
let plugin = EmailVerificationPlugin::new();
let config = test_helpers::create_test_config().trusted_origin("https://trusted.com");
let ctx = test_helpers::create_test_context_with_config(config).await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-trusted@test.com")
.with_name("Redirect Trusted"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-trusted@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
query.insert(
"callbackURL".to_string(),
"https://trusted.com/verified".to_string(),
);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert_eq!(response.headers["Location"], "https://trusted.com/verified");
}
#[tokio::test]
async fn test_verify_email_skips_origin_check_when_disabled() {
let plugin = EmailVerificationPlugin::new();
let config = test_helpers::create_test_config().disable_origin_check(true);
let ctx = test_helpers::create_test_context_with_config(config).await;
let _user = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-disabled@test.com")
.with_name("Redirect Disabled"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-disabled@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_string(), token_value);
query.insert(
"callbackURL".to_string(),
"https://evil.com/anywhere".to_string(),
);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
}