Skip to main content

alibi_plugins/user_management/
mod.rs

1pub(super) mod handlers;
2pub(super) mod types;
3mod update;
4
5use alibi_core::error::codes;
6use alibi_core::wire::UserView;
7use alibi_core::{
8    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
9    HttpMethod,
10};
11use async_trait::async_trait;
12use chrono::Duration;
13use handlers::{change_email_core, delete_user_callback_core, delete_user_core};
14use std::sync::Arc;
15pub use types::{ChangeEmailRequest, DeleteUserCallbackQuery, DeleteUserRequest};
16pub use update::{
17    BuiltinUserEndpoints, error_endpoint, handle_update_user, ok_endpoint, update_user_endpoint,
18};
19
20/// The initialized user's full snapshot passed to application lifecycle hooks.
21pub type UserInfo = UserView;
22
23/// Custom callback for sending change-email confirmation emails.
24///
25/// If set on [`ChangeEmailConfig`], this callback is invoked instead of the
26/// default [`EmailProvider`](alibi_core::EmailProvider). This allows callers to customise the email
27/// subject, template, and delivery mechanism.
28#[async_trait]
29pub trait SendChangeEmailConfirmation: Send + Sync {
30    async fn send(
31        &self,
32        user: &UserInfo,
33        new_email: &str,
34        url: &str,
35        token: &str,
36    ) -> AuthResult<()>;
37}
38
39/// Application delivery of an issued account-deletion proof. The original
40/// request is available through `current_request_hook_context` during delivery.
41#[async_trait]
42pub trait SendDeleteAccountVerification: Send + Sync {
43    async fn send(&self, user: &UserInfo, url: &str, token: &str) -> AuthResult<()>;
44}
45
46/// Hook invoked **before** a user is deleted.
47///
48/// Return `Err(...)` from [`before_delete`](BeforeDeleteUser::before_delete) to
49/// abort the deletion.
50#[async_trait]
51pub trait BeforeDeleteUser: Send + Sync {
52    async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
53}
54
55/// Hook invoked **after** a user has been deleted.
56#[async_trait]
57pub trait AfterDeleteUser: Send + Sync {
58    async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
59}
60
61/// Configuration for the change-email feature.
62#[derive(Clone, Default)]
63pub struct ChangeEmailConfig {
64    /// Whether the change-email endpoints are enabled. Default: `false`.
65    pub enabled: bool,
66    /// If `true`, the new email is updated immediately without sending a
67    /// verification email. Default: `false`.
68    pub update_without_verification: bool,
69    /// Optional custom callback for sending the confirmation email.
70    pub send_change_email_confirmation: Option<Arc<dyn SendChangeEmailConfirmation>>,
71}
72
73impl std::fmt::Debug for ChangeEmailConfig {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("ChangeEmailConfig")
76            .field("enabled", &self.enabled)
77            .field(
78                "update_without_verification",
79                &self.update_without_verification,
80            )
81            .field(
82                "send_change_email_confirmation",
83                &self.send_change_email_confirmation.is_some(),
84            )
85            .finish()
86    }
87}
88
89/// Configuration for the delete-user feature.
90#[derive(Clone)]
91pub struct DeleteUserConfig {
92    /// Whether the delete-user endpoints are enabled. Default: `false`.
93    pub enabled: bool,
94    /// How long a delete-confirmation token remains valid. Default: 1 day.
95    pub delete_token_expires_in: Duration,
96    /// If `true`, a verification email must be confirmed before the account is
97    /// deleted using the configured `EmailProvider`. Default: `false`.
98    pub require_verification: bool,
99    /// Custom delivery of an account-deletion proof. A configured callback
100    /// selects verification before the session-freshness check.
101    pub send_delete_account_verification: Option<Arc<dyn SendDeleteAccountVerification>>,
102    /// Hook called before the user record is removed.
103    pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
104    /// Hook called after the user record has been removed.
105    pub after_delete: Option<Arc<dyn AfterDeleteUser>>,
106}
107
108impl std::fmt::Debug for DeleteUserConfig {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("DeleteUserConfig")
111            .field("enabled", &self.enabled)
112            .field("delete_token_expires_in", &self.delete_token_expires_in)
113            .field("require_verification", &self.require_verification)
114            .field(
115                "send_delete_account_verification",
116                &self.send_delete_account_verification.is_some(),
117            )
118            .field("before_delete", &self.before_delete.is_some())
119            .field("after_delete", &self.after_delete.is_some())
120            .finish()
121    }
122}
123
124impl Default for DeleteUserConfig {
125    fn default() -> Self {
126        Self {
127            enabled: false,
128            delete_token_expires_in: Duration::hours(24),
129            require_verification: false,
130            send_delete_account_verification: None,
131            before_delete: None,
132            after_delete: None,
133        }
134    }
135}
136
137/// Combined configuration for the [`UserManagementPlugin`].
138#[derive(Debug, Clone, Default)]
139pub struct UserManagementConfig {
140    pub change_email: ChangeEmailConfig,
141    pub delete_user: DeleteUserConfig,
142}
143
144/// User self-service management plugin (change email & delete account).
145#[derive(Default)]
146pub struct UserManagementPlugin {
147    config: UserManagementConfig,
148}
149
150impl std::fmt::Debug for UserManagementPlugin {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("UserManagementPlugin")
153            .finish_non_exhaustive()
154    }
155}
156
157impl UserManagementPlugin {
158    #[must_use]
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    #[must_use]
164    pub const fn with_config(config: UserManagementConfig) -> Self {
165        Self { config }
166    }
167
168    #[must_use]
169    pub const fn change_email_enabled(mut self, enabled: bool) -> Self {
170        self.config.change_email.enabled = enabled;
171        self
172    }
173
174    #[must_use]
175    pub const fn update_without_verification(mut self, flag: bool) -> Self {
176        self.config.change_email.update_without_verification = flag;
177        self
178    }
179
180    #[must_use]
181    pub fn send_change_email_confirmation(
182        mut self,
183        cb: Arc<dyn SendChangeEmailConfirmation>,
184    ) -> Self {
185        self.config.change_email.send_change_email_confirmation = Some(cb);
186        self
187    }
188
189    #[must_use]
190    pub const fn delete_user_enabled(mut self, enabled: bool) -> Self {
191        self.config.delete_user.enabled = enabled;
192        self
193    }
194
195    #[must_use]
196    pub const fn delete_token_expires_in(mut self, duration: Duration) -> Self {
197        self.config.delete_user.delete_token_expires_in = duration;
198        self
199    }
200
201    #[must_use]
202    pub const fn require_delete_verification(mut self, require: bool) -> Self {
203        self.config.delete_user.require_verification = require;
204        self
205    }
206
207    #[must_use]
208    pub fn send_delete_account_verification(
209        mut self,
210        sender: Arc<dyn SendDeleteAccountVerification>,
211    ) -> Self {
212        self.config.delete_user.send_delete_account_verification = Some(sender);
213        self
214    }
215
216    #[must_use]
217    pub fn before_delete(mut self, hook: Arc<dyn BeforeDeleteUser>) -> Self {
218        self.config.delete_user.before_delete = Some(hook);
219        self
220    }
221
222    #[must_use]
223    pub fn after_delete(mut self, hook: Arc<dyn AfterDeleteUser>) -> Self {
224        self.config.delete_user.after_delete = Some(hook);
225        self
226    }
227}
228
229impl UserManagementPlugin {
230    /// Start an authenticated mailbox change through the public server API.
231    pub fn change_email_endpoint(
232        body: &ChangeEmailRequest,
233    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
234        alibi_core::endpoint::ServerEndpoint::new("user-management", "changeEmail").with_body(body)
235    }
236
237    /// Complete a delivered deletion proof through the public server API.
238    pub fn delete_user_callback_endpoint(
239        query: &DeleteUserCallbackQuery,
240    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::SuccessMessageResponse>> {
241        alibi_core::endpoint::ServerEndpoint::new("user-management", "deleteUserCallback")
242            .with_query(query)
243    }
244
245    /// Delete the authenticated owner through the public server API.
246    pub fn delete_user_endpoint(
247        body: &DeleteUserRequest,
248    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::SuccessMessageResponse>> {
249        alibi_core::endpoint::ServerEndpoint::new("user-management", "deleteUser").with_body(body)
250    }
251
252    async fn handle_delete_user_callback_endpoint(
253        &self,
254        call: &alibi_core::endpoint::EndpointCall,
255        ctx: &AuthContext<impl alibi_core::AuthSchema>,
256    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
257        let query: DeleteUserCallbackQuery = call.query_as()?;
258        if call.request().is_some()
259            && let Some(url) = query.callback_url.as_deref().filter(|url| !url.is_empty())
260            && !ctx.config.current_origin_check_disabled()
261            && !ctx.config.is_redirect_target_trusted(url)
262        {
263            return Err(AuthError::forbidden("Invalid callbackURL"));
264        }
265        if !self.config.delete_user.enabled {
266            return Err(AuthError::Upstream {
267                status: 404,
268                code: codes::NOT_FOUND,
269                message: "Not found",
270            });
271        }
272        // Source keeps an already established context session, otherwise its
273        // nested getter bypasses cookie cache when server-side authority exists.
274        let mut input = call.clone();
275        if ctx.config.session.has_server_session_store() {
276            alibi_core::endpoint::EndpointContextPatch {
277                query: Some(alibi_core::utils::json::parse_value(
278                    r#"{"disableCookieCache":true}"#,
279                )?),
280                ..Default::default()
281            }
282            .apply(&mut input);
283        }
284        let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, &input, false)
285            .await
286            .ok()
287            .flatten()
288            .ok_or(AuthError::Upstream {
289                status: 404,
290                code: codes::FAILED_TO_GET_USER_INFO,
291                message: "Failed to get user info",
292            })?;
293        call.record_authenticated_session(ctx.user_view(&read.user), read.session.clone());
294        let response =
295            delete_user_callback_core(&query.token, &read.user, call, true, &self.config, ctx)
296                .await?;
297        alibi_core::session::cookie_cache::runtime::clear_session_cookies(ctx, call)?;
298        if let Some(url) = query.callback_url.filter(|url| !url.is_empty()) {
299            return Ok(alibi_core::endpoint::EndpointResponse::redirect(url));
300        }
301        alibi_core::endpoint::EndpointResponse::json(&response)
302    }
303
304    /// `POST /change-email`
305    async fn handle_change_email(
306        &self,
307        req: &AuthRequest,
308        ctx: &AuthContext<impl alibi_core::AuthSchema>,
309    ) -> AuthResult<AuthResponse> {
310        let (user, session) = handlers::authoritative_session(req, ctx).await?;
311        let body: ChangeEmailRequest = match alibi_core::validate_request_body(req) {
312            Ok(v) => v,
313            Err(resp) => return Ok(resp),
314        };
315        if !self.config.change_email.enabled {
316            return Err(AuthError::bad_request("Change email is disabled"));
317        }
318        let projection = change_email_core(&body, &user, &session, &self.config, ctx).await?;
319        let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
320        if projection.is_some() {
321            append_session_cookie(&mut response, req, &session.token, &ctx.config)?;
322        }
323        Ok(response)
324    }
325
326    /// `POST /delete-user`
327    async fn handle_delete_user(
328        &self,
329        req: &AuthRequest,
330        ctx: &AuthContext<impl alibi_core::AuthSchema>,
331    ) -> AuthResult<AuthResponse> {
332        let (user, session) = handlers::authoritative_session(req, ctx).await?;
333        let body: DeleteUserRequest = match alibi_core::validate_request_body(req) {
334            Ok(v) => v,
335            Err(resp) => return Ok(resp),
336        };
337        if !self.config.delete_user.enabled {
338            return Ok(AuthResponse::new(404).with_header("Content-Type", "application/json"));
339        }
340        let response = delete_user_core(&body, &user, &session, req, &self.config, ctx).await?;
341        let deleted =
342            response.message == "User deleted" && body.token.as_deref().is_none_or(str::is_empty);
343        let mut response = AuthResponse::json(200, &response)?;
344        if deleted {
345            append_clear_session_cookies(&mut response, &ctx.config)?;
346        }
347        Ok(response)
348    }
349
350    /// `GET /delete-user/callback`
351    async fn handle_delete_user_callback(
352        &self,
353        req: &AuthRequest,
354        ctx: &AuthContext<impl alibi_core::AuthSchema>,
355    ) -> AuthResult<AuthResponse> {
356        if !self.config.delete_user.enabled {
357            return Err(AuthError::Api {
358                status: 404,
359                code: Some(codes::NOT_FOUND.into()),
360                message: "Not found".into(),
361            });
362        }
363        let (user, _) = handlers::authoritative_session(req, ctx)
364            .await
365            .map_err(|_error| AuthError::not_found("Failed to get user info"))?;
366        let query: DeleteUserCallbackQuery = serde_json::from_value(serde_json::json!({
367            "token": req.query.get("token").cloned(),
368            "callbackURL": req.query.get("callbackURL").cloned(),
369        }))
370        .map_err(|_error| AuthError::bad_request("Verification token is required"))?;
371        let response =
372            delete_user_callback_core(&query.token, &user, req, true, &self.config, ctx).await?;
373        if let Some(callback_url) = query.callback_url.filter(|url| !url.is_empty()) {
374            let mut headers = alibi_core::Headers::new();
375            _ = headers.insert("Location", callback_url);
376            _ = headers.insert("Content-Type", "application/json");
377            let mut redirect = AuthResponse {
378                status: 302,
379                headers,
380                body: Vec::new(),
381            };
382            append_clear_session_cookies(&mut redirect, &ctx.config)?;
383            return Ok(redirect);
384        }
385
386        let mut response = AuthResponse::json(200, &response)?;
387        append_clear_session_cookies(&mut response, &ctx.config)?;
388        Ok(response)
389    }
390}
391
392#[async_trait]
393impl<S: alibi_core::AuthSchema> AuthPlugin<S> for UserManagementPlugin {
394    route_openapi_metadata!(S);
395
396    fn name(&self) -> &'static str {
397        "user-management"
398    }
399
400    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
401        vec![
402            crate::endpoint::definition(
403                "changeEmail",
404                "changeEmail",
405                Some("/change-email"),
406                HttpMethod::Post,
407            ),
408            crate::endpoint::definition(
409                "deleteUserCallback",
410                "deleteUserCallback",
411                Some("/delete-user/callback"),
412                HttpMethod::Get,
413            ),
414            crate::endpoint::definition(
415                "deleteUser",
416                "deleteUser",
417                Some("/delete-user"),
418                HttpMethod::Post,
419            ),
420        ]
421    }
422
423    fn validate_endpoint(
424        &self,
425        call: &alibi_core::endpoint::EndpointCall,
426        _: &AuthContext<S>,
427    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
428        use crate::authentication_helpers::RequestBody;
429        if call.operation_id() == "deleteUserCallback" {
430            return Ok(alibi_core::endpoint::EndpointInput {
431                body: call.body().cloned(),
432                query: Some(crate::endpoint::validate_fields(
433                    call.query(),
434                    "query",
435                    DeleteUserCallbackQuery::FIELDS,
436                )?),
437            });
438        }
439        Ok(alibi_core::endpoint::EndpointInput {
440            body: Some(crate::endpoint::validate_fields(
441                call.body(),
442                "body",
443                if call.operation_id() == "changeEmail" {
444                    ChangeEmailRequest::FIELDS
445                } else {
446                    DeleteUserRequest::FIELDS
447                },
448            )?),
449            query: call.query().cloned(),
450        })
451    }
452
453    async fn on_endpoint(
454        &self,
455        call: &alibi_core::endpoint::EndpointCall,
456        ctx: &AuthContext<S>,
457    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
458        if call.operation_id() == "deleteUserCallback" {
459            return self.handle_delete_user_callback_endpoint(call, ctx).await;
460        }
461        let (user, session) = ctx
462            .require_authoritative_session_record(call)
463            .await
464            .map_err(|_| AuthError::Upstream {
465                status: 401,
466                code: codes::UNAUTHORIZED,
467                message: "Unauthorized",
468            })?;
469        call.record_authenticated_session(ctx.user_view(&user), session.clone());
470        if call.operation_id() == "changeEmail" {
471            if !self.config.change_email.enabled {
472                return Err(AuthError::bad_request("Change email is disabled"));
473            }
474            let body: ChangeEmailRequest = call.body_as()?;
475            let projection = change_email_core(&body, &user, &session, &self.config, ctx).await?;
476            let mut response =
477                alibi_core::endpoint::EndpointResponse::json(&alibi_core::StatusResponse {
478                    status: true,
479                })?;
480            if projection.is_some() {
481                response.merge_headers(crate::authentication_helpers::session_cookie_headers(
482                    &ctx.config,
483                    &session.token,
484                    ctx.session_manager().has_dont_remember_cookie(call),
485                )?);
486            }
487            return Ok(response);
488        }
489        if !self.config.delete_user.enabled {
490            return Ok(
491                alibi_core::endpoint::EndpointResponse::error(AuthError::Api {
492                    status: 404,
493                    code: None,
494                    message: String::new(),
495                })
496                .without_error_body(),
497            );
498        }
499        let body: DeleteUserRequest = call.body_as()?;
500        let response = delete_user_core(&body, &user, &session, call, &self.config, ctx).await?;
501        let deleted =
502            response.message == "User deleted" && body.token.as_deref().is_none_or(str::is_empty);
503        if deleted {
504            alibi_core::session::cookie_cache::runtime::clear_session_cookies(ctx, call)?;
505        }
506        alibi_core::endpoint::EndpointResponse::json(&response)
507    }
508
509    fn routes(&self) -> Vec<AuthRoute> {
510        vec![
511            AuthRoute::post("/change-email", "change_email"),
512            AuthRoute::post("/delete-user", "delete_user"),
513            AuthRoute::get("/delete-user/callback", "delete_user_callback"),
514        ]
515    }
516
517    async fn on_request(
518        &self,
519        req: &AuthRequest,
520        ctx: &AuthContext<S>,
521    ) -> AuthResult<Option<AuthResponse>> {
522        let response = match (req.method(), req.path()) {
523            (HttpMethod::Post, "/change-email") => self.handle_change_email(req, ctx).await?,
524            (HttpMethod::Post, "/delete-user") => self.handle_delete_user(req, ctx).await?,
525            (HttpMethod::Get, "/delete-user/callback") => {
526                self.handle_delete_user_callback(req, ctx).await?
527            }
528            _ => return Ok(None),
529        };
530        Ok(Some(response))
531    }
532}
533
534pub(super) fn append_clear_session_cookies(
535    response: &mut AuthResponse,
536    config: &alibi_core::AuthConfig,
537) -> AuthResult<()> {
538    response.headers.append(
539        "Set-Cookie",
540        alibi_core::utils::cookie_utils::create_clear_session_cookie(config)?,
541    );
542    response.headers.append(
543        "Set-Cookie",
544        alibi_core::utils::cookie_utils::create_clear_cookie(
545            &related_cookie_name(config, "session_data"),
546            config,
547        )?,
548    );
549    response.headers.append(
550        "Set-Cookie",
551        alibi_core::utils::cookie_utils::create_clear_cookie(
552            &related_cookie_name(config, "dont_remember"),
553            config,
554        )?,
555    );
556    if config.account.store_account_cookie {
557        response.headers.append(
558            "Set-Cookie",
559            alibi_core::utils::cookie_utils::create_clear_cookie(
560                &related_cookie_name(config, "account_data"),
561                config,
562            )?,
563        );
564    }
565    Ok(())
566}
567
568pub(crate) fn append_session_cookie(
569    response: &mut AuthResponse,
570    req: &AuthRequest,
571    token: &str,
572    config: &alibi_core::AuthConfig,
573) -> AuthResult<()> {
574    use alibi_core::utils::cookie_utils::{
575        create_session_cookie_with_max_age, verify_cookie_value,
576    };
577    let dont_remember =
578        super::helpers::get_cookie(req, &related_cookie_name(config, "dont_remember"))
579            .and_then(|value| verify_cookie_value(&value, config.current_secret()))
580            .is_some_and(|value| !value.is_empty());
581    response.headers.append(
582        "Set-Cookie",
583        create_session_cookie_with_max_age(
584            Some(token),
585            (!dont_remember).then(|| config.session.expires_in.num_seconds()),
586            config,
587        )?,
588    );
589    Ok(())
590}
591
592fn related_cookie_name(config: &alibi_core::AuthConfig, suffix: &str) -> String {
593    config
594        .session
595        .cookie_name
596        .strip_suffix("session_token")
597        .map_or_else(
598            || format!("better-auth.{suffix}"),
599            |prefix| format!("{prefix}{suffix}"),
600        )
601}
602
603// LCOV_EXCL_START
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::test_helpers;
608    use alibi_core::AuthUser;
609    use alibi_core::{AccountConfig, CreateUser};
610    use async_trait::async_trait;
611    use chrono::Duration;
612    use std::collections::HashMap;
613    use std::sync::Arc;
614
615    // -- change email tests ────────────────────────────────────────────
616
617    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
618    #[tokio::test]
619    async fn test_change_email_success() {
620        let plugin = UserManagementPlugin::new().change_email_enabled(true);
621        let (mut ctx, _user, session) = test_helpers::create_test_context_with_user(
622            CreateUser::new()
623                .with_email("test@example.com")
624                .with_name("Test User")
625                .with_email_verified(true),
626            Duration::hours(24),
627        )
628        .await;
629
630        ctx.email_provider = Some(Arc::new(alibi_core::email::ConsoleEmailProvider));
631
632        let body = serde_json::json!({ "newEmail": "new@example.com" });
633        let req = test_helpers::create_auth_request(
634            HttpMethod::Post,
635            "/change-email",
636            Some(&session.token),
637            Some(body.to_string().into_bytes()),
638            HashMap::new(),
639        );
640
641        let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
642        assert_eq!(response.status, 200);
643    }
644
645    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
646    #[tokio::test]
647    async fn test_change_email_same_email() {
648        let plugin = UserManagementPlugin::new().change_email_enabled(true);
649        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
650            CreateUser::new()
651                .with_email("test@example.com")
652                .with_name("Test User")
653                .with_email_verified(true),
654            Duration::hours(24),
655        )
656        .await;
657
658        let body = serde_json::json!({ "newEmail": "test@example.com" });
659        let req = test_helpers::create_auth_request(
660            HttpMethod::Post,
661            "/change-email",
662            Some(&session.token),
663            Some(body.to_string().into_bytes()),
664            HashMap::new(),
665        );
666
667        let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
668        assert_eq!(err.status_code(), 400);
669    }
670
671    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
672    #[tokio::test]
673    async fn test_change_email_unauthenticated() {
674        let plugin = UserManagementPlugin::new().change_email_enabled(true);
675        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
676            CreateUser::new()
677                .with_email("test@example.com")
678                .with_name("Test User")
679                .with_email_verified(true),
680            Duration::hours(24),
681        )
682        .await;
683
684        let body = serde_json::json!({ "newEmail": "new@example.com" });
685        let req = test_helpers::create_auth_request(
686            HttpMethod::Post,
687            "/change-email",
688            None,
689            Some(body.to_string().into_bytes()),
690            HashMap::new(),
691        );
692
693        let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
694        assert_eq!(err.status_code(), 401);
695    }
696
697    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
698    #[tokio::test]
699    async fn test_change_email_immediate_when_update_without_verification() {
700        let plugin = UserManagementPlugin::new()
701            .change_email_enabled(true)
702            .update_without_verification(true);
703        let (ctx, user, session) = test_helpers::create_test_context_with_user(
704            CreateUser::new()
705                .with_email("test@example.com")
706                .with_name("Test User")
707                .with_email_verified(false),
708            Duration::hours(24),
709        )
710        .await;
711
712        // Initiate change -- should update immediately, no verification token
713        let body = serde_json::json!({ "newEmail": "new@example.com" });
714        let req = test_helpers::create_auth_request(
715            HttpMethod::Post,
716            "/change-email",
717            Some(&session.token),
718            Some(body.to_string().into_bytes()),
719            HashMap::new(),
720        );
721        let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
722        assert_eq!(response.status, 200);
723
724        // Email should be updated immediately
725        let updated_user = ctx
726            .database
727            .get_user_by_id(&user.id)
728            .await
729            .unwrap()
730            .unwrap();
731        assert_eq!(updated_user.email(), Some("new@example.com"));
732        // email_verified should be false (no verification was performed)
733        assert!(!updated_user.email_verified);
734
735        // No verification token should have been created
736        let identifier = format!("change_email:{}:new@example.com", user.id);
737        let verification = ctx
738            .database
739            .get_verification_by_identifier(&identifier)
740            .await
741            .unwrap();
742        assert!(
743            verification.is_none(),
744            "no verification token should be created when update_without_verification=true"
745        );
746    }
747
748    // -- delete user tests ─────────────────────────────────────────────
749
750    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
751    #[tokio::test]
752    async fn test_delete_user_immediate() {
753        let plugin = UserManagementPlugin::new()
754            .delete_user_enabled(true)
755            .require_delete_verification(false);
756        let (ctx, user, session) = test_helpers::create_test_context_with_user(
757            CreateUser::new()
758                .with_email("test@example.com")
759                .with_name("Test User")
760                .with_email_verified(true),
761            Duration::hours(24),
762        )
763        .await;
764
765        let req = test_helpers::create_auth_request(
766            HttpMethod::Post,
767            "/delete-user",
768            Some(&session.token),
769            Some(b"{}".to_vec()),
770            HashMap::new(),
771        );
772
773        let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
774        assert_eq!(response.status, 200);
775
776        // User should be gone
777        let deleted_user = ctx.database.get_user_by_id(&user.id).await.unwrap();
778        assert!(deleted_user.is_none());
779    }
780
781    // Upstream reference: packages/better-auth/src/api/routes/update-user.ts :: deleteUser calls `deleteSessionCookie(ctx)`, which clears the account_data cookie when account.storeAccountCookie is enabled.
782    #[tokio::test]
783    async fn test_delete_user_immediate_clears_account_cookie_when_enabled() {
784        let plugin = UserManagementPlugin::new()
785            .delete_user_enabled(true)
786            .require_delete_verification(false);
787        let config = test_helpers::create_test_config().account(AccountConfig {
788            store_account_cookie: true,
789            ..Default::default()
790        });
791        let ctx = test_helpers::create_test_context_with_config(config).await;
792        let (_user, session) = test_helpers::create_user_and_session(
793            &ctx,
794            CreateUser::new()
795                .with_email("account-cookie@test.com")
796                .with_name("Account Cookie")
797                .with_email_verified(true),
798            Duration::hours(24),
799        )
800        .await;
801
802        let req = test_helpers::create_auth_request(
803            HttpMethod::Post,
804            "/delete-user",
805            Some(&session.token),
806            Some(b"{}".to_vec()),
807            HashMap::new(),
808        );
809
810        let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
811        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
812        assert!(
813            response
814                .headers
815                .get_all("Set-Cookie")
816                .any(|cookie| cookie.starts_with(&account_cookie_name)),
817            "delete-user should clear the account_data cookie when store_account_cookie is enabled"
818        );
819    }
820
821    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
822    #[tokio::test]
823    async fn test_delete_user_with_verification() {
824        let plugin = UserManagementPlugin::new()
825            .delete_user_enabled(true)
826            .require_delete_verification(true);
827        let (ctx, user, session) = test_helpers::create_test_context_with_user(
828            CreateUser::new()
829                .with_email("test@example.com")
830                .with_name("Test User")
831                .with_email_verified(true),
832            Duration::hours(24),
833        )
834        .await;
835
836        // 1. Request deletion -- should return pending status
837        let req = test_helpers::create_auth_request(
838            HttpMethod::Post,
839            "/delete-user",
840            Some(&session.token),
841            Some(b"{}".to_vec()),
842            HashMap::new(),
843        );
844
845        let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
846        assert_eq!(response.status, 200);
847
848        // User should still exist
849        let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
850        assert!(still_exists.is_some());
851
852        // 2. Seed and confirm the callback token
853        let token = "delete-token-123";
854        ctx.database
855            .create_verification(alibi_core::CreateVerification {
856                identifier: format!("delete-account-{token}"),
857                value: user.id.clone(),
858                expires_at: chrono::Utc::now() + Duration::hours(24),
859            })
860            .await
861            .unwrap();
862
863        let mut query = HashMap::new();
864        query.insert("token".to_owned(), token.to_owned());
865        let req_2 = test_helpers::create_auth_request(
866            HttpMethod::Get,
867            "/delete-user/callback",
868            Some(&session.token),
869            None,
870            query,
871        );
872        let response_2 = plugin
873            .handle_delete_user_callback(&req_2, &ctx)
874            .await
875            .unwrap();
876        assert_eq!(response_2.status, 200);
877
878        // User should now be gone
879        let deleted = ctx.database.get_user_by_id(&user.id).await.unwrap();
880        assert!(deleted.is_none());
881    }
882
883    // Upstream reference: packages/better-auth/src/api/routes/update-user.ts :: deleteUserCallback calls `deleteSessionCookie(ctx)`, which clears the account_data cookie when account.storeAccountCookie is enabled.
884    #[tokio::test]
885    async fn test_delete_user_callback_clears_account_cookie_when_enabled() {
886        let plugin = UserManagementPlugin::new()
887            .delete_user_enabled(true)
888            .require_delete_verification(true);
889        let config = test_helpers::create_test_config().account(AccountConfig {
890            store_account_cookie: true,
891            ..Default::default()
892        });
893        let ctx = test_helpers::create_test_context_with_config(config).await;
894        let (user, session) = test_helpers::create_user_and_session(
895            &ctx,
896            CreateUser::new()
897                .with_email("callback-cookie@test.com")
898                .with_name("Callback Cookie")
899                .with_email_verified(true),
900            Duration::hours(24),
901        )
902        .await;
903
904        let token = "delete-cookie-token";
905        ctx.database
906            .create_verification(alibi_core::CreateVerification {
907                identifier: format!("delete-account-{token}"),
908                value: user.id.clone(),
909                expires_at: chrono::Utc::now() + Duration::hours(24),
910            })
911            .await
912            .unwrap();
913
914        let mut query = HashMap::new();
915        query.insert("token".to_owned(), token.to_owned());
916        query.insert(
917            "callbackURL".to_owned(),
918            "https://example.com/goodbye".to_owned(),
919        );
920        let req = test_helpers::create_auth_request(
921            HttpMethod::Get,
922            "/delete-user/callback",
923            Some(&session.token),
924            None,
925            query,
926        );
927
928        let response = plugin
929            .handle_delete_user_callback(&req, &ctx)
930            .await
931            .unwrap();
932        assert_eq!(response.status, 302);
933        let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
934        assert!(
935            response
936                .headers
937                .get_all("Set-Cookie")
938                .any(|cookie| cookie.starts_with(&account_cookie_name)),
939            "delete-user callback should clear the account_data cookie when store_account_cookie is enabled"
940        );
941    }
942
943    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
944    #[tokio::test]
945    async fn test_delete_user_unauthenticated() {
946        let plugin = UserManagementPlugin::new()
947            .delete_user_enabled(true)
948            .require_delete_verification(false);
949        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
950            CreateUser::new()
951                .with_email("test@example.com")
952                .with_name("Test User")
953                .with_email_verified(true),
954            Duration::hours(24),
955        )
956        .await;
957
958        let req = test_helpers::create_auth_request(
959            HttpMethod::Post,
960            "/delete-user",
961            None,
962            Some(b"{}".to_vec()),
963            HashMap::new(),
964        );
965
966        let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
967        assert_eq!(err.status_code(), 401);
968    }
969
970    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
971    #[tokio::test]
972    async fn test_delete_user_verify_invalid_token() {
973        let plugin = UserManagementPlugin::new().delete_user_enabled(true);
974        let (ctx, _user, fixture_session) = test_helpers::create_test_context_with_user(
975            CreateUser::new()
976                .with_email("test@example.com")
977                .with_name("Test User")
978                .with_email_verified(true),
979            Duration::hours(24),
980        )
981        .await;
982
983        let mut query = HashMap::new();
984        query.insert("token".to_owned(), "invalid-token".to_owned());
985        let req = test_helpers::create_auth_request(
986            HttpMethod::Get,
987            "/delete-user/callback",
988            Some(&fixture_session.token),
989            None,
990            query,
991        );
992
993        let err = plugin
994            .handle_delete_user_callback(&req, &ctx)
995            .await
996            .unwrap_err();
997        assert_eq!(err.status_code(), 404);
998    }
999
1000    // Upstream reference: packages/better-auth/src/api/routes/update-user.test.ts :: describe("updateUser") and packages/better-auth/src/api/routes/update-user.ts; adapted to the Rust user-management plugin.
1001    #[tokio::test]
1002    async fn test_delete_user_before_hook_abort() {
1003        use std::sync::atomic::{AtomicBool, Ordering};
1004
1005        struct AbortHook;
1006
1007        #[async_trait]
1008        impl BeforeDeleteUser for AbortHook {
1009            async fn before_delete(&self, _user: &UserInfo) -> AuthResult<()> {
1010                Err(AuthError::forbidden("Deletion blocked by policy"))
1011            }
1012        }
1013
1014        struct AfterHook(Arc<AtomicBool>);
1015
1016        #[async_trait]
1017        impl AfterDeleteUser for AfterHook {
1018            async fn after_delete(&self, _user: &UserInfo) -> AuthResult<()> {
1019                self.0.store(true, Ordering::SeqCst);
1020                Ok(())
1021            }
1022        }
1023
1024        let called = Arc::new(AtomicBool::new(false));
1025        let called_clone = std::sync::Arc::clone(&called);
1026
1027        let plugin = UserManagementPlugin::new()
1028            .delete_user_enabled(true)
1029            .require_delete_verification(false)
1030            .before_delete(Arc::new(AbortHook))
1031            .after_delete(Arc::new(AfterHook(called_clone)));
1032        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1033            CreateUser::new()
1034                .with_email("test@example.com")
1035                .with_name("Test User")
1036                .with_email_verified(true),
1037            Duration::hours(24),
1038        )
1039        .await;
1040
1041        let req = test_helpers::create_auth_request(
1042            HttpMethod::Post,
1043            "/delete-user",
1044            Some(&session.token),
1045            Some(b"{}".to_vec()),
1046            HashMap::new(),
1047        );
1048
1049        let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
1050        assert_eq!(err.status_code(), 403);
1051
1052        // User should still exist
1053        let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
1054        assert!(still_exists.is_some());
1055
1056        // after_delete should NOT have been called
1057        assert!(!called.load(Ordering::SeqCst));
1058    }
1059
1060    // The full-document SDK owner verifies default registration. This authenticated
1061    // native boundary independently proves disabled handlers cannot mutate storage.
1062    #[tokio::test]
1063    async fn disabled_user_management_rejects_authenticated_mutations() {
1064        let plugin = UserManagementPlugin::new();
1065        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1066            CreateUser::new()
1067                .with_email("test@example.com")
1068                .with_name("Test User")
1069                .with_email_verified(true),
1070            Duration::hours(24),
1071        )
1072        .await;
1073        for (path, body, status) in [
1074            (
1075                "/change-email",
1076                serde_json::json!({"newEmail":"changed@example.com"}),
1077                400,
1078            ),
1079            ("/delete-user", serde_json::json!({}), 404),
1080        ] {
1081            let req = test_helpers::create_auth_request(
1082                HttpMethod::Post,
1083                path,
1084                Some(&session.token),
1085                Some(body.to_string().into_bytes()),
1086                HashMap::new(),
1087            );
1088            let result = plugin.on_request(&req, &ctx).await;
1089            match result {
1090                Ok(Some(response)) => assert_eq!(response.status, status),
1091                Err(error) => assert_eq!(error.status_code(), status),
1092                other => panic!("registered disabled handler did not reject: {other:?}"),
1093            }
1094            let stored = ctx
1095                .database
1096                .get_user_by_id(&user.id)
1097                .await
1098                .unwrap()
1099                .unwrap();
1100            assert_eq!(Some(stored.email), user.email);
1101            assert!(
1102                ctx.database
1103                    .get_session(&session.token)
1104                    .await
1105                    .unwrap()
1106                    .is_some()
1107            );
1108        }
1109    }
1110}
1111// LCOV_EXCL_STOP