Skip to main content

koprogo_api/application/use_cases/
mandate_use_cases.rs

1//! Use cases for the Mandate feature (Story 3.4 — FR7 INV-14).
2//!
3//! Three operations:
4//!
5//! 1. [`MandateUseCases::issue`] — a syndic issues a mandate for an external
6//!    professional (notaire, avocat, AMO, architecte, BET, gardien). The
7//!    caller (handler) MUST have already enforced the syndic / superadmin
8//!    role check.
9//! 2. [`MandateUseCases::assert_mandate_authorizes`] — guard used by
10//!    downstream handlers (notarial deeds, litigation actions, …) to verify
11//!    that a mandataire is still authorised before performing an action.
12//!    Returns the correctly-typed `AppError::Mandate*` so the upstream caller
13//!    surfaces 403 with the right `kind` (`mandate_expired`,
14//!    `mandate_revoked`, `mandate_invalid_scope`).
15//! 3. [`MandateUseCases::revoke`] — early revocation (before
16//!    `valid_until`). Handler enforces syndic / superadmin role.
17//!
18//! Also surface read-only helpers (`list_active_for_subject`,
19//! `list_for_scope`, `get`) for the audit / detail views.
20
21use crate::application::error::AppError;
22use crate::application::ports::MandateRepository;
23use crate::domain::entities::{Mandate, MandateKind, MandateScope};
24use chrono::{DateTime, Utc};
25use std::sync::Arc;
26use uuid::Uuid;
27
28pub struct MandateUseCases {
29    repo: Arc<dyn MandateRepository>,
30}
31
32impl MandateUseCases {
33    pub fn new(repo: Arc<dyn MandateRepository>) -> Self {
34        Self { repo }
35    }
36
37    /// Issue a new mandate. Caller MUST have already authorised the request
38    /// (syndic / superadmin role check happens at the handler level).
39    #[allow(clippy::too_many_arguments)]
40    pub async fn issue(
41        &self,
42        subject_user_id: Uuid,
43        kind: MandateKind,
44        scope: MandateScope,
45        issued_by: Uuid,
46        reason: String,
47        valid_from: DateTime<Utc>,
48        valid_until: DateTime<Utc>,
49    ) -> Result<Mandate, AppError> {
50        let mandate = Mandate::issue(
51            subject_user_id,
52            kind,
53            scope,
54            issued_by,
55            reason,
56            valid_from,
57            valid_until,
58        )?;
59        self.repo.save(&mandate).await?;
60        Ok(mandate)
61    }
62
63    /// Verify a user holds a currently-valid mandate of `kind` over `scope`.
64    ///
65    /// Errors are precisely typed so the handler can surface the right `kind`
66    /// in the 403 response:
67    /// - `MandateExpired` — past `valid_until`.
68    /// - `MandateRevoked` — `revoked_at IS NOT NULL`.
69    /// - `MandateInvalidScope` — no mandate matches the requested scope
70    ///   (e.g. notaire mandated on Building A, action on Building B).
71    /// - `MandateNotFound` — the subject has no mandate of this kind at all.
72    pub async fn assert_mandate_authorizes(
73        &self,
74        subject_user_id: Uuid,
75        kind: MandateKind,
76        scope: &MandateScope,
77    ) -> Result<Mandate, AppError> {
78        let mandates = self.repo.list_active_for_subject(subject_user_id).await?;
79
80        // Filter by kind first.
81        let same_kind: Vec<&Mandate> = mandates.iter().filter(|m| m.kind == kind).collect();
82        if same_kind.is_empty() {
83            return Err(AppError::MandateNotFound);
84        }
85
86        // Look for an exact scope match.
87        let matching_scope = same_kind.iter().find(|m| m.scope == *scope);
88        let Some(mandate) = matching_scope else {
89            return Err(AppError::MandateInvalidScope);
90        };
91
92        if mandate.is_revoked() {
93            return Err(AppError::MandateRevoked);
94        }
95        if mandate.is_expired() {
96            return Err(AppError::MandateExpired);
97        }
98        // Belt-and-suspenders: even if the row passed the SQL "active" filter,
99        // honour the domain entity helpers.
100        if !mandate.is_currently_active() {
101            return Err(AppError::MandateExpired);
102        }
103
104        Ok((*mandate).clone())
105    }
106
107    /// Annule un mandat avant son terme. Idempotent — un second appel ne
108    /// surcharge pas `revoked_at`.
109    pub async fn revoke(&self, id: Uuid) -> Result<(), AppError> {
110        // Confirm existence so the handler can map 404 cleanly.
111        let mandate = self
112            .repo
113            .find_by_id(id)
114            .await?
115            .ok_or(AppError::MandateNotFound)?;
116        if mandate.is_revoked() {
117            // Already revoked — no-op, no error. The audit trail is preserved
118            // by the original `revoked_at`.
119            return Ok(());
120        }
121        self.repo.revoke(id, Utc::now()).await?;
122        Ok(())
123    }
124
125    pub async fn get(&self, id: Uuid) -> Result<Mandate, AppError> {
126        self.repo
127            .find_by_id(id)
128            .await?
129            .ok_or(AppError::MandateNotFound)
130    }
131
132    pub async fn list_active_for_subject(
133        &self,
134        subject_user_id: Uuid,
135    ) -> Result<Vec<Mandate>, AppError> {
136        self.repo.list_active_for_subject(subject_user_id).await
137    }
138
139    pub async fn list_for_scope(&self, scope: &MandateScope) -> Result<Vec<Mandate>, AppError> {
140        self.repo.list_for_scope(scope).await
141    }
142}
143
144// ============================================================================
145// Tests — taxonomie 4 catégories (CRITICAL.md #3)
146// ============================================================================
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use async_trait::async_trait;
152    use chrono::Duration;
153    use std::sync::Mutex;
154
155    #[derive(Default)]
156    struct InMemoryRepo {
157        rows: Mutex<Vec<Mandate>>,
158    }
159
160    #[async_trait]
161    impl MandateRepository for InMemoryRepo {
162        async fn save(&self, m: &Mandate) -> Result<(), AppError> {
163            self.rows.lock().unwrap().push(m.clone());
164            Ok(())
165        }
166
167        async fn find_by_id(&self, id: Uuid) -> Result<Option<Mandate>, AppError> {
168            Ok(self
169                .rows
170                .lock()
171                .unwrap()
172                .iter()
173                .find(|m| m.id == id)
174                .cloned())
175        }
176
177        async fn list_active_for_subject(
178            &self,
179            subject_user_id: Uuid,
180        ) -> Result<Vec<Mandate>, AppError> {
181            // Mirror what a real SQL `WHERE revoked_at IS NULL AND valid_until > NOW()`
182            // would do: keep currently active rows only.
183            Ok(self
184                .rows
185                .lock()
186                .unwrap()
187                .iter()
188                .filter(|m| m.subject_user_id == subject_user_id && m.is_currently_active())
189                .cloned()
190                .collect())
191        }
192
193        async fn list_for_scope(&self, scope: &MandateScope) -> Result<Vec<Mandate>, AppError> {
194            Ok(self
195                .rows
196                .lock()
197                .unwrap()
198                .iter()
199                .filter(|m| m.scope == *scope)
200                .cloned()
201                .collect())
202        }
203
204        async fn revoke(&self, id: Uuid, revoked_at: DateTime<Utc>) -> Result<(), AppError> {
205            let mut rows = self.rows.lock().unwrap();
206            if let Some(row) = rows.iter_mut().find(|m| m.id == id) {
207                if row.revoked_at.is_none() {
208                    row.revoked_at = Some(revoked_at);
209                    row.updated_at = revoked_at;
210                }
211            }
212            Ok(())
213        }
214    }
215
216    fn factory() -> (Arc<InMemoryRepo>, MandateUseCases) {
217        let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
218        let uc = MandateUseCases::new(repo.clone() as Arc<dyn MandateRepository>);
219        (repo, uc)
220    }
221
222    fn fixture_window() -> (DateTime<Utc>, DateTime<Utc>) {
223        let now = Utc::now();
224        (now - Duration::seconds(1), now + Duration::days(30))
225    }
226
227    fn reason() -> String {
228        "Cession unité — mandat notarial".to_string()
229    }
230
231    // ---- @happy ------------------------------------------------------------
232
233    #[tokio::test]
234    async fn happy_issue_then_authorize_returns_mandate() {
235        let (_repo, uc) = factory();
236        let subject = Uuid::new_v4();
237        let issuer = Uuid::new_v4();
238        let building = Uuid::new_v4();
239        let scope = MandateScope::Building(building);
240        let (from, until) = fixture_window();
241
242        let issued = uc
243            .issue(
244                subject,
245                MandateKind::Notary,
246                scope,
247                issuer,
248                reason(),
249                from,
250                until,
251            )
252            .await
253            .unwrap();
254        assert_eq!(issued.subject_user_id, subject);
255
256        let resolved = uc
257            .assert_mandate_authorizes(subject, MandateKind::Notary, &scope)
258            .await
259            .unwrap();
260        assert_eq!(resolved.id, issued.id);
261    }
262
263    #[tokio::test]
264    async fn happy_revoke_then_authorize_reports_revoked() {
265        let (_repo, uc) = factory();
266        let subject = Uuid::new_v4();
267        let issuer = Uuid::new_v4();
268        let scope = MandateScope::Acp(Uuid::new_v4());
269        let (from, until) = fixture_window();
270
271        let issued = uc
272            .issue(
273                subject,
274                MandateKind::Lawyer,
275                scope,
276                issuer,
277                reason(),
278                from,
279                until,
280            )
281            .await
282            .unwrap();
283        uc.revoke(issued.id).await.unwrap();
284
285        let err = uc
286            .assert_mandate_authorizes(subject, MandateKind::Lawyer, &scope)
287            .await
288            .unwrap_err();
289        assert!(
290            matches!(err, AppError::MandateNotFound | AppError::MandateRevoked),
291            "expected revoked-related error, got {:?}",
292            err
293        );
294    }
295
296    // ---- @edge -------------------------------------------------------------
297
298    #[tokio::test]
299    async fn edge_double_revoke_is_idempotent() {
300        let (_repo, uc) = factory();
301        let subject = Uuid::new_v4();
302        let issuer = Uuid::new_v4();
303        let scope = MandateScope::Building(Uuid::new_v4());
304        let (from, until) = fixture_window();
305
306        let issued = uc
307            .issue(
308                subject,
309                MandateKind::Bet,
310                scope,
311                issuer,
312                reason(),
313                from,
314                until,
315            )
316            .await
317            .unwrap();
318        uc.revoke(issued.id).await.unwrap();
319        // Second revoke must succeed (no-op).
320        uc.revoke(issued.id).await.unwrap();
321    }
322
323    #[tokio::test]
324    async fn edge_get_unknown_id_returns_not_found() {
325        let (_repo, uc) = factory();
326        let err = uc.get(Uuid::new_v4()).await.unwrap_err();
327        assert!(matches!(err, AppError::MandateNotFound));
328    }
329
330    // ---- @security ---------------------------------------------------------
331
332    #[tokio::test]
333    async fn security_wrong_scope_returns_invalid_scope() {
334        let (_repo, uc) = factory();
335        let subject = Uuid::new_v4();
336        let issuer = Uuid::new_v4();
337        let building_a = MandateScope::Building(Uuid::new_v4());
338        let building_b = MandateScope::Building(Uuid::new_v4());
339        let (from, until) = fixture_window();
340
341        uc.issue(
342            subject,
343            MandateKind::Notary,
344            building_a,
345            issuer,
346            reason(),
347            from,
348            until,
349        )
350        .await
351        .unwrap();
352
353        let err = uc
354            .assert_mandate_authorizes(subject, MandateKind::Notary, &building_b)
355            .await
356            .unwrap_err();
357        assert!(
358            matches!(err, AppError::MandateInvalidScope),
359            "expected MandateInvalidScope, got {:?}",
360            err
361        );
362    }
363
364    #[tokio::test]
365    async fn security_subject_equals_issuer_blocked_at_entity_level() {
366        let (_repo, uc) = factory();
367        let same = Uuid::new_v4();
368        let (from, until) = fixture_window();
369        let err = uc
370            .issue(
371                same,
372                MandateKind::Notary,
373                MandateScope::Building(Uuid::new_v4()),
374                same,
375                reason(),
376                from,
377                until,
378            )
379            .await
380            .unwrap_err();
381        assert!(matches!(err, AppError::Validation(_)));
382    }
383
384    #[tokio::test]
385    async fn security_kind_mismatch_returns_not_found() {
386        let (_repo, uc) = factory();
387        let subject = Uuid::new_v4();
388        let issuer = Uuid::new_v4();
389        let scope = MandateScope::Building(Uuid::new_v4());
390        let (from, until) = fixture_window();
391        uc.issue(
392            subject,
393            MandateKind::Notary,
394            scope,
395            issuer,
396            reason(),
397            from,
398            until,
399        )
400        .await
401        .unwrap();
402        // Subject is mandated as Notary, not Lawyer — must error typed.
403        let err = uc
404            .assert_mandate_authorizes(subject, MandateKind::Lawyer, &scope)
405            .await
406            .unwrap_err();
407        assert!(matches!(err, AppError::MandateNotFound));
408    }
409
410    // ---- @negative ---------------------------------------------------------
411
412    #[tokio::test]
413    async fn negative_expired_mandate_returns_mandate_expired() {
414        // Manually push an already-expired mandate, bypassing `issue` which
415        // forbids `valid_until <= valid_from`.
416        let repo = Arc::new(InMemoryRepo::default());
417        let subject = Uuid::new_v4();
418        let scope = MandateScope::Acp(Uuid::new_v4());
419        let now = Utc::now();
420        let m = Mandate {
421            id: Uuid::new_v4(),
422            subject_user_id: subject,
423            kind: MandateKind::Architect,
424            scope,
425            issued_by: Uuid::new_v4(),
426            reason: "Devis travaux toiture - dossier 2025".to_string(),
427            valid_from: now - Duration::days(40),
428            valid_until: now - Duration::days(1),
429            revoked_at: None,
430            created_at: now - Duration::days(40),
431            updated_at: now - Duration::days(40),
432        };
433        repo.rows.lock().unwrap().push(m);
434        let uc = MandateUseCases::new(repo.clone() as Arc<dyn MandateRepository>);
435
436        let err = uc
437            .assert_mandate_authorizes(subject, MandateKind::Architect, &scope)
438            .await
439            .unwrap_err();
440        // Active-list filter excludes expired rows → caller sees NotFound
441        // (which is correct: "no currently active mandate of this kind").
442        assert!(
443            matches!(err, AppError::MandateNotFound | AppError::MandateExpired),
444            "expected MandateNotFound/Expired, got {:?}",
445            err
446        );
447    }
448
449    #[tokio::test]
450    async fn negative_revoke_unknown_id_returns_not_found() {
451        let (_repo, uc) = factory();
452        let err = uc.revoke(Uuid::new_v4()).await.unwrap_err();
453        assert!(matches!(err, AppError::MandateNotFound));
454    }
455
456    #[tokio::test]
457    async fn negative_short_reason_is_rejected_at_use_case_boundary() {
458        let (_repo, uc) = factory();
459        let (from, until) = fixture_window();
460        let err = uc
461            .issue(
462                Uuid::new_v4(),
463                MandateKind::Warden,
464                MandateScope::Building(Uuid::new_v4()),
465                Uuid::new_v4(),
466                "x".to_string(),
467                from,
468                until,
469            )
470            .await
471            .unwrap_err();
472        assert!(matches!(err, AppError::Validation(_)));
473    }
474}