Skip to main content

alibi_plugins/user_management/
mod.rs

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