Skip to main content

alibi_api/plugins/user_management/
mod.rs

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