Skip to main content

koprogo_api/application/use_cases/
module_registry_use_cases.rs

1//! Cas d'usage du registre de modules — Story 5.1 (#585), ADR-0015.
2//!
3//! Les règles d'activation vivent ici, pas dans l'adaptateur SQL ni dans le
4//! handler :
5//!
6//! - **lire** l'état demande d'être dans la portée de l'ACP ;
7//! - **changer** l'état demande en plus le droit de muter (SuperAdmin ou
8//!   Admin cabinet), la même frontière que pour modifier l'ACP elle-même ;
9//! - `identity` ne s'éteint pour personne — ce n'est pas un défaut de droits
10//!   mais une propriété de la capacité (`est_toujours_actif`).
11//!
12//! La garde de portée est **déléguée** à `AcpUseCases::assert_can_see_acp`
13//! plutôt que recopiée : un seul chemin, un seul test à maintenir. Si la
14//! règle de portée change, elle change une fois.
15
16use std::sync::Arc;
17
18use uuid::Uuid;
19
20use crate::application::error::AppError;
21use crate::application::ports::ModuleRegistry;
22use crate::application::use_cases::acp_use_cases::{AcpCaller, AcpUseCases};
23use crate::domain::entities::Module;
24
25pub struct ModuleRegistryUseCases {
26    registry: Arc<dyn ModuleRegistry>,
27    acp_use_cases: Arc<AcpUseCases>,
28}
29
30impl ModuleRegistryUseCases {
31    pub fn new(registry: Arc<dyn ModuleRegistry>, acp_use_cases: Arc<AcpUseCases>) -> Self {
32        Self {
33            registry,
34            acp_use_cases,
35        }
36    }
37
38    /// Traduit un nom reçu sur le fil en module connu.
39    ///
40    /// Rendu 422 `UnknownModule` (Story 5.1 @negative) — distinct d'un 403
41    /// `ModuleDisabled` : le client doit pouvoir distinguer « ce nom
42    /// n'existe pas » de « ce module est éteint », sinon il réessaie un nom
43    /// qui ne marchera jamais.
44    pub fn reconnaitre(nom: &str) -> Result<Module, AppError> {
45        Module::depuis_nom(nom).ok_or_else(|| AppError::UnknownModule {
46            module: nom.to_string(),
47        })
48    }
49
50    /// Les modules actifs d'une ACP, pour un appelant dans sa portée.
51    pub async fn list_enabled(
52        &self,
53        caller: &AcpCaller,
54        acp_id: Uuid,
55    ) -> Result<Vec<Module>, AppError> {
56        self.acp_use_cases
57            .assert_can_see_acp(caller, acp_id)
58            .await?;
59        self.registry.list_enabled(acp_id).await
60    }
61
62    /// Allume un module. Idempotent.
63    pub async fn enable(
64        &self,
65        caller: &AcpCaller,
66        acp_id: Uuid,
67        module: Module,
68    ) -> Result<(), AppError> {
69        self.assert_peut_changer_letat(caller, acp_id).await?;
70        self.registry.enable(acp_id, module).await
71    }
72
73    /// Éteint un module. Idempotent, et sans rien détruire (INV-27).
74    ///
75    /// Le refus sur `identity` est vérifié **après** la portée et les droits :
76    /// un appelant hors portée ne doit pas apprendre, via un 403 différent,
77    /// quels modules l'ACP possède.
78    pub async fn disable(
79        &self,
80        caller: &AcpCaller,
81        acp_id: Uuid,
82        module: Module,
83    ) -> Result<(), AppError> {
84        self.assert_peut_changer_letat(caller, acp_id).await?;
85        if module.est_toujours_actif() {
86            return Err(AppError::ModuleAlwaysOn {
87                module: module.to_string(),
88            });
89        }
90        self.registry.disable(acp_id, module).await
91    }
92
93    /// Chemin chaud de `ModuleGuard`. Pas de garde de portée ici : le
94    /// middleware a déjà résolu et vérifié la portée avant d'appeler.
95    pub async fn is_enabled(&self, acp_id: Uuid, module: Module) -> Result<bool, AppError> {
96        if module.est_toujours_actif() {
97            return Ok(true);
98        }
99        self.registry.is_enabled(acp_id, module).await
100    }
101
102    async fn assert_peut_changer_letat(
103        &self,
104        caller: &AcpCaller,
105        acp_id: Uuid,
106    ) -> Result<(), AppError> {
107        self.acp_use_cases
108            .assert_can_see_acp(caller, acp_id)
109            .await?;
110        if !caller.can_mutate() {
111            return Err(AppError::Forbidden(
112                "Seul un administrateur peut activer ou désactiver un module".to_string(),
113            ));
114        }
115        Ok(())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn negative_un_nom_inconnu_donne_422_et_pas_403() {
125        // Le client doit pouvoir distinguer « n'existe pas » de « éteint ».
126        let err = ModuleRegistryUseCases::reconnaitre("foobar").unwrap_err();
127        assert!(matches!(err, AppError::UnknownModule { ref module } if module == "foobar"));
128        assert_eq!(
129            actix_web::ResponseError::status_code(&err),
130            actix_web::http::StatusCode::UNPROCESSABLE_ENTITY
131        );
132    }
133
134    #[test]
135    fn happy_un_nom_connu_est_reconnu() {
136        assert_eq!(
137            ModuleRegistryUseCases::reconnaitre("accounting").unwrap(),
138            Module::Accounting
139        );
140    }
141
142    #[test]
143    fn negative_eteindre_identity_est_un_403_type_pas_une_chaine() {
144        let err = AppError::ModuleAlwaysOn {
145            module: Module::Identity.to_string(),
146        };
147        assert_eq!(
148            actix_web::ResponseError::status_code(&err),
149            actix_web::http::StatusCode::FORBIDDEN
150        );
151    }
152
153    #[test]
154    fn security_module_disabled_porte_le_nom_du_module() {
155        // `ModuleGate` est fail-closed : il doit pouvoir nommer le module
156        // éteint, pas seulement constater un refus.
157        let err = AppError::ModuleDisabled {
158            module: "accounting".to_string(),
159        };
160        assert_eq!(
161            actix_web::ResponseError::status_code(&err),
162            actix_web::http::StatusCode::FORBIDDEN
163        );
164        assert!(err.to_string().contains("accounting"));
165    }
166}