Skip to main content

koprogo_api/application/use_cases/
list_acps_use_case.rs

1//! `ListAcps` use-case — Story 1.3.
2//!
3//! Wrap `AcpRepository::list(scope)` derived from the caller's role
4//! (`AcpCaller::list_scope()`). Provides a dedicated entry point separated
5//! from the broader `AcpUseCases::list_acps` so that the scope-guard
6//! middleware (cf. `infrastructure::web::middleware::scope_guard`) can
7//! reuse the same `assert_caller_can_see_acp` helper without pulling in
8//! the full CRUD use-cases.
9//!
10//! Permissions (cf. architecture §3.3 + ADR-0010) :
11//! - `SuperAdmin` → `ListScope::All` (sees all ACPs)
12//! - `Admin { org_id }` / `Syndic { org_id }` → `ListScope::Organization(org)`
13//! - `Owner { user_id }` → `ListScope::Owner(user_id)`
14//!
15//! All returns are typed `Result<T, AppError>` — CRITICAL §4. No
16//! `Result<_, String>` introduced (cf. epic #555). The wider repository
17//! port `AcpRepository` already returns `AppError` natively (Story 1.1).
18
19use crate::application::dto::AcpResponseDto;
20use crate::application::error::AppError;
21use crate::application::ports::AcpRepository;
22use crate::application::use_cases::acp_use_cases::AcpCaller;
23use crate::domain::entities::Acp;
24use std::sync::Arc;
25use uuid::Uuid;
26
27/// Dedicated use-case for role-based ACP listing — Story 1.3.
28///
29/// Distinct from `AcpUseCases` to keep the middleware dependency surface
30/// minimal: the scope_guard middleware needs *listing* + *scope-check*
31/// helpers, not the full CRUD lifecycle.
32pub struct ListAcpsUseCase {
33    repository: Arc<dyn AcpRepository>,
34}
35
36impl ListAcpsUseCase {
37    pub fn new(repository: Arc<dyn AcpRepository>) -> Self {
38        Self { repository }
39    }
40
41    /// List ACPs visible to the caller (derived from role + scope).
42    pub async fn list_for_user(&self, caller: &AcpCaller) -> Result<Vec<AcpResponseDto>, AppError> {
43        let scope = caller.list_scope();
44        let acps = self.repository.list(scope).await?;
45        Ok(acps.iter().map(Self::to_response_dto).collect())
46    }
47
48    /// Assert that `caller` is allowed to see (read/use) the ACP `acp_id`.
49    ///
50    /// Used by the scope_guard middleware: the user may attach a
51    /// `X-Scope-AcpId` header or `?acp_id=` query, and we must refuse 403
52    /// `AcpNotInScope` if they try to address an ACP outside their
53    /// effective scope.
54    ///
55    /// Implementation: load the requested ACP, then delegate to
56    /// `caller_can_see(&caller, &acp)` (centralised here so the middleware
57    /// does not need to know domain entities).
58    pub async fn assert_caller_can_see(
59        &self,
60        caller: &AcpCaller,
61        acp_id: Uuid,
62    ) -> Result<(), AppError> {
63        let acp = self
64            .repository
65            .find_by_id(acp_id)
66            .await?
67            .ok_or(AppError::AcpNotInScope { acp_id })?;
68        Self::caller_can_see(caller, &acp)
69    }
70
71    /// Pure permission check (no I/O). Public so tests can exercise the
72    /// rule without spinning up a repository.
73    pub fn caller_can_see(caller: &AcpCaller, acp: &Acp) -> Result<(), AppError> {
74        match caller {
75            AcpCaller::SuperAdmin => Ok(()),
76            AcpCaller::Admin { organization_id } | AcpCaller::Syndic { organization_id } => {
77                match acp.organization_id {
78                    Some(org) if org == *organization_id => Ok(()),
79                    _ => Err(AppError::AcpNotInScope { acp_id: acp.id }),
80                }
81            }
82            // Story 1.3 — owner direct visibility on a single ACP is
83            // derived from `list_for_user(Owner { user_id })`. For a
84            // direct id-lookup we conservatively refuse here: the
85            // middleware will instead consult the listing to verify
86            // belonging once Story 3.5 ships scope/scope_id on
87            // user_role_assignments. Until then, an owner cannot pin a
88            // specific ACP via X-Scope-AcpId.
89            AcpCaller::Owner { .. } => Err(AppError::AcpNotInScope { acp_id: acp.id }),
90        }
91    }
92
93    fn to_response_dto(acp: &Acp) -> AcpResponseDto {
94        AcpResponseDto {
95            id: acp.id.to_string(),
96            organization_id: acp.organization_id.map(|u| u.to_string()),
97            name: acp.name.clone(),
98            slug: acp.slug.clone(),
99            legal_status: acp.legal_status.as_db_str().to_string(),
100            total_tantiemes: acp.total_tantiemes,
101            bce_number: acp.bce_number.clone(),
102            address_street: acp.address_street.clone(),
103            address_postal_code: acp.address_postal_code.clone(),
104            address_city: acp.address_city.clone(),
105            created_at: acp.created_at.to_rfc3339(),
106            updated_at: acp.updated_at.to_rfc3339(),
107        }
108    }
109}
110
111// ============================================================================
112// Tests — taxonomie 4-cat (CRITICAL.md §3).
113// ============================================================================
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::application::ports::{AcpRepository, ListScope};
119    use crate::domain::entities::Acp;
120    use async_trait::async_trait;
121    use mockall::mock;
122
123    mock! {
124        AcpRepo {}
125
126        #[async_trait]
127        impl AcpRepository for AcpRepo {
128            async fn create(&self, acp: &Acp) -> Result<Acp, AppError>;
129            async fn find_by_id(&self, id: Uuid) -> Result<Option<Acp>, AppError>;
130            async fn find_by_id_with_metrics(&self, id: Uuid) -> Result<Option<(Acp, crate::domain::entities::AcpMetrics)>, AppError>;
131            async fn list(&self, scope: ListScope) -> Result<Vec<Acp>, AppError>;
132            async fn list_with_metrics(
133                &self,
134                scope: ListScope,
135            ) -> Result<Vec<(Acp, crate::domain::entities::AcpMetrics)>, AppError>;
136            async fn update(&self, acp: &Acp) -> Result<Acp, AppError>;
137            async fn archive(&self, id: Uuid) -> Result<(), AppError>;
138            async fn count_buildings(&self, id: Uuid) -> Result<i64, AppError>;
139        }
140    }
141
142    fn acp(org_id: Option<Uuid>, name: &str) -> Acp {
143        Acp::new(
144            org_id,
145            name.to_string(),
146            "Rue X 1".to_string(),
147            "1000".to_string(),
148            "Bruxelles".to_string(),
149            None,
150        )
151        .expect("valid acp")
152    }
153
154    // ----- @happy --------------------------------------------------------------
155
156    #[tokio::test]
157    async fn happy_super_admin_lists_all_acps() {
158        let org_a = Uuid::new_v4();
159        let org_b = Uuid::new_v4();
160        let acps = vec![acp(Some(org_a), "Acp A1"), acp(Some(org_b), "Acp B1")];
161
162        let mut repo = MockAcpRepo::new();
163        let acps_clone = acps.clone();
164        repo.expect_list()
165            .withf(|s| matches!(s, ListScope::All))
166            .returning(move |_| Ok(acps_clone.clone()));
167
168        let uc = ListAcpsUseCase::new(Arc::new(repo));
169        let list = uc.list_for_user(&AcpCaller::SuperAdmin).await.expect("ok");
170        assert_eq!(list.len(), 2);
171    }
172
173    #[tokio::test]
174    async fn happy_syndic_lists_only_own_cabinet() {
175        let org_a = Uuid::new_v4();
176        let mut repo = MockAcpRepo::new();
177        repo.expect_list()
178            .withf(move |s| matches!(s, ListScope::Organization(o) if *o == org_a))
179            .returning(move |_| Ok(vec![acp(Some(org_a), "A1"), acp(Some(org_a), "A2")]));
180
181        let uc = ListAcpsUseCase::new(Arc::new(repo));
182        let list = uc
183            .list_for_user(&AcpCaller::Syndic {
184                organization_id: org_a,
185            })
186            .await
187            .expect("ok");
188        assert_eq!(list.len(), 2);
189    }
190
191    // ----- @edge ---------------------------------------------------------------
192
193    #[tokio::test]
194    async fn edge_owner_with_no_assignment_sees_empty_list() {
195        let user_id = Uuid::new_v4();
196        let mut repo = MockAcpRepo::new();
197        repo.expect_list()
198            .withf(move |s| matches!(s, ListScope::Owner(u) if *u == user_id))
199            .returning(|_| Ok(vec![]));
200
201        let uc = ListAcpsUseCase::new(Arc::new(repo));
202        let list = uc
203            .list_for_user(&AcpCaller::Owner { user_id })
204            .await
205            .expect("ok");
206        assert!(list.is_empty());
207    }
208
209    // ----- @security -----------------------------------------------------------
210
211    #[tokio::test]
212    async fn security_syndic_cannot_see_acp_of_other_cabinet() {
213        let cabinet_a = Uuid::new_v4();
214        let cabinet_b = Uuid::new_v4();
215        let target = acp(Some(cabinet_a), "Foreign Acp");
216        let target_id = target.id;
217
218        let mut repo = MockAcpRepo::new();
219        let target_clone = target.clone();
220        repo.expect_find_by_id()
221            .returning(move |_| Ok(Some(target_clone.clone())));
222
223        let uc = ListAcpsUseCase::new(Arc::new(repo));
224        let err = uc
225            .assert_caller_can_see(
226                &AcpCaller::Syndic {
227                    organization_id: cabinet_b,
228                },
229                target_id,
230            )
231            .await
232            .unwrap_err();
233        match err {
234            AppError::AcpNotInScope { acp_id } => assert_eq!(acp_id, target_id),
235            other => panic!("expected AcpNotInScope, got {:?}", other),
236        }
237    }
238
239    #[tokio::test]
240    async fn security_owner_cannot_pin_arbitrary_acp_via_scope_header() {
241        let user_id = Uuid::new_v4();
242        let target = acp(Some(Uuid::new_v4()), "Some Acp");
243        let target_id = target.id;
244        let mut repo = MockAcpRepo::new();
245        let target_clone = target.clone();
246        repo.expect_find_by_id()
247            .returning(move |_| Ok(Some(target_clone.clone())));
248
249        let uc = ListAcpsUseCase::new(Arc::new(repo));
250        let err = uc
251            .assert_caller_can_see(&AcpCaller::Owner { user_id }, target_id)
252            .await
253            .unwrap_err();
254        assert!(matches!(err, AppError::AcpNotInScope { .. }));
255    }
256
257    // ----- @negative -----------------------------------------------------------
258
259    #[tokio::test]
260    async fn negative_assert_unknown_acp_returns_acp_not_in_scope() {
261        let mut repo = MockAcpRepo::new();
262        repo.expect_find_by_id().returning(|_| Ok(None));
263        let uc = ListAcpsUseCase::new(Arc::new(repo));
264        let err = uc
265            .assert_caller_can_see(&AcpCaller::SuperAdmin, Uuid::new_v4())
266            .await
267            .unwrap_err();
268        assert!(matches!(err, AppError::AcpNotInScope { .. }));
269    }
270}