Skip to main content

koprogo_api/application/use_cases/
lien_notaire_use_cases.rs

1//! Use cases for the notary link feature (#845 — ADR 0048, ADR 0051).
2//!
3//! Four operations, all syndic-gated except the last:
4//! 1. [`LienNotaireUseCases::issue`] — a syndic issues a link for one état
5//!    daté. Returns the clear token ONCE.
6//! 2. [`LienNotaireUseCases::renew`] — the syndic extends the active link by
7//!    seven more days from now. Same token, new `expire_le`.
8//! 3. [`LienNotaireUseCases::revoke`] — the syndic kills the active link
9//!    before term.
10//! 4. [`LienNotaireUseCases::verify_token`] — called on every
11//!    `GET /etats-dates/reference/{reference_number}?token=...`. Repeatable
12//!    (no consumption): the notary may read the same état daté several times
13//!    within the seven-day window (ADR 0051).
14
15use crate::application::error::AppError;
16use crate::application::ports::LienNotaireRepository;
17use crate::domain::entities::LienNotaire;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::sync::Arc;
21use uuid::Uuid;
22
23#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
24pub struct IssuedLienNotaireDto {
25    pub id: Uuid,
26    /// Jeton clair — à renvoyer au syndic UNE FOIS, jamais persisté ailleurs.
27    pub token: String,
28    pub expires_at: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
32pub struct LienNotaireStatusDto {
33    pub id: Uuid,
34    pub expires_at: DateTime<Utc>,
35    pub renewed_at: Option<DateTime<Utc>>,
36}
37
38impl From<&LienNotaire> for LienNotaireStatusDto {
39    fn from(lien: &LienNotaire) -> Self {
40        Self {
41            id: lien.id,
42            expires_at: lien.expire_le,
43            renewed_at: lien.renouvele_le,
44        }
45    }
46}
47
48pub struct LienNotaireUseCases {
49    repo: Arc<dyn LienNotaireRepository>,
50}
51
52impl LienNotaireUseCases {
53    pub fn new(repo: Arc<dyn LienNotaireRepository>) -> Self {
54        Self { repo }
55    }
56
57    /// Issue a new link. Caller MUST have already authorised the request
58    /// (org-scope check on `etat_date_id` happens at the handler level, via
59    /// `verify_etat_date_org_access`).
60    pub async fn issue(
61        &self,
62        etat_date_id: Uuid,
63        emis_par: Uuid,
64    ) -> Result<IssuedLienNotaireDto, AppError> {
65        let (lien, clair) = LienNotaire::emettre(etat_date_id, emis_par)?;
66        self.repo.save(&lien).await?;
67
68        Ok(IssuedLienNotaireDto {
69            id: lien.id,
70            token: clair,
71            expires_at: lien.expire_le,
72        })
73    }
74
75    /// Renew the active link for a given état daté — same token, expiry
76    /// pushed seven days from now.
77    pub async fn renew(&self, etat_date_id: Uuid) -> Result<LienNotaireStatusDto, AppError> {
78        let mut lien = self
79            .repo
80            .find_active_by_etat_date_id(etat_date_id)
81            .await?
82            .ok_or_else(|| AppError::NotFound(format!("notary link for {etat_date_id}")))?;
83
84        lien.renouveler(Utc::now())?;
85        self.repo.update(&lien).await?;
86
87        Ok(LienNotaireStatusDto::from(&lien))
88    }
89
90    /// Revoke the active link for a given état daté.
91    pub async fn revoke(&self, etat_date_id: Uuid, revoked_by: Uuid) -> Result<(), AppError> {
92        let mut lien = self
93            .repo
94            .find_active_by_etat_date_id(etat_date_id)
95            .await?
96            .ok_or_else(|| AppError::NotFound(format!("notary link for {etat_date_id}")))?;
97
98        lien.revoquer(Utc::now(), revoked_by);
99        self.repo.update(&lien).await
100    }
101
102    /// Verify a clear token grants access to `etat_date_id`. Repeatable —
103    /// does NOT consume the link (ADR 0051, multi-lecture).
104    ///
105    /// Possible errors (all 403 by design, uniform with "unknown token" to
106    /// defeat enumeration — same rationale as `MagicLinkInvalid`):
107    /// - `AppError::NotaryLinkInvalid` — forged / unknown / wrong état daté.
108    /// - `AppError::NotaryLinkExpired` — the seven days elapsed.
109    /// - `AppError::NotaryLinkRevoked` — the syndic killed it early.
110    pub async fn verify_token(
111        &self,
112        etat_date_id: Uuid,
113        clear_token: &str,
114    ) -> Result<LienNotaire, AppError> {
115        if clear_token.trim().is_empty() {
116            return Err(AppError::NotaryLinkInvalid);
117        }
118
119        let token_hash = LienNotaire::hacher(clear_token);
120        let lien = self
121            .repo
122            .find_by_token_hash(&token_hash)
123            .await?
124            .ok_or(AppError::NotaryLinkInvalid)?;
125
126        if lien.etat_date_id != etat_date_id {
127            // Un jeton d'un autre état daté ne doit pas distinguablement
128            // échouer d'un jeton forgé (#845 @security).
129            return Err(AppError::NotaryLinkInvalid);
130        }
131        if lien.est_revoque() {
132            return Err(AppError::NotaryLinkRevoked);
133        }
134        if lien.est_expire(Utc::now()) {
135            return Err(AppError::NotaryLinkExpired);
136        }
137
138        Ok(lien)
139    }
140}
141
142// ============================================================================
143// Tests — taxonomie 4 catégories (CRITICAL.md #3). Bornes reprises d'ADR 0051
144// §"Les quatre classes de tests".
145// ============================================================================
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use async_trait::async_trait;
151    use chrono::Duration;
152    use std::sync::Mutex;
153
154    #[derive(Default)]
155    struct InMemoryRepo {
156        rows: Mutex<Vec<LienNotaire>>,
157    }
158
159    #[async_trait]
160    impl LienNotaireRepository for InMemoryRepo {
161        async fn save(&self, lien: &LienNotaire) -> Result<(), AppError> {
162            self.rows.lock().unwrap().push(lien.clone());
163            Ok(())
164        }
165
166        async fn find_by_token_hash(
167            &self,
168            token_hash: &str,
169        ) -> Result<Option<LienNotaire>, AppError> {
170            Ok(self
171                .rows
172                .lock()
173                .unwrap()
174                .iter()
175                .find(|l| l.token_hash == token_hash)
176                .cloned())
177        }
178
179        async fn find_active_by_etat_date_id(
180            &self,
181            etat_date_id: Uuid,
182        ) -> Result<Option<LienNotaire>, AppError> {
183            Ok(self
184                .rows
185                .lock()
186                .unwrap()
187                .iter()
188                .filter(|l| l.etat_date_id == etat_date_id && !l.est_revoque())
189                .max_by_key(|l| l.cree_le)
190                .cloned())
191        }
192
193        async fn update(&self, lien: &LienNotaire) -> Result<(), AppError> {
194            let mut rows = self.rows.lock().unwrap();
195            if let Some(row) = rows.iter_mut().find(|l| l.id == lien.id) {
196                *row = lien.clone();
197            }
198            Ok(())
199        }
200    }
201
202    fn use_cases() -> (Arc<InMemoryRepo>, LienNotaireUseCases) {
203        let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
204        let uc = LienNotaireUseCases::new(repo.clone() as Arc<dyn LienNotaireRepository>);
205        (repo, uc)
206    }
207
208    // ---- @happy — lecture dans la fenêtre --------------------------------
209
210    #[tokio::test]
211    async fn happy_issue_then_verify_succeeds_and_is_repeatable() {
212        let (_repo, uc) = use_cases();
213        let etat_date_id = Uuid::new_v4();
214        let syndic = Uuid::new_v4();
215
216        let issued = uc.issue(etat_date_id, syndic).await.unwrap();
217        assert!(!issued.token.is_empty());
218
219        let lien_1 = uc.verify_token(etat_date_id, &issued.token).await.unwrap();
220        let lien_2 = uc.verify_token(etat_date_id, &issued.token).await.unwrap();
221        assert_eq!(
222            lien_1.id, lien_2.id,
223            "multi-lecture : le même jeton se relit"
224        );
225    }
226
227    #[tokio::test]
228    async fn happy_renew_extends_expiry_and_keeps_same_token() {
229        let (_repo, uc) = use_cases();
230        let etat_date_id = Uuid::new_v4();
231        let issued = uc.issue(etat_date_id, Uuid::new_v4()).await.unwrap();
232
233        let status = uc.renew(etat_date_id).await.unwrap();
234        assert!(status.expires_at > issued.expires_at);
235        assert!(status.renewed_at.is_some());
236
237        // Le jeton d'origine ouvre toujours — le renouvellement ne le change pas.
238        uc.verify_token(etat_date_id, &issued.token).await.unwrap();
239    }
240
241    // ---- @edge — bornes J+7 / J+8, renouvellement en chaîne ----------------
242
243    #[tokio::test]
244    async fn edge_verify_at_j8_after_expiry_fails() {
245        let repo = Arc::new(InMemoryRepo::default());
246        let (mut lien, clair) = LienNotaire::emettre(Uuid::new_v4(), Uuid::new_v4()).unwrap();
247        lien.expire_le = Utc::now() - Duration::seconds(1);
248        let etat_date_id = lien.etat_date_id;
249        repo.rows.lock().unwrap().push(lien);
250
251        let uc = LienNotaireUseCases::new(repo as Arc<dyn LienNotaireRepository>);
252        let err = uc.verify_token(etat_date_id, &clair).await.unwrap_err();
253        assert!(matches!(err, AppError::NotaryLinkExpired));
254    }
255
256    #[tokio::test]
257    async fn edge_renew_missing_link_returns_not_found() {
258        let (_repo, uc) = use_cases();
259        let err = uc.renew(Uuid::new_v4()).await.unwrap_err();
260        assert!(matches!(err, AppError::NotFound(_)));
261    }
262
263    #[tokio::test]
264    async fn edge_renewal_chain_keeps_pushing_the_deadline() {
265        let (_repo, uc) = use_cases();
266        let etat_date_id = Uuid::new_v4();
267        uc.issue(etat_date_id, Uuid::new_v4()).await.unwrap();
268
269        let first = uc.renew(etat_date_id).await.unwrap();
270        let second = uc.renew(etat_date_id).await.unwrap();
271        assert!(second.expires_at >= first.expires_at);
272    }
273
274    // ---- @security — jeton forgé, autre état daté, révoqué, rejeu ----------
275
276    #[tokio::test]
277    async fn security_forged_token_returns_invalid() {
278        let (_repo, uc) = use_cases();
279        let err = uc
280            .verify_token(Uuid::new_v4(), "forged-not-in-db")
281            .await
282            .unwrap_err();
283        assert!(matches!(err, AppError::NotaryLinkInvalid));
284    }
285
286    #[tokio::test]
287    async fn security_token_scoped_to_another_etat_date_is_rejected_uniformly() {
288        let (_repo, uc) = use_cases();
289        let etat_date_a = Uuid::new_v4();
290        let etat_date_b = Uuid::new_v4();
291        let issued = uc.issue(etat_date_a, Uuid::new_v4()).await.unwrap();
292
293        let err = uc
294            .verify_token(etat_date_b, &issued.token)
295            .await
296            .unwrap_err();
297        // Même erreur qu'un jeton forgé — pas de "wrong scope" distinguable.
298        assert!(matches!(err, AppError::NotaryLinkInvalid));
299    }
300
301    #[tokio::test]
302    async fn security_revoked_link_is_rejected_even_within_the_window() {
303        let (_repo, uc) = use_cases();
304        let etat_date_id = Uuid::new_v4();
305        let issued = uc.issue(etat_date_id, Uuid::new_v4()).await.unwrap();
306
307        uc.revoke(etat_date_id, Uuid::new_v4()).await.unwrap();
308
309        let err = uc
310            .verify_token(etat_date_id, &issued.token)
311            .await
312            .unwrap_err();
313        assert!(matches!(err, AppError::NotaryLinkRevoked));
314    }
315
316    #[tokio::test]
317    async fn security_replay_after_expiry_still_fails() {
318        // Le jeton reste multi-lecture DANS la fenêtre, mais un rejeu après
319        // l'échéance échoue comme n'importe quelle autre lecture tardive.
320        let repo = Arc::new(InMemoryRepo::default());
321        let (mut lien, clair) = LienNotaire::emettre(Uuid::new_v4(), Uuid::new_v4()).unwrap();
322        lien.expire_le = Utc::now() - Duration::days(1);
323        let etat_date_id = lien.etat_date_id;
324        repo.rows.lock().unwrap().push(lien);
325
326        let uc = LienNotaireUseCases::new(repo as Arc<dyn LienNotaireRepository>);
327        uc.verify_token(etat_date_id, &clair).await.unwrap_err();
328        let err = uc.verify_token(etat_date_id, &clair).await.unwrap_err();
329        assert!(matches!(err, AppError::NotaryLinkExpired));
330    }
331
332    // ---- @negative — référence inconnue, jeton malformé --------------------
333
334    #[tokio::test]
335    async fn negative_empty_token_returns_invalid_without_db_lookup() {
336        let (repo, uc) = use_cases();
337        let err = uc.verify_token(Uuid::new_v4(), "   ").await.unwrap_err();
338        assert!(matches!(err, AppError::NotaryLinkInvalid));
339        assert!(repo.rows.lock().unwrap().is_empty());
340    }
341
342    #[tokio::test]
343    async fn negative_revoke_missing_link_returns_not_found_not_panic() {
344        let (_repo, uc) = use_cases();
345        let err = uc.revoke(Uuid::new_v4(), Uuid::new_v4()).await.unwrap_err();
346        assert!(matches!(err, AppError::NotFound(_)));
347    }
348
349    #[tokio::test]
350    async fn negative_issue_with_nil_etat_date_id_is_typed_not_a_panic() {
351        let (_repo, uc) = use_cases();
352        let err = uc.issue(Uuid::nil(), Uuid::new_v4()).await.unwrap_err();
353        assert!(matches!(err, AppError::Validation(_)));
354    }
355}