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
17pub type UserInfo = UserView;
23
24#[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#[async_trait]
47pub trait SendDeleteAccountVerification: Send + Sync {
48 async fn send(&self, user: &UserInfo, url: &str, token: &str) -> AuthResult<()>;
49}
50
51#[async_trait]
56pub trait BeforeDeleteUser: Send + Sync {
57 async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
58}
59
60#[async_trait]
62pub trait AfterDeleteUser: Send + Sync {
63 async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
64}
65
66#[derive(Clone, Default)]
72pub struct ChangeEmailConfig {
73 pub enabled: bool,
75 pub update_without_verification: bool,
78 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#[derive(Clone)]
100pub struct DeleteUserConfig {
101 pub enabled: bool,
103 pub delete_token_expires_in: Duration,
105 pub require_verification: bool,
108 pub send_delete_account_verification: Option<Arc<dyn SendDeleteAccountVerification>>,
111 pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
113 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#[derive(Debug, Clone, Default)]
148pub struct UserManagementConfig {
149 pub change_email: ChangeEmailConfig,
150 pub delete_user: DeleteUserConfig,
151}
152
153pub 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 #[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
251impl UserManagementPlugin {
256 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 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 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#[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 (HttpMethod::Post, "/change-email") => {
388 Ok(Some(self.handle_change_email(req, ctx).await?))
389 }
390 (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#[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 #[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 #[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 #[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 #[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 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 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 assert!(!updated_user.email_verified);
601
602 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 #[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 let deleted_user = ctx.database.get_user_by_id(&user.id).await.unwrap();
645 assert!(deleted_user.is_none());
646 }
647
648 #[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 #[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 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 let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
717 assert!(still_exists.is_some());
718
719 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 let deleted = ctx.database.get_user_by_id(&user.id).await.unwrap();
747 assert!(deleted.is_none());
748 }
749
750 #[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 #[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 #[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 #[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 let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
921 assert!(still_exists.is_some());
922
923 assert!(!called.load(Ordering::SeqCst));
925 }
926
927 #[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