Skip to main content

alibi_api/plugins/email_verification/
mod.rs

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