Skip to main content

alibi_plugins/email_verification/
mod.rs

1pub(super) mod handlers;
2
3pub(crate) mod token;
4
5pub(super) mod types;
6
7use super::StatusResponse;
8use alibi_core::AuthUser;
9use alibi_core::wire::UserView;
10use alibi_core::{AuthContext, AuthError, AuthResult};
11use alibi_core::{AuthRequest, AuthResponse};
12use async_trait::async_trait;
13use chrono::Duration;
14use handlers::{send_verification_email_core, verification_url, verify_email_core};
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use types::VerifyEmailResult;
19pub use types::{SendVerificationEmailRequest, VerifyEmailQuery, VerifyEmailResponse};
20
21/// Trait for custom email sending logic.
22///
23/// When set on [`EmailVerificationConfig::send_verification_email`], this
24/// callback overrides the default `EmailProvider`-based sending.
25#[async_trait]
26pub trait SendVerificationEmail: Send + Sync {
27    async fn send(&self, user: &UserView, url: &str, token: &str) -> AuthResult<()>;
28}
29
30/// Shorthand for the async hook closure type used by
31/// [`EmailVerificationConfig::before_email_verification`] and
32/// [`EmailVerificationConfig::after_email_verification`].
33pub type EmailVerificationHook =
34    Arc<dyn Fn(&UserView) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
35
36/// Email verification plugin for handling email verification flows
37pub struct EmailVerificationPlugin {
38    config: EmailVerificationConfig,
39}
40
41impl std::fmt::Debug for EmailVerificationPlugin {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("EmailVerificationPlugin")
44            .finish_non_exhaustive()
45    }
46}
47
48#[derive(Clone, alibi_core::PluginConfig)]
49#[plugin(name = "EmailVerificationPlugin")]
50#[expect(
51    clippy::struct_excessive_bools,
52    reason = "Independent configuration switches model distinct upstream behavior, rather than mutually exclusive states"
53)]
54pub struct EmailVerificationConfig {
55    /// How long a verification token stays valid. Default: one hour.
56    #[config(default = Duration::hours(1))]
57    pub verification_token_expiry: Duration,
58    /// Whether to send email notifications (on sign-up). Default: true.
59    #[config(default = true)]
60    pub send_email_notifications: bool,
61    /// Send the verification challenge during password sign-up. When unset,
62    /// the email/password requirement decides, as in the upstream defaults.
63    #[config(default = None)]
64    pub send_on_sign_up: Option<bool>,
65    /// Whether email verification is required before sign-in. Default: false.
66    #[config(default = false)]
67    pub require_verification_for_signin: bool,
68    /// Whether to auto-verify newly created users. Default: false.
69    #[config(default = false)]
70    pub auto_verify_new_users: bool,
71    /// When true, automatically send a verification email on sign-in if the
72    /// user is unverified. Default: false.
73    #[config(default = false)]
74    pub send_on_sign_in: bool,
75    /// When true, create a session after email verification and return the
76    /// session token in the verify-email response. Default: false.
77    #[config(default = false)]
78    pub auto_sign_in_after_verification: bool,
79    /// Optional custom email sender. When set this overrides the default
80    /// `EmailProvider`-based sending.
81    #[config(default = None, skip)]
82    pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
83    /// Hook invoked **before** email verification (before updating the user).
84    #[config(default = None)]
85    pub before_email_verification: Option<EmailVerificationHook>,
86    /// Hook invoked **after** email verification (after the user has been updated).
87    #[config(default = None)]
88    pub after_email_verification: Option<EmailVerificationHook>,
89}
90
91impl std::fmt::Debug for EmailVerificationConfig {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("EmailVerificationConfig")
94            .finish_non_exhaustive()
95    }
96}
97
98impl EmailVerificationPlugin {
99    #[must_use]
100    pub fn custom_send_verification_email(
101        mut self,
102        sender: Arc<dyn SendVerificationEmail>,
103    ) -> Self {
104        self.config.send_verification_email = Some(sender);
105        self
106    }
107}
108
109alibi_core::impl_auth_plugin! {
110    EmailVerificationPlugin, "email-verification";
111    routes {
112        post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
113        get "/verify-email" => handle_verify_email, "verify_email";
114    }
115    extra {
116    route_openapi_metadata!(S);
117
118        fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
119            vec![
120                crate::endpoint::definition(
121                    "sendVerificationEmail", "sendVerificationEmail", Some("/send-verification-email"), alibi_core::HttpMethod::Post,
122                ),
123                crate::endpoint::definition(
124                    "verifyEmail", "verifyEmail", Some("/verify-email"), alibi_core::HttpMethod::Get,
125                ),
126            ]
127        }
128
129        fn validate_endpoint(
130            &self,
131            call: &alibi_core::endpoint::EndpointCall,
132            _: &AuthContext<S>,
133        ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
134            use crate::authentication_helpers::RequestBody;
135            if call.operation_id() == "sendVerificationEmail" {
136                return Ok(alibi_core::endpoint::EndpointInput {
137                    body: Some(crate::endpoint::validate_fields(
138                        call.body(), "body", SendVerificationEmailRequest::FIELDS,
139                    )?),
140                    query: call.query().cloned(),
141                });
142            }
143            Ok(alibi_core::endpoint::EndpointInput {
144                body: call.body().cloned(),
145                query: Some(crate::endpoint::validate_fields(
146                    call.query(), "query", VerifyEmailQuery::FIELDS,
147                )?),
148            })
149        }
150
151        async fn on_endpoint(
152            &self,
153            call: &alibi_core::endpoint::EndpointCall,
154            ctx: &AuthContext<S>,
155        ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
156            if call.operation_id() == "sendVerificationEmail" {
157                let body: SendVerificationEmailRequest = call.body_as()?;
158                let current = alibi_core::session::cookie_cache::runtime::authenticated(ctx, call, false)
159                    .await.ok().flatten();
160                if let Some(read) = &current {
161                    call.record_authenticated_session(ctx.user_view(&read.user), read.session.clone());
162                }
163                return alibi_core::endpoint::EndpointResponse::json(
164                    &send_verification_email_core(&body, current.as_ref().map(|read| &read.user), &self.config, ctx).await?,
165                );
166            }
167            let mut query: VerifyEmailQuery = call.query_as()?;
168            query.callback_url = query.callback_url.filter(|url| !url.is_empty());
169            if call.request().is_some()
170                && let Some(url) = &query.callback_url
171                && !ctx.config.current_origin_check_disabled()
172                && !ctx.config.is_redirect_target_trusted(url)
173            {
174                return Err(AuthError::forbidden("Invalid callbackURL"));
175            }
176            let headers = call.headers().cloned().unwrap_or_default();
177            let meta = alibi_core::RequestMeta {
178                ip_address: Some(ctx.config.advanced.ip_address.resolve_ip(&headers).unwrap_or_default()),
179                user_agent: Some(headers.get("user-agent").cloned().unwrap_or_default()),
180            };
181            let (mut response, token) = match verify_email_core(&query, &self.config, call, ctx, meta).await? {
182                VerifyEmailResult::Redirect { url, session_token } =>
183                    (alibi_core::endpoint::EndpointResponse::redirect(url), session_token),
184                VerifyEmailResult::Json { body, session_token } =>
185                    (alibi_core::endpoint::EndpointResponse::json(&body)?, session_token),
186            };
187            if let Some(token) = token {
188                response.merge_headers(crate::authentication_helpers::session_cookie_headers(
189                    &ctx.config, &token, ctx.session_manager().has_dont_remember_cookie(call),
190                )?);
191            }
192            Ok(response)
193        }
194
195        async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
196            ctx.extensions.insert(self.config.clone());
197            Ok(())
198        }
199
200        async fn on_user_created(&self, user: &S::User, ctx: &AuthContext<S>) -> AuthResult<()> {
201            // Send verification email for new users if configured.
202            // Also fire when a custom sender is set, even if send_email_notifications is false.
203            if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
204                && !user.email_verified()
205                && let Some(email) = user.email()
206            {
207                self.send_verification_email_for_user(user, email, None, ctx)
208                    .await?;
209            }
210            Ok(())
211        }
212    }
213}
214
215impl EmailVerificationPlugin {
216    /// Deliver an email verification challenge through the public server API.
217    pub fn send_verification_email_endpoint(
218        body: &SendVerificationEmailRequest,
219    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
220        alibi_core::endpoint::ServerEndpoint::new("email-verification", "sendVerificationEmail")
221            .with_body(body)
222    }
223
224    /// Verify an email challenge through the public server API.
225    pub fn verify_email_endpoint(
226        query: &VerifyEmailQuery,
227    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<VerifyEmailResponse>> {
228        alibi_core::endpoint::ServerEndpoint::new("email-verification", "verifyEmail")
229            .with_query(query)
230    }
231
232    async fn handle_send_verification_email(
233        &self,
234        req: &AuthRequest,
235        ctx: &AuthContext<impl alibi_core::AuthSchema>,
236    ) -> AuthResult<AuthResponse> {
237        let body: SendVerificationEmailRequest =
238            match super::authentication_helpers::parse_body(req) {
239                Ok(v) => v,
240                Err(resp) => return Ok(resp),
241            };
242        let current_user =
243            alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, false)
244                .await
245                .ok()
246                .flatten()
247                .map(|read| read.user);
248        let response =
249            send_verification_email_core(&body, current_user.as_ref(), &self.config, ctx).await?;
250        Ok(AuthResponse::json(200, &response)?)
251    }
252
253    async fn handle_verify_email(
254        &self,
255        req: &AuthRequest,
256        ctx: &AuthContext<impl alibi_core::AuthSchema>,
257    ) -> AuthResult<AuthResponse> {
258        let token = req.query.get("token").ok_or(AuthError::Upstream {
259            status: 400,
260            code: "VALIDATION_ERROR",
261            message: "[query.token] Invalid input: expected string, received undefined",
262        })?;
263        let callback_url = req
264            .query
265            .get("callbackURL")
266            .filter(|url| !url.is_empty())
267            .cloned();
268
269        // Validate callbackURL against trusted origins, matching the TS
270        // `originCheck` middleware applied to the verify-email endpoint.
271        if let Some(ref url) = callback_url
272            && !ctx.config.current_origin_check_disabled()
273            && !ctx.config.is_redirect_target_trusted(url)
274        {
275            return Ok(AuthError::forbidden("Invalid callbackURL").to_auth_response());
276        }
277
278        let query = VerifyEmailQuery {
279            token: token.clone(),
280            callback_url,
281        };
282
283        match verify_email_core(
284            &query,
285            &self.config,
286            req,
287            ctx,
288            alibi_core::RequestMeta::from_request(req),
289        )
290        .await?
291        {
292            VerifyEmailResult::Redirect { url, session_token } => {
293                let mut headers = alibi_core::Headers::new();
294                _ = headers.insert("Location".to_owned(), url);
295                _ = headers.insert("content-type".to_owned(), "application/json".to_owned());
296                let mut response = AuthResponse {
297                    status: 302,
298                    headers,
299                    body: Vec::new(),
300                };
301                if let Some(token) = session_token {
302                    super::user_management::append_session_cookie(
303                        &mut response,
304                        req,
305                        &token,
306                        &ctx.config,
307                    )?;
308                }
309                Ok(response)
310            }
311            VerifyEmailResult::Json {
312                body,
313                session_token,
314            } => {
315                let mut response = AuthResponse::json(200, &body)?;
316                if let Some(token) = session_token {
317                    super::user_management::append_session_cookie(
318                        &mut response,
319                        req,
320                        &token,
321                        &ctx.config,
322                    )?;
323                }
324                Ok(response)
325            }
326        }
327    }
328
329    /// Send a verification email for a specific user.
330    ///
331    /// If [`EmailVerificationConfig::send_verification_email`] is set the
332    /// custom callback is used; otherwise the default `EmailProvider` path is
333    /// taken.
334    pub(crate) async fn send_verification_email_for_user(
335        &self,
336        user: &impl AuthUser,
337        email: &str,
338        callback_url: Option<&str>,
339        ctx: &AuthContext<impl alibi_core::AuthSchema>,
340    ) -> AuthResult<()> {
341        if self.config.send_verification_email.is_none()
342            && let Some(sender) = ctx.email_verification_override()
343        {
344            return sender.0.send(&ctx.trusted_user_view(user), None, ctx).await;
345        }
346        let verification_token = token::create_email_verification_token(
347            ctx.config.current_secret(),
348            email,
349            None,
350            self.config.verification_token_expiry,
351            None,
352        )?;
353        let verification_url = verification_url(&ctx.config, &verification_token, callback_url);
354
355        if let Some(ref custom_sender) = self.config.send_verification_email {
356            let user = ctx.trusted_user_view(user);
357            send_custom_verification_email(
358                ctx,
359                custom_sender,
360                user,
361                verification_url,
362                verification_token,
363                ctx.config.awaited_notification_errors,
364            )
365            .await?;
366        } else if self.config.send_email_notifications {
367            if let Some(provider) = ctx.email_provider.clone() {
368                let subject = "Verify your email address";
369                let html = format!(
370                    "<p>Click the link below to verify your email address:</p>\
371                     <p><a href=\"{verification_url}\">Verify Email</a></p>"
372                );
373                let text = format!("Verify your email address: {verification_url}");
374
375                let email = email.to_owned();
376                super::authentication_helpers::run_owned_notification(
377                    ctx,
378                    async move { provider.send(&email, subject, &html, &text).await },
379                    ctx.config.awaited_notification_errors,
380                )
381                .await?;
382            } else {
383                tracing::warn!("No email provider configured, skipping verification email");
384            }
385        }
386
387        Ok(())
388    }
389
390    /// Send a verification email on sign-in for an unverified user.
391    ///
392    /// Callers (e.g. the sign-in plugin) should invoke this when
393    /// [`EmailVerificationConfig::send_on_sign_in`] is `true` and the user is
394    /// not yet verified.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error if verification token creation or email delivery fails.
399    pub async fn send_verification_on_sign_in(
400        &self,
401        user: &impl AuthUser,
402        callback_url: Option<&str>,
403        ctx: &AuthContext<impl alibi_core::AuthSchema>,
404    ) -> AuthResult<()> {
405        if !self.config.send_on_sign_in {
406            return Ok(());
407        }
408
409        if user.email_verified() {
410            return Ok(());
411        }
412
413        if let Some(email) = user.email() {
414            self.send_verification_email_for_user(user, email, callback_url, ctx)
415                .await?;
416        }
417
418        Ok(())
419    }
420
421    /// Check if `send_on_sign_in` is enabled.
422    #[must_use]
423    pub const fn should_send_on_sign_in(&self) -> bool {
424        self.config.send_on_sign_in
425    }
426
427    /// Check if email verification is required for signin
428    #[must_use]
429    pub const fn is_verification_required(&self) -> bool {
430        self.config.require_verification_for_signin
431    }
432
433    /// Check if user is verified or verification is not required
434    pub fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
435        user.email_verified() || !self.config.require_verification_for_signin
436    }
437}
438
439/// Apply the configured delivery policy to an owned verification email.
440pub(crate) async fn send_custom_verification_email(
441    ctx: &AuthContext<impl alibi_core::AuthSchema>,
442    sender: &Arc<dyn SendVerificationEmail>,
443    user: alibi_core::wire::UserView,
444    url: String,
445    token: String,
446    error_policy: alibi_core::AwaitedNotificationErrorPolicy,
447) -> AuthResult<()> {
448    let sender = Arc::clone(sender);
449    super::authentication_helpers::run_owned_notification(
450        ctx,
451        async move { sender.send(&user, &url, &token).await },
452        error_policy,
453    )
454    .await
455}
456
457/// Password registration emits verification before creating its session. The
458/// transaction handle keeps OTP challenges in the same transaction as the user.
459pub(crate) async fn send_signup_verification<S: alibi_core::AuthSchema>(
460    user: &impl AuthUser,
461    callback_url: Option<&str>,
462    required: bool,
463    ctx: &AuthContext<S>,
464    tx: &dyn alibi_core::store::AuthTransaction<S>,
465) -> AuthResult<()> {
466    let config = ctx.extensions.get::<EmailVerificationConfig>();
467    if !config
468        .as_ref()
469        .and_then(|config| config.send_on_sign_up)
470        .unwrap_or(required)
471    {
472        return Ok(());
473    }
474    let user = ctx.trusted_user_view(user);
475    if config
476        .as_ref()
477        .is_none_or(|config| config.send_verification_email.is_none())
478        && let Some(sender) = ctx.email_verification_override()
479    {
480        return sender.0.send_in_transaction(&user, None, ctx, tx).await;
481    }
482    let Some(config) = config else {
483        return Ok(());
484    };
485    let Some(sender) = &config.send_verification_email else {
486        return Ok(());
487    };
488    let Some(email) = user.email.as_deref() else {
489        return Ok(());
490    };
491    let token = token::create_email_verification_token(
492        ctx.config.current_secret(),
493        email,
494        None,
495        config.verification_token_expiry,
496        None,
497    )?;
498    let url = verification_url(&ctx.config, &token, callback_url);
499    send_custom_verification_email(
500        ctx,
501        sender,
502        user,
503        url,
504        token,
505        ctx.config.awaited_notification_errors,
506    )
507    .await
508}
509
510// LCOV_EXCL_START
511#[cfg(test)]
512mod tests {
513    use super::token::create_email_verification_token;
514    use super::*;
515    use crate::test_helpers;
516    use alibi_core::utils::cookie_utils::create_session_cookie;
517    use alibi_core::wire::UserView;
518    use alibi_core::{AuthPlugin, HttpMethod};
519    use alibi_core::{AuthResult, AuthSession, CreateUser, UpdateUser};
520    use async_trait::async_trait;
521    use chrono::{Duration, Utc};
522    use std::collections::HashMap;
523    use std::sync::Arc;
524    use std::sync::atomic::{AtomicU32, Ordering};
525
526    struct DummySender;
527
528    #[async_trait]
529    impl SendVerificationEmail for DummySender {
530        async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
531            Ok(())
532        }
533    }
534
535    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
536    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
537    #[test]
538    fn test_default_config() {
539        let config = EmailVerificationConfig::default();
540        assert_eq!(config.verification_token_expiry, Duration::hours(1));
541        assert!(config.send_email_notifications);
542        assert!(!config.require_verification_for_signin);
543        assert!(!config.auto_verify_new_users);
544        assert!(!config.send_on_sign_in);
545        assert!(!config.auto_sign_in_after_verification);
546        assert!(config.send_verification_email.is_none());
547        assert!(config.before_email_verification.is_none());
548        assert!(config.after_email_verification.is_none());
549    }
550
551    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
552    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
553    #[test]
554    fn test_builder_verification_token_expiry() {
555        let plugin =
556            EmailVerificationPlugin::new().verification_token_expiry(Duration::minutes(30));
557        assert_eq!(
558            plugin.config.verification_token_expiry,
559            Duration::minutes(30)
560        );
561    }
562
563    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
564    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
565    #[test]
566    fn test_builder_send_on_sign_in() {
567        let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
568        assert!(plugin.config.send_on_sign_in);
569    }
570
571    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
572    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
573    #[test]
574    fn test_builder_auto_sign_in_after_verification() {
575        let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
576        assert!(plugin.config.auto_sign_in_after_verification);
577    }
578
579    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
580    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
581    #[test]
582    fn test_builder_send_email_notifications() {
583        let plugin = EmailVerificationPlugin::new().send_email_notifications(false);
584        assert!(!plugin.config.send_email_notifications);
585    }
586
587    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
588    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
589    #[test]
590    fn test_builder_require_verification_for_signin() {
591        let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
592        assert!(plugin.config.require_verification_for_signin);
593    }
594
595    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
596    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
597    #[test]
598    fn test_builder_auto_verify_new_users() {
599        let plugin = EmailVerificationPlugin::new().auto_verify_new_users(true);
600        assert!(plugin.config.auto_verify_new_users);
601    }
602
603    // Rust-specific surface: `EmailVerificationPlugin` builder methods and
604    // `EmailVerificationConfig` are public Rust APIs with no direct TS analogue.
605    #[test]
606    fn test_builder_chaining() {
607        let plugin = EmailVerificationPlugin::new()
608            .verification_token_expiry(Duration::hours(2))
609            .send_on_sign_in(true)
610            .auto_sign_in_after_verification(true)
611            .send_email_notifications(false)
612            .require_verification_for_signin(true);
613        assert_eq!(plugin.config.verification_token_expiry, Duration::hours(2));
614        assert!(plugin.config.send_on_sign_in);
615        assert!(plugin.config.auto_sign_in_after_verification);
616        assert!(!plugin.config.send_email_notifications);
617        assert!(plugin.config.require_verification_for_signin);
618    }
619
620    // Rust-specific surface: `EmailVerificationPlugin::custom_send_verification_email`
621    // is a public Rust-only builder API.
622    #[test]
623    fn test_builder_custom_send_verification_email() {
624        let plugin =
625            EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(DummySender));
626        assert!(plugin.config.send_verification_email.is_some());
627    }
628
629    // Rust-specific surface: Rust hook builder methods on
630    // `EmailVerificationPlugin` have no direct TS analogue.
631    #[test]
632    fn test_builder_before_email_verification_hook() {
633        let hook: EmailVerificationHook = Arc::new(|_user: &UserView| Box::pin(async { Ok(()) }));
634        let plugin = EmailVerificationPlugin::new().before_email_verification(hook);
635        assert!(plugin.config.before_email_verification.is_some());
636    }
637
638    // Rust-specific surface: Rust hook builder methods on
639    // `EmailVerificationPlugin` have no direct TS analogue.
640    #[test]
641    fn test_builder_after_email_verification_hook() {
642        let hook: EmailVerificationHook = Arc::new(|_user: &UserView| Box::pin(async { Ok(()) }));
643        let plugin = EmailVerificationPlugin::new().after_email_verification(hook);
644        assert!(plugin.config.after_email_verification.is_some());
645    }
646
647    /// Helper to create a minimal wire user view for unit tests.
648    fn make_test_user(email: &str, verified: bool) -> UserView {
649        UserView {
650            omitted_fields: std::collections::BTreeSet::default(),
651            id: "test-id".into(),
652            name: Some("Test".into()),
653            email: Some(email.into()),
654            email_verified: verified,
655            image: None,
656            created_at: Utc::now(),
657            updated_at: Utc::now(),
658            username: None,
659            display_username: None,
660            two_factor_enabled: Some(false),
661            role: None,
662            banned: Some(false),
663            ban_reason: None,
664            ban_expires: None,
665            metadata: serde_json::Value::Null,
666            is_anonymous: None,
667            phone_number: None,
668            phone_number_verified: None,
669            last_login_method: None,
670            extension_fields: std::collections::BTreeMap::default(),
671        }
672    }
673
674    fn jwt_token(
675        ctx: &AuthContext<impl alibi_core::AuthSchema>,
676        email: &str,
677        update_to: Option<&str>,
678        request_type: Option<&str>,
679    ) -> String {
680        create_email_verification_token(
681            &ctx.config.secret,
682            email,
683            update_to,
684            Duration::hours(1),
685            request_type,
686        )
687        .unwrap()
688    }
689
690    // The legacy updateTo flow always sets a real session cookie and sends its
691    // follow-up proof with the token helper's default lifetime, despite expiresIn.
692    #[tokio::test]
693    #[expect(
694        clippy::too_many_lines,
695        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
696    )]
697    async fn legacy_email_change_reuses_or_issues_session_and_default_lifetime_followup() {
698        use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
699
700        struct Sender {
701            calls: Arc<std::sync::Mutex<Vec<(UserView, String)>>>,
702            fail: bool,
703        }
704        #[derive(Default)]
705        struct Observer(std::sync::Mutex<Option<alibi_core::BackgroundTaskCompletion>>);
706        impl alibi_core::BackgroundTaskHandler for Observer {
707            fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
708                *self.0.lock().unwrap() = Some(completion);
709                Ok(())
710            }
711        }
712        #[async_trait]
713        impl SendVerificationEmail for Sender {
714            async fn send(&self, user: &UserView, _: &str, token: &str) -> AuthResult<()> {
715                self.calls
716                    .lock()
717                    .unwrap()
718                    .push((user.clone(), token.into()));
719                if self.fail {
720                    Err(AuthError::bad_request("fixture delivery failed"))
721                } else {
722                    Ok(())
723                }
724            }
725        }
726
727        for (authenticated, fail, background) in [
728            (false, false, false),
729            (true, false, false),
730            (false, true, false),
731            (true, true, false),
732            (false, true, true),
733            (true, true, true),
734        ] {
735            let mut ctx = test_helpers::create_test_context().await;
736            // AuthBuilder finalizes even empty session policies, installing the
737            // adapter projection instead of the physical model's serialization.
738            let mut init = alibi_core::AuthInitContext::new(
739                Arc::clone(&ctx.config),
740                Arc::clone(&ctx.database),
741            );
742            init.extensions
743                .insert(alibi_core::field_policy::SessionFields::default());
744            ctx.database = init.database_with_registered_transforms();
745            ctx.extensions = init.into_parts().extensions;
746            let observer = Arc::new(Observer::default());
747            if background {
748                Arc::make_mut(&mut ctx.config).background_tasks = Some(observer.clone());
749            }
750            let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
751            let plugin = EmailVerificationPlugin::new()
752                .verification_token_expiry(Duration::seconds(90))
753                .custom_send_verification_email(Arc::new(Sender {
754                    calls: Arc::clone(&calls),
755                    fail,
756                }));
757            let user = ctx
758                .database
759                .create_user(CreateUser::new().with_email("before@legacy.fixture.test"))
760                .await
761                .unwrap();
762            let current = if authenticated {
763                Some(
764                    ctx.database
765                        .create_session(alibi_core::CreateSession {
766                            additional_fields: alibi_core::field_policy::FieldValues::default(),
767                            token: None,
768                            user_id: user.id().to_string(),
769                            expires_at: Utc::now() + ctx.config.session.expires_in,
770                            ip_address: None,
771                            user_agent: None,
772                            impersonated_by: None,
773                            active_organization_id: None,
774                            active_team_id: None,
775                        })
776                        .await
777                        .unwrap(),
778                )
779            } else {
780                None
781            };
782            let mut query = HashMap::new();
783            query.insert(
784                "token".into(),
785                jwt_token(
786                    &ctx,
787                    "before@legacy.fixture.test",
788                    Some("After@legacy.fixture.test"),
789                    None,
790                ),
791            );
792            let req = test_helpers::create_auth_request(
793                HttpMethod::Get,
794                "/verify-email",
795                current.as_ref().map(|session| session.token.as_str()),
796                None,
797                query,
798            );
799            let result = plugin.handle_verify_email(&req, &ctx).await;
800            let response = if fail && !background {
801                assert_eq!(result.unwrap_err().status_code(), 400);
802                None
803            } else {
804                let response = result.unwrap();
805                assert_eq!(response.status, 200);
806                let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
807                assert_eq!(
808                    (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
809                        .get("id")
810                        .unwrap_or(&serde_json::Value::Null))
811                    .as_str(),
812                    Some(user.id().as_ref())
813                );
814                assert_eq!(
815                    (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
816                        .get("email")
817                        .unwrap_or(&serde_json::Value::Null)),
818                    "after@legacy.fixture.test"
819                );
820                assert_eq!(
821                    (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
822                        .get("emailVerified")
823                        .unwrap_or(&serde_json::Value::Null)),
824                    false
825                );
826                Some(response)
827            };
828            if background {
829                let completion = observer.0.lock().unwrap().take().unwrap();
830                completion.await.unwrap();
831            }
832            let sessions = ctx.database.get_user_sessions(&user.id()).await.unwrap();
833            assert_eq!(sessions.len(), 1, "Anonymous proof must issue a session");
834            let session = sessions.first().unwrap();
835            if let Some(previous) = current {
836                assert_eq!(session.id(), previous.id());
837                assert_eq!(session.token(), previous.token());
838            }
839            if let Some(response) = response {
840                assert_eq!(
841                    response.headers.get("set-cookie"),
842                    Some(&create_session_cookie(session.token(), &ctx.config).unwrap())
843                );
844            }
845            let updated = ctx
846                .database
847                .get_user_by_email("after@legacy.fixture.test")
848                .await
849                .unwrap()
850                .unwrap();
851            assert_eq!(updated.id(), user.id());
852            assert!(!updated.email_verified());
853            let calls = calls.lock().unwrap();
854            assert_eq!(calls.len(), 1);
855            let (recipient, token) = calls.first().unwrap();
856            assert_eq!(recipient.id, user.id());
857            assert_eq!(
858                recipient.email.as_deref(),
859                Some("after@legacy.fixture.test")
860            );
861            let encoded = token.split('.').nth(1).unwrap();
862            let claims: serde_json::Value =
863                serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()).unwrap();
864            assert_eq!(
865                (*(claims).get("email").unwrap_or(&serde_json::Value::Null)),
866                "after@legacy.fixture.test"
867            );
868            assert_eq!(
869                (*(claims).get("exp").unwrap_or(&serde_json::Value::Null))
870                    .as_i64()
871                    .unwrap()
872                    - (*(claims).get("iat").unwrap_or(&serde_json::Value::Null))
873                        .as_i64()
874                        .unwrap(),
875                3600
876            );
877            assert!(claims.get("updateTo").is_none());
878            drop(calls);
879        }
880    }
881
882    fn external_verification_token(
883        secret: &str,
884        algorithm: jsonwebtoken::Algorithm,
885        payload: &serde_json::Value,
886    ) -> String {
887        let mut header = jsonwebtoken::Header::new(algorithm);
888        header.typ = None;
889        jsonwebtoken::encode(
890            &header,
891            payload,
892            &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
893        )
894        .unwrap()
895    }
896
897    // Proof must fail at the signature/algorithm/date guard before touching its
898    // claimed user's verification state, including the exact expiry second.
899    #[tokio::test]
900    async fn external_verification_proofs_enforce_signature_algorithm_and_numeric_dates() {
901        use alibi_core::AuthUser;
902        let ctx = test_helpers::create_test_context().await;
903        let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
904        let user = ctx
905            .database
906            .create_user(CreateUser::new().with_email("proof-owner@fixture.test"))
907            .await
908            .unwrap();
909        let now = Utc::now().timestamp();
910        for (secret, algorithm, dates, code) in [
911            (
912                ctx.config.secret.as_str(),
913                jsonwebtoken::Algorithm::HS256,
914                serde_json::json!({"exp":now-30}),
915                "TOKEN_EXPIRED",
916            ),
917            (
918                ctx.config.secret.as_str(),
919                jsonwebtoken::Algorithm::HS256,
920                serde_json::json!({"exp":now}),
921                "TOKEN_EXPIRED",
922            ),
923            (
924                ctx.config.secret.as_str(),
925                jsonwebtoken::Algorithm::HS256,
926                serde_json::json!({"exp":now+300,"nbf":now+30}),
927                "INVALID_TOKEN",
928            ),
929            (
930                ctx.config.secret.as_str(),
931                jsonwebtoken::Algorithm::HS256,
932                serde_json::json!({"exp":now+300,"iat":"invalid"}),
933                "INVALID_TOKEN",
934            ),
935            (
936                ctx.config.secret.as_str(),
937                jsonwebtoken::Algorithm::HS384,
938                serde_json::json!({"exp":now+300}),
939                "INVALID_TOKEN",
940            ),
941            (
942                "different-signing-key-for-fixture-only",
943                jsonwebtoken::Algorithm::HS256,
944                serde_json::json!({"exp":now+300}),
945                "INVALID_TOKEN",
946            ),
947        ] {
948            let mut payload = dates;
949            _ = payload
950                .as_object_mut()
951                .expect("token payload is an object")
952                .insert(
953                    "email".to_owned(),
954                    serde_json::json!("proof-owner@fixture.test"),
955                );
956            let token = external_verification_token(secret, algorithm, &payload);
957            let query = HashMap::from([("token".to_owned(), token.clone())]);
958            let req = test_helpers::create_auth_request(
959                HttpMethod::Get,
960                "/verify-email",
961                None,
962                None,
963                query,
964            );
965            let error = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
966            assert_eq!(error.status_code(), 401);
967            assert_eq!(error.error_payload().1.as_deref(), Some(code));
968            let callback = "/verification-error?source=caller&error=existing#details";
969            let query_2 = HashMap::from([
970                ("token".to_owned(), token),
971                ("callbackURL".to_owned(), callback.to_owned()),
972            ]);
973            let req_2 = test_helpers::create_auth_request(
974                HttpMethod::Get,
975                "/verify-email",
976                None,
977                None,
978                query_2,
979            );
980            let response = plugin.handle_verify_email(&req_2, &ctx).await.unwrap();
981            assert_eq!(response.status, 302);
982            assert_eq!(
983                response.headers.get("Location"),
984                Some(&format!(
985                    "/verification-error?source=caller&error=existing&error={code}#details"
986                ))
987            );
988            let persisted = ctx
989                .database
990                .get_user_by_id(&user.id())
991                .await
992                .unwrap()
993                .unwrap();
994            assert!(!persisted.email_verified());
995            assert_eq!(
996                ctx.database
997                    .get_user_sessions(&user.id())
998                    .await
999                    .unwrap()
1000                    .len(),
1001                0
1002            );
1003        }
1004    }
1005
1006    // JOSE does not require iat/exp, an audience, or an issuer for this endpoint.
1007    // Accepting a legitimately signed proof must preserve that embedding contract.
1008    #[tokio::test]
1009    async fn externally_signed_verification_without_dates_or_matching_audience_is_accepted() {
1010        use alibi_core::AuthUser;
1011        let ctx = test_helpers::create_test_context().await;
1012        let plugin = EmailVerificationPlugin::new();
1013        let user = ctx
1014            .database
1015            .create_user(CreateUser::new().with_email("legacy-proof@fixture.test"))
1016            .await
1017            .unwrap();
1018        let token = external_verification_token(
1019            &ctx.config.secret,
1020            jsonwebtoken::Algorithm::HS256,
1021            &serde_json::json!({"email":"legacy-proof@fixture.test","aud":"embedding-app","iss":"embedding-issuer"}),
1022        );
1023        let query = HashMap::from([("token".to_owned(), token)]);
1024        let req =
1025            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1026        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1027        assert_eq!(response.status, 200);
1028        let persisted = ctx
1029            .database
1030            .get_user_by_id(&user.id())
1031            .await
1032            .unwrap()
1033            .unwrap();
1034        assert!(persisted.email_verified());
1035        assert_eq!(
1036            ctx.database
1037                .get_user_sessions(&user.id())
1038                .await
1039                .unwrap()
1040                .len(),
1041            0
1042        );
1043    }
1044
1045    // Rust-specific surface: helper methods exposing plugin state are public Rust
1046    // APIs with no direct TS analogue.
1047    #[test]
1048    fn test_should_send_on_sign_in() {
1049        let plugin = EmailVerificationPlugin::new();
1050        assert!(!plugin.should_send_on_sign_in());
1051
1052        let plugin_2 = EmailVerificationPlugin::new().send_on_sign_in(true);
1053        assert!(plugin_2.should_send_on_sign_in());
1054    }
1055
1056    // Rust-specific surface: helper methods exposing plugin state are public Rust
1057    // APIs with no direct TS analogue.
1058    #[test]
1059    fn test_is_verification_required() {
1060        let plugin = EmailVerificationPlugin::new();
1061        assert!(!plugin.is_verification_required());
1062
1063        let plugin_2 = EmailVerificationPlugin::new().require_verification_for_signin(true);
1064        assert!(plugin_2.is_verification_required());
1065    }
1066
1067    // Rust-specific surface: helper methods exposing plugin state are public Rust
1068    // APIs with no direct TS analogue.
1069    #[tokio::test]
1070    async fn test_is_user_verified_or_not_required() {
1071        let plugin = EmailVerificationPlugin::new();
1072        let user = make_test_user("a@b.com", false);
1073        // verification not required -> true even if unverified
1074        assert!(plugin.is_user_verified_or_not_required(&user));
1075
1076        let plugin_2 = EmailVerificationPlugin::new().require_verification_for_signin(true);
1077        // verification required + unverified -> false
1078        assert!(!plugin_2.is_user_verified_or_not_required(&user));
1079
1080        let verified_user = make_test_user("a@b.com", true);
1081        // verified -> always true
1082        assert!(plugin_2.is_user_verified_or_not_required(&verified_user));
1083    }
1084
1085    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1086    #[test]
1087    fn test_to_user_preserves_fields() {
1088        let user = UserView {
1089            omitted_fields: std::collections::BTreeSet::default(),
1090            id: "test-id".into(),
1091            name: Some("Test User".into()),
1092            email: Some("test@example.com".into()),
1093            email_verified: true,
1094            image: Some("https://img.example.com/a.png".into()),
1095            created_at: Utc::now(),
1096            updated_at: Utc::now(),
1097            username: Some("testuser".into()),
1098            display_username: Some("TestUser".into()),
1099            two_factor_enabled: Some(true),
1100            role: Some("admin".into()),
1101            banned: Some(true),
1102            ban_reason: Some("spam".into()),
1103            ban_expires: None,
1104            metadata: serde_json::Value::Null,
1105            is_anonymous: None,
1106            phone_number: None,
1107            phone_number_verified: None,
1108            last_login_method: None,
1109            extension_fields: std::collections::BTreeMap::default(),
1110        };
1111        let converted = UserView::from(&user);
1112        assert_eq!(converted.id, "test-id");
1113        assert_eq!(converted.name.as_deref(), Some("Test User"));
1114        assert_eq!(converted.email.as_deref(), Some("test@example.com"));
1115        assert!(converted.email_verified);
1116        assert_eq!(
1117            converted.image.as_deref(),
1118            Some("https://img.example.com/a.png")
1119        );
1120        assert_eq!(converted.username.as_deref(), Some("testuser"));
1121        assert_eq!(converted.display_username.as_deref(), Some("TestUser"));
1122        assert_eq!(converted.two_factor_enabled, Some(true));
1123        assert_eq!(converted.role.as_deref(), Some("admin"));
1124        assert_eq!(converted.banned, Some(true));
1125        assert_eq!(converted.ban_reason.as_deref(), Some("spam"));
1126    }
1127
1128    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1129    #[test]
1130    fn test_plugin_name() {
1131        let plugin = EmailVerificationPlugin::new();
1132        assert_eq!(
1133            AuthPlugin::<
1134                alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1135            >::name(&plugin,),
1136            "email-verification"
1137        );
1138    }
1139
1140    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1141    #[test]
1142    fn test_plugin_routes() {
1143        let plugin = EmailVerificationPlugin::new();
1144        let routes = AuthPlugin::<
1145            alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1146        >::routes(&plugin);
1147        assert_eq!(routes.len(), 2);
1148        assert!(
1149            routes
1150                .iter()
1151                .any(|r| r.path == "/send-verification-email" && r.method == HttpMethod::Post)
1152        );
1153        assert!(
1154            routes
1155                .iter()
1156                .any(|r| r.path == "/verify-email" && r.method == HttpMethod::Get)
1157        );
1158    }
1159
1160    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1161    #[tokio::test]
1162    async fn test_on_request_unknown_route_returns_none() {
1163        let plugin = EmailVerificationPlugin::new();
1164        let ctx = test_helpers::create_test_context().await;
1165        let req = test_helpers::create_auth_request(
1166            HttpMethod::Get,
1167            "/unknown",
1168            None,
1169            None,
1170            HashMap::new(),
1171        );
1172        let result = plugin.on_request(&req, &ctx).await.unwrap();
1173        assert!(result.is_none());
1174    }
1175
1176    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1177    #[tokio::test]
1178    async fn test_send_verification_on_sign_in_disabled() {
1179        let plugin = EmailVerificationPlugin::new().send_on_sign_in(false);
1180        let ctx = test_helpers::create_test_context().await;
1181        let user = ctx
1182            .database
1183            .create_user(
1184                CreateUser::new()
1185                    .with_email("unverified@test.com")
1186                    .with_name("Test"),
1187            )
1188            .await
1189            .unwrap();
1190        // Should return Ok(()) immediately when disabled
1191        plugin
1192            .send_verification_on_sign_in(&user, None, &ctx)
1193            .await
1194            .unwrap();
1195    }
1196
1197    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1198    #[tokio::test]
1199    async fn test_send_verification_on_sign_in_verified_user() {
1200        let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
1201        let ctx = test_helpers::create_test_context().await;
1202        let user = ctx
1203            .database
1204            .create_user(
1205                CreateUser::new()
1206                    .with_email("verified@test.com")
1207                    .with_name("Test"),
1208            )
1209            .await
1210            .unwrap();
1211        // Mark user as verified
1212        let update = UpdateUser {
1213            email_verified: Some(true),
1214            ..Default::default()
1215        };
1216        let verified = ctx.database.update_user(&user.id, update).await.unwrap();
1217        // Should return Ok(()) for already-verified user
1218        plugin
1219            .send_verification_on_sign_in(&verified, None, &ctx)
1220            .await
1221            .unwrap();
1222    }
1223
1224    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1225    #[tokio::test]
1226    async fn test_send_verification_on_sign_in_creates_token() {
1227        struct CountingSender(Arc<AtomicU32>);
1228
1229        #[async_trait]
1230        impl SendVerificationEmail for CountingSender {
1231            async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
1232                self.0.fetch_add(1, Ordering::Relaxed);
1233                Ok(())
1234            }
1235        }
1236
1237        // Use a custom sender that records calls instead of needing an
1238        // email provider.
1239        let call_count = Arc::new(AtomicU32::new(0));
1240        let counter = std::sync::Arc::clone(&call_count);
1241
1242        let plugin = EmailVerificationPlugin::new()
1243        .send_on_sign_in(true)
1244        .send_email_notifications(false) // disable default path
1245        .custom_send_verification_email(Arc::new(CountingSender(counter)));
1246
1247        let ctx = test_helpers::create_test_context().await;
1248        let user = ctx
1249            .database
1250            .create_user(
1251                CreateUser::new()
1252                    .with_email("unverified@test.com")
1253                    .with_name("Test"),
1254            )
1255            .await
1256            .unwrap();
1257
1258        plugin
1259            .send_verification_on_sign_in(&user, None, &ctx)
1260            .await
1261            .unwrap();
1262
1263        assert_eq!(call_count.load(Ordering::Relaxed), 1);
1264    }
1265
1266    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1267    #[tokio::test]
1268    async fn test_on_user_created_custom_sender_fires_without_notifications() {
1269        struct CountingSender(Arc<AtomicU32>);
1270
1271        #[async_trait]
1272        impl SendVerificationEmail for CountingSender {
1273            async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
1274                self.0.fetch_add(1, Ordering::Relaxed);
1275                Ok(())
1276            }
1277        }
1278
1279        let call_count = Arc::new(AtomicU32::new(0));
1280        let counter = std::sync::Arc::clone(&call_count);
1281
1282        let plugin = EmailVerificationPlugin::new()
1283            .send_email_notifications(false)
1284            .custom_send_verification_email(Arc::new(CountingSender(counter)));
1285
1286        let ctx = test_helpers::create_test_context().await;
1287        let user = ctx
1288            .database
1289            .create_user(
1290                CreateUser::new()
1291                    .with_email("newuser@test.com")
1292                    .with_name("New"),
1293            )
1294            .await
1295            .unwrap();
1296
1297        plugin.on_user_created(&user, &ctx).await.unwrap();
1298
1299        // Custom sender should have been called even though
1300        // send_email_notifications is false.
1301        assert_eq!(call_count.load(Ordering::Relaxed), 1);
1302    }
1303
1304    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1305    #[tokio::test]
1306    async fn test_on_user_created_verified_user_skips_email() {
1307        struct CountingSender(Arc<AtomicU32>);
1308
1309        #[async_trait]
1310        impl SendVerificationEmail for CountingSender {
1311            async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
1312                self.0.fetch_add(1, Ordering::Relaxed);
1313                Ok(())
1314            }
1315        }
1316
1317        let call_count = Arc::new(AtomicU32::new(0));
1318        let counter = std::sync::Arc::clone(&call_count);
1319
1320        let plugin = EmailVerificationPlugin::new()
1321            .custom_send_verification_email(Arc::new(CountingSender(counter)));
1322
1323        let ctx = test_helpers::create_test_context().await;
1324        let user = ctx
1325            .database
1326            .create_user(
1327                CreateUser::new()
1328                    .with_email("newuser@test.com")
1329                    .with_name("New"),
1330            )
1331            .await
1332            .unwrap();
1333        // Mark verified
1334        let update = UpdateUser {
1335            email_verified: Some(true),
1336            ..Default::default()
1337        };
1338        let verified = ctx.database.update_user(&user.id, update).await.unwrap();
1339
1340        plugin.on_user_created(&verified, &ctx).await.unwrap();
1341
1342        // Should NOT have been called because user is already verified.
1343        assert_eq!(call_count.load(Ordering::Relaxed), 0);
1344    }
1345
1346    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1347    #[tokio::test]
1348    async fn test_verify_email_basic_flow() {
1349        let plugin = EmailVerificationPlugin::new();
1350        let ctx = test_helpers::create_test_context().await;
1351
1352        // Create an unverified user
1353        _ = ctx
1354            .database
1355            .create_user(
1356                CreateUser::new()
1357                    .with_email("verify@test.com")
1358                    .with_name("Verify Me"),
1359            )
1360            .await
1361            .unwrap();
1362
1363        let token_value = jwt_token(&ctx, "verify@test.com", None, None);
1364
1365        // Call verify-email
1366        let mut query = HashMap::new();
1367        query.insert("token".to_owned(), token_value.clone());
1368        let req =
1369            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1370        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1371
1372        assert_eq!(response.status, 200);
1373        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1374        assert_eq!(
1375            (*(body).get("status").unwrap_or(&serde_json::Value::Null)),
1376            true
1377        );
1378        assert!((*(body).get("user").unwrap_or(&serde_json::Value::Null)).is_null());
1379
1380        // User should now be verified in the database
1381        let updated = ctx
1382            .database
1383            .get_user_by_email("verify@test.com")
1384            .await
1385            .unwrap()
1386            .unwrap();
1387        assert!(updated.email_verified);
1388    }
1389
1390    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1391    #[tokio::test]
1392    async fn test_verify_email_calls_before_and_after_hooks() {
1393        let before_count = Arc::new(AtomicU32::new(0));
1394        let after_count = Arc::new(AtomicU32::new(0));
1395        let bc = std::sync::Arc::clone(&before_count);
1396        let ac = std::sync::Arc::clone(&after_count);
1397
1398        let before_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
1399            let c = std::sync::Arc::clone(&bc);
1400            Box::pin(async move {
1401                c.fetch_add(1, Ordering::Relaxed);
1402                Ok(())
1403            })
1404        });
1405        let after_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
1406            let c = std::sync::Arc::clone(&ac);
1407            Box::pin(async move {
1408                c.fetch_add(1, Ordering::Relaxed);
1409                Ok(())
1410            })
1411        });
1412
1413        let plugin = EmailVerificationPlugin::new()
1414            .before_email_verification(before_hook)
1415            .after_email_verification(after_hook);
1416
1417        let ctx = test_helpers::create_test_context().await;
1418        _ = ctx
1419            .database
1420            .create_user(
1421                CreateUser::new()
1422                    .with_email("hooks@test.com")
1423                    .with_name("Hooks"),
1424            )
1425            .await
1426            .unwrap();
1427
1428        let token_value = jwt_token(&ctx, "hooks@test.com", None, None);
1429
1430        let mut query = HashMap::new();
1431        query.insert("token".to_owned(), token_value);
1432        let req =
1433            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1434        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1435        assert_eq!(response.status, 200);
1436
1437        assert_eq!(before_count.load(Ordering::Relaxed), 1);
1438        assert_eq!(after_count.load(Ordering::Relaxed), 1);
1439    }
1440
1441    // Upstream reference: packages/better-auth/src/api/routes/email-verification.ts :: change-email-verification branch runs `afterEmailVerification(updatedUser, ...)` only after the email update succeeds.
1442    #[tokio::test]
1443    async fn test_change_email_verification_after_hook_observes_updated_user() {
1444        let captured = Arc::new(std::sync::Mutex::new(Vec::<(Option<String>, bool)>::new()));
1445        let hook_state = std::sync::Arc::clone(&captured);
1446        let after_hook: EmailVerificationHook = Arc::new(move |user: &UserView| {
1447            let hook_state = std::sync::Arc::clone(&hook_state);
1448            let email = user.email.clone();
1449            let verified = user.email_verified;
1450            Box::pin(async move {
1451                hook_state.lock().unwrap().push((email, verified));
1452                Ok(())
1453            })
1454        });
1455
1456        let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
1457
1458        let ctx = test_helpers::create_test_context().await;
1459        _ = ctx
1460            .database
1461            .create_user(
1462                CreateUser::new()
1463                    .with_email("change-email@test.com")
1464                    .with_name("ChangeEmail"),
1465            )
1466            .await
1467            .unwrap();
1468
1469        let token_value = jwt_token(
1470            &ctx,
1471            "change-email@test.com",
1472            Some("updated-email@test.com"),
1473            Some("change-email-verification"),
1474        );
1475
1476        let mut query = HashMap::new();
1477        query.insert("token".to_owned(), token_value);
1478        let req =
1479            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1480        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1481
1482        assert_eq!(response.status, 200);
1483        assert_eq!(
1484            *captured.lock().unwrap(),
1485            vec![(Some("updated-email@test.com".to_owned()), true)]
1486        );
1487
1488        let updated_user = ctx
1489            .database
1490            .get_user_by_email("updated-email@test.com")
1491            .await
1492            .unwrap()
1493            .unwrap();
1494        assert!(updated_user.email_verified);
1495    }
1496
1497    // Upstream reference: packages/better-auth/src/api/routes/email-verification.ts :: `afterEmailVerification` runs only after `updateUserByEmail(...)`, so failed updates must not fire the hook.
1498    #[tokio::test]
1499    async fn test_change_email_verification_does_not_fire_after_hook_when_update_fails() {
1500        let after_count = Arc::new(AtomicU32::new(0));
1501        let counter = std::sync::Arc::clone(&after_count);
1502        let after_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
1503            let counter = std::sync::Arc::clone(&counter);
1504            Box::pin(async move {
1505                counter.fetch_add(1, Ordering::Relaxed);
1506                Ok(())
1507            })
1508        });
1509
1510        let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
1511
1512        let ctx = test_helpers::create_test_context().await;
1513        _ = ctx
1514            .database
1515            .create_user(
1516                CreateUser::new()
1517                    .with_email("duplicate-source@test.com")
1518                    .with_name("DuplicateSource"),
1519            )
1520            .await
1521            .unwrap();
1522        _ = ctx
1523            .database
1524            .create_user(
1525                CreateUser::new()
1526                    .with_email("duplicate-target@test.com")
1527                    .with_name("DuplicateTarget"),
1528            )
1529            .await
1530            .unwrap();
1531
1532        let token_value = jwt_token(
1533            &ctx,
1534            "duplicate-source@test.com",
1535            Some("duplicate-target@test.com"),
1536            Some("change-email-verification"),
1537        );
1538
1539        let mut query = HashMap::new();
1540        query.insert("token".to_owned(), token_value);
1541        let req =
1542            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1543        let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1544
1545        assert_ne!(err.status_code(), 200);
1546        assert_eq!(after_count.load(Ordering::Relaxed), 0);
1547
1548        let source_user = ctx
1549            .database
1550            .get_user_by_email("duplicate-source@test.com")
1551            .await
1552            .unwrap()
1553            .unwrap();
1554        assert_eq!(source_user.email(), Some("duplicate-source@test.com"));
1555        assert!(!source_user.email_verified);
1556    }
1557
1558    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1559    #[tokio::test]
1560    async fn test_verify_email_before_hook_error_aborts() {
1561        let before_hook: EmailVerificationHook = Arc::new(|_user: &UserView| {
1562            Box::pin(async { Err(AuthError::forbidden("hook rejected")) })
1563        });
1564
1565        let plugin = EmailVerificationPlugin::new().before_email_verification(before_hook);
1566
1567        let ctx = test_helpers::create_test_context().await;
1568        _ = ctx
1569            .database
1570            .create_user(
1571                CreateUser::new()
1572                    .with_email("hook-err@test.com")
1573                    .with_name("HookErr"),
1574            )
1575            .await
1576            .unwrap();
1577
1578        let token_value = jwt_token(&ctx, "hook-err@test.com", None, None);
1579
1580        let mut query = HashMap::new();
1581        query.insert("token".to_owned(), token_value.clone());
1582        let req =
1583            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1584        let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1585        assert_eq!(err.status_code(), 403);
1586
1587        // User should still be unverified
1588        let u = ctx
1589            .database
1590            .get_user_by_email("hook-err@test.com")
1591            .await
1592            .unwrap()
1593            .unwrap();
1594        assert!(!u.email_verified);
1595    }
1596
1597    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1598    #[tokio::test]
1599    async fn test_verify_email_auto_sign_in_creates_session() {
1600        struct CapturingSender(Arc<std::sync::Mutex<String>>);
1601
1602        #[async_trait]
1603        impl SendVerificationEmail for CapturingSender {
1604            async fn send(&self, _user: &UserView, _url: &str, token: &str) -> AuthResult<()> {
1605                *self.0.lock().unwrap() = token.to_owned();
1606                Ok(())
1607            }
1608        }
1609
1610        let captured = Arc::new(std::sync::Mutex::new(String::new()));
1611
1612        let plugin = EmailVerificationPlugin::new()
1613            .auto_sign_in_after_verification(true)
1614            .custom_send_verification_email(Arc::new(CapturingSender(std::sync::Arc::clone(
1615                &captured,
1616            ))));
1617
1618        let ctx = test_helpers::create_test_context().await;
1619        _ = ctx
1620            .database
1621            .create_user(
1622                CreateUser::new()
1623                    .with_email("autosign@test.com")
1624                    .with_name("AutoSign"),
1625            )
1626            .await
1627            .unwrap();
1628
1629        let body = serde_json::json!({ "email": "autosign@test.com" });
1630        let mut headers = HashMap::new();
1631        headers.insert("content-type".to_owned(), "application/json".to_owned());
1632        let send_req = AuthRequest::from_parts(
1633            HttpMethod::Post,
1634            "/send-verification-email".to_owned(),
1635            headers,
1636            Some(body.to_string().into_bytes()),
1637            HashMap::new(),
1638        );
1639        let send_response = plugin
1640            .handle_send_verification_email(&send_req, &ctx)
1641            .await
1642            .unwrap();
1643        assert_eq!(send_response.status, 200);
1644
1645        let mut query = HashMap::new();
1646        query.insert("token".to_owned(), captured.lock().unwrap().clone());
1647        let req =
1648            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1649        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1650
1651        assert_eq!(response.status, 200);
1652        let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1653        assert_eq!(
1654            (*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
1655            true
1656        );
1657        assert!((*(body_2).get("user").unwrap_or(&serde_json::Value::Null)).is_null());
1658
1659        // Set-Cookie header should be present
1660        assert!(response.headers.contains_key("Set-Cookie"));
1661        let cookie_header = (response.headers)
1662            .get("Set-Cookie")
1663            .expect("fixture contains the requested index");
1664        assert!(cookie_header.contains("better-auth.session"));
1665    }
1666
1667    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1668    #[tokio::test]
1669    async fn test_verify_email_no_auto_sign_in_no_session() {
1670        let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
1671
1672        let ctx = test_helpers::create_test_context().await;
1673        _ = ctx
1674            .database
1675            .create_user(
1676                CreateUser::new()
1677                    .with_email("noautosign@test.com")
1678                    .with_name("NoAutoSign"),
1679            )
1680            .await
1681            .unwrap();
1682
1683        let token_value = jwt_token(&ctx, "noautosign@test.com", None, None);
1684
1685        let mut query = HashMap::new();
1686        query.insert("token".to_owned(), token_value);
1687        let req =
1688            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1689        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1690
1691        assert_eq!(response.status, 200);
1692        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1693        assert_eq!(
1694            (*(body).get("status").unwrap_or(&serde_json::Value::Null)),
1695            true
1696        );
1697        // No session field expected
1698        assert!(body.get("session").is_none());
1699        // No Set-Cookie header expected
1700        assert!(!response.headers.contains_key("Set-Cookie"));
1701    }
1702
1703    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1704    #[tokio::test]
1705    async fn test_verify_email_auto_sign_in_redirect_includes_cookie() {
1706        let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
1707
1708        let ctx = test_helpers::create_test_context().await;
1709        _ = ctx
1710            .database
1711            .create_user(
1712                CreateUser::new()
1713                    .with_email("redirect@test.com")
1714                    .with_name("Redirect"),
1715            )
1716            .await
1717            .unwrap();
1718
1719        let token_value = jwt_token(&ctx, "redirect@test.com", None, None);
1720
1721        let mut query = HashMap::new();
1722        query.insert("token".to_owned(), token_value);
1723        query.insert("callbackURL".to_owned(), "/verified".to_owned());
1724        let req =
1725            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1726        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1727
1728        assert_eq!(response.status, 302);
1729        assert_eq!(
1730            (*(response.headers)
1731                .get("Location")
1732                .expect("fixture contains the requested index")),
1733            "/verified"
1734        );
1735        // Session cookie should be present on the redirect
1736        assert!(response.headers.contains_key("Set-Cookie"));
1737        assert!(
1738            (*(response.headers)
1739                .get("Set-Cookie")
1740                .expect("fixture contains the requested index"))
1741            .contains("better-auth.session")
1742        );
1743    }
1744
1745    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1746    #[tokio::test]
1747    async fn test_verify_email_redirect_without_auto_sign_in_no_cookie() {
1748        let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
1749
1750        let ctx = test_helpers::create_test_context().await;
1751        _ = ctx
1752            .database
1753            .create_user(
1754                CreateUser::new()
1755                    .with_email("redir-nocookie@test.com")
1756                    .with_name("Redir"),
1757            )
1758            .await
1759            .unwrap();
1760
1761        let token_value = jwt_token(&ctx, "redir-nocookie@test.com", None, None);
1762
1763        let mut query = HashMap::new();
1764        query.insert("token".to_owned(), token_value);
1765        query.insert("callbackURL".to_owned(), "/verified".to_owned());
1766        let req =
1767            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1768        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1769
1770        assert_eq!(response.status, 302);
1771        assert!(!response.headers.contains_key("Set-Cookie"));
1772    }
1773
1774    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1775    #[tokio::test]
1776    async fn test_verify_email_invalid_token() {
1777        let plugin = EmailVerificationPlugin::new();
1778        let ctx = test_helpers::create_test_context().await;
1779
1780        let mut query = HashMap::new();
1781        query.insert("token".to_owned(), "bogus-token".to_owned());
1782        let req =
1783            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1784        let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1785        assert_eq!(err.status_code(), 401);
1786        assert_eq!(err.error_payload().1.as_deref(), Some("INVALID_TOKEN"));
1787    }
1788
1789    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1790    #[tokio::test]
1791    async fn test_verify_email_missing_token() {
1792        let plugin = EmailVerificationPlugin::new();
1793        let ctx = test_helpers::create_test_context().await;
1794
1795        let req = test_helpers::create_auth_request(
1796            HttpMethod::Get,
1797            "/verify-email",
1798            None,
1799            None,
1800            HashMap::new(),
1801        );
1802        let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1803        assert_eq!(err.status_code(), 400);
1804    }
1805
1806    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1807    #[tokio::test]
1808    async fn test_verify_email_already_verified_returns_ok() {
1809        let plugin = EmailVerificationPlugin::new();
1810        let ctx = test_helpers::create_test_context().await;
1811
1812        let user = ctx
1813            .database
1814            .create_user(
1815                CreateUser::new()
1816                    .with_email("already@test.com")
1817                    .with_name("Already"),
1818            )
1819            .await
1820            .unwrap();
1821        // Mark verified
1822        ctx.database
1823            .update_user(
1824                &user.id,
1825                UpdateUser {
1826                    email_verified: Some(true),
1827                    ..Default::default()
1828                },
1829            )
1830            .await
1831            .unwrap();
1832
1833        let token_value = jwt_token(&ctx, "already@test.com", None, None);
1834
1835        let mut query = HashMap::new();
1836        query.insert("token".to_owned(), token_value);
1837        let req =
1838            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1839        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1840        assert_eq!(response.status, 200);
1841        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1842        assert_eq!(
1843            (*(body).get("status").unwrap_or(&serde_json::Value::Null)),
1844            true
1845        );
1846    }
1847
1848    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1849    #[tokio::test]
1850    async fn test_send_verification_email_already_verified_returns_error() {
1851        let plugin = EmailVerificationPlugin::new();
1852        let ctx = test_helpers::create_test_context().await;
1853
1854        let user = ctx
1855            .database
1856            .create_user(
1857                CreateUser::new()
1858                    .with_email("verified@test.com")
1859                    .with_name("Verified"),
1860            )
1861            .await
1862            .unwrap();
1863        ctx.database
1864            .update_user(
1865                &user.id,
1866                UpdateUser {
1867                    email_verified: Some(true),
1868                    ..Default::default()
1869                },
1870            )
1871            .await
1872            .unwrap();
1873
1874        let body = serde_json::json!({ "email": "verified@test.com" });
1875        let mut headers = HashMap::new();
1876        headers.insert("content-type".to_owned(), "application/json".to_owned());
1877        let req = AuthRequest::from_parts(
1878            HttpMethod::Post,
1879            "/send-verification-email".to_owned(),
1880            headers,
1881            Some(body.to_string().into_bytes()),
1882            HashMap::new(),
1883        );
1884        let err = plugin
1885            .handle_send_verification_email(&req, &ctx)
1886            .await
1887            .unwrap_err();
1888        assert_eq!(err.status_code(), 400);
1889    }
1890
1891    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1892    #[tokio::test]
1893    async fn unauthenticated_verification_email_has_fixed_floor_for_all_mailbox_states() {
1894        struct Sender {
1895            calls: Arc<AtomicU32>,
1896            fail: bool,
1897        }
1898        #[async_trait]
1899        impl SendVerificationEmail for Sender {
1900            async fn send(&self, _: &UserView, _: &str, _: &str) -> AuthResult<()> {
1901                self.calls.fetch_add(1, Ordering::SeqCst);
1902                if self.fail {
1903                    Err(AuthError::bad_request("fixture delivery failed"))
1904                } else {
1905                    Ok(())
1906                }
1907            }
1908        }
1909        for (existing, fail) in [
1910            (None, false),
1911            (Some(true), false),
1912            (Some(false), false),
1913            (Some(false), true),
1914        ] {
1915            let ctx = test_helpers::create_test_context().await;
1916            let email = "enumeration@fixture.test";
1917            let user = if let Some(verified) = existing {
1918                Some(
1919                    ctx.database
1920                        .create_user(
1921                            CreateUser::new()
1922                                .with_email(email)
1923                                .with_email_verified(verified),
1924                        )
1925                        .await
1926                        .unwrap(),
1927                )
1928            } else {
1929                None
1930            };
1931            let calls = Arc::new(AtomicU32::new(0));
1932            let plugin =
1933                EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(Sender {
1934                    calls: std::sync::Arc::clone(&calls),
1935                    fail,
1936                }));
1937            let req = test_helpers::create_auth_json_request_no_query(
1938                HttpMethod::Post,
1939                "/send-verification-email",
1940                None,
1941                Some(serde_json::json!({"email":email})),
1942            );
1943            let start = std::time::Instant::now();
1944            let response = plugin.handle_send_verification_email(&req, &ctx).await;
1945            assert!(
1946                start.elapsed() >= std::time::Duration::from_millis(500),
1947                "Every unauthenticated mailbox result must retain the timing floor"
1948            );
1949            if fail {
1950                assert_eq!(response.unwrap_err().status_code(), 400);
1951            } else {
1952                assert_eq!(response.unwrap().status, 200);
1953            }
1954            assert_eq!(
1955                calls.load(Ordering::SeqCst),
1956                u32::from(existing == Some(false))
1957            );
1958            let after = ctx.database.get_user_by_email(email).await.unwrap();
1959            assert_eq!(after.as_ref().map(AuthUser::email_verified), existing);
1960            if let Some(user) = user {
1961                assert_eq!(after.as_ref().unwrap().id(), user.id());
1962                assert_eq!(
1963                    ctx.database
1964                        .get_user_sessions(&user.id())
1965                        .await
1966                        .unwrap()
1967                        .len(),
1968                    0
1969                );
1970            }
1971        }
1972    }
1973
1974    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1975    #[test]
1976    fn test_create_session_cookie_format() {
1977        use alibi_core::utils::cookie_utils::create_session_cookie;
1978
1979        let ctx = test_helpers::create_test_context_blocking();
1980        let cookie_str = create_session_cookie("my-token-123", &ctx.config).unwrap();
1981        // Should contain the cookie name and value
1982        assert!(cookie_str.contains("better-auth.session_token=my-token-123"));
1983        // Should contain Path
1984        assert!(cookie_str.contains("Path=/"));
1985        // Should contain HttpOnly (default)
1986        assert!(cookie_str.contains("HttpOnly"));
1987        // Should contain SameSite
1988        assert!(cookie_str.contains("SameSite=Lax"));
1989    }
1990
1991    // Upstream reference: packages/better-auth/src/api/routes/email-verification.test.ts :: describe("Email Verification") and packages/better-auth/src/api/routes/email-verification.ts; adapted to the Rust email verification plugin.
1992    #[test]
1993    fn test_create_session_cookie_special_characters_in_token() {
1994        use alibi_core::utils::cookie_utils::create_session_cookie;
1995
1996        let ctx = test_helpers::create_test_context_blocking();
1997        let token = "token+with/special=chars&more";
1998        let cookie_str = create_session_cookie(token, &ctx.config).unwrap();
1999        // The cookie crate should handle encoding properly
2000        assert!(cookie_str.contains("better-auth.session_token="));
2001    }
2002
2003    // Upstream reference: packages/better-auth/src/api/routes/email-verification.ts :: `use: [originCheck((ctx) => ctx.query.callbackURL)]` on the verifyEmail endpoint.
2004    #[tokio::test]
2005    async fn test_verify_email_rejects_untrusted_callback_url() {
2006        let plugin = EmailVerificationPlugin::new();
2007        let ctx = test_helpers::create_test_context().await;
2008
2009        _ = ctx
2010            .database
2011            .create_user(
2012                CreateUser::new()
2013                    .with_email("redirect-guard@test.com")
2014                    .with_name("Redirect Guard"),
2015            )
2016            .await
2017            .unwrap();
2018
2019        let token_value = jwt_token(&ctx, "redirect-guard@test.com", None, None);
2020
2021        let mut query = HashMap::new();
2022        query.insert("token".to_owned(), token_value);
2023        query.insert(
2024            "callbackURL".to_owned(),
2025            "https://evil.com/phish".to_owned(),
2026        );
2027        let req =
2028            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2029        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2030
2031        assert_eq!(response.status, 403);
2032    }
2033
2034    // Upstream reference: packages/better-auth/src/api/routes/email-verification.ts :: `use: [originCheck((ctx) => ctx.query.callbackURL)]` on the verifyEmail endpoint.
2035    #[tokio::test]
2036    async fn test_verify_email_allows_relative_callback_url() {
2037        let plugin = EmailVerificationPlugin::new();
2038        let ctx = test_helpers::create_test_context().await;
2039
2040        _ = ctx
2041            .database
2042            .create_user(
2043                CreateUser::new()
2044                    .with_email("redirect-rel@test.com")
2045                    .with_name("Redirect Rel"),
2046            )
2047            .await
2048            .unwrap();
2049
2050        let token_value = jwt_token(&ctx, "redirect-rel@test.com", None, None);
2051
2052        let mut query = HashMap::new();
2053        query.insert("token".to_owned(), token_value);
2054        query.insert("callbackURL".to_owned(), "/dashboard".to_owned());
2055        let req =
2056            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2057        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2058
2059        assert_eq!(response.status, 302);
2060        assert_eq!(
2061            (*(response.headers)
2062                .get("Location")
2063                .expect("fixture contains the requested index")),
2064            "/dashboard"
2065        );
2066    }
2067
2068    // Upstream reference: packages/better-auth/src/api/routes/email-verification.ts :: `use: [originCheck((ctx) => ctx.query.callbackURL)]` on the verifyEmail endpoint.
2069    #[tokio::test]
2070    async fn test_verify_email_allows_trusted_origin_callback_url() {
2071        let plugin = EmailVerificationPlugin::new();
2072        let config = test_helpers::create_test_config().trusted_origin("https://trusted.com");
2073        let ctx = test_helpers::create_test_context_with_config(config).await;
2074
2075        _ = ctx
2076            .database
2077            .create_user(
2078                CreateUser::new()
2079                    .with_email("redirect-trusted@test.com")
2080                    .with_name("Redirect Trusted"),
2081            )
2082            .await
2083            .unwrap();
2084
2085        let token_value = jwt_token(&ctx, "redirect-trusted@test.com", None, None);
2086
2087        let mut query = HashMap::new();
2088        query.insert("token".to_owned(), token_value);
2089        query.insert(
2090            "callbackURL".to_owned(),
2091            "https://trusted.com/verified".to_owned(),
2092        );
2093        let req =
2094            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2095        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2096
2097        assert_eq!(response.status, 302);
2098        assert_eq!(
2099            (*(response.headers)
2100                .get("Location")
2101                .expect("fixture contains the requested index")),
2102            "https://trusted.com/verified"
2103        );
2104    }
2105
2106    // Upstream reference: packages/better-auth/src/api/routes/email-verification.ts :: `use: [originCheck((ctx) => ctx.query.callbackURL)]` on the verifyEmail endpoint; `advanced.disableOriginCheck` skips the check.
2107    #[tokio::test]
2108    async fn test_verify_email_skips_origin_check_when_disabled() {
2109        let plugin = EmailVerificationPlugin::new();
2110        let config = test_helpers::create_test_config().disable_origin_check(true);
2111        let ctx = test_helpers::create_test_context_with_config(config).await;
2112
2113        _ = ctx
2114            .database
2115            .create_user(
2116                CreateUser::new()
2117                    .with_email("redirect-disabled@test.com")
2118                    .with_name("Redirect Disabled"),
2119            )
2120            .await
2121            .unwrap();
2122
2123        let token_value = jwt_token(&ctx, "redirect-disabled@test.com", None, None);
2124
2125        let mut query = HashMap::new();
2126        query.insert("token".to_owned(), token_value);
2127        query.insert(
2128            "callbackURL".to_owned(),
2129            "https://evil.com/anywhere".to_owned(),
2130        );
2131        let req =
2132            test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2133        let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2134
2135        // With origin check disabled, the redirect should proceed
2136        assert_eq!(response.status, 302);
2137    }
2138}
2139// LCOV_EXCL_STOP