Skip to main content

koprogo_api/application/use_cases/
auth_use_cases.rs

1use crate::application::dto::{
2    Claims, LoginRequest, LoginResponse, RefreshTokenRequest, RegisterRequest, UserResponse,
3    UserRoleSummary,
4};
5use crate::application::error::AppError;
6use crate::application::ports::{RefreshTokenRepository, UserRepository, UserRoleRepository};
7use crate::domain::entities::{RefreshToken, User, UserRole, UserRoleAssignment};
8use crate::infrastructure::audit::{log_audit_event, AuditEventType};
9use bcrypt::{hash, verify, DEFAULT_COST};
10use chrono::Utc;
11use jsonwebtoken::{encode, EncodingKey, Header};
12use std::sync::Arc;
13use uuid::Uuid;
14
15// ── bcrypt est du CPU bloquant, et il ne doit pas tenir un worker (#718) ───
16//
17// `hash` et `verify` coûtent ~1,7 s de CPU à `DEFAULT_COST` sur l'hôte
18// `ecosolva` — mesuré le 2026-09-13 sur 722 appels : médiane 1,69 s,
19// p90 2,02 s, max 3,84 s.
20//
21// Appelés SYNCHRONEMENT dans un `async fn`, ils ne rendent la main à rien
22// pendant ce temps : ni à une autre requête du même worker, ni à la boucle
23// d'événements. Avec `ACTIX_WORKERS` à 2, deux inscriptions simultanées
24// consomment toute la capacité d'accueil pendant deux secondes ; la
25// troisième requête attend, Traefik n'obtient pas de réponse, et rend 502.
26//
27// C'est le mécanisme de #718, et il est pire en production : la démo tourne
28// avec `ACTIX_WORKERS: 1`. UNE connexion y bloque toute l'API.
29//
30// ── Ce que ce correctif ne fait PAS ────────────────────────────────────────
31//
32// Il ne baisse pas le coût bcrypt. La lenteur de bcrypt n'est pas le défaut,
33// c'est sa raison d'être : elle est ce qui protège les empreintes stockées.
34// Le défaut est de la subir sur un thread qui doit rester disponible.
35//
36// Il ne remplace pas non plus la révision d'`ACTIX_WORKERS: 1`, qui reste
37// indéfendable une fois ce blocage retiré — il déplaçait seulement le seuil.
38//
39// L'idiome est déjà employé dans ce dépôt : `infrastructure/email.rs:248`
40// enveloppe l'envoi SMTP de la même façon. Il n'avait pas été appliqué à
41// l'endroit où il compte le plus.
42
43/// Hache un mot de passe hors des threads de travail Actix (#718).
44async fn hacher_hors_worker(mot_de_passe: String) -> Result<String, String> {
45    tokio::task::spawn_blocking(move || hash(&mot_de_passe, DEFAULT_COST))
46        .await
47        .map_err(|e| format!("Hachage interrompu : {e}"))?
48        .map_err(|e| format!("Failed to hash password: {e}"))
49}
50
51/// Vérifie un mot de passe hors des threads de travail Actix (#718).
52///
53/// Rend `AppError` et non `BcryptError` à dessein. Un `spawn_blocking`
54/// interrompu n'est PAS une empreinte invalide, et `BcryptError` n'a aucune
55/// variante pour le dire — sa seule variante à message porte un
56/// `&'static str`. Le forcer dans `InvalidHash` ferait rendre « identifiants
57/// invalides » sur un incident de runtime, et enverrait l'utilisateur
58/// chercher un défaut de mot de passe qui n'existe pas.
59async fn verifier_hors_worker(mot_de_passe: String, empreinte: String) -> Result<bool, AppError> {
60    tokio::task::spawn_blocking(move || verify(&mot_de_passe, &empreinte))
61        .await
62        .map_err(|e| AppError::Internal(format!("Vérification du mot de passe interrompue : {e}")))?
63        .map_err(AppError::from)
64}
65
66pub struct AuthUseCases {
67    user_repo: Arc<dyn UserRepository>,
68    refresh_token_repo: Arc<dyn RefreshTokenRepository>,
69    user_role_repo: Arc<dyn UserRoleRepository>,
70    jwt_secret: String,
71}
72
73impl AuthUseCases {
74    pub fn new(
75        user_repo: Arc<dyn UserRepository>,
76        refresh_token_repo: Arc<dyn RefreshTokenRepository>,
77        user_role_repo: Arc<dyn UserRoleRepository>,
78        jwt_secret: String,
79    ) -> Self {
80        Self {
81            user_repo,
82            refresh_token_repo,
83            user_role_repo,
84            jwt_secret,
85        }
86    }
87
88    pub async fn login(&self, request: LoginRequest) -> Result<LoginResponse, AppError> {
89        // Emails sont stockés normalisés (lowercase, cf. `User::new`) — la
90        // recherche doit l'être aussi, sinon un login avec une casse
91        // différente de celle saisie à l'inscription échoue à tort en
92        // "Invalid credentials" (trouvé en investiguant #617 C4).
93        let normalized_email = request.email.trim().to_lowercase();
94        let user = self
95            .user_repo
96            .find_by_email(&normalized_email)
97            .await?
98            .ok_or_else(|| {
99                // Audit failed login attempt
100                tokio::spawn(async move {
101                    log_audit_event(
102                        AuditEventType::AuthenticationFailed,
103                        None,
104                        None,
105                        Some(format!("Failed login attempt for email: {}", request.email)),
106                        None,
107                    )
108                    .await;
109                });
110                // Uniform InvalidCredentials prevents username enumeration.
111                AppError::InvalidCredentials
112            })?;
113
114        if !user.is_active {
115            // Audit login attempt on deactivated account
116            let user_id = user.id;
117            tokio::spawn(async move {
118                log_audit_event(
119                    AuditEventType::AuthenticationFailed,
120                    Some(user_id),
121                    None,
122                    Some("Login attempt on deactivated account".to_string()),
123                    None,
124                )
125                .await;
126            });
127            return Err(AppError::AccountDeactivated);
128        }
129
130        // Hors worker : bcrypt bloque ~1,7 s (#718).
131        let is_valid =
132            verifier_hors_worker(request.password.clone(), user.password_hash.clone()).await?;
133
134        if !is_valid {
135            // Audit failed password verification
136            let user_id = user.id;
137            tokio::spawn(async move {
138                log_audit_event(
139                    AuditEventType::AuthenticationFailed,
140                    Some(user_id),
141                    None,
142                    Some("Invalid password".to_string()),
143                    None,
144                )
145                .await;
146            });
147            return Err(AppError::InvalidCredentials);
148        }
149
150        let (roles, active_role) = self.ensure_role_assignments(&user).await?;
151        let user_for_token = self.apply_active_role_metadata(&user, &active_role).await?;
152        let token = self.generate_token(&user_for_token, &active_role)?;
153
154        let refresh_token_string = self.generate_refresh_token_string(&user_for_token);
155        let refresh_token = RefreshToken::new(user_for_token.id, refresh_token_string.clone());
156        self.refresh_token_repo.create(&refresh_token).await?;
157
158        // Audit successful login
159        let user_id = user_for_token.id;
160        let organization_id = active_role.organization_id;
161        tokio::spawn(async move {
162            log_audit_event(
163                AuditEventType::UserLogin,
164                Some(user_id),
165                organization_id,
166                Some("Successful login".to_string()),
167                None,
168            )
169            .await;
170        });
171
172        Ok(LoginResponse {
173            token,
174            refresh_token: refresh_token_string,
175            user: self.build_user_response(&user_for_token, &roles, &active_role),
176        })
177    }
178
179    pub async fn register(&self, request: RegisterRequest) -> Result<LoginResponse, String> {
180        // Même normalisation que `User::new` (stockage lowercase) — sinon un
181        // doublon de casse ("Test@x.com" après "test@x.com") échappe au
182        // check applicatif et remonte comme erreur DB brute au lieu d'un
183        // "Email already exists" propre (trouvé en investiguant #617 C4).
184        let normalized_email = request.email.trim().to_lowercase();
185        if (self.user_repo.find_by_email(&normalized_email).await?).is_some() {
186            return Err("Email already exists".to_string());
187        }
188
189        let role: UserRole = request
190            .role
191            .parse()
192            .map_err(|e| format!("Invalid role: {}", e))?;
193
194        // Hors worker : bcrypt bloque ~1,7 s (#718).
195        let password_hash = hacher_hors_worker(request.password.clone()).await?;
196
197        let user = User::new(
198            request.email,
199            password_hash,
200            request.first_name,
201            request.last_name,
202            role.clone(),
203            request.organization_id,
204        )?;
205
206        let created_user = self.user_repo.create(&user).await?;
207
208        // Create primary role assignment
209        let primary_assignment = self
210            .user_role_repo
211            .create(&UserRoleAssignment::new(
212                created_user.id,
213                role,
214                created_user.organization_id,
215                true,
216            ))
217            .await?;
218        let roles = vec![primary_assignment.clone()];
219        let user_for_token = self
220            .apply_active_role_metadata(&created_user, &primary_assignment)
221            .await?;
222
223        let token = self.generate_token(&user_for_token, &primary_assignment)?;
224        let refresh_token_string = self.generate_refresh_token_string(&user_for_token);
225        let refresh_token = RefreshToken::new(user_for_token.id, refresh_token_string.clone());
226        self.refresh_token_repo.create(&refresh_token).await?;
227
228        // Audit successful registration
229        let user_id = created_user.id;
230        let organization_id = created_user.organization_id;
231        let email = created_user.email.clone();
232        tokio::spawn(async move {
233            log_audit_event(
234                AuditEventType::UserRegistration,
235                Some(user_id),
236                organization_id,
237                Some(format!("New user registered: {}", email)),
238                None,
239            )
240            .await;
241        });
242
243        Ok(LoginResponse {
244            token,
245            refresh_token: refresh_token_string,
246            user: self.build_user_response(&user_for_token, &roles, &primary_assignment),
247        })
248    }
249
250    pub async fn switch_active_role(
251        &self,
252        user_id: Uuid,
253        role_id: Uuid,
254    ) -> Result<LoginResponse, String> {
255        let user = self
256            .user_repo
257            .find_by_id(user_id)
258            .await?
259            .ok_or("User not found")?;
260
261        if !user.is_active {
262            return Err("User account is deactivated".to_string());
263        }
264
265        let target_role = self
266            .user_role_repo
267            .find_by_id(role_id)
268            .await?
269            .ok_or("Role assignment not found")?;
270
271        if target_role.user_id != user.id {
272            return Err("Role assignment does not belong to user".to_string());
273        }
274
275        let updated_primary = self
276            .user_role_repo
277            .set_primary_role(user.id, role_id)
278            .await?;
279
280        let roles = self.user_role_repo.list_for_user(user.id).await?;
281        let active_role = roles
282            .iter()
283            .find(|assignment| assignment.is_primary)
284            .cloned()
285            .unwrap_or(updated_primary.clone());
286
287        let updated_user = self.apply_active_role_metadata(&user, &active_role).await?;
288
289        let token = self.generate_token(&updated_user, &active_role)?;
290        let refresh_token_string = self.generate_refresh_token_string(&updated_user);
291        let refresh_token = RefreshToken::new(updated_user.id, refresh_token_string.clone());
292        self.refresh_token_repo.create(&refresh_token).await?;
293
294        Ok(LoginResponse {
295            token,
296            refresh_token: refresh_token_string,
297            user: self.build_user_response(&updated_user, &roles, &active_role),
298        })
299    }
300
301    pub async fn get_user_by_id(&self, user_id: uuid::Uuid) -> Result<UserResponse, AppError> {
302        let user = self
303            .user_repo
304            .find_by_id(user_id)
305            .await?
306            .ok_or_else(|| AppError::NotFound(format!("user {}", user_id)))?;
307
308        let (roles, active_role) = self.ensure_role_assignments(&user).await?;
309        Ok(self.build_user_response(&user, &roles, &active_role))
310    }
311
312    pub fn verify_token(&self, token: &str) -> Result<Claims, AppError> {
313        use jsonwebtoken::{decode, DecodingKey, Validation};
314
315        let token_data = decode::<Claims>(
316            token,
317            &DecodingKey::from_secret(self.jwt_secret.as_bytes()),
318            &Validation::default(),
319        )?;
320
321        Ok(token_data.claims)
322    }
323
324    pub async fn refresh_token(
325        &self,
326        request: RefreshTokenRequest,
327    ) -> Result<LoginResponse, AppError> {
328        let refresh_token = self
329            .refresh_token_repo
330            .find_by_token(&request.refresh_token)
331            .await?
332            .ok_or_else(|| {
333                // Audit invalid refresh token attempt
334                tokio::spawn(async {
335                    log_audit_event(
336                        AuditEventType::InvalidToken,
337                        None,
338                        None,
339                        Some("Invalid refresh token attempted".to_string()),
340                        None,
341                    )
342                    .await;
343                });
344                AppError::TokenError("invalid refresh token".to_string())
345            })?;
346
347        if !refresh_token.is_valid() {
348            // Audit expired/revoked token attempt
349            let user_id = refresh_token.user_id;
350            let reason = if refresh_token.is_expired() {
351                "Expired refresh token"
352            } else {
353                "Revoked refresh token"
354            };
355            tokio::spawn(async move {
356                log_audit_event(
357                    AuditEventType::InvalidToken,
358                    Some(user_id),
359                    None,
360                    Some(format!("{} attempted", reason)),
361                    None,
362                )
363                .await;
364            });
365            return Err(AppError::TokenError(
366                "refresh token expired or revoked".to_string(),
367            ));
368        }
369
370        let user = self
371            .user_repo
372            .find_by_id(refresh_token.user_id)
373            .await?
374            .ok_or_else(|| AppError::NotFound("user for refresh token".to_string()))?;
375
376        if !user.is_active {
377            // Audit refresh attempt on deactivated account
378            let user_id = user.id;
379            tokio::spawn(async move {
380                log_audit_event(
381                    AuditEventType::AuthenticationFailed,
382                    Some(user_id),
383                    None,
384                    Some("Refresh token attempt on deactivated account".to_string()),
385                    None,
386                )
387                .await;
388            });
389            return Err(AppError::AccountDeactivated);
390        }
391
392        let (roles, active_role) = self.ensure_role_assignments(&user).await?;
393        let user_for_token = self.apply_active_role_metadata(&user, &active_role).await?;
394        let token = self.generate_token(&user_for_token, &active_role)?;
395
396        // Revoke old token (refresh token rotation)
397        self.refresh_token_repo
398            .revoke(&request.refresh_token)
399            .await?;
400
401        let new_refresh_token_string = self.generate_refresh_token_string(&user_for_token);
402        let new_refresh_token =
403            RefreshToken::new(user_for_token.id, new_refresh_token_string.clone());
404        self.refresh_token_repo.create(&new_refresh_token).await?;
405
406        // Audit successful token refresh
407        let user_id = user_for_token.id;
408        let organization_id = active_role.organization_id;
409        tokio::spawn(async move {
410            log_audit_event(
411                AuditEventType::TokenRefresh,
412                Some(user_id),
413                organization_id,
414                Some("Refresh token successfully exchanged".to_string()),
415                None,
416            )
417            .await;
418        });
419
420        Ok(LoginResponse {
421            token,
422            refresh_token: new_refresh_token_string,
423            user: self.build_user_response(&user_for_token, &roles, &active_role),
424        })
425    }
426
427    pub async fn revoke_all_refresh_tokens(&self, user_id: Uuid) -> Result<u64, String> {
428        self.refresh_token_repo.revoke_all_for_user(user_id).await
429    }
430
431    fn build_user_response(
432        &self,
433        user: &User,
434        roles: &[UserRoleAssignment],
435        active_role: &UserRoleAssignment,
436    ) -> UserResponse {
437        UserResponse {
438            id: user.id,
439            email: user.email.clone(),
440            first_name: user.first_name.clone(),
441            last_name: user.last_name.clone(),
442            role: active_role.role.to_string(),
443            organization_id: active_role.organization_id,
444            is_active: user.is_active,
445            created_at: user.created_at,
446            roles: roles.iter().map(Self::summarize_role).collect(),
447            active_role: Some(Self::summarize_role(active_role)),
448        }
449    }
450
451    async fn ensure_role_assignments(
452        &self,
453        user: &User,
454    ) -> Result<(Vec<UserRoleAssignment>, UserRoleAssignment), String> {
455        let mut assignments = self.user_role_repo.list_for_user(user.id).await?;
456
457        if assignments.is_empty() {
458            let assignment = self
459                .user_role_repo
460                .create(&UserRoleAssignment::new(
461                    user.id,
462                    user.role.clone(),
463                    user.organization_id,
464                    true,
465                ))
466                .await?;
467            assignments.push(assignment.clone());
468        }
469
470        if !assignments.iter().any(|assignment| assignment.is_primary) {
471            let first = assignments[0].id;
472            self.user_role_repo.set_primary_role(user.id, first).await?;
473            assignments = self.user_role_repo.list_for_user(user.id).await?;
474        }
475
476        let active = assignments
477            .iter()
478            .find(|assignment| assignment.is_primary)
479            .cloned()
480            .unwrap_or_else(|| assignments[0].clone());
481
482        Ok((assignments, active))
483    }
484
485    async fn apply_active_role_metadata(
486        &self,
487        user: &User,
488        active_role: &UserRoleAssignment,
489    ) -> Result<User, String> {
490        let mut updated_user = user.clone();
491        let mut requires_update = false;
492
493        if updated_user.role != active_role.role {
494            updated_user.role = active_role.role.clone();
495            requires_update = true;
496        }
497
498        if updated_user.organization_id != active_role.organization_id {
499            updated_user.organization_id = active_role.organization_id;
500            requires_update = true;
501        }
502
503        if requires_update {
504            updated_user.updated_at = Utc::now();
505            return self.user_repo.update(&updated_user).await;
506        }
507
508        Ok(updated_user)
509    }
510
511    fn summarize_role(assignment: &UserRoleAssignment) -> UserRoleSummary {
512        UserRoleSummary {
513            id: assignment.id,
514            role: assignment.role.to_string(),
515            organization_id: assignment.organization_id,
516            is_primary: assignment.is_primary,
517        }
518    }
519
520    fn generate_token(
521        &self,
522        user: &User,
523        active_role: &UserRoleAssignment,
524    ) -> Result<String, String> {
525        let now = Utc::now().timestamp();
526        let expiration = now + (15 * 60);
527
528        let claims = Claims {
529            sub: user.id.to_string(),
530            email: user.email.clone(),
531            role: active_role.role.to_string(),
532            organization_id: active_role.organization_id,
533            role_id: Some(active_role.id),
534            exp: expiration,
535            iat: now,
536        };
537
538        encode(
539            &Header::default(),
540            &claims,
541            &EncodingKey::from_secret(self.jwt_secret.as_bytes()),
542        )
543        .map_err(|e| format!("Failed to generate token: {}", e))
544    }
545
546    fn generate_refresh_token_string(&self, user: &User) -> String {
547        let now = Utc::now().timestamp();
548        format!("{}:{}:{}", user.id, now, uuid::Uuid::new_v4())
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::application::ports::{RefreshTokenRepository, UserRoleRepository};
556    use crate::domain::entities::{RefreshToken, User, UserRole, UserRoleAssignment};
557    use async_trait::async_trait;
558    use mockall::mock;
559    use std::sync::Arc;
560
561    // Re-use the existing MockUserRepo from the user_repository port
562    use crate::application::ports::user_repository::MockUserRepo;
563
564    // Create mock for RefreshTokenRepository (not defined elsewhere)
565    mock! {
566        pub RefreshTokenRepo {}
567
568        #[async_trait]
569        impl RefreshTokenRepository for RefreshTokenRepo {
570            async fn create(&self, refresh_token: &RefreshToken) -> Result<RefreshToken, String>;
571            async fn find_by_token(&self, token: &str) -> Result<Option<RefreshToken>, String>;
572            async fn find_by_user_id(&self, user_id: Uuid) -> Result<Vec<RefreshToken>, String>;
573            async fn revoke(&self, token: &str) -> Result<bool, String>;
574            async fn revoke_all_for_user(&self, user_id: Uuid) -> Result<u64, String>;
575            async fn delete_expired(&self) -> Result<u64, String>;
576        }
577    }
578
579    // Create mock for UserRoleRepository (not defined elsewhere)
580    mock! {
581        pub UserRoleRepo {}
582
583        #[async_trait]
584        impl UserRoleRepository for UserRoleRepo {
585            async fn create(&self, assignment: &UserRoleAssignment) -> Result<UserRoleAssignment, String>;
586            async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<UserRoleAssignment>, String>;
587            async fn list_for_users(&self, user_ids: &[Uuid]) -> Result<std::collections::HashMap<Uuid, Vec<UserRoleAssignment>>, String>;
588            async fn replace_all(&self, user_id: Uuid, assignments: &[UserRoleAssignment]) -> Result<(), String>;
589            async fn find_by_id(&self, id: Uuid) -> Result<Option<UserRoleAssignment>, String>;
590            async fn set_primary_role(&self, user_id: Uuid, role_id: Uuid) -> Result<UserRoleAssignment, String>;
591            async fn delete_by_id(&self, id: Uuid) -> Result<bool, String>;
592        }
593    }
594
595    const TEST_JWT_SECRET: &str = "test-secret-key-that-is-long-enough-for-jwt";
596
597    /// Helper: create a valid active User with a bcrypt-hashed password.
598    fn make_user(email: &str, password: &str, org_id: Option<Uuid>) -> User {
599        let hash = bcrypt::hash(password, 4).expect("bcrypt hash");
600        User::new(
601            email.to_string(),
602            hash,
603            "Test".to_string(),
604            "User".to_string(),
605            UserRole::Syndic,
606            org_id,
607        )
608        .expect("valid user")
609    }
610
611    /// Helper: create a UserRoleAssignment marked as primary for a given user.
612    fn make_primary_role(user_id: Uuid, org_id: Option<Uuid>) -> UserRoleAssignment {
613        UserRoleAssignment::new(user_id, UserRole::Syndic, org_id, true)
614    }
615
616    /// Build an AuthUseCases from the three mocks.
617    fn build_use_cases(
618        user_repo: MockUserRepo,
619        refresh_repo: MockRefreshTokenRepo,
620        role_repo: MockUserRoleRepo,
621    ) -> AuthUseCases {
622        AuthUseCases::new(
623            Arc::new(user_repo),
624            Arc::new(refresh_repo),
625            Arc::new(role_repo),
626            TEST_JWT_SECRET.to_string(),
627        )
628    }
629
630    // ── 1. login success ────────────────────────────────────────────────
631
632    #[tokio::test]
633    async fn test_login_success() {
634        let org_id = Some(Uuid::new_v4());
635        let user = make_user("login@example.com", "password123", org_id);
636        let user_clone = user.clone();
637        let _user_for_update = user.clone();
638        let role = make_primary_role(user.id, org_id);
639        let role_clone = role.clone();
640
641        let mut user_repo = MockUserRepo::new();
642        user_repo
643            .expect_find_by_email()
644            .withf(|e| e == "login@example.com")
645            .returning(move |_| Ok(Some(user_clone.clone())));
646        user_repo.expect_update().returning(move |u| Ok(u.clone()));
647
648        let mut refresh_repo = MockRefreshTokenRepo::new();
649        refresh_repo.expect_create().returning(|rt| Ok(rt.clone()));
650
651        let mut role_repo = MockUserRoleRepo::new();
652        role_repo
653            .expect_list_for_user()
654            .returning(move |_| Ok(vec![role_clone.clone()]));
655
656        let uc = build_use_cases(user_repo, refresh_repo, role_repo);
657
658        let result = uc
659            .login(LoginRequest {
660                email: "login@example.com".to_string(),
661                password: "password123".to_string(),
662            })
663            .await;
664
665        assert!(result.is_ok(), "login should succeed: {:?}", result.err());
666        let response = result.unwrap();
667        assert!(!response.token.is_empty());
668        assert!(!response.refresh_token.is_empty());
669        assert_eq!(response.user.email, "login@example.com");
670        assert!(response.user.is_active);
671    }
672
673    // ── 2. login — user not found ───────────────────────────────────────
674
675    #[tokio::test]
676    async fn test_login_invalid_email() {
677        let mut user_repo = MockUserRepo::new();
678        user_repo.expect_find_by_email().returning(|_| Ok(None));
679
680        let uc = build_use_cases(
681            user_repo,
682            MockRefreshTokenRepo::new(),
683            MockUserRoleRepo::new(),
684        );
685
686        let result = uc
687            .login(LoginRequest {
688                email: "nonexistent@example.com".to_string(),
689                password: "whatever".to_string(),
690            })
691            .await;
692
693        // @security : doit être InvalidCredentials (uniforme avec wrong password
694        // pour ne pas leaker l'existence du compte).
695        assert!(
696            matches!(result, Err(AppError::InvalidCredentials)),
697            "expected AppError::InvalidCredentials, got {:?}",
698            result
699        );
700    }
701
702    // ── 3. login — wrong password ───────────────────────────────────────
703
704    #[tokio::test]
705    async fn test_login_invalid_password() {
706        let user = make_user("user@example.com", "correct_password", None);
707        let user_clone = user.clone();
708
709        let mut user_repo = MockUserRepo::new();
710        user_repo
711            .expect_find_by_email()
712            .returning(move |_| Ok(Some(user_clone.clone())));
713
714        let uc = build_use_cases(
715            user_repo,
716            MockRefreshTokenRepo::new(),
717            MockUserRoleRepo::new(),
718        );
719
720        let result = uc
721            .login(LoginRequest {
722                email: "user@example.com".to_string(),
723                password: "wrong_password".to_string(),
724            })
725            .await;
726
727        // @security : InvalidCredentials uniforme (anti-énumération).
728        assert!(
729            matches!(result, Err(AppError::InvalidCredentials)),
730            "expected AppError::InvalidCredentials, got {:?}",
731            result
732        );
733    }
734
735    // ── 4. login — deactivated account ──────────────────────────────────
736
737    #[tokio::test]
738    async fn test_login_deactivated_account() {
739        let mut user = make_user("deactivated@example.com", "password123", None);
740        user.deactivate();
741        let user_clone = user.clone();
742
743        let mut user_repo = MockUserRepo::new();
744        user_repo
745            .expect_find_by_email()
746            .returning(move |_| Ok(Some(user_clone.clone())));
747
748        let uc = build_use_cases(
749            user_repo,
750            MockRefreshTokenRepo::new(),
751            MockUserRoleRepo::new(),
752        );
753
754        let result = uc
755            .login(LoginRequest {
756                email: "deactivated@example.com".to_string(),
757                password: "password123".to_string(),
758            })
759            .await;
760
761        // @security : AccountDeactivated → 403 Forbidden (cf. AppError::status_code).
762        // Note: déballer cette info VS uniforme InvalidCredentials est un trade-off
763        // sécurité (énumération potentielle). À reconsidérer en RFC dédié.
764        assert!(
765            matches!(result, Err(AppError::AccountDeactivated)),
766            "expected AppError::AccountDeactivated, got {:?}",
767            result
768        );
769    }
770
771    // ── 5. register success ─────────────────────────────────────────────
772
773    #[tokio::test]
774    async fn test_register_success() {
775        let mut user_repo = MockUserRepo::new();
776        user_repo.expect_find_by_email().returning(|_| Ok(None)); // email not taken
777        user_repo.expect_create().returning(|u| Ok(u.clone()));
778        user_repo.expect_update().returning(|u| Ok(u.clone()));
779
780        let mut refresh_repo = MockRefreshTokenRepo::new();
781        refresh_repo.expect_create().returning(|rt| Ok(rt.clone()));
782
783        let mut role_repo = MockUserRoleRepo::new();
784        role_repo.expect_create().returning(|a| Ok(a.clone()));
785
786        let uc = build_use_cases(user_repo, refresh_repo, role_repo);
787
788        let result = uc
789            .register(RegisterRequest {
790                email: "new@example.com".to_string(),
791                password: "password123".to_string(),
792                first_name: "Alice".to_string(),
793                last_name: "Dupont".to_string(),
794                role: "syndic".to_string(),
795                organization_id: None,
796            })
797            .await;
798
799        assert!(
800            result.is_ok(),
801            "register should succeed: {:?}",
802            result.err()
803        );
804        let response = result.unwrap();
805        assert!(!response.token.is_empty());
806        assert_eq!(response.user.email, "new@example.com");
807        assert_eq!(response.user.first_name, "Alice");
808    }
809
810    // ── 6. register — duplicate email ───────────────────────────────────
811
812    #[tokio::test]
813    async fn test_register_duplicate_email() {
814        let existing = make_user("taken@example.com", "pw123456", None);
815        let existing_clone = existing.clone();
816
817        let mut user_repo = MockUserRepo::new();
818        user_repo
819            .expect_find_by_email()
820            .returning(move |_| Ok(Some(existing_clone.clone())));
821
822        let uc = build_use_cases(
823            user_repo,
824            MockRefreshTokenRepo::new(),
825            MockUserRoleRepo::new(),
826        );
827
828        let result = uc
829            .register(RegisterRequest {
830                email: "taken@example.com".to_string(),
831                password: "password123".to_string(),
832                first_name: "Bob".to_string(),
833                last_name: "Martin".to_string(),
834                role: "owner".to_string(),
835                organization_id: None,
836            })
837            .await;
838
839        assert!(result.is_err());
840        assert_eq!(result.unwrap_err(), "Email already exists");
841    }
842
843    // ── 7. switch_active_role success ───────────────────────────────────
844
845    #[tokio::test]
846    async fn test_switch_role_success() {
847        let org_id = Some(Uuid::new_v4());
848        let user = make_user("multi@example.com", "password123", org_id);
849        let user_clone = user.clone();
850
851        // The target role that we want to switch to
852        let target_role = UserRoleAssignment::new(user.id, UserRole::Accountant, org_id, false);
853        let target_role_id = target_role.id;
854        let target_clone = target_role.clone();
855
856        // After switching, the target role becomes primary
857        let mut switched_role = target_role.clone();
858        switched_role.is_primary = true;
859        let switched_clone = switched_role.clone();
860        let switched_for_list = switched_role.clone();
861
862        let mut user_repo = MockUserRepo::new();
863        user_repo
864            .expect_find_by_id()
865            .returning(move |_| Ok(Some(user_clone.clone())));
866        user_repo.expect_update().returning(|u| Ok(u.clone()));
867
868        let mut refresh_repo = MockRefreshTokenRepo::new();
869        refresh_repo.expect_create().returning(|rt| Ok(rt.clone()));
870
871        let mut role_repo = MockUserRoleRepo::new();
872        role_repo
873            .expect_find_by_id()
874            .returning(move |_| Ok(Some(target_clone.clone())));
875        role_repo
876            .expect_set_primary_role()
877            .returning(move |_, _| Ok(switched_clone.clone()));
878        role_repo
879            .expect_list_for_user()
880            .returning(move |_| Ok(vec![switched_for_list.clone()]));
881
882        let uc = build_use_cases(user_repo, refresh_repo, role_repo);
883
884        let result = uc.switch_active_role(user.id, target_role_id).await;
885
886        assert!(
887            result.is_ok(),
888            "switch_role should succeed: {:?}",
889            result.err()
890        );
891        let response = result.unwrap();
892        assert!(!response.token.is_empty());
893        assert_eq!(response.user.role, "accountant");
894    }
895
896    // ── 8. switch_active_role — role not found ──────────────────────────
897
898    #[tokio::test]
899    async fn test_switch_role_not_found() {
900        let user = make_user("user@example.com", "password123", None);
901        let user_clone = user.clone();
902
903        let mut user_repo = MockUserRepo::new();
904        user_repo
905            .expect_find_by_id()
906            .returning(move |_| Ok(Some(user_clone.clone())));
907
908        let mut role_repo = MockUserRoleRepo::new();
909        role_repo.expect_find_by_id().returning(|_| Ok(None)); // role not found
910
911        let uc = build_use_cases(user_repo, MockRefreshTokenRepo::new(), role_repo);
912
913        let result = uc.switch_active_role(user.id, Uuid::new_v4()).await;
914
915        assert!(result.is_err());
916        assert_eq!(result.unwrap_err(), "Role assignment not found");
917    }
918
919    // ── 9. verify_token — valid token ───────────────────────────────────
920
921    #[tokio::test]
922    async fn test_verify_token_valid() {
923        let org_id = Some(Uuid::new_v4());
924        let user = make_user("verify@example.com", "password123", org_id);
925        let role = make_primary_role(user.id, org_id);
926
927        let uc = build_use_cases(
928            MockUserRepo::new(),
929            MockRefreshTokenRepo::new(),
930            MockUserRoleRepo::new(),
931        );
932
933        // Generate a token using the same secret
934        let token = uc.generate_token(&user, &role).expect("token generation");
935
936        let claims = uc.verify_token(&token);
937        assert!(claims.is_ok(), "verify should succeed: {:?}", claims.err());
938        let claims = claims.unwrap();
939        assert_eq!(claims.sub, user.id.to_string());
940        assert_eq!(claims.email, "verify@example.com");
941        assert_eq!(claims.role, "syndic");
942        assert_eq!(claims.organization_id, org_id);
943    }
944
945    // ── 10. verify_token — invalid token ────────────────────────────────
946
947    #[tokio::test]
948    async fn test_verify_token_invalid() {
949        let uc = build_use_cases(
950            MockUserRepo::new(),
951            MockRefreshTokenRepo::new(),
952            MockUserRoleRepo::new(),
953        );
954
955        let result = uc.verify_token("this.is.not.a.valid.jwt");
956        // @negative : token invalide → AppError::TokenError (mappé 401 par ResponseError).
957        assert!(
958            matches!(result, Err(AppError::TokenError(_))),
959            "expected AppError::TokenError, got {:?}",
960            result
961        );
962    }
963
964    // ── 11. revoke_all_refresh_tokens ───────────────────────────────────
965
966    #[tokio::test]
967    async fn test_revoke_all_refresh_tokens() {
968        let user_id = Uuid::new_v4();
969
970        let mut refresh_repo = MockRefreshTokenRepo::new();
971        refresh_repo
972            .expect_revoke_all_for_user()
973            .withf(move |id| *id == user_id)
974            .returning(|_| Ok(3));
975
976        let uc = build_use_cases(MockUserRepo::new(), refresh_repo, MockUserRoleRepo::new());
977
978        let result = uc.revoke_all_refresh_tokens(user_id).await;
979        assert!(result.is_ok());
980        assert_eq!(result.unwrap(), 3);
981    }
982    // ── 12. bcrypt ne tient pas un thread de travail (#718) ──────────────
983
984    /// Témoin de rougeur pour #718, et il est DÉTERMINISTE.
985    ///
986    /// ── Pourquoi pas un chronomètre ───────────────────────────────────────
987    ///
988    /// La tentation était de mesurer que N hachages concurrents durent moins
989    /// que N fois un hachage seul. Sur un hôte qui porte trente conteneurs,
990    /// une telle assertion clignote, et un test qui clignote finit désactivé
991    /// — c'est ainsi que le blocage a survécu jusqu'ici.
992    ///
993    /// ── Ce que ce test exploite ───────────────────────────────────────────
994    ///
995    /// Un runtime `current_thread` n'a QU'UN seul thread pour les tâches
996    /// asynchrones. Une tâche témoin qui rend la main (`yield_now`) ne
997    /// progresse donc que si le hachage libère ce thread.
998    ///
999    /// - `hash()` appelé directement : le thread est tenu pendant ~1,7 s, la
1000    ///   témoin n'est jamais replanifiée, le compteur reste à **0**.
1001    /// - `hacher_hors_worker()` : le hachage part sur le pool bloquant, le
1002    ///   `.await` rend la main, la témoin tourne, le compteur est **> 0**.
1003    ///
1004    /// Aucune durée n'est assertée. Le test dit « la boucle a-t-elle
1005    /// avancé », pas « a-t-elle avancé assez vite ».
1006    #[tokio::test(flavor = "current_thread")]
1007    async fn le_hachage_ne_retient_pas_la_boucle_devenements() {
1008        use std::sync::atomic::{AtomicUsize, Ordering};
1009        use std::sync::Arc as StdArc;
1010
1011        let tours = StdArc::new(AtomicUsize::new(0));
1012        let compteur = tours.clone();
1013
1014        let temoin = tokio::spawn(async move {
1015            // Borne haute : sans elle, un correctif régressé ferait pendre le
1016            // test au lieu de le faire échouer, et un test pendu se lit comme
1017            // une lenteur d'infrastructure.
1018            for _ in 0..100_000 {
1019                compteur.fetch_add(1, Ordering::Relaxed);
1020                tokio::task::yield_now().await;
1021            }
1022        });
1023
1024        let empreinte = hacher_hors_worker("mot-de-passe-temoin".to_string())
1025            .await
1026            .expect("le hachage doit réussir");
1027
1028        let avances = tours.load(Ordering::Relaxed);
1029        temoin.abort();
1030
1031        assert!(
1032            avances > 0,
1033            "La tâche témoin n'a pas avancé d'un seul tour pendant le hachage : \
1034             bcrypt tient le thread de travail (#718). Sur un runtime à un \
1035             thread, c'est exactement ce qui fait rendre 502 à Traefik quand \
1036             une deuxième requête arrive."
1037        );
1038
1039        // Et la vérification aussi, par le même chemin.
1040        let compteur2 = StdArc::new(AtomicUsize::new(0));
1041        let c2 = compteur2.clone();
1042        let temoin2 = tokio::spawn(async move {
1043            for _ in 0..100_000 {
1044                c2.fetch_add(1, Ordering::Relaxed);
1045                tokio::task::yield_now().await;
1046            }
1047        });
1048
1049        let valide = verifier_hors_worker("mot-de-passe-temoin".to_string(), empreinte)
1050            .await
1051            .expect("la vérification doit réussir");
1052        let avances2 = compteur2.load(Ordering::Relaxed);
1053        temoin2.abort();
1054
1055        assert!(valide, "l'empreinte fraîchement produite doit se vérifier");
1056        assert!(
1057            avances2 > 0,
1058            "La tâche témoin n'a pas avancé pendant la vérification : \
1059             `verify` tient le thread de travail (#718). Le login est le \
1060             chemin le plus chaud du produit."
1061        );
1062    }
1063}