Skip to main content

koprogo_api/domain/copropriete/
lien_notaire.rs

1//! Le lien notaire — accès signé, temporaire et révocable à un état daté.
2//!
3//! [ADR 0048](../../../../../docs/adr/0048-identite-notaire-et-routes-publiques.md)
4//! a tranché la destination : `GET /etats-dates/reference/{reference_number}`
5//! cesse d'être publique. Un état daté porte les dettes d'un copropriétaire
6//! nommé ; le rendre lisible à quiconque connaît une référence revenait à
7//! publier une situation financière individuelle derrière un identifiant
8//! devinable, transmissible et jamais révocable.
9//!
10//! [ADR 0051](../../../../../docs/adr/0051-lien-notaire-sept-jours-renouvelable.md)
11//! a tranché la modalité : un jeton signé, émis par le syndic, qui vaut
12//! **sept jours calendaires**, autorise **plusieurs lectures** pendant cette
13//! fenêtre, et que le syndic peut **renouveler** ou **révoquer** avant terme.
14//!
15//! Distinct de [`super::releve_notaire::DemandeDeReleve`], qui suit le délai
16//! **légal** de fourniture (Art. 3.89 § 5, 5°, trente jours). Ici, la durée
17//! est un paramètre de **sécurité** : le temps pendant lequel un secret reste
18//! opposable. ADR 0051 explique pourquoi les deux durées ne doivent pas
19//! coïncider.
20//!
21//! Contrairement au [`crate::domain::plateforme::magic_link::MagicLink`]
22//! générique — à usage unique (`consumed_at`) — ce jeton est **multi-lecture
23//! par construction** : aucun champ ne marque une consultation comme
24//! consommée. Chaque lecture est journalisée au niveau use-case/handler, pas
25//! ici.
26
27use chrono::{DateTime, Duration, Utc};
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30use uuid::Uuid;
31
32/// Durée de validité d'un lien, fixée par ADR 0051.
33pub const DUREE_JOURS: i64 = 7;
34
35/// Erreurs domaine pures — zéro dépendance application/infra (pureté
36/// hexagonale, cf. CLAUDE.md).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum LienNotaireError {
39    /// `etat_date_id` est nil : rien à protéger.
40    EtatDateIdNul,
41    /// `emis_par` est nil : aucun syndic ne porte l'émission.
42    EmisParNul,
43    /// Un lien révoqué ne se renouvelle pas — il faut en émettre un nouveau.
44    DejaRevoque,
45}
46
47impl std::fmt::Display for LienNotaireError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::EtatDateIdNul => write!(f, "etat_date_id must not be nil"),
51            Self::EmisParNul => write!(f, "emis_par must not be nil"),
52            Self::DejaRevoque => write!(f, "a revoked notary link cannot be renewed"),
53        }
54    }
55}
56
57impl std::error::Error for LienNotaireError {}
58
59/// Un lien notaire vers un état daté précis.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct LienNotaire {
62    pub id: Uuid,
63    /// L'état daté que ce lien donne le droit de lire — jamais réinterprété :
64    /// une lecture qui vise un autre état daté avec ce jeton doit échouer.
65    pub etat_date_id: Uuid,
66    /// SHA-256 hex du jeton clair. Le jeton clair n'est JAMAIS stocké.
67    pub token_hash: String,
68    /// Le syndic qui a émis le lien (traçabilité).
69    pub emis_par: Uuid,
70    pub cree_le: DateTime<Utc>,
71    pub expire_le: DateTime<Utc>,
72    /// `Some` si le syndic a révoqué le lien avant terme.
73    pub revoque_le: Option<DateTime<Utc>>,
74    pub revoque_par: Option<Uuid>,
75    /// Date du dernier renouvellement, s'il y en a eu un.
76    pub renouvele_le: Option<DateTime<Utc>>,
77    pub mis_a_jour_le: DateTime<Utc>,
78}
79
80impl LienNotaire {
81    /// Émet un nouveau lien. Retourne l'entité persistée ET le jeton clair,
82    /// qui doit être renvoyé UNE FOIS au syndic et jamais stocké ailleurs.
83    pub fn emettre(etat_date_id: Uuid, emis_par: Uuid) -> Result<(Self, String), LienNotaireError> {
84        if etat_date_id.is_nil() {
85            return Err(LienNotaireError::EtatDateIdNul);
86        }
87        if emis_par.is_nil() {
88            return Err(LienNotaireError::EmisParNul);
89        }
90
91        let clair = Self::generer_jeton();
92        let token_hash = Self::hacher(&clair);
93        let maintenant = Utc::now();
94
95        let entite = Self {
96            id: Uuid::new_v4(),
97            etat_date_id,
98            token_hash,
99            emis_par,
100            cree_le: maintenant,
101            expire_le: maintenant + Duration::days(DUREE_JOURS),
102            revoque_le: None,
103            revoque_par: None,
104            renouvele_le: None,
105            mis_a_jour_le: maintenant,
106        };
107
108        Ok((entite, clair))
109    }
110
111    /// SHA-256 hex du jeton clair. Public pour que le repository/handler
112    /// puisse hacher un jeton reçu et le chercher.
113    pub fn hacher(clair: &str) -> String {
114        let mut hasher = Sha256::new();
115        hasher.update(clair.as_bytes());
116        format!("{:x}", hasher.finalize())
117    }
118
119    /// 32 octets aléatoires, encodés base64url (~256 bits d'entropie).
120    fn generer_jeton() -> String {
121        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
122        let mut octets = [0u8; 32];
123        for o in octets.iter_mut() {
124            *o = rand::random::<u8>();
125        }
126        URL_SAFE_NO_PAD.encode(octets)
127    }
128
129    pub fn est_expire(&self, moment: DateTime<Utc>) -> bool {
130        moment > self.expire_le
131    }
132
133    pub fn est_revoque(&self) -> bool {
134        self.revoque_le.is_some()
135    }
136
137    /// Valide = ni expiré, ni révoqué. Aucune notion de consommation : un
138    /// même lien reste valide sur plusieurs lectures (ADR 0051).
139    pub fn est_valide(&self, moment: DateTime<Utc>) -> bool {
140        !self.est_expire(moment) && !self.est_revoque()
141    }
142
143    /// Renouvelle le lien pour sept nouveaux jours à partir de `moment`.
144    ///
145    /// C'est un ACTE explicite du syndic, pas un automatisme (ADR 0051) —
146    /// autorisé même après expiration : une vente qui dépasse sept jours est
147    /// normale, et c'est au syndic de le constater. Seule la révocation est
148    /// terminale.
149    pub fn renouveler(&mut self, moment: DateTime<Utc>) -> Result<(), LienNotaireError> {
150        if self.est_revoque() {
151            return Err(LienNotaireError::DejaRevoque);
152        }
153        self.expire_le = moment + Duration::days(DUREE_JOURS);
154        self.renouvele_le = Some(moment);
155        self.mis_a_jour_le = moment;
156        Ok(())
157    }
158
159    /// Révoque le lien avant terme. Idempotent : une seconde révocation ne
160    /// réécrit pas le moment ni l'auteur de la première (même idiome que
161    /// `MagicLink::consume`).
162    pub fn revoquer(&mut self, moment: DateTime<Utc>, revoque_par: Uuid) {
163        if self.revoque_le.is_none() {
164            self.revoque_le = Some(moment);
165            self.revoque_par = Some(revoque_par);
166        }
167        self.mis_a_jour_le = moment;
168    }
169}
170
171// ============================================================================
172// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3)
173// ============================================================================
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn paire() -> (Uuid, Uuid) {
180        (Uuid::new_v4(), Uuid::new_v4())
181    }
182
183    // ------------------------------------------------------------------------
184    // @happy
185    // ------------------------------------------------------------------------
186
187    #[test]
188    fn happy_emettre_retourne_un_lien_valide_sept_jours() {
189        let (etat_date_id, emis_par) = paire();
190        let (lien, clair) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
191
192        assert_eq!(lien.etat_date_id, etat_date_id);
193        assert_eq!(lien.emis_par, emis_par);
194        assert!(lien.est_valide(Utc::now()));
195        assert_eq!(
196            (lien.expire_le - lien.cree_le).num_days(),
197            DUREE_JOURS,
198            "ADR 0051 fixe la durée à sept jours"
199        );
200        assert!(
201            clair.len() >= 40,
202            "jeton clair trop court : {}",
203            clair.len()
204        );
205    }
206
207    #[test]
208    fn happy_hacher_est_deterministe_et_produit_64_caracteres_hex() {
209        let h1 = LienNotaire::hacher("un-jeton");
210        let h2 = LienNotaire::hacher("un-jeton");
211        assert_eq!(h1, h2);
212        assert_eq!(h1.len(), 64);
213        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
214    }
215
216    #[test]
217    fn happy_lecture_dans_la_fenetre_est_valide() {
218        // Lecture au jour 3 : dans la fenêtre des sept jours.
219        let (etat_date_id, emis_par) = paire();
220        let (lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
221        let jour_3 = lien.cree_le + Duration::days(3);
222        assert!(lien.est_valide(jour_3));
223    }
224
225    #[test]
226    fn happy_renouveler_prolonge_de_sept_jours_depuis_le_moment_donne() {
227        let (etat_date_id, emis_par) = paire();
228        let (mut lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
229        let renouvellement = lien.cree_le + Duration::days(6);
230
231        lien.renouveler(renouvellement).unwrap();
232
233        assert_eq!(lien.renouvele_le, Some(renouvellement));
234        assert_eq!(lien.expire_le, renouvellement + Duration::days(DUREE_JOURS));
235    }
236
237    // ------------------------------------------------------------------------
238    // @edge — bornes de l'expiration, J+7 / J+8, renouvellement en chaîne
239    // ------------------------------------------------------------------------
240
241    #[test]
242    fn edge_lecture_a_j7_pile_est_encore_valide() {
243        let (etat_date_id, emis_par) = paire();
244        let (lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
245        assert!(
246            lien.est_valide(lien.expire_le),
247            "l'instant exact de l'échéance n'est pas encore un dépassement"
248        );
249    }
250
251    #[test]
252    fn edge_lecture_a_j8_apres_lecheance_est_invalide() {
253        let (etat_date_id, emis_par) = paire();
254        let (lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
255        let j8 = lien.expire_le + Duration::seconds(1);
256        assert!(!lien.est_valide(j8));
257        assert!(lien.est_expire(j8));
258    }
259
260    #[test]
261    fn edge_renouvellement_en_chaine_repousse_lecheance_a_chaque_fois() {
262        let (etat_date_id, emis_par) = paire();
263        let (mut lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
264
265        let premiere_echeance = lien.expire_le;
266        let renouvellement_1 = premiere_echeance - Duration::days(1);
267        lien.renouveler(renouvellement_1).unwrap();
268        let deuxieme_echeance = lien.expire_le;
269        assert!(deuxieme_echeance > premiere_echeance);
270
271        let renouvellement_2 = deuxieme_echeance - Duration::days(1);
272        lien.renouveler(renouvellement_2).unwrap();
273        assert!(lien.expire_le > deuxieme_echeance);
274        assert_eq!(lien.renouvele_le, Some(renouvellement_2));
275    }
276
277    #[test]
278    fn edge_renouveler_un_lien_deja_expire_le_repousse_depuis_maintenant() {
279        // « Une vente qui dépasse sept jours est normale » (ADR 0051) : le
280        // renouvellement n'est pas bloqué par une expiration déjà passée.
281        let (etat_date_id, emis_par) = paire();
282        let (mut lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
283        let bien_apres_lecheance = lien.expire_le + Duration::days(10);
284
285        lien.renouveler(bien_apres_lecheance).unwrap();
286
287        assert!(lien.est_valide(bien_apres_lecheance));
288    }
289
290    // ------------------------------------------------------------------------
291    // @security — jeton forgé (hash), lien révoqué, distinction des lecteurs
292    // ------------------------------------------------------------------------
293
294    #[test]
295    fn security_deux_emissions_produisent_des_jetons_et_hachages_distincts() {
296        let (etat_date_id, emis_par) = paire();
297        let (lien_a, clair_a) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
298        let (lien_b, clair_b) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
299
300        assert_ne!(clair_a, clair_b);
301        assert_ne!(lien_a.token_hash, lien_b.token_hash);
302        assert_ne!(lien_a.id, lien_b.id);
303    }
304
305    #[test]
306    fn security_le_jeton_clair_ne_vaut_jamais_le_hachage_stocke() {
307        let (etat_date_id, emis_par) = paire();
308        let (lien, clair) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
309        assert_ne!(clair, lien.token_hash);
310        assert_eq!(LienNotaire::hacher(&clair), lien.token_hash);
311    }
312
313    #[test]
314    fn security_lien_revoque_est_invalide_meme_avant_lecheance() {
315        let (etat_date_id, emis_par) = paire();
316        let (mut lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
317        let revocateur = Uuid::new_v4();
318
319        lien.revoquer(Utc::now(), revocateur);
320
321        assert!(lien.est_revoque());
322        assert!(!lien.est_valide(Utc::now()));
323        assert_eq!(lien.revoque_par, Some(revocateur));
324    }
325
326    #[test]
327    fn security_double_revocation_est_idempotente_et_garde_le_premier_auteur() {
328        let (etat_date_id, emis_par) = paire();
329        let (mut lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
330        let premier = Uuid::new_v4();
331        let second = Uuid::new_v4();
332
333        lien.revoquer(Utc::now(), premier);
334        let premiere_date = lien.revoque_le;
335        lien.revoquer(Utc::now() + Duration::seconds(5), second);
336
337        assert_eq!(lien.revoque_le, premiere_date);
338        assert_eq!(lien.revoque_par, Some(premier));
339    }
340
341    #[test]
342    fn security_renouveler_un_lien_revoque_est_refuse() {
343        let (etat_date_id, emis_par) = paire();
344        let (mut lien, _) = LienNotaire::emettre(etat_date_id, emis_par).unwrap();
345        lien.revoquer(Utc::now(), Uuid::new_v4());
346
347        let err = lien.renouveler(Utc::now()).unwrap_err();
348        assert_eq!(err, LienNotaireError::DejaRevoque);
349    }
350
351    // ------------------------------------------------------------------------
352    // @negative — défaillance correcte, jamais de panic
353    // ------------------------------------------------------------------------
354
355    #[test]
356    fn negative_etat_date_id_nul_est_rejete() {
357        let err = LienNotaire::emettre(Uuid::nil(), Uuid::new_v4()).unwrap_err();
358        assert_eq!(err, LienNotaireError::EtatDateIdNul);
359    }
360
361    #[test]
362    fn negative_emis_par_nul_est_rejete() {
363        let err = LienNotaire::emettre(Uuid::new_v4(), Uuid::nil()).unwrap_err();
364        assert_eq!(err, LienNotaireError::EmisParNul);
365    }
366
367    #[test]
368    fn negative_display_des_erreurs_ne_panique_pas_et_reste_lisible() {
369        for err in [
370            LienNotaireError::EtatDateIdNul,
371            LienNotaireError::EmisParNul,
372            LienNotaireError::DejaRevoque,
373        ] {
374            assert!(!err.to_string().is_empty());
375        }
376    }
377}