Skip to main content

alibi_plugins/email_verification/
mod.rs

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