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