Skip to main content

alibi_api/plugins/password_management/
mod.rs

1mod set_password;
2pub use set_password::set_password;
3
4pub(super) mod handlers;
5
6pub(super) mod types;
7
8use super::StatusResponse;
9use alibi_core::RequestMeta;
10use alibi_core::utils::password::PasswordHasher;
11use alibi_core::{AuthContext, AuthPlugin, AuthRoute};
12use alibi_core::{AuthError, AuthResult};
13use alibi_core::{AuthRequest, AuthResponse, HttpMethod};
14use async_trait::async_trait;
15use handlers::{
16    change_password_core, request_password_reset_core, reset_password_core,
17    reset_password_token_core, verify_password_core,
18};
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::Arc;
22use types::{
23    ChangePasswordRequest, RequestPasswordResetRequest, ResetPasswordRequest,
24    ResetPasswordTokenQuery, ResetPasswordTokenResult, VerifyPasswordRequest,
25};
26
27/// Type alias for the async password-reset callback to keep Clippy happy.
28pub type OnPasswordResetCallback =
29    dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync;
30
31/// Trait for sending password reset emails.
32///
33/// This callback powers `POST /request-password-reset` and is required to
34/// enable that route. The user is provided as a serialized `serde_json::Value`
35/// since `AuthUser` is not object-safe.
36#[async_trait]
37pub trait SendResetPassword: Send + Sync {
38    /// Send a password reset notification.
39    ///
40    /// * `user` - The initialized user schema as a serialized JSON value
41    /// * `url` - The full reset URL including the token
42    /// * `token` - The raw reset token
43    async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
44}
45
46/// Password management plugin for password reset and change functionality
47pub struct PasswordManagementPlugin {
48    config: PasswordManagementConfig,
49}
50
51impl std::fmt::Debug for PasswordManagementPlugin {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("PasswordManagementPlugin")
54            .finish_non_exhaustive()
55    }
56}
57
58#[derive(Clone, alibi_core::PluginConfig)]
59#[plugin(name = "PasswordManagementPlugin")]
60pub struct PasswordManagementConfig {
61    #[config(default = 1)]
62    pub reset_token_expiry_hours: i64,
63    /// Overrides the legacy hour setting with a precise reset-token duration.
64    /// Zero retains the default expiry, matching the upstream option.
65    #[config(default = None)]
66    pub reset_token_expiry: Option<chrono::Duration>,
67    #[config(default = true)]
68    pub require_current_password: bool,
69    #[config(default = true)]
70    pub send_email_notifications: bool,
71    /// When true, all existing sessions are revoked on password reset (default: false).
72    #[config(default = false)]
73    pub revoke_sessions_on_password_reset: bool,
74    /// Password reset email sender for `POST /request-password-reset`.
75    /// This route is disabled when no sender is configured.
76    #[config(default = None)]
77    pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
78    /// Callback invoked after a password is successfully reset.
79    /// The initialized user schema is provided as a serialized JSON value.
80    /// Errors propagate after the credential write and before session revocation.
81    #[config(default = None)]
82    pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
83    /// Custom password hasher. When `None`, the default scrypt hasher is used.
84    /// Resetting a password inherits an initialized email/password hasher first.
85    #[config(default = None)]
86    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
87}
88
89impl std::fmt::Debug for PasswordManagementConfig {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("PasswordManagementConfig")
92            .field("reset_token_expiry_hours", &self.reset_token_expiry_hours)
93            .field("reset_token_expiry", &self.reset_token_expiry)
94            .field("require_current_password", &self.require_current_password)
95            .field("send_email_notifications", &self.send_email_notifications)
96            .field(
97                "revoke_sessions_on_password_reset",
98                &self.revoke_sessions_on_password_reset,
99            )
100            .field(
101                "send_reset_password",
102                &self.send_reset_password.as_ref().map(|_| "custom"),
103            )
104            .field(
105                "on_password_reset",
106                &self.on_password_reset.as_ref().map(|_| "custom"),
107            )
108            .field(
109                "password_hasher",
110                &self.password_hasher.as_ref().map(|_| "custom"),
111            )
112            .finish()
113    }
114}
115
116#[async_trait]
117impl<S: alibi_core::AuthSchema> AuthPlugin<S> for PasswordManagementPlugin {
118    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
119        crate::metadata::plugin_metadata(
120            <Self as alibi_core::AuthPlugin<S>>::name(self),
121            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
122        )
123    }
124
125    fn openapi_metadata(
126        &self,
127        ctx: &alibi_core::AuthInitContext<S>,
128    ) -> alibi_core::PluginOpenApiMetadata {
129        crate::metadata::instance_plugin_metadata(
130            <Self as alibi_core::AuthPlugin<S>>::name(self),
131            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
132            ctx,
133        )
134    }
135
136    fn name(&self) -> &'static str {
137        "password-management"
138    }
139
140    async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
141        ctx.extensions.insert(self.config.clone());
142        Ok(())
143    }
144
145    fn routes(&self) -> Vec<AuthRoute> {
146        vec![
147            AuthRoute::post("/request-password-reset", "request_password_reset"),
148            AuthRoute::post("/reset-password", "reset_password"),
149            AuthRoute::get("/reset-password/{token}", "reset_password_token"),
150            AuthRoute::post("/change-password", "change_password"),
151            AuthRoute::post("/verify-password", "verify_password"),
152        ]
153    }
154
155    async fn on_request(
156        &self,
157        req: &AuthRequest,
158        ctx: &AuthContext<S>,
159    ) -> AuthResult<Option<AuthResponse>> {
160        match (req.method(), req.path()) {
161            (HttpMethod::Post, "/request-password-reset") => {
162                Ok(Some(self.handle_request_password_reset(req, ctx).await?))
163            }
164            (HttpMethod::Post, "/reset-password") => {
165                Ok(Some(self.handle_reset_password(req, ctx).await?))
166            }
167            (HttpMethod::Post, "/change-password") => {
168                Ok(Some(self.handle_change_password(req, ctx).await?))
169            }
170            (HttpMethod::Post, "/verify-password") => {
171                Ok(Some(self.handle_verify_password(req, ctx).await?))
172            }
173            (HttpMethod::Get, path) if path.starts_with("/reset-password/") => {
174                let token = path.get(16..).unwrap_or(""); // Remove "/reset-password/" prefix
175                Ok(Some(
176                    self.handle_reset_password_token(token, req, ctx).await?,
177                ))
178            }
179            _ => Ok(None),
180        }
181    }
182}
183
184// Implementation methods outside the trait
185impl PasswordManagementPlugin {
186    async fn handle_request_password_reset(
187        &self,
188        req: &AuthRequest,
189        ctx: &AuthContext<impl alibi_core::AuthSchema>,
190    ) -> AuthResult<AuthResponse> {
191        let body: RequestPasswordResetRequest = match alibi_core::validate_request_body(req) {
192            Ok(v) => v,
193            Err(resp) => return Ok(resp),
194        };
195        let response = request_password_reset_core(&body, &self.config, ctx).await?;
196        Ok(AuthResponse::json(200, &response)?)
197    }
198
199    async fn handle_reset_password(
200        &self,
201        req: &AuthRequest,
202        ctx: &AuthContext<impl alibi_core::AuthSchema>,
203    ) -> AuthResult<AuthResponse> {
204        let mut body: ResetPasswordRequest = match alibi_core::validate_request_body(req) {
205            Ok(v) => v,
206            Err(resp) => return Ok(resp),
207        };
208        if body.token.as_ref().is_none_or(String::is_empty) {
209            body.token = req.query.get("token").cloned();
210        }
211        let response = reset_password_core(&body, &self.config, ctx).await?;
212        Ok(AuthResponse::json(200, &response)?)
213    }
214
215    async fn handle_change_password(
216        &self,
217        req: &AuthRequest,
218        ctx: &AuthContext<impl alibi_core::AuthSchema>,
219    ) -> AuthResult<AuthResponse> {
220        let body: ChangePasswordRequest = match alibi_core::validate_request_body(req) {
221            Ok(v) => v,
222            Err(resp) => return Ok(resp),
223        };
224
225        let (user, _session) = ctx
226            .require_authoritative_session_record(req)
227            .await
228            .map_err(|error| {
229                if matches!(
230                    error,
231                    AuthError::Unauthenticated
232                        | AuthError::SessionNotFound
233                        | AuthError::UserNotFound
234                ) {
235                    AuthError::Upstream {
236                        status: 401,
237                        code: "UNAUTHORIZED",
238                        message: "Unauthorized",
239                    }
240                } else {
241                    error
242                }
243            })?;
244        let meta = RequestMeta::from_request(req);
245
246        let (response, new_token) =
247            change_password_core(&body, &user, &self.config, &meta, ctx).await?;
248
249        let auth_response = AuthResponse::json(200, &response)?;
250
251        // Set session cookie if a new session was created
252        if let Some(token) = new_token {
253            use alibi_core::utils::cookie_utils::{
254                create_session_cookie_with_max_age, create_session_like_cookie,
255                related_cookie_name, sign_cookie_value, verify_cookie_value,
256            };
257            let preference = related_cookie_name(&ctx.config, "dont_remember");
258            let dont_remember = super::helpers::get_cookie(req, &preference)
259                .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
260                .is_some_and(|value| !value.is_empty());
261            let cookie_header = create_session_cookie_with_max_age(
262                Some(&token),
263                (!dont_remember).then(|| ctx.config.session.expires_in.num_seconds()),
264                &ctx.config,
265            )?;
266            let mut response = auth_response.with_header("Set-Cookie", cookie_header);
267            if dont_remember {
268                response.headers.append(
269                    "Set-Cookie",
270                    create_session_like_cookie(
271                        &preference,
272                        &sign_cookie_value("true", ctx.config.current_secret()),
273                        None,
274                        &ctx.config,
275                    )?,
276                );
277            }
278            Ok(response)
279        } else {
280            Ok(auth_response)
281        }
282    }
283
284    async fn handle_verify_password(
285        &self,
286        req: &AuthRequest,
287        ctx: &AuthContext<impl alibi_core::AuthSchema>,
288    ) -> AuthResult<AuthResponse> {
289        let body: VerifyPasswordRequest = match alibi_core::validate_request_body(req) {
290            Ok(v) => v,
291            Err(resp) => return Ok(resp),
292        };
293
294        let user = self.get_current_user(req, ctx).await?;
295        let Some(user) = user else {
296            // better-call's default body for a session-gated endpoint hit
297            // without a session, which upstream returns verbatim.
298            return Ok(
299                AuthError::AuthenticationFailed("Unauthorized".to_owned()).to_auth_response()
300            );
301        };
302        let response = verify_password_core(&body, &user, &self.config, ctx).await?;
303        Ok(AuthResponse::json(200, &response)?)
304    }
305
306    async fn handle_reset_password_token(
307        &self,
308        token: &str,
309        req: &AuthRequest,
310        ctx: &AuthContext<impl alibi_core::AuthSchema>,
311    ) -> AuthResult<AuthResponse> {
312        let query = ResetPasswordTokenQuery {
313            callback_url: req.query.get("callbackURL").cloned(),
314        };
315        match reset_password_token_core(token, &query, ctx).await? {
316            ResetPasswordTokenResult::Redirect(url) => {
317                let mut headers = alibi_core::Headers::new();
318                drop(headers.insert("Location".to_owned(), url));
319                drop(headers.insert("content-type".to_owned(), "application/json".to_owned()));
320                Ok(AuthResponse {
321                    status: 302,
322                    headers,
323                    body: Vec::new(),
324                })
325            }
326        }
327    }
328
329    async fn get_current_user<S: alibi_core::AuthSchema>(
330        &self,
331        req: &AuthRequest,
332        ctx: &AuthContext<S>,
333    ) -> AuthResult<Option<S::User>> {
334        let session_manager = ctx.session_manager();
335
336        if let Some(token) = session_manager.extract_session_token(req)
337            && let Some(session) = session_manager.get_session(&token).await?
338        {
339            return ctx.session_user(&session).await;
340        }
341
342        Ok(None)
343    }
344}
345
346// LCOV_EXCL_START
347#[cfg(test)]
348mod tests {
349    use super::types::RequestPasswordResetResponse;
350    use super::*;
351    use crate::plugins::test_helpers;
352    use alibi_core::AuthContext;
353    use alibi_core::config::{AuthConfig, PasswordConfig};
354    use alibi_core::utils::password::{hash_password, verify_password};
355    use alibi_core::wire::{SessionView, UserView};
356    use alibi_core::{CreateAccount, CreateUser, CreateVerification};
357    use chrono::{Duration, Utc};
358    use std::collections::HashMap;
359    use std::sync::Arc;
360
361    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
362
363    const PASSWORD_RESET_SUCCESS_MESSAGE: &str =
364        "If this email exists in our system, check your email for the reset link";
365
366    struct NoopResetSender;
367
368    #[async_trait::async_trait]
369    impl SendResetPassword for NoopResetSender {
370        async fn send(
371            &self,
372            _user: &serde_json::Value,
373            _url: &str,
374            _token: &str,
375        ) -> AuthResult<()> {
376            Ok(())
377        }
378    }
379
380    fn plugin_with_reset_sender() -> PasswordManagementPlugin {
381        PasswordManagementPlugin::new().send_reset_password(Arc::new(NoopResetSender))
382    }
383
384    async fn create_test_context_with_user() -> (AuthContext<TestSchema>, UserView, SessionView) {
385        let mut config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
386        config.password = PasswordConfig {
387            require_uppercase: true,
388            require_lowercase: true,
389            require_numbers: true,
390            require_special: true,
391        };
392
393        let ctx = test_helpers::create_test_context_with_config(config).await;
394
395        // Create test user with hashed password
396        let password_hash = hash_password(None, "Password123!").await.unwrap();
397
398        let create_user = CreateUser::new()
399            .with_email("test@example.com")
400            .with_name("Test User");
401        let user = test_helpers::create_user(&ctx, create_user).await;
402        drop(
403            ctx.database
404                .create_account(CreateAccount {
405                    additional_fields: Default::default(),
406                    user_id: user.id.clone(),
407                    account_id: user.id.clone(),
408                    provider_id: "credential".to_owned(),
409                    access_token: None,
410                    refresh_token: None,
411                    id_token: None,
412                    access_token_expires_at: None,
413                    refresh_token_expires_at: None,
414                    scope: None,
415                    password: Some(password_hash),
416                })
417                .await
418                .unwrap(),
419        );
420        let session =
421            test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(24)).await;
422
423        (ctx, user, session)
424    }
425
426    async fn create_test_context_with_oauth_only_user()
427    -> (AuthContext<TestSchema>, UserView, SessionView) {
428        let (ctx, user, session) = create_test_context_with_user().await;
429
430        let existing_accounts = ctx.database.get_user_accounts(&user.id).await.unwrap();
431        for account in existing_accounts {
432            ctx.database.delete_account(&account.id).await.unwrap();
433        }
434
435        drop(
436            ctx.database
437                .create_account(CreateAccount {
438                    additional_fields: Default::default(),
439                    user_id: user.id.clone(),
440                    account_id: "google-account-id".to_owned(),
441                    provider_id: "google".to_owned(),
442                    access_token: Some("oauth-access-token".to_owned()),
443                    refresh_token: Some("oauth-refresh-token".to_owned()),
444                    id_token: None,
445                    access_token_expires_at: None,
446                    refresh_token_expires_at: None,
447                    scope: Some("email profile".to_owned()),
448                    password: None,
449                })
450                .await
451                .unwrap(),
452        );
453
454        (ctx, user, session)
455    }
456
457    /// Helper: create a reset-password verification token for the given user
458    /// and store it in the database. Returns the token string.
459    async fn create_reset_token(
460        ctx: &AuthContext<impl alibi_core::AuthSchema>,
461        user_id: &str,
462    ) -> String {
463        let reset_token = uuid::Uuid::new_v4().simple().to_string();
464        let create_verification = CreateVerification {
465            identifier: format!("reset-password:{reset_token}"),
466            value: user_id.to_owned(),
467            expires_at: Utc::now() + Duration::hours(24),
468        };
469        ctx.database
470            .create_verification(create_verification)
471            .await
472            .unwrap();
473        reset_token
474    }
475
476    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
477    #[tokio::test]
478    async fn test_request_password_reset_success() {
479        let plugin = plugin_with_reset_sender();
480        let (ctx, _user, _session) = create_test_context_with_user().await;
481
482        let body = serde_json::json!({
483            "email": "test@example.com",
484            "redirectTo": "http://localhost:3000/reset"
485        });
486
487        let req = test_helpers::create_auth_request_no_query(
488            HttpMethod::Post,
489            "/request-password-reset",
490            None,
491            Some(body.to_string().into_bytes()),
492        );
493
494        let response = plugin
495            .handle_request_password_reset(&req, &ctx)
496            .await
497            .unwrap();
498        assert_eq!(response.status, 200);
499
500        let body_str = String::from_utf8(response.body).unwrap();
501        let response_data: RequestPasswordResetResponse = serde_json::from_str(&body_str).unwrap();
502        assert!(response_data.status);
503        assert_eq!(response_data.message, PASSWORD_RESET_SUCCESS_MESSAGE);
504    }
505
506    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
507    #[tokio::test]
508    async fn test_request_password_reset_unknown_email() {
509        let plugin = plugin_with_reset_sender();
510        let (ctx, _user, _session) = create_test_context_with_user().await;
511
512        let body = serde_json::json!({
513            "email": "unknown@example.com"
514        });
515
516        let req = test_helpers::create_auth_request_no_query(
517            HttpMethod::Post,
518            "/request-password-reset",
519            None,
520            Some(body.to_string().into_bytes()),
521        );
522
523        let response = plugin
524            .handle_request_password_reset(&req, &ctx)
525            .await
526            .unwrap();
527        assert_eq!(response.status, 200);
528
529        let body_str = String::from_utf8(response.body).unwrap();
530        let response_data: RequestPasswordResetResponse = serde_json::from_str(&body_str).unwrap();
531        assert!(response_data.status);
532        assert_eq!(response_data.message, PASSWORD_RESET_SUCCESS_MESSAGE);
533    }
534
535    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
536    #[tokio::test]
537    async fn test_reset_password_success() {
538        let plugin = PasswordManagementPlugin::new();
539        let (ctx, user, _session) = create_test_context_with_user().await;
540
541        let reset_token = create_reset_token(&ctx, &user.id).await;
542
543        let body = serde_json::json!({
544            "newPassword": "NewPassword123!",
545            "token": reset_token
546        });
547
548        let req = test_helpers::create_auth_request_no_query(
549            HttpMethod::Post,
550            "/reset-password",
551            None,
552            Some(body.to_string().into_bytes()),
553        );
554
555        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
556        assert_eq!(response.status, 200);
557
558        let body_str = String::from_utf8(response.body).unwrap();
559        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
560        assert!(response_data.status);
561
562        // Verify password was updated
563        let accounts = ctx.database.get_user_accounts(&user.id).await.unwrap();
564        let stored_hash = accounts
565            .iter()
566            .find(|account| account.provider_id == "credential")
567            .and_then(|account| account.password.as_deref())
568            .unwrap();
569        assert!(
570            verify_password(None, "NewPassword123!", stored_hash)
571                .await
572                .is_ok()
573        );
574
575        let verification_check = ctx
576            .database
577            .get_verification_by_identifier(&format!("reset-password:{reset_token}"))
578            .await
579            .unwrap();
580        assert!(verification_check.is_none());
581    }
582
583    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
584    #[tokio::test]
585    async fn test_reset_password_invalid_token() {
586        let plugin = PasswordManagementPlugin::new();
587        let (ctx, _user, _session) = create_test_context_with_user().await;
588
589        let body = serde_json::json!({
590            "newPassword": "NewPassword123!",
591            "token": "invalid_token"
592        });
593
594        let req = test_helpers::create_auth_request_no_query(
595            HttpMethod::Post,
596            "/reset-password",
597            None,
598            Some(body.to_string().into_bytes()),
599        );
600
601        let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
602        assert_eq!(err.status_code(), 400);
603    }
604
605    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
606    #[tokio::test]
607    async fn test_reset_password_weak_password() {
608        let plugin = PasswordManagementPlugin::new();
609        let (ctx, user, _session) = create_test_context_with_user().await;
610
611        let reset_token = create_reset_token(&ctx, &user.id).await;
612
613        let body = serde_json::json!({
614            "newPassword": "weak",
615            "token": reset_token
616        });
617
618        let req = test_helpers::create_auth_request_no_query(
619            HttpMethod::Post,
620            "/reset-password",
621            None,
622            Some(body.to_string().into_bytes()),
623        );
624
625        let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
626        assert_eq!(err.status_code(), 400);
627    }
628
629    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
630    #[tokio::test]
631    async fn test_change_password_success() {
632        let plugin = PasswordManagementPlugin::new();
633        let (ctx, _user, session) = create_test_context_with_user().await;
634
635        let body = serde_json::json!({
636            "currentPassword": "Password123!",
637            "newPassword": "NewPassword123!",
638            "revokeOtherSessions": "false"
639        });
640
641        let req = test_helpers::create_auth_request_no_query(
642            HttpMethod::Post,
643            "/change-password",
644            Some(&session.token),
645            Some(body.to_string().into_bytes()),
646        );
647
648        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
649        assert_eq!(response.status, 200);
650
651        let body_str = String::from_utf8(response.body).unwrap();
652        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
653        assert!(
654            (*(response_data)
655                .get("token")
656                .unwrap_or(&serde_json::Value::Null))
657            .is_null()
658        ); // No new token when not revoking sessions
659
660        // Verify password was updated by checking the database directly
661        let user_id = (*(*(response_data)
662            .get("user")
663            .unwrap_or(&serde_json::Value::Null))
664        .get("id")
665        .unwrap_or(&serde_json::Value::Null))
666        .as_str()
667        .unwrap();
668        let accounts = ctx.database.get_user_accounts(user_id).await.unwrap();
669        let stored_hash = accounts
670            .iter()
671            .find(|account| account.provider_id == "credential")
672            .and_then(|account| account.password.as_deref())
673            .unwrap();
674        assert!(
675            verify_password(None, "NewPassword123!", stored_hash)
676                .await
677                .is_ok()
678        );
679    }
680
681    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
682    #[tokio::test]
683    async fn test_change_password_with_session_revocation() {
684        let plugin = PasswordManagementPlugin::new();
685        let (ctx, _user, session) = create_test_context_with_user().await;
686
687        let body = serde_json::json!({
688            "currentPassword": "Password123!",
689            "newPassword": "NewPassword123!",
690            "revokeOtherSessions": "true"
691        });
692
693        let req = test_helpers::create_auth_request_no_query(
694            HttpMethod::Post,
695            "/change-password",
696            Some(&session.token),
697            Some(body.to_string().into_bytes()),
698        );
699
700        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
701        assert_eq!(response.status, 200);
702
703        let body_str = String::from_utf8(response.body).unwrap();
704        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
705        assert!(
706            (*(response_data)
707                .get("token")
708                .unwrap_or(&serde_json::Value::Null))
709            .is_string()
710        ); // New token when revoking sessions
711    }
712
713    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
714    #[tokio::test]
715    async fn test_change_password_sets_cookie_on_session_revocation() {
716        let plugin = PasswordManagementPlugin::new();
717        let (ctx, _user, session) = create_test_context_with_user().await;
718
719        let body = serde_json::json!({
720            "currentPassword": "Password123!",
721            "newPassword": "NewPassword123!",
722            "revokeOtherSessions": true
723        });
724
725        let req = test_helpers::create_auth_request_no_query(
726            HttpMethod::Post,
727            "/change-password",
728            Some(&session.token),
729            Some(body.to_string().into_bytes()),
730        );
731
732        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
733        assert_eq!(response.status, 200);
734
735        // Verify Set-Cookie header is present
736        let set_cookie = response.headers.get("Set-Cookie");
737        assert!(
738            set_cookie.is_some(),
739            "Set-Cookie header must be set when revokeOtherSessions is true"
740        );
741
742        let cookie_value = set_cookie.unwrap();
743        assert!(
744            cookie_value.contains(&ctx.config.session.cookie_name),
745            "Cookie must contain the session cookie name"
746        );
747        assert!(
748            cookie_value.contains("Path=/"),
749            "Cookie must contain Path=/"
750        );
751        assert!(
752            cookie_value.contains("Max-Age=604800"),
753            "Cookie must retain the configured session lifetime"
754        );
755        assert!(
756            !cookie_value.contains("Expires="),
757            "Source does not synthesize Expires from Max-Age"
758        );
759    }
760
761    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
762    #[tokio::test]
763    async fn test_change_password_no_cookie_without_revocation() {
764        let plugin = PasswordManagementPlugin::new();
765        let (ctx, _user, session) = create_test_context_with_user().await;
766
767        let body = serde_json::json!({
768            "currentPassword": "Password123!",
769            "newPassword": "NewPassword123!"
770        });
771
772        let req = test_helpers::create_auth_request_no_query(
773            HttpMethod::Post,
774            "/change-password",
775            Some(&session.token),
776            Some(body.to_string().into_bytes()),
777        );
778
779        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
780        assert_eq!(response.status, 200);
781
782        // Verify Set-Cookie header is NOT present when not revoking sessions
783        let set_cookie = response.headers.get("Set-Cookie");
784        assert!(
785            set_cookie.is_none(),
786            "Set-Cookie header must not be set when revokeOtherSessions is not true"
787        );
788    }
789
790    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
791    #[tokio::test]
792    async fn test_change_password_revoke_with_boolean() {
793        let plugin = PasswordManagementPlugin::new();
794        let (ctx, _user, session) = create_test_context_with_user().await;
795
796        // Send revokeOtherSessions as a boolean (as better-auth TS SDK does)
797        let body = serde_json::json!({
798            "currentPassword": "Password123!",
799            "newPassword": "NewPassword123!",
800            "revokeOtherSessions": true
801        });
802
803        let req = test_helpers::create_auth_request_no_query(
804            HttpMethod::Post,
805            "/change-password",
806            Some(&session.token),
807            Some(body.to_string().into_bytes()),
808        );
809
810        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
811        assert_eq!(response.status, 200);
812
813        let body_str = String::from_utf8(response.body).unwrap();
814        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
815        assert!(
816            (*(response_data)
817                .get("token")
818                .unwrap_or(&serde_json::Value::Null))
819            .is_string(),
820            "New token must be returned when revokeOtherSessions is boolean true"
821        );
822    }
823
824    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
825    #[tokio::test]
826    async fn test_change_password_wrong_current_password() {
827        let plugin = PasswordManagementPlugin::new();
828        let (ctx, _user, session) = create_test_context_with_user().await;
829
830        let body = serde_json::json!({
831            "currentPassword": "WrongPassword123!",
832            "newPassword": "NewPassword123!"
833        });
834
835        let req = test_helpers::create_auth_request_no_query(
836            HttpMethod::Post,
837            "/change-password",
838            Some(&session.token),
839            Some(body.to_string().into_bytes()),
840        );
841
842        let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
843        assert_eq!(err.status_code(), 400);
844    }
845
846    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
847    #[tokio::test]
848    async fn test_change_password_unauthorized() {
849        let plugin = PasswordManagementPlugin::new();
850        let (ctx, _user, _session) = create_test_context_with_user().await;
851
852        let body = serde_json::json!({
853            "currentPassword": "Password123!",
854            "newPassword": "NewPassword123!"
855        });
856
857        let req = test_helpers::create_auth_request_no_query(
858            HttpMethod::Post,
859            "/change-password",
860            None,
861            Some(body.to_string().into_bytes()),
862        );
863
864        let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
865        assert_eq!(err.status_code(), 401);
866    }
867
868    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: verifyPassword with
869    // a valid session and the correct credential password succeeds.
870    #[tokio::test]
871    async fn test_verify_password_success() {
872        let plugin = PasswordManagementPlugin::new();
873        let (ctx, _user, session) = create_test_context_with_user().await;
874
875        let body = serde_json::json!({
876            "password": "Password123!"
877        });
878
879        let req = test_helpers::create_auth_request_no_query(
880            HttpMethod::Post,
881            "/verify-password",
882            Some(&session.token),
883            Some(body.to_string().into_bytes()),
884        );
885
886        let response = plugin.handle_verify_password(&req, &ctx).await.unwrap();
887        assert_eq!(response.status, 200);
888
889        let body_str = String::from_utf8(response.body).unwrap();
890        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
891        assert!(response_data.status);
892    }
893
894    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: verifyPassword with
895    // a bad password returns BAD_REQUEST / Invalid password.
896    #[tokio::test]
897    async fn test_verify_password_invalid_password() {
898        let plugin = PasswordManagementPlugin::new();
899        let (ctx, _user, session) = create_test_context_with_user().await;
900
901        let body = serde_json::json!({
902            "password": "wrong-password"
903        });
904
905        let req = test_helpers::create_auth_request_no_query(
906            HttpMethod::Post,
907            "/verify-password",
908            Some(&session.token),
909            Some(body.to_string().into_bytes()),
910        );
911
912        let err = plugin.handle_verify_password(&req, &ctx).await.unwrap_err();
913        assert_eq!(err.status_code(), 400);
914        assert_eq!(err.to_string(), "Invalid password");
915    }
916
917    // Upstream reference: packages/better-auth/src/api/routes/password.ts :: verifyPassword returns
918    // Invalid password when the signed-in user does not have a credential account.
919    #[tokio::test]
920    async fn test_verify_password_oauth_only_user_returns_invalid_password() {
921        let plugin = PasswordManagementPlugin::new();
922        let (ctx, _user, session) = create_test_context_with_oauth_only_user().await;
923
924        let body = serde_json::json!({
925            "password": "Password123!"
926        });
927
928        let req = test_helpers::create_auth_request_no_query(
929            HttpMethod::Post,
930            "/verify-password",
931            Some(&session.token),
932            Some(body.to_string().into_bytes()),
933        );
934
935        let err = plugin.handle_verify_password(&req, &ctx).await.unwrap_err();
936        assert_eq!(err.status_code(), 400);
937        assert_eq!(err.to_string(), "Invalid password");
938    }
939
940    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: verifyPassword
941    // requires an authenticated session.
942    #[tokio::test]
943    async fn test_verify_password_requires_session() {
944        let plugin = PasswordManagementPlugin::new();
945        let (ctx, _user, _session) = create_test_context_with_user().await;
946
947        let body = serde_json::json!({
948            "password": "Password123!"
949        });
950
951        let req = test_helpers::create_auth_request_no_query(
952            HttpMethod::Post,
953            "/verify-password",
954            None,
955            Some(body.to_string().into_bytes()),
956        );
957
958        let response = plugin.handle_verify_password(&req, &ctx).await.unwrap();
959        assert_eq!(response.status, 401);
960        // Upstream returns better-call's default 401 body rather than an empty one.
961        let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
962        assert_eq!(
963            (*(body_2).get("code").unwrap_or(&serde_json::Value::Null)),
964            "UNAUTHORIZED"
965        );
966        assert_eq!(
967            (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
968            "Unauthorized"
969        );
970    }
971
972    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
973    #[tokio::test]
974    async fn test_reset_password_token_endpoint_redirects_with_callback_token() {
975        let plugin = PasswordManagementPlugin::new();
976        let (ctx, user, _session) = create_test_context_with_user().await;
977
978        let reset_token = create_reset_token(&ctx, &user.id).await;
979
980        let mut query = HashMap::new();
981        query.insert(
982            "callbackURL".to_owned(),
983            "http://localhost:3000/reset".to_owned(),
984        );
985
986        let req = AuthRequest::from_parts(
987            HttpMethod::Get,
988            "/reset-password/token".to_owned(),
989            HashMap::new(),
990            None,
991            query,
992        );
993
994        let response = plugin
995            .handle_reset_password_token(&reset_token, &req, &ctx)
996            .await
997            .unwrap();
998        assert_eq!(response.status, 302);
999        assert!(
1000            (*(response.headers)
1001                .get("Location")
1002                .expect("fixture contains the requested index"))
1003            .contains("http://localhost:3000/reset"),
1004            "redirect must preserve the callback URL"
1005        );
1006        assert!(
1007            (*(response.headers)
1008                .get("Location")
1009                .expect("fixture contains the requested index"))
1010            .contains(&format!("token={reset_token}")),
1011            "redirect must contain the reset token"
1012        );
1013    }
1014
1015    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1016    #[tokio::test]
1017    async fn test_reset_password_token_endpoint_with_callback() {
1018        let plugin = PasswordManagementPlugin::new();
1019        let (ctx, user, _session) = create_test_context_with_user().await;
1020
1021        let reset_token = create_reset_token(&ctx, &user.id).await;
1022
1023        let mut query = HashMap::new();
1024        query.insert(
1025            "callbackURL".to_owned(),
1026            "http://localhost:3000/reset".to_owned(),
1027        );
1028
1029        let req = AuthRequest::from_parts(
1030            HttpMethod::Get,
1031            "/reset-password/token".to_owned(),
1032            HashMap::new(),
1033            None,
1034            query,
1035        );
1036
1037        let response = plugin
1038            .handle_reset_password_token(&reset_token, &req, &ctx)
1039            .await
1040            .unwrap();
1041        assert_eq!(response.status, 302);
1042
1043        // Check redirect URL
1044        let location_header = response
1045            .headers
1046            .iter()
1047            .find(|(key, _)| *key == "Location")
1048            .map(|(_, value)| value);
1049        assert!(location_header.is_some());
1050        assert!(
1051            location_header
1052                .unwrap()
1053                .contains("http://localhost:3000/reset")
1054        );
1055        assert!(location_header.unwrap().contains(&reset_token));
1056    }
1057
1058    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1059    #[tokio::test]
1060    async fn test_reset_password_token_endpoint_invalid_token() {
1061        let plugin = PasswordManagementPlugin::new();
1062        let (ctx, _user, _session) = create_test_context_with_user().await;
1063
1064        let mut query = HashMap::new();
1065        query.insert(
1066            "callbackURL".to_owned(),
1067            "http://localhost:3000/reset".to_owned(),
1068        );
1069        let req = AuthRequest::from_parts(
1070            HttpMethod::Get,
1071            "/reset-password/token".to_owned(),
1072            HashMap::new(),
1073            None,
1074            query,
1075        );
1076
1077        let response = plugin
1078            .handle_reset_password_token("invalid_token", &req, &ctx)
1079            .await
1080            .unwrap();
1081        assert_eq!(response.status, 302);
1082        assert!(
1083            (*(response.headers)
1084                .get("Location")
1085                .expect("fixture contains the requested index"))
1086            .contains("error=INVALID_TOKEN")
1087        );
1088    }
1089
1090    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1091    #[tokio::test]
1092    async fn test_plugin_routes() {
1093        let plugin = PasswordManagementPlugin::new();
1094        let routes = AuthPlugin::<
1095            alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1096        >::routes(&plugin);
1097
1098        assert_eq!(routes.len(), 5);
1099        assert!(
1100            routes
1101                .iter()
1102                .any(|r| r.path == "/request-password-reset" && r.method == HttpMethod::Post)
1103        );
1104        assert!(
1105            routes
1106                .iter()
1107                .any(|r| r.path == "/reset-password" && r.method == HttpMethod::Post)
1108        );
1109        assert!(
1110            routes
1111                .iter()
1112                .any(|r| r.path == "/reset-password/{token}" && r.method == HttpMethod::Get)
1113        );
1114        assert!(
1115            routes
1116                .iter()
1117                .any(|r| r.path == "/verify-password" && r.method == HttpMethod::Post)
1118        );
1119        assert!(
1120            routes
1121                .iter()
1122                .any(|r| r.path == "/change-password" && r.method == HttpMethod::Post)
1123        );
1124    }
1125
1126    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1127    #[tokio::test]
1128    async fn test_plugin_on_request_routing() {
1129        let plugin = plugin_with_reset_sender();
1130        let (ctx, _user, session) = create_test_context_with_user().await;
1131
1132        let body = serde_json::json!({"email": "test@example.com"});
1133        let req = test_helpers::create_auth_request_no_query(
1134            HttpMethod::Post,
1135            "/request-password-reset",
1136            None,
1137            Some(body.to_string().into_bytes()),
1138        );
1139        let response = plugin.on_request(&req, &ctx).await.unwrap();
1140        assert!(response.is_some());
1141        assert_eq!(response.unwrap().status, 200);
1142
1143        // Test change password
1144        let body_2 = serde_json::json!({
1145            "currentPassword": "Password123!",
1146            "newPassword": "NewPassword123!"
1147        });
1148        let req_2 = test_helpers::create_auth_request_no_query(
1149            HttpMethod::Post,
1150            "/change-password",
1151            Some(&session.token),
1152            Some(body_2.to_string().into_bytes()),
1153        );
1154        let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1155        assert!(response_2.is_some());
1156        assert_eq!(response_2.unwrap().status, 200);
1157
1158        // Test invalid route
1159        let req_3 = test_helpers::create_auth_request_no_query(
1160            HttpMethod::Get,
1161            "/invalid-route",
1162            None,
1163            None,
1164        );
1165        let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1166        assert!(response_3.is_none());
1167    }
1168
1169    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1170    #[tokio::test]
1171    async fn test_configuration() {
1172        let config = PasswordManagementConfig {
1173            reset_token_expiry_hours: 48,
1174            require_current_password: false,
1175            send_email_notifications: false,
1176            ..Default::default()
1177        };
1178
1179        let plugin = PasswordManagementPlugin::with_config(config);
1180        assert_eq!(plugin.config.reset_token_expiry_hours, 48);
1181        assert!(!plugin.config.require_current_password);
1182        assert!(!plugin.config.send_email_notifications);
1183    }
1184
1185    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1186    #[tokio::test]
1187    async fn test_send_reset_password_custom_sender() {
1188        use alibi_core::{BackgroundTaskCompletion, BackgroundTaskHandler};
1189        use std::sync::{
1190            Mutex,
1191            atomic::{AtomicU32, Ordering},
1192        };
1193
1194        struct Sender {
1195            calls: Arc<AtomicU32>,
1196            fail: bool,
1197            token: Arc<Mutex<Option<String>>>,
1198        }
1199        #[async_trait::async_trait]
1200        impl SendResetPassword for Sender {
1201            async fn send(&self, _: &serde_json::Value, _: &str, token: &str) -> AuthResult<()> {
1202                self.calls.fetch_add(1, Ordering::SeqCst);
1203                *self.token.lock().unwrap() = Some(token.to_owned());
1204                if self.fail {
1205                    Err(AuthError::internal("Email queue unavailable"))
1206                } else {
1207                    Ok(())
1208                }
1209            }
1210        }
1211        #[derive(Default)]
1212        struct Observer(Mutex<Option<BackgroundTaskCompletion>>);
1213        impl BackgroundTaskHandler for Observer {
1214            fn handle(&self, completion: BackgroundTaskCompletion) -> AuthResult<()> {
1215                *self.0.lock().unwrap() = Some(completion);
1216                Ok(())
1217            }
1218        }
1219        for (fail, background) in [(false, false), (true, false), (true, true)] {
1220            let calls = Arc::new(AtomicU32::new(0));
1221            let token = Arc::new(Mutex::new(None));
1222            let plugin = PasswordManagementPlugin::new().send_reset_password(Arc::new(Sender {
1223                calls: Arc::clone(&calls),
1224                fail,
1225                token: Arc::clone(&token),
1226            }));
1227            let (mut ctx, user, _) = create_test_context_with_user().await;
1228            let observer = Arc::new(Observer::default());
1229            if background {
1230                Arc::make_mut(&mut ctx.config).background_tasks = Some(observer.clone());
1231            }
1232            let req = test_helpers::create_auth_json_request_no_query(
1233                HttpMethod::Post,
1234                "/request-password-reset",
1235                None,
1236                Some(serde_json::json!({"email":"test@example.com"})),
1237            );
1238            let result = plugin.handle_request_password_reset(&req, &ctx).await;
1239            if fail && !background {
1240                assert_eq!(result.unwrap_err().status_code(), 500);
1241            } else {
1242                assert_eq!(result.unwrap().status, 200);
1243            }
1244            if background {
1245                let completion = observer.0.lock().unwrap().take().unwrap();
1246                completion.await.unwrap();
1247            }
1248            assert_eq!(calls.load(Ordering::SeqCst), 1);
1249            let token = token.lock().unwrap().take().unwrap();
1250            let proof = ctx
1251                .database
1252                .get_verification_by_identifier(&format!("reset-password:{token}"))
1253                .await
1254                .unwrap()
1255                .unwrap();
1256            assert_eq!(proof.value, user.id);
1257        }
1258    }
1259
1260    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1261    #[tokio::test]
1262    async fn test_on_password_reset_callback() {
1263        use std::sync::atomic::{AtomicBool, Ordering};
1264
1265        let callback_called = Arc::new(AtomicBool::new(false));
1266        let called_clone = std::sync::Arc::clone(&callback_called);
1267
1268        let callback: Arc<OnPasswordResetCallback> = Arc::new(move |_user_value| {
1269            let called = std::sync::Arc::clone(&called_clone);
1270            Box::pin(async move {
1271                called.store(true, Ordering::SeqCst);
1272                Ok(())
1273            })
1274        });
1275
1276        let plugin = PasswordManagementPlugin::new().on_password_reset(callback);
1277        let (ctx, user, _session) = create_test_context_with_user().await;
1278
1279        let reset_token = create_reset_token(&ctx, &user.id).await;
1280
1281        let body = serde_json::json!({
1282            "newPassword": "NewPassword123!",
1283            "token": reset_token
1284        });
1285        let req = test_helpers::create_auth_request_no_query(
1286            HttpMethod::Post,
1287            "/reset-password",
1288            None,
1289            Some(body.to_string().into_bytes()),
1290        );
1291
1292        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1293        assert_eq!(response.status, 200);
1294
1295        // The on_password_reset callback should have been called
1296        assert!(
1297            callback_called.load(Ordering::SeqCst),
1298            "on_password_reset callback should be invoked after password reset"
1299        );
1300    }
1301
1302    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1303    #[tokio::test]
1304    async fn test_revoke_sessions_on_password_reset_false() {
1305        let plugin = PasswordManagementPlugin::new().revoke_sessions_on_password_reset(false);
1306        let (ctx, user, session) = create_test_context_with_user().await;
1307
1308        let reset_token = create_reset_token(&ctx, &user.id).await;
1309
1310        let body = serde_json::json!({
1311            "newPassword": "NewPassword123!",
1312            "token": reset_token
1313        });
1314        let req = test_helpers::create_auth_request_no_query(
1315            HttpMethod::Post,
1316            "/reset-password",
1317            None,
1318            Some(body.to_string().into_bytes()),
1319        );
1320
1321        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1322        assert_eq!(response.status, 200);
1323
1324        // Session should still exist since revoke_sessions_on_password_reset=false
1325        let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1326        assert!(
1327            !sessions.is_empty(),
1328            "Sessions should remain when revoke_sessions_on_password_reset=false"
1329        );
1330        assert!(
1331            sessions.iter().any(|s| s.token == session.token),
1332            "The original session should still exist"
1333        );
1334    }
1335
1336    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1337    #[tokio::test]
1338    async fn test_revoke_sessions_on_password_reset_true() {
1339        let plugin = PasswordManagementPlugin::new().revoke_sessions_on_password_reset(true);
1340        let (ctx, user, _session) = create_test_context_with_user().await;
1341
1342        let reset_token = create_reset_token(&ctx, &user.id).await;
1343
1344        let body = serde_json::json!({
1345            "newPassword": "NewPassword123!",
1346            "token": reset_token
1347        });
1348        let req = test_helpers::create_auth_request_no_query(
1349            HttpMethod::Post,
1350            "/reset-password",
1351            None,
1352            Some(body.to_string().into_bytes()),
1353        );
1354
1355        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1356        assert_eq!(response.status, 200);
1357
1358        // Sessions should be revoked since revoke_sessions_on_password_reset=true (default)
1359        let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1360        assert!(
1361            sessions.is_empty(),
1362            "Sessions should be revoked when revoke_sessions_on_password_reset=true"
1363        );
1364    }
1365}
1366// LCOV_EXCL_STOP