koprogo_api/application/use_cases/
lien_notaire_use_cases.rs1use 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 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 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 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 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 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 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#[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 #[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 uc.verify_token(etat_date_id, &issued.token).await.unwrap();
239 }
240
241 #[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 #[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 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 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 #[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}