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
17pub type UserInfo = UserView;
19
20#[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#[async_trait]
39pub trait SendDeleteAccountVerification: Send + Sync {
40 async fn send(&self, user: &UserInfo, url: &str, token: &str) -> AuthResult<()>;
41}
42
43#[async_trait]
48pub trait BeforeDeleteUser: Send + Sync {
49 async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
50}
51
52#[async_trait]
54pub trait AfterDeleteUser: Send + Sync {
55 async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
56}
57
58#[derive(Clone, Default)]
60pub struct ChangeEmailConfig {
61 pub enabled: bool,
63 pub update_without_verification: bool,
66 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#[derive(Clone)]
88pub struct DeleteUserConfig {
89 pub enabled: bool,
91 pub delete_token_expires_in: Duration,
93 pub require_verification: bool,
96 pub send_delete_account_verification: Option<Arc<dyn SendDeleteAccountVerification>>,
99 pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
101 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#[derive(Debug, Clone, Default)]
136pub struct UserManagementConfig {
137 pub change_email: ChangeEmailConfig,
138 pub delete_user: DeleteUserConfig,
139}
140
141#[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 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 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 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#[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 #[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 #[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 #[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 #[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 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 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 assert!(!updated_user.email_verified);
547
548 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 #[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 let deleted_user = ctx.database.get_user_by_id(&user.id).await.unwrap();
591 assert!(deleted_user.is_none());
592 }
593
594 #[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 #[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 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 let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
663 assert!(still_exists.is_some());
664
665 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 let deleted = ctx.database.get_user_by_id(&user.id).await.unwrap();
693 assert!(deleted.is_none());
694 }
695
696 #[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 #[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 #[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 #[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 let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
867 assert!(still_exists.is_some());
868
869 assert!(!called.load(Ordering::SeqCst));
871 }
872
873 #[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