Skip to main content

alibi_plugins/email_otp/
mod.rs

1//! Passwordless email authentication and verification using single-use codes.
2//!
3//! [`EmailOtpPlugin::create_verification_otp`] and
4//! [`EmailOtpPlugin::get_verification_otp`] are server-only operations. Neither
5//! operation registers an HTTP endpoint.
6
7mod endpoint;
8mod handlers;
9
10mod helpers;
11
12mod storage;
13
14mod types;
15
16use alibi_core::AuthError;
17use alibi_core::{AuthContext, AuthRequest, AuthResponse, AuthResult};
18use async_trait::async_trait;
19pub use endpoint::{
20    EmailOtpChangeInput, EmailOtpChangeRequestInput, EmailOtpChangeRequested, EmailOtpChanged,
21    EmailOtpCheck, EmailOtpPasswordReset, EmailOtpPasswordResetInput, EmailOtpRead,
22    EmailOtpResetRequested, EmailOtpSent, EmailOtpSignInInput, EmailOtpSignInResult,
23    EmailOtpVerification,
24};
25use std::sync::Arc;
26pub use storage::{EmailOtpCodec, EmailOtpStorage};
27pub use types::{EmailOtpDelivery, EmailOtpType, OtpResendStrategy};
28
29/// Delivers a code to its intended mailbox with the real native callback context.
30/// Delivery awaits and propagates errors by default; configured background work
31/// owns its context and logs failures while retaining the issued verification.
32#[async_trait]
33pub trait SendEmailOtp: Send + Sync {
34    async fn send(
35        &self,
36        delivery: &EmailOtpDelivery,
37        context: &alibi_core::CallbackContext,
38    ) -> AuthResult<()>;
39}
40
41/// Optional application code generator. Returning `None` selects the default
42/// cryptographically random numeric generator.
43#[async_trait]
44pub trait EmailOtpGenerator: Send + Sync {
45    async fn generate(
46        &self,
47        email: &str,
48        otp_type: EmailOtpType,
49        context: &alibi_core::CallbackContext,
50    ) -> AuthResult<Option<String>>;
51}
52
53/// Configuration for email OTP verification, login, password reset and email change.
54#[derive(Clone)]
55#[expect(
56    clippy::struct_excessive_bools,
57    reason = "Independent configuration switches model distinct upstream behavior, rather than mutually exclusive states"
58)]
59pub struct EmailOtpConfig {
60    pub send_verification_otp: Option<Arc<dyn SendEmailOtp>>,
61    pub generate_otp: Option<Arc<dyn EmailOtpGenerator>>,
62    /// Raw numeric length. Safe positive fractions round up; NaN generates an
63    /// empty code. Nonpositive and resource-unsafe lengths fail generation.
64    pub otp_length: f64,
65    /// Lifetime in seconds. Fractions retain JavaScript millisecond rounding;
66    /// invalid dates fail before persistence.
67    pub expires_in: f64,
68    /// Raw attempt budget compared with the persisted integer counter. Zero and
69    /// NaN use three; negative values reject even an unused proof.
70    pub allowed_attempts: f64,
71    pub rate_limit: alibi_core::EndpointRateLimit,
72    pub storage: EmailOtpStorage,
73    pub resend_strategy: OtpResendStrategy,
74    pub disable_sign_up: bool,
75    pub send_verification_on_sign_up: bool,
76    pub override_default_email_verification: bool,
77    pub change_email_enabled: bool,
78    pub verify_current_email: bool,
79    pub auto_sign_in_after_verification: bool,
80    pub before_email_verification: Option<super::email_verification::EmailVerificationHook>,
81    pub after_email_verification: Option<super::email_verification::EmailVerificationHook>,
82    pub password_hasher: Option<Arc<dyn alibi_core::PasswordHasher>>,
83    pub revoke_sessions_on_password_reset: bool,
84    pub on_password_reset: Option<Arc<super::password_management::OnPasswordResetCallback>>,
85}
86
87impl std::fmt::Debug for EmailOtpConfig {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("EmailOtpConfig").finish_non_exhaustive()
90    }
91}
92
93impl Default for EmailOtpConfig {
94    fn default() -> Self {
95        Self {
96            send_verification_otp: None,
97            generate_otp: None,
98            otp_length: 6.0,
99            expires_in: 300.0,
100            allowed_attempts: 3.0,
101            rate_limit: alibi_core::EndpointRateLimit {
102                window_seconds: 60.0,
103                max_requests: 3.0,
104            },
105            storage: EmailOtpStorage::Plain,
106            resend_strategy: OtpResendStrategy::Rotate,
107            disable_sign_up: false,
108            send_verification_on_sign_up: false,
109            override_default_email_verification: false,
110            change_email_enabled: false,
111            verify_current_email: false,
112            auto_sign_in_after_verification: false,
113            before_email_verification: None,
114            after_email_verification: None,
115            password_hasher: None,
116            revoke_sessions_on_password_reset: false,
117            on_password_reset: None,
118        }
119    }
120}
121
122#[derive(Clone)]
123pub struct EmailOtpPlugin {
124    config: EmailOtpConfig,
125}
126
127impl std::fmt::Debug for EmailOtpPlugin {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("EmailOtpPlugin").finish_non_exhaustive()
130    }
131}
132
133impl EmailOtpPlugin {
134    #[must_use]
135    pub const fn new(config: EmailOtpConfig) -> Self {
136        Self { config }
137    }
138
139    fn verification_settings(
140        &self,
141        ctx: &AuthContext<impl alibi_core::AuthSchema>,
142    ) -> VerificationSettings {
143        let inherited = ctx
144            .extensions
145            .get::<super::email_verification::EmailVerificationConfig>();
146        VerificationSettings {
147            before: inherited
148                .as_ref()
149                .and_then(|config| config.before_email_verification.clone())
150                .or_else(|| self.config.before_email_verification.clone()),
151            after: inherited
152                .as_ref()
153                .and_then(|config| config.after_email_verification.clone())
154                .or_else(|| self.config.after_email_verification.clone()),
155            auto_sign_in: inherited
156                .as_ref()
157                .is_some_and(|config| config.auto_sign_in_after_verification)
158                || self.config.auto_sign_in_after_verification,
159        }
160    }
161
162    fn password_settings(
163        &self,
164        ctx: &AuthContext<impl alibi_core::AuthSchema>,
165    ) -> PasswordSettings {
166        let passwords = ctx
167            .extensions
168            .get::<super::email_password::EmailPasswordConfig>();
169        let resets = ctx
170            .extensions
171            .get::<super::password_management::PasswordManagementConfig>();
172        let (minimum, maximum) = super::email_password::password_length_limits(ctx);
173        PasswordSettings {
174            minimum,
175            maximum,
176            hasher: resets
177                .as_ref()
178                .and_then(|config| config.password_hasher.clone())
179                .or_else(|| {
180                    let config = passwords.as_ref()?;
181                    config.password_hasher.clone()
182                })
183                .or_else(|| self.config.password_hasher.clone()),
184            on_reset: resets
185                .as_ref()
186                .and_then(|config| config.on_password_reset.clone())
187                .or_else(|| self.config.on_password_reset.clone()),
188            revoke_sessions: resets
189                .as_ref()
190                .is_some_and(|config| config.revoke_sessions_on_password_reset)
191                || self.config.revoke_sessions_on_password_reset,
192        }
193    }
194
195    /// Create and persist a code without delivering it or checking whether the
196    /// mailbox has an account. This operation is available only to server code.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if OTP generation, encoding, or persistence fails.
201    pub async fn create_verification_otp(
202        &self,
203        ctx: &AuthContext<impl alibi_core::AuthSchema>,
204        email: &str,
205        otp_type: EmailOtpType,
206    ) -> AuthResult<String> {
207        self.issue_code(ctx, None, &email.to_lowercase(), otp_type, None)
208            .await
209    }
210
211    /// Retrieve a live plaintext/decryptable code without consuming it.
212    /// Hashed storage rejects this operation, since a hash cannot reveal a code.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if storage fails or the configured OTP representation cannot be recovered.
217    pub async fn get_verification_otp(
218        &self,
219        ctx: &AuthContext<impl alibi_core::AuthSchema>,
220        email: &str,
221        otp_type: EmailOtpType,
222    ) -> AuthResult<Option<String>> {
223        let identifier = types::identifier(otp_type, &email.to_lowercase());
224        let Some(value) =
225            super::authentication_helpers::find_verification(ctx, &identifier).await?
226        else {
227            return Ok(None);
228        };
229        if value.is_expired() {
230            return Ok(None);
231        }
232        let (stored, _) = types::split_value(value.value()?);
233        self.config
234            .storage
235            .retrieve(stored, &ctx.config)
236            .await
237            .map_err(|error| match error {
238                error if error.status_code() != 500 => error,
239                AuthError::Api { .. }
240                | AuthError::Upstream { .. }
241                | AuthError::CallbackFailure(_) => error,
242                error => AuthError::CallbackFailure(Box::new(error)),
243            })
244    }
245}
246
247struct VerificationSettings {
248    before: Option<super::email_verification::EmailVerificationHook>,
249    after: Option<super::email_verification::EmailVerificationHook>,
250    auto_sign_in: bool,
251}
252
253struct PasswordSettings {
254    minimum: usize,
255    maximum: usize,
256    hasher: Option<Arc<dyn alibi_core::PasswordHasher>>,
257    on_reset: Option<Arc<super::password_management::OnPasswordResetCallback>>,
258    revoke_sessions: bool,
259}
260
261alibi_core::impl_auth_plugin! {
262    EmailOtpPlugin, "email-otp";
263    routes {
264        post "/email-otp/send-verification-otp" => send_verification, "sendEmailVerificationOTP";
265        post "/email-otp/check-verification-otp" => check_verification, "verifyEmailWithOTP";
266        post "/email-otp/verify-email" => verify_email, "verifyEmailOTP";
267        post "/sign-in/email-otp" => sign_in, "signInWithEmailOTP";
268        post "/email-otp/request-password-reset" => request_password_reset, "requestPasswordResetWithEmailOTP";
269        post "/forget-password/email-otp" => request_password_reset, "forgetPasswordWithEmailOTP";
270        post "/email-otp/reset-password" => reset_password, "resetPasswordWithEmailOTP";
271        post "/email-otp/request-email-change" => request_email_change, "requestEmailChangeWithEmailOTP";
272        post "/email-otp/change-email" => change_email, "changeEmailWithEmailOTP";
273    }
274    extra {
275    route_openapi_metadata!(S);
276
277        fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
278            vec![alibi_core::PluginRateLimit { matches: |path| matches!(path, "/email-otp/send-verification-otp" | "/email-otp/check-verification-otp" | "/email-otp/verify-email" | "/sign-in/email-otp" | "/email-otp/request-password-reset" | "/email-otp/reset-password" | "/forget-password/email-otp" | "/email-otp/request-email-change" | "/email-otp/change-email"), limit: alibi_core::EndpointRateLimit {
279                window_seconds: if self.config.rate_limit.window_seconds == 0.0 || self.config.rate_limit.window_seconds.is_nan() { 60.0 } else { self.config.rate_limit.window_seconds },
280                max_requests: if self.config.rate_limit.max_requests == 0.0 || self.config.rate_limit.max_requests.is_nan() { 3.0 } else { self.config.rate_limit.max_requests },
281            } }]
282        }
283        fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> { vec![self] }
284        fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
285
286        fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
287
288        async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
289
290        async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
291            ctx.set_metadata("email-otp.enabled", serde_json::json!(true));
292            if self.config.override_default_email_verification {
293                ctx.set_email_verification_override(Arc::new(self.clone()));
294            }
295            Ok(())
296        }
297
298        async fn after_request(
299            &self,
300            req: &AuthRequest,
301            ctx: &AuthContext<S>,
302            response: AuthResponse,
303        ) -> AuthResult<AuthResponse> {
304            if response.status < 400 && req.path().starts_with("/sign-up")
305                && self.config.send_verification_on_sign_up
306                && !self.config.override_default_email_verification
307            {
308                let value: serde_json::Value = serde_json::from_slice(&response.body)?;
309                if let Some(email) = value.get("user").and_then(|user| user.get("email")).and_then(serde_json::Value::as_str) {
310                    let otp = self.issue_code(ctx, Some(req), email, EmailOtpType::EmailVerification, None).await?;
311                    self.deliver(ctx, Some(req), email, otp, EmailOtpType::EmailVerification).await?;
312                }
313            }
314            Ok(response)
315        }
316    }
317}
318
319#[async_trait]
320impl<S: alibi_core::AuthSchema> alibi_core::VerificationEmailOverride<S> for EmailOtpPlugin {
321    async fn send(
322        &self,
323        user: &alibi_core::wire::UserView,
324        request: Option<&AuthRequest>,
325        ctx: &AuthContext<S>,
326    ) -> AuthResult<()> {
327        let Some(email) = user.email.as_deref() else {
328            return Ok(());
329        };
330        let _ = self
331            .send_verification_core(
332                request,
333                ctx,
334                types::SendRequest {
335                    email: email.to_owned(),
336                    otp_type: EmailOtpType::EmailVerification,
337                },
338            )
339            .await?;
340        Ok(())
341    }
342
343    async fn send_in_transaction(
344        &self,
345        user: &alibi_core::wire::UserView,
346        request: Option<&AuthRequest>,
347        ctx: &AuthContext<S>,
348        tx: &dyn alibi_core::store::AuthTransaction<S>,
349    ) -> AuthResult<()> {
350        let Some(email) = user.email.as_deref() else {
351            return Ok(());
352        };
353        let email = crate::authentication_helpers::parse_email(email)?;
354        let (otp, value) = self
355            .prepare_code(ctx, request, &email, EmailOtpType::EmailVerification, None)
356            .await?;
357        _ = ctx.verifications().create_in_transaction(tx, value).await?;
358        self.deliver(ctx, request, &email, otp, EmailOtpType::EmailVerification)
359            .await
360    }
361}
362
363#[async_trait]
364impl<S: alibi_core::AuthSchema> alibi_core::endpoint::EndpointHook<S> for EmailOtpPlugin {
365    fn matches_after(
366        &self,
367        call: &alibi_core::endpoint::EndpointCall,
368        _ctx: &AuthContext<S>,
369        _response: &alibi_core::endpoint::EndpointResponse,
370    ) -> AuthResult<bool> {
371        Ok(call.path().is_some_and(|path| path.starts_with("/sign-up"))
372            && self.config.send_verification_on_sign_up
373            && !self.config.override_default_email_verification)
374    }
375    async fn after(
376        &self,
377        call: &alibi_core::endpoint::EndpointCall,
378        ctx: &AuthContext<S>,
379        response: alibi_core::endpoint::EndpointResponse,
380    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
381        if let Ok(value) = response.result()
382            && let Some(email) = value
383                .get("user")
384                .and_then(|user| user.get("email"))
385                .and_then(alibi_core::utils::json::JsValue::as_str)
386                .filter(|email| !email.is_empty())
387        {
388            let otp = self
389                .issue_code(
390                    ctx,
391                    call.request(),
392                    email,
393                    EmailOtpType::EmailVerification,
394                    None,
395                )
396                .await?;
397            self.deliver(
398                ctx,
399                call.request(),
400                email,
401                otp,
402                EmailOtpType::EmailVerification,
403            )
404            .await?;
405        }
406        Ok(response)
407    }
408}
409
410// LCOV_EXCL_START
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::test_helpers::{self, create_auth_json_request_no_query};
415    use alibi_core::{
416        AuthError, AuthPlugin, AuthSession, AuthUser, AuthVerification, CreateAccount, CreateUser,
417        CreateVerification, HttpMethod,
418    };
419    use chrono::Duration;
420    use serde_json::{Value, json};
421    use std::sync::Mutex;
422    use std::sync::atomic::{AtomicUsize, Ordering};
423
424    #[derive(Default)]
425    struct Outbox(Mutex<Vec<EmailOtpDelivery>>);
426
427    #[async_trait]
428    impl SendEmailOtp for Outbox {
429        async fn send(
430            &self,
431            delivery: &EmailOtpDelivery,
432            _context: &alibi_core::CallbackContext,
433        ) -> AuthResult<()> {
434            self.0.lock().unwrap().push(delivery.clone());
435            Ok(())
436        }
437    }
438
439    struct RejectingSender(Arc<Outbox>, bool);
440
441    #[async_trait]
442    impl SendEmailOtp for RejectingSender {
443        async fn send(
444            &self,
445            delivery: &EmailOtpDelivery,
446            _context: &alibi_core::CallbackContext,
447        ) -> AuthResult<()> {
448            self.0.0.lock().unwrap().push(delivery.clone());
449            if self.1 {
450                Err(AuthError::Upstream {
451                    status: 409,
452                    code: "DELIVERY_REJECTED",
453                    message: "fixture delivery failed",
454                })
455            } else {
456                Err(AuthError::bad_request("fixture delivery failed"))
457            }
458        }
459    }
460
461    struct CounterGenerator(AtomicUsize);
462
463    #[async_trait]
464    impl EmailOtpGenerator for CounterGenerator {
465        async fn generate(
466            &self,
467            _: &str,
468            _: EmailOtpType,
469            _: &alibi_core::CallbackContext,
470        ) -> AuthResult<Option<String>> {
471            Ok(Some(format!(
472                "{:06}",
473                self.0.fetch_add(1, Ordering::SeqCst) + 100_000
474            )))
475        }
476    }
477
478    struct CancelVerificationUpdate(Arc<AtomicUsize>);
479
480    #[async_trait]
481    impl
482        alibi_seaorm::DatabaseHooks<
483            alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
484            alibi_seaorm::SeaOrmBackend,
485        > for CancelVerificationUpdate
486    {
487        async fn before_update_verification(
488            &self,
489            _: &str,
490            _: &mut alibi_core::UpdateVerification,
491            _: &alibi_seaorm::SeaOrmHookContext<'_>,
492        ) -> AuthResult<alibi_seaorm::HookControl> {
493            self.0.fetch_add(1, Ordering::SeqCst);
494            Ok(alibi_seaorm::HookControl::Cancel)
495        }
496    }
497
498    fn configured() -> (EmailOtpConfig, Arc<Outbox>) {
499        let outbox = Arc::new(Outbox::default());
500        (
501            EmailOtpConfig {
502                send_verification_otp: Some(Arc::<Outbox>::clone(&outbox)),
503                generate_otp: Some(Arc::new(CounterGenerator(AtomicUsize::new(0)))),
504                ..Default::default()
505            },
506            outbox,
507        )
508    }
509
510    async fn post(
511        plugin: &EmailOtpPlugin,
512        ctx: &AuthContext<impl alibi_core::AuthSchema>,
513        path: &str,
514        body: Value,
515    ) -> AuthResponse {
516        let req = create_auth_json_request_no_query(HttpMethod::Post, path, None, Some(body));
517        match plugin.on_request(&req, ctx).await {
518            Ok(Some(response)) => response,
519            Ok(None) => AuthResponse::text(404, "missing test route"),
520            Err(error) => error.to_auth_response(),
521        }
522    }
523
524    // The pinned endpoint awaits runInBackgroundOrAwait: a rejected notification
525    // leaves its real proof available and does not replace the successful response.
526    #[tokio::test]
527    async fn notification_failure_retains_the_issued_otp_for_single_use_signin() {
528        for (policy, coded) in [
529            (alibi_core::AwaitedNotificationErrorPolicy::Propagate, false),
530            (alibi_core::AwaitedNotificationErrorPolicy::Propagate, true),
531            (
532                alibi_core::AwaitedNotificationErrorPolicy::LogAndContinue,
533                false,
534            ),
535            (
536                alibi_core::AwaitedNotificationErrorPolicy::LogAndContinue,
537                true,
538            ),
539        ] {
540            let mut ctx = test_helpers::create_test_context().await;
541            ctx.config = Arc::new((*ctx.config).clone().awaited_notification_errors(policy));
542            let (mut config, outbox) = configured();
543            config.send_verification_otp = Some(Arc::new(RejectingSender(
544                Arc::<Outbox>::clone(&outbox),
545                coded,
546            )));
547            let plugin = EmailOtpPlugin::new(config);
548            let email = "delivery-failure@fixture.test";
549            let issued = post(
550                &plugin,
551                &ctx,
552                "/email-otp/send-verification-otp",
553                json!({"email":email,"type":"sign-in"}),
554            )
555            .await;
556            if policy == alibi_core::AwaitedNotificationErrorPolicy::Propagate {
557                assert_eq!(issued.status, if coded { 409 } else { 400 });
558                if coded {
559                    assert_eq!(
560                        serde_json::from_slice::<Value>(&issued.body)
561                            .unwrap()
562                            .get("code")
563                            .and_then(Value::as_str),
564                        Some("DELIVERY_REJECTED")
565                    );
566                }
567            } else {
568                assert_eq!(issued.status, 200);
569                assert_eq!(
570                    serde_json::from_slice::<Value>(&issued.body).unwrap(),
571                    json!({"success":true})
572                );
573            }
574            let delivery = outbox.0.lock().unwrap().first().unwrap().clone();
575            assert_eq!(delivery.email, email);
576            let identifier = format!("sign-in-otp-{email}");
577            let proof = ctx
578                .database
579                .get_latest_verification_by_identifier(&identifier)
580                .await
581                .unwrap()
582                .unwrap();
583            assert_eq!(proof.value(), format!("{}:0", delivery.otp));
584            assert!(
585                ctx.database
586                    .get_user_by_email(email)
587                    .await
588                    .unwrap()
589                    .is_none()
590            );
591            let received = post(
592                &plugin,
593                &ctx,
594                "/sign-in/email-otp",
595                json!({"email":email,"otp":delivery.otp}),
596            )
597            .await;
598            assert_eq!(received.status, 200);
599            let user = ctx
600                .database
601                .get_user_by_email(email)
602                .await
603                .unwrap()
604                .unwrap();
605            assert!(user.email_verified());
606            assert_eq!(
607                ctx.database
608                    .get_user_sessions(&user.id())
609                    .await
610                    .unwrap()
611                    .len(),
612                1
613            );
614            assert!(
615                ctx.database
616                    .get_latest_verification_by_identifier(&identifier)
617                    .await
618                    .unwrap()
619                    .is_none()
620            );
621            assert_eq!(
622                post(
623                    &plugin,
624                    &ctx,
625                    "/sign-in/email-otp",
626                    json!({"email":email,"otp":delivery.otp})
627                )
628                .await
629                .status,
630                400
631            );
632            assert_eq!(outbox.0.lock().unwrap().len(), 1);
633        }
634    }
635
636    // Upstream checkVerificationOTP rejects once when a database update hook
637    // returns false. Hook cancellation must not be retried as CAS contention.
638    #[tokio::test]
639    async fn cancelled_attempt_update_rejects_once_without_hanging_or_consuming_proof() {
640        use alibi_seaorm::store::__private_test_support::{
641            bundled_schema::BundledSchema, migrator,
642        };
643        use alibi_seaorm::{Database, SeaOrmStore};
644        let config = Arc::new(test_helpers::create_test_config());
645        let database = Database::connect("sqlite::memory:").await.unwrap();
646        migrator::run_migrations(&database).await.unwrap();
647        let calls = Arc::new(AtomicUsize::new(0));
648        let store = Arc::new(
649            SeaOrmStore::<BundledSchema>::new(Arc::clone(&config), database)
650                .hook(CancelVerificationUpdate(Arc::clone(&calls))),
651        );
652        let ctx = AuthContext::new(config, store);
653        _ = ctx
654            .database
655            .create_user(CreateUser::new().with_email("veto@example.com"))
656            .await
657            .unwrap();
658        let (config_2, _) = configured();
659        let plugin = EmailOtpPlugin::new(config_2);
660        let otp = plugin
661            .create_verification_otp(&ctx, "veto@example.com", EmailOtpType::EmailVerification)
662            .await
663            .unwrap();
664        let identifier = "email-verification-otp-veto@example.com";
665        let before = ctx
666            .database
667            .get_latest_verification_by_identifier(identifier)
668            .await
669            .unwrap()
670            .unwrap();
671        let rejected = tokio::time::timeout(
672            std::time::Duration::from_millis(500),
673            post(
674                &plugin,
675                &ctx,
676                "/email-otp/check-verification-otp",
677                json!({"email":"veto@example.com","type":"email-verification","otp":"incorrect"}),
678            ),
679        )
680        .await
681        .expect("A cancelled attempt update must return rather than retry forever");
682        assert_eq!(rejected.status, 400);
683        assert_eq!(
684            serde_json::from_slice::<Value>(&rejected.body).unwrap(),
685            json!({"code":"INVALID_OTP","message":"Invalid OTP"})
686        );
687        assert_eq!(calls.load(Ordering::SeqCst), 1);
688        let after = ctx
689            .database
690            .get_latest_verification_by_identifier(identifier)
691            .await
692            .unwrap()
693            .unwrap();
694        assert_eq!(after.id(), before.id());
695        assert_eq!(after.value(), before.value());
696        assert_eq!(after.expires_at(), before.expires_at());
697        let accepted = post(
698            &plugin,
699            &ctx,
700            "/email-otp/check-verification-otp",
701            json!({"email":"veto@example.com","type":"email-verification","otp":otp}),
702        )
703        .await;
704        assert_eq!(accepted.status, 200);
705        assert_eq!(
706            serde_json::from_slice::<Value>(&accepted.body).unwrap(),
707            json!({"success":true})
708        );
709        assert_eq!(calls.load(Ordering::SeqCst), 1);
710    }
711
712    // Upstream: email-otp/routes.ts :: sendVerificationOTP anti-enumeration branch.
713    #[tokio::test]
714    async fn unknown_verification_and_reset_mailboxes_leave_no_code_or_delivery() {
715        let ctx = test_helpers::create_test_context().await;
716        let (config, outbox) = configured();
717        let plugin = EmailOtpPlugin::new(config);
718        for otp_type in [
719            EmailOtpType::EmailVerification,
720            EmailOtpType::ForgetPassword,
721        ] {
722            let response = post(
723                &plugin,
724                &ctx,
725                "/email-otp/send-verification-otp",
726                json!({"email":"missing@example.com","type":otp_type}),
727            )
728            .await;
729            assert_eq!(response.status, 200);
730            assert!(
731                ctx.database
732                    .get_latest_verification_by_identifier(&types::identifier(
733                        otp_type,
734                        "missing@example.com"
735                    ))
736                    .await
737                    .unwrap()
738                    .is_none()
739            );
740        }
741        assert!(outbox.0.lock().unwrap().is_empty());
742    }
743
744    // Upstream: email-otp/routes.ts :: signInEmailOTP + atomicVerifyOTP.
745    #[tokio::test]
746    #[expect(
747        clippy::too_many_lines,
748        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
749    )]
750    async fn sign_in_creates_verified_user_and_owned_session_then_rejects_replay() {
751        use alibi_core::utils::cookie_utils::{
752            related_cookie_name, sign_cookie_value, verify_cookie_value,
753        };
754
755        for lifetime in [604_800, 90] {
756            for (preference, persistent) in [
757                (None, true),
758                (Some(""), true),
759                (Some("false"), false),
760                (Some("true"), false),
761            ] {
762                let mut auth_config = test_helpers::create_test_config();
763                auth_config.session.expires_in = Duration::seconds(lifetime);
764                auth_config.advanced.cookie_prefix = Some("otp-fixture".into());
765                let ctx = test_helpers::create_test_context_with_config(auth_config).await;
766                let (config, outbox) = configured();
767                let plugin = EmailOtpPlugin::new(config);
768                assert_eq!(
769                    post(
770                        &plugin,
771                        &ctx,
772                        "/email-otp/send-verification-otp",
773                        json!({"email":"Owner@Example.com","type":"sign-in"})
774                    )
775                    .await
776                    .status,
777                    200
778                );
779                let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
780                assert_eq!(delivery.email, "owner@example.com");
781                let body =
782                    json!({"email":"OWNER@example.com","otp":delivery.otp,"name":"Mailbox Owner"});
783                let mut request = create_auth_json_request_no_query(
784                    HttpMethod::Post,
785                    "/sign-in/email-otp",
786                    None,
787                    Some(body.clone()),
788                );
789                if let Some(value) = preference {
790                    _ = request.headers.insert(
791                        "cookie".to_owned(),
792                        format!(
793                            "{}={}",
794                            related_cookie_name(&ctx.config, "dont_remember"),
795                            sign_cookie_value(value, &ctx.config.secret)
796                        ),
797                    );
798                }
799                let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
800                assert_eq!(response.status, 200);
801                let payload: Value = serde_json::from_slice(&response.body).unwrap();
802                let token = payload.get("token").and_then(Value::as_str).unwrap();
803                let user = ctx
804                    .database
805                    .get_user_by_email("owner@example.com")
806                    .await
807                    .unwrap()
808                    .unwrap();
809                assert!(user.email_verified());
810                assert_eq!(user.name(), Some("Mailbox Owner"));
811                let session = ctx.database.get_session(token).await.unwrap().unwrap();
812                assert_eq!(session.user_id(), user.id());
813                assert!(
814                    ((session.expires_at() - session.created_at()).num_milliseconds()
815                        - lifetime * 1_000)
816                        .abs()
817                        < 1_000,
818                    "preference {preference:?} must preserve the configured persisted session lifetime",
819                );
820                let cookies = response
821                    .headers
822                    .get_all("set-cookie")
823                    .map(|header| cookie::Cookie::parse(header.clone()).unwrap())
824                    .collect::<Vec<_>>();
825                let session_cookie = cookies
826                    .iter()
827                    .find(|cookie| cookie.name() == "otp-fixture.session_token")
828                    .unwrap();
829                assert_eq!(
830                    session_cookie
831                        .max_age()
832                        .map(cookie::time::Duration::whole_seconds),
833                    persistent.then_some(lifetime),
834                    "signed preference {preference:?}, configured lifetime {lifetime}",
835                );
836                assert_eq!(session_cookie.http_only(), Some(true));
837                assert_eq!(session_cookie.path(), Some("/"));
838                assert_eq!(
839                    verify_cookie_value(session_cookie.value(), &ctx.config.secret).as_deref(),
840                    Some(token)
841                );
842                let preference_cookie = cookies
843                    .iter()
844                    .find(|cookie| cookie.name() == "otp-fixture.dont_remember");
845                assert_eq!(preference_cookie.is_some(), !persistent);
846                if let Some(cookie) = preference_cookie {
847                    assert_eq!(
848                        verify_cookie_value(cookie.value(), &ctx.config.secret).as_deref(),
849                        Some("true")
850                    );
851                    assert_eq!(cookie.max_age(), None);
852                    assert_eq!(cookie.http_only(), Some(true));
853                }
854                assert!(
855                    ctx.database
856                        .get_latest_verification_by_identifier("sign-in-otp-owner@example.com")
857                        .await
858                        .unwrap()
859                        .is_none()
860                );
861                assert_eq!(
862                    post(&plugin, &ctx, "/sign-in/email-otp", body).await.status,
863                    400
864                );
865            }
866        }
867    }
868
869    // Upstream: atomicVerifyOTP attempt count is enforced before code validation.
870    #[tokio::test]
871    async fn invalid_attempts_preserve_deadline_and_exhaust_the_budget() {
872        let ctx = test_helpers::create_test_context().await;
873        let (config, _) = configured();
874        let plugin = EmailOtpPlugin::new(config);
875        let code = plugin
876            .create_verification_otp(&ctx, "attempts@example.com", EmailOtpType::SignIn)
877            .await
878            .unwrap();
879        let key = "sign-in-otp-attempts@example.com";
880        let original = ctx
881            .database
882            .get_latest_verification_by_identifier(key)
883            .await
884            .unwrap()
885            .unwrap();
886        for attempts in 1..=3 {
887            let response = post(
888                &plugin,
889                &ctx,
890                "/sign-in/email-otp",
891                json!({"email":"attempts@example.com","otp":"wrong"}),
892            )
893            .await;
894            assert_eq!(response.status, 400);
895            let value = ctx
896                .database
897                .get_latest_verification_by_identifier(key)
898                .await
899                .unwrap()
900                .unwrap();
901            assert_eq!(value.expires_at(), original.expires_at());
902            assert_eq!(types::split_value(value.value()).1, attempts);
903        }
904        let response = post(
905            &plugin,
906            &ctx,
907            "/sign-in/email-otp",
908            json!({"email":"attempts@example.com","otp":code}),
909        )
910        .await;
911        assert_eq!(response.status, 403);
912        assert!(
913            ctx.database
914                .get_latest_verification_by_identifier(key)
915                .await
916                .unwrap()
917                .is_none()
918        );
919        assert!(
920            ctx.database
921                .get_user_by_email("attempts@example.com")
922                .await
923                .unwrap()
924                .is_none()
925        );
926    }
927
928    // Upstream: checkVerificationOTP is non-consuming; verifyEmailOTP consumes.
929    #[tokio::test]
930    async fn checking_does_not_verify_or_consume_then_verification_persists_state() {
931        let ctx = test_helpers::create_test_context().await;
932        let user = ctx
933            .database
934            .create_user(CreateUser::new().with_email("verify@example.com"))
935            .await
936            .unwrap();
937        let (config, _) = configured();
938        let plugin = EmailOtpPlugin::new(config);
939        let otp = plugin
940            .create_verification_otp(&ctx, "verify@example.com", EmailOtpType::EmailVerification)
941            .await
942            .unwrap();
943        assert_eq!(
944            post(
945                &plugin,
946                &ctx,
947                "/email-otp/check-verification-otp",
948                json!({"email":"verify@example.com","type":"email-verification","otp":otp})
949            )
950            .await
951            .status,
952            200
953        );
954        assert!(
955            !ctx.database
956                .get_user_by_id(&user.id())
957                .await
958                .unwrap()
959                .unwrap()
960                .email_verified()
961        );
962        assert!(
963            plugin
964                .get_verification_otp(&ctx, "verify@example.com", EmailOtpType::EmailVerification)
965                .await
966                .unwrap()
967                .is_some()
968        );
969        let response = post(
970            &plugin,
971            &ctx,
972            "/email-otp/verify-email",
973            json!({"email":"verify@example.com","otp":otp}),
974        )
975        .await;
976        assert_eq!(response.status, 200);
977        let payload: Value = serde_json::from_slice(&response.body).unwrap();
978        assert_eq!(payload.get("token"), Some(&Value::Null));
979        assert!(
980            ctx.database
981                .get_user_by_id(&user.id())
982                .await
983                .unwrap()
984                .unwrap()
985                .email_verified()
986        );
987        assert!(
988            plugin
989                .get_verification_otp(&ctx, "verify@example.com", EmailOtpType::EmailVerification)
990                .await
991                .unwrap()
992                .is_none()
993        );
994    }
995
996    // Upstream: expired code has a distinct error and is removed before consuming.
997    #[tokio::test]
998    async fn expired_and_cross_scope_codes_cannot_authenticate() {
999        let ctx = test_helpers::create_test_context().await;
1000        let (config, _) = configured();
1001        let plugin = EmailOtpPlugin::new(config);
1002        _ = ctx
1003            .database
1004            .create_verification(CreateVerification {
1005                identifier: "sign-in-otp-expired@example.com".into(),
1006                value: "654321:0".into(),
1007                expires_at: chrono::Utc::now() - Duration::seconds(1),
1008            })
1009            .await
1010            .unwrap();
1011        let expired = post(
1012            &plugin,
1013            &ctx,
1014            "/sign-in/email-otp",
1015            json!({"email":"expired@example.com","otp":"654321"}),
1016        )
1017        .await;
1018        let payload: Value = serde_json::from_slice(&expired.body).unwrap();
1019        assert_eq!(
1020            payload.get("code").and_then(Value::as_str),
1021            Some("OTP_EXPIRED")
1022        );
1023        assert!(
1024            ctx.database
1025                .get_latest_verification_by_identifier("sign-in-otp-expired@example.com")
1026                .await
1027                .unwrap()
1028                .is_none()
1029        );
1030        let code = plugin
1031            .create_verification_otp(
1032                &ctx,
1033                "wrong-scope@example.com",
1034                EmailOtpType::EmailVerification,
1035            )
1036            .await
1037            .unwrap();
1038        assert_eq!(
1039            post(
1040                &plugin,
1041                &ctx,
1042                "/sign-in/email-otp",
1043                json!({"email":"wrong-scope@example.com","otp":code})
1044            )
1045            .await
1046            .status,
1047            400
1048        );
1049        assert_eq!(
1050            post(
1051                &plugin,
1052                &ctx,
1053                "/sign-in/email-otp",
1054                json!({"email":"foreign@example.com","otp":code})
1055            )
1056            .await
1057            .status,
1058            400
1059        );
1060        assert!(
1061            ctx.database
1062                .get_user_by_email("wrong-scope@example.com")
1063                .await
1064                .unwrap()
1065                .is_none()
1066        );
1067    }
1068
1069    // Upstream: a mailbox proof promotes an unverified row only after deleting
1070    // every standing account/session that predates proof of ownership.
1071    #[tokio::test]
1072    async fn existing_unverified_account_loses_password_oauth_and_old_sessions() {
1073        let ctx = test_helpers::create_test_context().await;
1074        let user = ctx
1075            .database
1076            .create_user(CreateUser::new().with_email("promote@example.com"))
1077            .await
1078            .unwrap();
1079        for provider in ["credential", "google"] {
1080            _ = ctx
1081                .database
1082                .create_account(CreateAccount {
1083                    additional_fields: alibi_core::field_policy::FieldValues::default(),
1084                    user_id: user.id().to_string(),
1085                    account_id: format!("{provider}-identity"),
1086                    provider_id: provider.into(),
1087                    access_token: None,
1088                    refresh_token: None,
1089                    id_token: None,
1090                    access_token_expires_at: None,
1091                    refresh_token_expires_at: None,
1092                    scope: None,
1093                    password: (provider == "credential").then_some("old-password-hash".into()),
1094                })
1095                .await
1096                .unwrap();
1097        }
1098        let old_session = ctx
1099            .session_manager()
1100            .create_session(&user, None, None)
1101            .await
1102            .unwrap();
1103        let (config, _) = configured();
1104        let plugin = EmailOtpPlugin::new(config);
1105        let otp = plugin
1106            .create_verification_otp(&ctx, "promote@example.com", EmailOtpType::SignIn)
1107            .await
1108            .unwrap();
1109        assert_eq!(
1110            post(
1111                &plugin,
1112                &ctx,
1113                "/sign-in/email-otp",
1114                json!({"email":"promote@example.com","otp":otp})
1115            )
1116            .await
1117            .status,
1118            200
1119        );
1120        let promoted = ctx
1121            .database
1122            .get_user_by_id(&user.id())
1123            .await
1124            .unwrap()
1125            .unwrap();
1126        assert!(promoted.email_verified());
1127        assert_eq!(
1128            ctx.database
1129                .get_user_accounts(&user.id())
1130                .await
1131                .unwrap()
1132                .len(),
1133            0
1134        );
1135        assert!(
1136            ctx.database
1137                .get_session(old_session.token())
1138                .await
1139                .unwrap()
1140                .is_none()
1141        );
1142    }
1143
1144    // Upstream: atomicVerifyOTP has exactly one successful concurrent consumer.
1145    #[tokio::test]
1146    async fn concurrent_sign_in_cannot_reuse_a_code() {
1147        let ctx = test_helpers::create_test_context().await;
1148        let (config, _) = configured();
1149        let plugin = EmailOtpPlugin::new(config);
1150        let otp = plugin
1151            .create_verification_otp(&ctx, "race@example.com", EmailOtpType::SignIn)
1152            .await
1153            .unwrap();
1154        let body = json!({"email":"race@example.com","otp":otp});
1155        let (first, second) = tokio::join!(
1156            post(&plugin, &ctx, "/sign-in/email-otp", body.clone()),
1157            post(&plugin, &ctx, "/sign-in/email-otp", body)
1158        );
1159        let mut statuses = [first.status, second.status];
1160        statuses.sort_unstable();
1161        assert_eq!(statuses, [200, 400]);
1162    }
1163
1164    // Upstream: parseUserInput ignores fields contributed by an absent plugin.
1165    // Persisted fields are checked because response projection alone can hide writes.
1166    #[tokio::test]
1167    async fn signup_ignores_unregistered_username_fields() {
1168        let ctx = test_helpers::create_test_context().await;
1169        let (config, _) = configured();
1170        let plugin = EmailOtpPlugin::new(config);
1171        for (index, username, display) in [
1172            (0, json!("ab"), json!("Ignored Display")),
1173            (1, json!(7), json!({"unexpected":true})),
1174        ] {
1175            let email = format!("unregistered-{index}@example.com");
1176            let otp = plugin
1177                .create_verification_otp(&ctx, &email, EmailOtpType::SignIn)
1178                .await
1179                .unwrap();
1180            let response = post(
1181                &plugin,
1182                &ctx,
1183                "/sign-in/email-otp",
1184                json!({"email":email,"otp":otp,"username":username,"displayUsername":display}),
1185            )
1186            .await;
1187            assert_eq!(
1188                response.status,
1189                200,
1190                "{}",
1191                String::from_utf8_lossy(&response.body)
1192            );
1193            let user = ctx
1194                .database
1195                .get_user_by_email(&email)
1196                .await
1197                .unwrap()
1198                .unwrap();
1199            assert_eq!(user.username(), None);
1200            assert_eq!(user.display_username(), None);
1201            assert!(user.email_verified());
1202            assert_eq!(
1203                ctx.database
1204                    .get_user_sessions(&user.id())
1205                    .await
1206                    .unwrap()
1207                    .len(),
1208                1
1209            );
1210            assert!(
1211                ctx.database
1212                    .get_latest_verification_by_identifier(&format!("sign-in-otp-{email}"))
1213                    .await
1214                    .unwrap()
1215                    .is_none()
1216            );
1217        }
1218    }
1219
1220    // Upstream: reusable plain OTPs keep attempts and extend expiry; hashes cannot
1221    // recover a plaintext for delivery, and must therefore rotate even in reuse mode.
1222    #[tokio::test]
1223    async fn reuse_extends_existing_code_and_hashed_storage_rotates() {
1224        let ctx = test_helpers::create_test_context().await;
1225        let (mut config, outbox) = configured();
1226        config.resend_strategy = OtpResendStrategy::Reuse;
1227        let mut plugin = EmailOtpPlugin::new(config);
1228        let request = json!({"email":"reuse@example.com","type":"sign-in"});
1229        assert_eq!(
1230            post(
1231                &plugin,
1232                &ctx,
1233                "/email-otp/send-verification-otp",
1234                request.clone()
1235            )
1236            .await
1237            .status,
1238            200
1239        );
1240        assert_eq!(
1241            post(
1242                &plugin,
1243                &ctx,
1244                "/email-otp/send-verification-otp",
1245                request.clone()
1246            )
1247            .await
1248            .status,
1249            200
1250        );
1251        let codes = outbox
1252            .0
1253            .lock()
1254            .unwrap()
1255            .iter()
1256            .map(|delivery| delivery.otp.clone())
1257            .collect::<Vec<_>>();
1258        assert_eq!(codes.first(), codes.last());
1259        plugin.config.storage = EmailOtpStorage::Hashed;
1260        assert_eq!(
1261            post(
1262                &plugin,
1263                &ctx,
1264                "/email-otp/send-verification-otp",
1265                request.clone()
1266            )
1267            .await
1268            .status,
1269            200
1270        );
1271        assert_eq!(
1272            post(&plugin, &ctx, "/email-otp/send-verification-otp", request)
1273                .await
1274                .status,
1275            200
1276        );
1277        let codes_2 = outbox
1278            .0
1279            .lock()
1280            .unwrap()
1281            .iter()
1282            .map(|delivery| delivery.otp.clone())
1283            .collect::<Vec<_>>();
1284        assert_ne!(codes_2.get(2), codes_2.get(3));
1285        assert!(
1286            plugin
1287                .get_verification_otp(&ctx, "reuse@example.com", EmailOtpType::SignIn)
1288                .await
1289                .is_err()
1290        );
1291        let raw = ctx
1292            .database
1293            .get_latest_verification_by_identifier("sign-in-otp-reuse@example.com")
1294            .await
1295            .unwrap()
1296            .unwrap();
1297        assert!(!codes_2.iter().any(|code| raw.value().contains(code)));
1298        assert_eq!(
1299            post(
1300                &plugin,
1301                &ctx,
1302                "/sign-in/email-otp",
1303                json!({"email":"reuse@example.com","otp":codes_2.last().unwrap()})
1304            )
1305            .await
1306            .status,
1307            200
1308        );
1309    }
1310
1311    // Upstream: change-email identifier binds both current and target mailboxes.
1312    #[tokio::test]
1313    async fn changing_email_rejects_another_users_code_and_keeps_current_session() {
1314        let ctx = test_helpers::create_test_context().await;
1315        let owner = ctx
1316            .database
1317            .create_user(
1318                CreateUser::new()
1319                    .with_email("owner@example.com")
1320                    .with_email_verified(true),
1321            )
1322            .await
1323            .unwrap();
1324        let stranger = ctx
1325            .database
1326            .create_user(
1327                CreateUser::new()
1328                    .with_email("stranger@example.com")
1329                    .with_email_verified(true),
1330            )
1331            .await
1332            .unwrap();
1333        let owner_session = ctx
1334            .session_manager()
1335            .create_session(&owner, None, None)
1336            .await
1337            .unwrap();
1338        let stranger_session = ctx
1339            .session_manager()
1340            .create_session(&stranger, None, None)
1341            .await
1342            .unwrap();
1343        let (mut config, outbox) = configured();
1344        config.change_email_enabled = true;
1345        let plugin = EmailOtpPlugin::new(config);
1346        let req = create_auth_json_request_no_query(
1347            HttpMethod::Post,
1348            "/email-otp/request-email-change",
1349            Some(owner_session.token()),
1350            Some(json!({"newEmail":"target@example.com"})),
1351        );
1352        assert_eq!(
1353            plugin.on_request(&req, &ctx).await.unwrap().unwrap().status,
1354            200
1355        );
1356        let code = outbox.0.lock().unwrap().last().unwrap().otp.clone();
1357        let req_2 = create_auth_json_request_no_query(
1358            HttpMethod::Post,
1359            "/email-otp/change-email",
1360            Some(stranger_session.token()),
1361            Some(json!({"newEmail":"target@example.com","otp":code})),
1362        );
1363        assert_eq!(
1364            plugin
1365                .on_request(&req_2, &ctx)
1366                .await
1367                .unwrap_err()
1368                .status_code(),
1369            400
1370        );
1371        assert_eq!(
1372            ctx.database
1373                .get_user_by_id(&stranger.id())
1374                .await
1375                .unwrap()
1376                .unwrap()
1377                .email(),
1378            Some("stranger@example.com")
1379        );
1380        let req_3 = create_auth_json_request_no_query(
1381            HttpMethod::Post,
1382            "/email-otp/change-email",
1383            Some(owner_session.token()),
1384            Some(json!({"newEmail":"target@example.com","otp":code})),
1385        );
1386        assert_eq!(
1387            plugin
1388                .on_request(&req_3, &ctx)
1389                .await
1390                .unwrap()
1391                .unwrap()
1392                .status,
1393            200
1394        );
1395        assert_eq!(
1396            ctx.database
1397                .get_user_by_id(&owner.id())
1398                .await
1399                .unwrap()
1400                .unwrap()
1401                .email(),
1402            Some("target@example.com")
1403        );
1404        assert_eq!(
1405            ctx.database
1406                .get_session(owner_session.token())
1407                .await
1408                .unwrap()
1409                .unwrap()
1410                .user_id(),
1411            owner.id()
1412        );
1413    }
1414
1415    // Ciphertext produced by the installed 1.7.6 symmetricEncrypt runtime. This
1416    // proves the persistence encoding independently of our encryption round-trip.
1417    #[tokio::test]
1418    async fn encrypted_codec_reads_upstream_ciphertext_and_hides_plaintext() {
1419        let fixture = "89c516b9ba08b2f347ecf25375ca9524bca3e8c57ec3982eaf21996f87742a4a739b47adfce936e33fe71e424a45";
1420        let storage = EmailOtpStorage::Encrypted;
1421        assert_eq!(
1422            storage
1423                .retrieve(
1424                    fixture,
1425                    &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret")
1426                )
1427                .await
1428                .unwrap(),
1429            Some("654321".into())
1430        );
1431        let encrypted = storage
1432            .store(
1433                "654321",
1434                &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret"),
1435            )
1436            .await
1437            .unwrap();
1438        assert_ne!(encrypted, "654321");
1439        assert!(
1440            storage
1441                .verify(
1442                    &encrypted,
1443                    "654321",
1444                    &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret")
1445                )
1446                .await
1447                .unwrap()
1448        );
1449        assert!(
1450            !storage
1451                .verify(
1452                    &encrypted,
1453                    "000000",
1454                    &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret")
1455                )
1456                .await
1457                .unwrap()
1458        );
1459        assert!(
1460            storage
1461                .verify(
1462                    &encrypted,
1463                    "654321",
1464                    &alibi_core::AuthConfig::new("wrong-secret")
1465                )
1466                .await
1467                .is_err()
1468        );
1469    }
1470
1471    // Upstream: password policy runs before consumption; reset callbacks see the
1472    // pre-verification user and configured session revocation applies afterward.
1473    #[tokio::test]
1474    #[expect(
1475        clippy::too_many_lines,
1476        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1477    )]
1478    async fn reset_updates_password_runs_hook_and_revokes_owned_sessions() {
1479        use alibi_core::AuthAccount;
1480        let ctx = test_helpers::create_test_context().await;
1481        let user = ctx
1482            .database
1483            .create_user(CreateUser::new().with_email("reset@example.com"))
1484            .await
1485            .unwrap();
1486        let session = ctx
1487            .session_manager()
1488            .create_session(&user, None, None)
1489            .await
1490            .unwrap();
1491        let account = ctx
1492            .database
1493            .create_account(CreateAccount {
1494                additional_fields: alibi_core::field_policy::FieldValues::default(),
1495                user_id: user.id().to_string(),
1496                account_id: user.id().to_string(),
1497                provider_id: "credential".into(),
1498                access_token: None,
1499                refresh_token: None,
1500                id_token: None,
1501                access_token_expires_at: None,
1502                refresh_token_expires_at: None,
1503                scope: None,
1504                password: Some("old-hash".into()),
1505            })
1506            .await
1507            .unwrap();
1508        let calls = Arc::new(Mutex::new(Vec::new()));
1509        let (mut config, outbox) = configured();
1510        let calls_for_hook = Arc::clone(&calls);
1511        config.on_password_reset = Some(Arc::new(move |user_2| {
1512            let calls_2 = Arc::clone(&calls_for_hook);
1513            Box::pin(async move {
1514                calls_2.lock().unwrap().push(user_2);
1515                Ok(())
1516            })
1517        }));
1518        config.revoke_sessions_on_password_reset = true;
1519        let plugin = EmailOtpPlugin::new(config);
1520        for path in [
1521            "/email-otp/request-password-reset",
1522            "/forget-password/email-otp",
1523        ] {
1524            assert_eq!(
1525                post(&plugin, &ctx, path, json!({"email":"RESET@example.com"}))
1526                    .await
1527                    .status,
1528                200
1529            );
1530        }
1531        let otp = outbox.0.lock().unwrap().last().unwrap().otp.clone();
1532        assert_eq!(
1533            post(
1534                &plugin,
1535                &ctx,
1536                "/email-otp/reset-password",
1537                json!({"email":"reset@example.com","otp":otp,"password":"short"})
1538            )
1539            .await
1540            .status,
1541            400
1542        );
1543        assert!(
1544            plugin
1545                .get_verification_otp(&ctx, "reset@example.com", EmailOtpType::ForgetPassword)
1546                .await
1547                .unwrap()
1548                .is_some()
1549        );
1550        assert_eq!(
1551            post(
1552                &plugin,
1553                &ctx,
1554                "/email-otp/reset-password",
1555                json!({"email":"reset@example.com","otp":otp,"password":"replacement-password"})
1556            )
1557            .await
1558            .status,
1559            200
1560        );
1561        let updated = ctx
1562            .database
1563            .get_account("credential", account.account_id())
1564            .await
1565            .unwrap()
1566            .unwrap();
1567        alibi_core::utils::password::verify_password(
1568            None,
1569            "replacement-password",
1570            updated.password().unwrap(),
1571        )
1572        .await
1573        .unwrap();
1574        assert!(
1575            ctx.database
1576                .get_session(session.token())
1577                .await
1578                .unwrap()
1579                .is_none()
1580        );
1581        assert!(
1582            ctx.database
1583                .get_user_by_id(&user.id())
1584                .await
1585                .unwrap()
1586                .unwrap()
1587                .email_verified()
1588        );
1589        assert_eq!(calls.lock().unwrap().len(), 1);
1590        assert_eq!(
1591            post(
1592                &plugin,
1593                &ctx,
1594                "/email-otp/reset-password",
1595                json!({"email":"reset@example.com","otp":otp,"password":"replacement-password"})
1596            )
1597            .await
1598            .status,
1599            400
1600        );
1601    }
1602
1603    // Upstream: an OTP reset can create the credential for a passwordless account;
1604    // unknown mailboxes do not receive reset codes.
1605    #[tokio::test]
1606    async fn reset_creates_missing_credential_and_unknown_reset_is_indistinguishable() {
1607        use alibi_core::AuthAccount;
1608
1609        let ctx = test_helpers::create_test_context().await;
1610        let user = ctx
1611            .database
1612            .create_user(
1613                CreateUser::new()
1614                    .with_email("passwordless@example.com")
1615                    .with_email_verified(true),
1616            )
1617            .await
1618            .unwrap();
1619        let (config, outbox) = configured();
1620        let plugin = EmailOtpPlugin::new(config);
1621        assert_eq!(
1622            post(
1623                &plugin,
1624                &ctx,
1625                "/email-otp/request-password-reset",
1626                json!({"email":"absent@example.com"})
1627            )
1628            .await
1629            .status,
1630            200
1631        );
1632        assert!(outbox.0.lock().unwrap().is_empty());
1633        let otp = plugin
1634            .create_verification_otp(
1635                &ctx,
1636                "passwordless@example.com",
1637                EmailOtpType::ForgetPassword,
1638            )
1639            .await
1640            .unwrap();
1641        assert_eq!(
1642            post(
1643                &plugin,
1644                &ctx,
1645                "/email-otp/reset-password",
1646                json!({"email":"passwordless@example.com","otp":otp,"password":"new-password123"})
1647            )
1648            .await
1649            .status,
1650            200
1651        );
1652        let accounts = ctx.database.get_user_accounts(&user.id()).await.unwrap();
1653        assert_eq!(accounts.len(), 1);
1654
1655        assert_eq!(accounts.first().unwrap().account_id(), user.id());
1656        assert_eq!(accounts.first().unwrap().provider_id(), "credential");
1657    }
1658
1659    // Upstream: checks preserve a correct code even if the mailbox has no user,
1660    // while wrong checks count toward the same budget and expired checks delete it.
1661    #[tokio::test]
1662    async fn nonconsuming_checks_count_attempts_and_reject_unowned_and_expired_mailboxes() {
1663        let ctx = test_helpers::create_test_context().await;
1664        let (mut config, _) = configured();
1665        config.allowed_attempts = 1.0;
1666        let plugin = EmailOtpPlugin::new(config);
1667        let otp = plugin
1668            .create_verification_otp(&ctx, "unknown@example.com", EmailOtpType::EmailVerification)
1669            .await
1670            .unwrap();
1671        let checked = post(
1672            &plugin,
1673            &ctx,
1674            "/email-otp/check-verification-otp",
1675            json!({"email":"unknown@example.com","type":"email-verification","otp":otp}),
1676        )
1677        .await;
1678        let payload: Value = serde_json::from_slice(&checked.body).unwrap();
1679        assert_eq!(payload.get("code"), Some(&json!("USER_NOT_FOUND")));
1680        assert_eq!(
1681            plugin
1682                .get_verification_otp(&ctx, "unknown@example.com", EmailOtpType::EmailVerification)
1683                .await
1684                .unwrap(),
1685            Some(otp)
1686        );
1687        assert_eq!(
1688            post(
1689                &plugin,
1690                &ctx,
1691                "/email-otp/check-verification-otp",
1692                json!({"email":"unknown@example.com","type":"email-verification","otp":"wrong"})
1693            )
1694            .await
1695            .status,
1696            400
1697        );
1698        assert_eq!(
1699            post(
1700                &plugin,
1701                &ctx,
1702                "/email-otp/check-verification-otp",
1703                json!({"email":"unknown@example.com","type":"email-verification","otp":"wrong"})
1704            )
1705            .await
1706            .status,
1707            403
1708        );
1709        assert!(
1710            plugin
1711                .get_verification_otp(&ctx, "unknown@example.com", EmailOtpType::EmailVerification)
1712                .await
1713                .unwrap()
1714                .is_none()
1715        );
1716        _ = ctx
1717            .database
1718            .create_verification(CreateVerification {
1719                identifier: "email-verification-otp-expired-check@example.com".into(),
1720                value: "654321:0".into(),
1721                expires_at: chrono::Utc::now() - Duration::seconds(1),
1722            })
1723            .await
1724            .unwrap();
1725        assert!(
1726            plugin
1727                .get_verification_otp(
1728                    &ctx,
1729                    "expired-check@example.com",
1730                    EmailOtpType::EmailVerification
1731                )
1732                .await
1733                .unwrap()
1734                .is_none()
1735        );
1736        assert_eq!(
1737        post(
1738            &plugin,
1739            &ctx,
1740            "/email-otp/check-verification-otp",
1741            json!({"email":"expired-check@example.com","type":"email-verification","otp":"654321"})
1742        )
1743        .await
1744        .status,
1745        400
1746    );
1747        assert!(
1748            ctx.database
1749                .get_latest_verification_by_identifier(
1750                    "email-verification-otp-expired-check@example.com"
1751                )
1752                .await
1753                .unwrap()
1754                .is_none()
1755        );
1756    }
1757
1758    // Upstream: verification invokes before/after hooks around the persisted change
1759    // and auto sign-in creates an actual owned session. Hook errors consume the OTP.
1760    #[tokio::test]
1761    async fn verification_hooks_and_auto_signin_observe_order_and_ownership() {
1762        let ctx = test_helpers::create_test_context().await;
1763        let user = ctx
1764            .database
1765            .create_user(CreateUser::new().with_email("hooks@example.com"))
1766            .await
1767            .unwrap();
1768        let seen = Arc::new(Mutex::new(Vec::new()));
1769        let (mut config, _) = configured();
1770        config.auto_sign_in_after_verification = true;
1771        let before = Arc::clone(&seen);
1772        config.before_email_verification = Some(Arc::new(move |user_2| {
1773            let before = Arc::clone(&before);
1774            let verified = user_2.email_verified;
1775            Box::pin(async move {
1776                before.lock().unwrap().push(("before", verified));
1777                Ok(())
1778            })
1779        }));
1780        let after = Arc::clone(&seen);
1781        config.after_email_verification = Some(Arc::new(move |user_3| {
1782            let after = Arc::clone(&after);
1783            let verified = user_3.email_verified;
1784            Box::pin(async move {
1785                after.lock().unwrap().push(("after", verified));
1786                Ok(())
1787            })
1788        }));
1789        let mut plugin = EmailOtpPlugin::new(config);
1790        let otp = plugin
1791            .create_verification_otp(&ctx, "hooks@example.com", EmailOtpType::EmailVerification)
1792            .await
1793            .unwrap();
1794        let response = post(
1795            &plugin,
1796            &ctx,
1797            "/email-otp/verify-email",
1798            json!({"email":"hooks@example.com","otp":otp}),
1799        )
1800        .await;
1801        assert_eq!(response.status, 200);
1802        let payload: Value = serde_json::from_slice(&response.body).unwrap();
1803        let token = payload.get("token").and_then(Value::as_str).unwrap();
1804        assert_eq!(
1805            ctx.database
1806                .get_session(token)
1807                .await
1808                .unwrap()
1809                .unwrap()
1810                .user_id(),
1811            user.id()
1812        );
1813        assert_eq!(*seen.lock().unwrap(), [("before", false), ("after", true)]);
1814        plugin.config.before_email_verification = Some(Arc::new(|_| {
1815            Box::pin(async { Err(AuthError::forbidden("blocked by verification policy")) })
1816        }));
1817        let otp_2 = plugin
1818            .create_verification_otp(&ctx, "hooks@example.com", EmailOtpType::EmailVerification)
1819            .await
1820            .unwrap();
1821        assert_eq!(
1822            post(
1823                &plugin,
1824                &ctx,
1825                "/email-otp/verify-email",
1826                json!({"email":"hooks@example.com","otp":otp_2})
1827            )
1828            .await
1829            .status,
1830            403
1831        );
1832        assert!(
1833            plugin
1834                .get_verification_otp(&ctx, "hooks@example.com", EmailOtpType::EmailVerification)
1835                .await
1836                .unwrap()
1837                .is_none()
1838        );
1839    }
1840
1841    // Upstream: changeEmail.verifyCurrentEmail consumes current-mailbox proof before
1842    // issuing a code bound to both mailboxes; occupied targets remain undisclosed.
1843    #[tokio::test]
1844    #[expect(
1845        clippy::too_many_lines,
1846        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1847    )]
1848    async fn email_change_requires_current_proof_and_hides_existing_target() {
1849        let ctx = test_helpers::create_test_context().await;
1850        let user = ctx
1851            .database
1852            .create_user(CreateUser::new().with_email("proof@example.com"))
1853            .await
1854            .unwrap();
1855        _ = ctx
1856            .database
1857            .create_user(CreateUser::new().with_email("occupied@example.com"))
1858            .await
1859            .unwrap();
1860        let session = ctx
1861            .session_manager()
1862            .create_session(&user, None, None)
1863            .await
1864            .unwrap();
1865        let (mut config, outbox) = configured();
1866        config.change_email_enabled = true;
1867        config.verify_current_email = true;
1868        let plugin = EmailOtpPlugin::new(config);
1869        let request = |body| {
1870            create_auth_json_request_no_query(
1871                HttpMethod::Post,
1872                "/email-otp/request-email-change",
1873                Some(session.token()),
1874                Some(body),
1875            )
1876        };
1877        assert_eq!(
1878            plugin
1879                .on_request(&request(json!({"newEmail":"target@example.com"})), &ctx)
1880                .await
1881                .unwrap_err()
1882                .status_code(),
1883            400
1884        );
1885        assert!(outbox.0.lock().unwrap().is_empty());
1886        let current = plugin
1887            .create_verification_otp(&ctx, "proof@example.com", EmailOtpType::EmailVerification)
1888            .await
1889            .unwrap();
1890        assert_eq!(
1891            plugin
1892                .on_request(
1893                    &request(json!({"newEmail":"occupied@example.com","otp":current})),
1894                    &ctx
1895                )
1896                .await
1897                .unwrap()
1898                .unwrap()
1899                .status,
1900            200
1901        );
1902        assert!(outbox.0.lock().unwrap().is_empty());
1903        assert!(
1904            plugin
1905                .get_verification_otp(&ctx, "proof@example.com", EmailOtpType::EmailVerification)
1906                .await
1907                .unwrap()
1908                .is_none()
1909        );
1910        assert!(
1911            ctx.database
1912                .get_latest_verification_by_identifier(
1913                    "change-email-otp-proof@example.com-occupied@example.com"
1914                )
1915                .await
1916                .unwrap()
1917                .is_none()
1918        );
1919        let current_2 = plugin
1920            .create_verification_otp(&ctx, "proof@example.com", EmailOtpType::EmailVerification)
1921            .await
1922            .unwrap();
1923        assert_eq!(
1924            plugin
1925                .on_request(
1926                    &request(json!({"newEmail":"target@example.com","otp":current_2})),
1927                    &ctx
1928                )
1929                .await
1930                .unwrap()
1931                .unwrap()
1932                .status,
1933            200
1934        );
1935        let code = outbox.0.lock().unwrap().last().unwrap().otp.clone();
1936        let confirm = create_auth_json_request_no_query(
1937            HttpMethod::Post,
1938            "/email-otp/change-email",
1939            Some(session.token()),
1940            Some(json!({"newEmail":"target@example.com","otp":code})),
1941        );
1942        assert_eq!(
1943            plugin
1944                .on_request(&confirm, &ctx)
1945                .await
1946                .unwrap()
1947                .unwrap()
1948                .status,
1949            200
1950        );
1951        assert_eq!(
1952            ctx.database
1953                .get_user_by_id(&user.id())
1954                .await
1955                .unwrap()
1956                .unwrap()
1957                .email(),
1958            Some("target@example.com")
1959        );
1960    }
1961
1962    // Golden error vectors from the 1.7.6 runtime. Optional nulls are invalid;
1963    // the schema reports every failed field before authentication middleware.
1964    #[tokio::test]
1965    async fn request_validation_matches_pinned_runtime_error_vectors() {
1966        let ctx = test_helpers::create_test_context().await;
1967        let (config, _) = configured();
1968        let plugin = EmailOtpPlugin::new(config);
1969        for (path, body, message) in [
1970            (
1971                "/email-otp/send-verification-otp",
1972                json!({}),
1973                "[body.email] Invalid input: expected string, received undefined; [body.type] Invalid option: expected one of \"email-verification\"|\"sign-in\"|\"forget-password\"|\"change-email\"",
1974            ),
1975            (
1976                "/sign-in/email-otp",
1977                json!({"email":5,"otp":false,"name":null,"image":1}),
1978                "[body.email] Invalid input: expected string, received number; [body.otp] Invalid input: expected string, received boolean; [body.name] Invalid input: expected string, received null; [body.image] Invalid input: expected string, received number",
1979            ),
1980            (
1981                "/email-otp/change-email",
1982                json!({}),
1983                "[body.newEmail] Invalid input: expected string, received undefined; [body.otp] Invalid input: expected string, received undefined",
1984            ),
1985        ] {
1986            let response = post(&plugin, &ctx, path, body).await;
1987            assert_eq!(response.status, 400);
1988            let payload: Value = serde_json::from_slice(&response.body).unwrap();
1989            assert_eq!(
1990                payload,
1991                json!({"message":message,"code":"VALIDATION_ERROR"})
1992            );
1993        }
1994        for email in [
1995            "a@b.c",
1996            ".a@example.com",
1997            "a..b@example.com",
1998            "a!b@example.com",
1999            "a'@example.com",
2000        ] {
2001            assert_eq!(
2002                post(
2003                    &plugin,
2004                    &ctx,
2005                    "/email-otp/send-verification-otp",
2006                    json!({"email":email,"type":"sign-in"})
2007                )
2008                .await
2009                .status,
2010                400
2011            );
2012        }
2013        assert_eq!(
2014            post(
2015                &plugin,
2016                &ctx,
2017                "/email-otp/send-verification-otp",
2018                json!({"email":"a'b@example.com","type":"change-email"})
2019            )
2020            .await
2021            .status,
2022            400
2023        );
2024    }
2025
2026    // Upstream: sendVerificationOnSignUp runs after a successful sign-up response,
2027    // and disabled signup never issues a delivered login for an unknown mailbox.
2028    #[tokio::test]
2029    async fn signup_hook_and_disabled_signup_preserve_delivery_and_state_contracts() {
2030        let ctx = test_helpers::create_test_context().await;
2031        let (mut config, outbox) = configured();
2032        config.send_verification_on_sign_up = true;
2033        config.disable_sign_up = true;
2034        let plugin = EmailOtpPlugin::new(config);
2035        let request = AuthRequest::new(HttpMethod::Post, "/sign-up/email");
2036        let response =
2037            AuthResponse::json(200, &json!({"user":{"email":"signup@example.com"}})).unwrap();
2038        _ = plugin
2039            .after_request(&request, &ctx, response)
2040            .await
2041            .unwrap();
2042        assert_eq!(
2043            outbox.0.lock().unwrap().last().unwrap().otp_type,
2044            EmailOtpType::EmailVerification
2045        );
2046        assert_eq!(
2047            post(
2048                &plugin,
2049                &ctx,
2050                "/email-otp/send-verification-otp",
2051                json!({"email":"missing@example.com","type":"sign-in"})
2052            )
2053            .await
2054            .status,
2055            200
2056        );
2057        assert!(
2058            ctx.database
2059                .get_latest_verification_by_identifier("sign-in-otp-missing@example.com")
2060                .await
2061                .unwrap()
2062                .is_none()
2063        );
2064        let otp = plugin
2065            .create_verification_otp(&ctx, "missing@example.com", EmailOtpType::SignIn)
2066            .await
2067            .unwrap();
2068        assert_eq!(
2069            post(
2070                &plugin,
2071                &ctx,
2072                "/sign-in/email-otp",
2073                json!({"email":"missing@example.com","otp":otp})
2074            )
2075            .await
2076            .status,
2077            400
2078        );
2079        assert!(
2080            ctx.database
2081                .get_user_by_email("missing@example.com")
2082                .await
2083                .unwrap()
2084                .is_none()
2085        );
2086    }
2087
2088    /// Distinct delivery lifecycle contract: an abandoned completion observation
2089    /// still owns the real request/context and can read the issued proof after the
2090    /// request returns. No fixture supplies the proof or completes delivery.
2091    #[tokio::test]
2092    async fn background_delivery_retains_request_store_and_proof_after_observer_rejection() {
2093        type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
2094        struct RejectObservation;
2095        impl alibi_core::BackgroundTaskHandler for RejectObservation {
2096            fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
2097                drop(completion);
2098                Err(AuthError::internal("observer rejected"))
2099            }
2100        }
2101        struct Deferred {
2102            resume: tokio::sync::Notify,
2103            result: tokio::sync::Mutex<Option<(EmailOtpDelivery, String, bool)>>,
2104            done: tokio::sync::Notify,
2105        }
2106        #[async_trait]
2107        impl SendEmailOtp for Deferred {
2108            async fn send(
2109                &self,
2110                delivery: &EmailOtpDelivery,
2111                callback: &alibi_core::CallbackContext,
2112            ) -> AuthResult<()> {
2113                self.resume.notified().await;
2114                let ctx = callback.context::<Schema>().unwrap();
2115                let proof = ctx
2116                    .database
2117                    .get_verification_by_identifier(&format!("sign-in-otp-{}", delivery.email))
2118                    .await?;
2119                let marker = callback
2120                    .request
2121                    .as_ref()
2122                    .unwrap()
2123                    .headers
2124                    .get("x-callback-marker")
2125                    .unwrap()
2126                    .clone();
2127                *self.result.lock().await = Some((delivery.clone(), marker, proof.is_some()));
2128                self.done.notify_one();
2129                Err(AuthError::Upstream {
2130                    status: 409,
2131                    code: "DELIVERY_REJECTED",
2132                    message: "delivery rejected",
2133                })
2134            }
2135        }
2136        let mut ctx = test_helpers::create_test_context().await;
2137        ctx.config = Arc::new(
2138            (*ctx.config)
2139                .clone()
2140                .background_tasks(Arc::new(RejectObservation)),
2141        );
2142        let sender = Arc::new(Deferred {
2143            resume: tokio::sync::Notify::new(),
2144            result: tokio::sync::Mutex::new(None),
2145            done: tokio::sync::Notify::new(),
2146        });
2147        let plugin = EmailOtpPlugin::new(EmailOtpConfig {
2148            send_verification_otp: Some(sender.clone()),
2149            ..Default::default()
2150        });
2151        let email = "background-context@fixture.test";
2152        let mut request = create_auth_json_request_no_query(
2153            HttpMethod::Post,
2154            "/email-otp/send-verification-otp",
2155            None,
2156            Some(json!({"email":email,"type":"sign-in"})),
2157        );
2158        request
2159            .headers
2160            .insert("x-callback-marker".into(), "real-request".into());
2161        let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
2162        assert_eq!(response.status, 200);
2163        assert!(sender.result.lock().await.is_none());
2164        drop(request);
2165        sender.resume.notify_one();
2166        tokio::time::timeout(std::time::Duration::from_secs(5), sender.done.notified())
2167            .await
2168            .unwrap();
2169        let (delivery, marker, proof_exists) = sender.result.lock().await.clone().unwrap();
2170        assert_eq!(marker, "real-request");
2171        assert!(proof_exists);
2172        let consumed = post(
2173            &plugin,
2174            &ctx,
2175            "/sign-in/email-otp",
2176            json!({"email":email,"otp":delivery.otp}),
2177        )
2178        .await;
2179        assert_eq!(consumed.status, 200);
2180        let replay = post(
2181            &plugin,
2182            &ctx,
2183            "/sign-in/email-otp",
2184            json!({"email":email,"otp":delivery.otp}),
2185        )
2186        .await;
2187        assert_eq!(replay.status, 400);
2188        assert_eq!(
2189            ctx.database
2190                .get_user_sessions(
2191                    serde_json::from_slice::<Value>(&consumed.body)
2192                        .unwrap()
2193                        .get("user")
2194                        .and_then(|user| user.get("id"))
2195                        .and_then(Value::as_str)
2196                        .unwrap()
2197                )
2198                .await
2199                .unwrap()
2200                .len(),
2201            1
2202        );
2203    }
2204}
2205// LCOV_EXCL_STOP