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