Skip to main content

koprogo_api/application/use_cases/
magic_link_use_cases.rs

1//! Use cases for the MagicLink feature (Story 3.2 — FR6, INV-13, INV-17).
2//!
3//! Two operations:
4//! 1. [`MagicLinkUseCases::issue`] — a syndic issues a magic link bound to a
5//!    `(scope_kind, scope_id)` + recipient user. Returns the clear token ONCE.
6//! 2. [`MagicLinkUseCases::validate_and_consume`] — the public `/c/{token}`
7//!    endpoint hashes the incoming token, looks it up, validates it, and marks
8//!    it consumed atomically. Returns the resolved [`MagicLink`] so the caller
9//!    handler can fetch the underlying scope resource.
10//!
11//! Security highlights:
12//! - Clear token is generated inside `MagicLink::issue` and returned to the
13//!   handler. It is NEVER logged and NEVER re-fetched from DB.
14//! - Lookup uses `find_by_token_hash(sha256(token))` — a forged token returns
15//!   `None` → translated to `MagicLinkInvalid` (uniform with "unknown token"
16//!   to defeat enumeration).
17//! - Single-use enforced by `mark_consumed` (race-safe `UPDATE ... WHERE
18//!   consumed_at IS NULL` at the repository layer).
19
20use crate::application::error::AppError;
21use crate::application::ports::MagicLinkRepository;
22use crate::domain::entities::{MagicLink, MagicLinkScopeKind};
23use chrono::{DateTime, Duration, Utc};
24use serde::{Deserialize, Serialize};
25use std::sync::Arc;
26use uuid::Uuid;
27
28/// Bounds for a MagicLink TTL (seconds). Smaller values are easy to misuse
29/// (expired before SMS arrives); larger values weaken the security model.
30const MIN_TTL_SECONDS: i64 = 60; // 1 minute
31const MAX_TTL_SECONDS: i64 = 60 * 60 * 24 * 30; // 30 days
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct IssuedMagicLinkDto {
35    pub id: Uuid,
36    /// Clear token — return to the client ONCE, never persist elsewhere.
37    pub token: String,
38    pub expires_at: DateTime<Utc>,
39    pub scope_kind: MagicLinkScopeKind,
40    pub scope_id: Uuid,
41}
42
43pub struct MagicLinkUseCases {
44    repo: Arc<dyn MagicLinkRepository>,
45}
46
47impl MagicLinkUseCases {
48    pub fn new(repo: Arc<dyn MagicLinkRepository>) -> Self {
49        Self { repo }
50    }
51
52    /// Issue a new MagicLink. Caller MUST have already authorised the request
53    /// (syndic / superadmin role check happens at the handler level).
54    pub async fn issue(
55        &self,
56        subject_user_id: Uuid,
57        scope_kind: MagicLinkScopeKind,
58        scope_id: Uuid,
59        issued_by: Uuid,
60        expires_in_seconds: i64,
61    ) -> Result<IssuedMagicLinkDto, AppError> {
62        if !(MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&expires_in_seconds) {
63            return Err(AppError::Validation(format!(
64                "expires_in_seconds must be in [{}, {}], got {}",
65                MIN_TTL_SECONDS, MAX_TTL_SECONDS, expires_in_seconds
66            )));
67        }
68
69        let ttl = Duration::seconds(expires_in_seconds);
70        let (link, clear_token) =
71            MagicLink::issue(subject_user_id, scope_kind, scope_id, issued_by, ttl)?;
72
73        self.repo.save(&link).await?;
74
75        Ok(IssuedMagicLinkDto {
76            id: link.id,
77            token: clear_token,
78            expires_at: link.expires_at,
79            scope_kind: link.scope_kind,
80            scope_id: link.scope_id,
81        })
82    }
83
84    /// Validate a clear token and atomically consume it.
85    ///
86    /// Possible errors (all map to HTTP 403 by design — see CRITICAL.md #4 and
87    /// the AppError mapping in error.rs):
88    /// - `AppError::MagicLinkInvalid` — token not found (forged / unknown).
89    /// - `AppError::MagicLinkExpired` — TTL elapsed.
90    /// - `AppError::MagicLinkAlreadyConsumed` — replay attempt on used token.
91    pub async fn validate_and_consume(&self, clear_token: &str) -> Result<MagicLink, AppError> {
92        if clear_token.trim().is_empty() {
93            return Err(AppError::MagicLinkInvalid);
94        }
95
96        let token_hash = MagicLink::hash_token(clear_token);
97        let link = self
98            .repo
99            .find_by_token_hash(&token_hash)
100            .await?
101            .ok_or(AppError::MagicLinkInvalid)?;
102
103        if link.is_consumed() {
104            return Err(AppError::MagicLinkAlreadyConsumed);
105        }
106        if link.is_expired() {
107            return Err(AppError::MagicLinkExpired);
108        }
109
110        self.repo.mark_consumed(link.id).await?;
111
112        let mut consumed = link;
113        consumed.consume();
114        Ok(consumed)
115    }
116
117    /// Resolve a token WITHOUT consuming it — for scopes whose workflow spans
118    /// several round-trips after the first `GET /c/{token}` (#835 @edge).
119    ///
120    /// `ContractorReport` is the first such scope: a prestataire opens the
121    /// link (which consumes it via [`Self::validate_and_consume`] as an audit
122    /// marker of "first redemption"), then edits a draft and submits later —
123    /// possibly offline, possibly the next day. Gating that later write on
124    /// `consumed_at` would make the very first view burn the only chance to
125    /// ever submit, which is incompatible with the offline requirement this
126    /// scope must keep (cf. system B being absorbed, which only ever checked
127    /// TTL). So `peek` checks hash lookup + expiry only, and deliberately
128    /// ignores `consumed_at`. Scope cloisonnement is enforced separately by
129    /// [`Self::ensure_scope`] at the call site — a token's `scope_kind` is
130    /// fixed at issuance and never reinterpreted.
131    pub async fn peek(&self, clear_token: &str) -> Result<MagicLink, AppError> {
132        if clear_token.trim().is_empty() {
133            return Err(AppError::MagicLinkInvalid);
134        }
135
136        let token_hash = MagicLink::hash_token(clear_token);
137        let link = self
138            .repo
139            .find_by_token_hash(&token_hash)
140            .await?
141            .ok_or(AppError::MagicLinkInvalid)?;
142
143        if link.is_expired() {
144            return Err(AppError::MagicLinkExpired);
145        }
146
147        Ok(link)
148    }
149
150    /// Enforce that a resolved link matches the scope the caller expects.
151    ///
152    /// Returns the same uniform `MagicLinkInvalid` as an unknown token — a
153    /// link issued for `Quote` must not distinguishably fail as "wrong scope"
154    /// (anti-enumeration, same rationale as `validate_and_consume`'s uniform
155    /// "unknown token" error), and must not open a `ContractorReport` (#835
156    /// @security — élargir un scope est le moyen le plus simple de
157    /// transformer un lien ciblé en passe-partout).
158    pub fn ensure_scope(link: &MagicLink, expected: MagicLinkScopeKind) -> Result<(), AppError> {
159        if link.scope_kind != expected {
160            return Err(AppError::MagicLinkInvalid);
161        }
162        Ok(())
163    }
164}
165
166// ============================================================================
167// Tests — taxonomie 4 catégories (CRITICAL.md #3)
168// ============================================================================
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use async_trait::async_trait;
174    use std::sync::Mutex;
175
176    /// In-memory MagicLinkRepository mock for use-case unit tests.
177    #[derive(Default)]
178    struct InMemoryRepo {
179        rows: Mutex<Vec<MagicLink>>,
180    }
181
182    #[async_trait]
183    impl MagicLinkRepository for InMemoryRepo {
184        async fn save(&self, link: &MagicLink) -> Result<(), AppError> {
185            self.rows.lock().unwrap().push(link.clone());
186            Ok(())
187        }
188
189        async fn find_by_token_hash(
190            &self,
191            token_hash: &str,
192        ) -> Result<Option<MagicLink>, AppError> {
193            Ok(self
194                .rows
195                .lock()
196                .unwrap()
197                .iter()
198                .find(|l| l.token_hash == token_hash)
199                .cloned())
200        }
201
202        async fn mark_consumed(&self, id: Uuid) -> Result<(), AppError> {
203            let mut rows = self.rows.lock().unwrap();
204            if let Some(row) = rows.iter_mut().find(|l| l.id == id) {
205                if row.consumed_at.is_some() {
206                    return Err(AppError::MagicLinkAlreadyConsumed);
207                }
208                row.consumed_at = Some(Utc::now());
209                row.updated_at = Utc::now();
210            }
211            Ok(())
212        }
213    }
214
215    fn use_cases() -> (Arc<InMemoryRepo>, MagicLinkUseCases) {
216        let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
217        let uc = MagicLinkUseCases::new(repo.clone() as Arc<dyn MagicLinkRepository>);
218        (repo, uc)
219    }
220
221    // ---- @happy ------------------------------------------------------------
222
223    #[tokio::test]
224    async fn happy_issue_then_validate_consumes_once() {
225        let (_repo, uc) = use_cases();
226        let subject = Uuid::new_v4();
227        let issuer = Uuid::new_v4();
228        let scope_id = Uuid::new_v4();
229
230        let issued = uc
231            .issue(
232                subject,
233                MagicLinkScopeKind::Ticket,
234                scope_id,
235                issuer,
236                7 * 24 * 3600,
237            )
238            .await
239            .unwrap();
240
241        assert_eq!(issued.scope_kind, MagicLinkScopeKind::Ticket);
242        assert_eq!(issued.scope_id, scope_id);
243        assert!(!issued.token.is_empty());
244
245        let resolved = uc.validate_and_consume(&issued.token).await.unwrap();
246        assert_eq!(resolved.scope_id, scope_id);
247        assert!(resolved.is_consumed());
248    }
249
250    // ---- @edge -------------------------------------------------------------
251
252    #[tokio::test]
253    async fn edge_double_consume_returns_already_consumed() {
254        let (_repo, uc) = use_cases();
255        let subject = Uuid::new_v4();
256        let issuer = Uuid::new_v4();
257        let scope_id = Uuid::new_v4();
258
259        let issued = uc
260            .issue(subject, MagicLinkScopeKind::Quote, scope_id, issuer, 3600)
261            .await
262            .unwrap();
263
264        uc.validate_and_consume(&issued.token).await.unwrap();
265        let err = uc.validate_and_consume(&issued.token).await.unwrap_err();
266        assert!(matches!(err, AppError::MagicLinkAlreadyConsumed));
267    }
268
269    #[tokio::test]
270    async fn edge_ttl_below_min_is_rejected() {
271        let (_repo, uc) = use_cases();
272        let err = uc
273            .issue(
274                Uuid::new_v4(),
275                MagicLinkScopeKind::Invoice,
276                Uuid::new_v4(),
277                Uuid::new_v4(),
278                30, // below MIN_TTL_SECONDS=60
279            )
280            .await
281            .unwrap_err();
282        assert!(matches!(err, AppError::Validation(_)));
283    }
284
285    #[tokio::test]
286    async fn edge_ttl_above_max_is_rejected() {
287        let (_repo, uc) = use_cases();
288        let err = uc
289            .issue(
290                Uuid::new_v4(),
291                MagicLinkScopeKind::Invoice,
292                Uuid::new_v4(),
293                Uuid::new_v4(),
294                MAX_TTL_SECONDS + 1,
295            )
296            .await
297            .unwrap_err();
298        assert!(matches!(err, AppError::Validation(_)));
299    }
300
301    // ---- @security ---------------------------------------------------------
302
303    #[tokio::test]
304    async fn security_forged_token_returns_invalid() {
305        let (_repo, uc) = use_cases();
306        let err = uc
307            .validate_and_consume("forged-not-in-db")
308            .await
309            .unwrap_err();
310        assert!(matches!(err, AppError::MagicLinkInvalid));
311    }
312
313    #[tokio::test]
314    async fn security_empty_token_returns_invalid_without_db_lookup() {
315        let (_repo, uc) = use_cases();
316        let err = uc.validate_and_consume("   ").await.unwrap_err();
317        assert!(matches!(err, AppError::MagicLinkInvalid));
318    }
319
320    #[tokio::test]
321    async fn security_subject_equals_issuer_is_blocked_at_entity_level() {
322        let (_repo, uc) = use_cases();
323        let same = Uuid::new_v4();
324        let err = uc
325            .issue(same, MagicLinkScopeKind::Ticket, Uuid::new_v4(), same, 3600)
326            .await
327            .unwrap_err();
328        assert!(matches!(err, AppError::Validation(_)));
329    }
330
331    // ---- @happy (peek / ensure_scope — #835) --------------------------------
332
333    #[tokio::test]
334    async fn happy_peek_resolves_valid_token_without_consuming() {
335        let (repo, uc) = use_cases();
336        let issued = uc
337            .issue(
338                Uuid::new_v4(),
339                MagicLinkScopeKind::ContractorReport,
340                Uuid::new_v4(),
341                Uuid::new_v4(),
342                3600,
343            )
344            .await
345            .unwrap();
346
347        let peeked = uc.peek(&issued.token).await.unwrap();
348        assert!(!peeked.is_consumed());
349
350        // Peeking again must still work — unlike validate_and_consume, it is
351        // repeatable (cf. #835 @edge — offline draft round-trips).
352        let peeked_again = uc.peek(&issued.token).await.unwrap();
353        assert!(!peeked_again.is_consumed());
354        assert_eq!(
355            repo.rows.lock().unwrap().len(),
356            1,
357            "peek must not create/consume rows"
358        );
359    }
360
361    #[tokio::test]
362    async fn happy_peek_still_resolves_after_the_link_was_consumed_elsewhere() {
363        // The initial GET /c/{token} DOES consume the link (audit marker of
364        // first redemption). A later write action (submit) must still be able
365        // to `peek` the same token — this is the crux of #835 @edge.
366        let (_repo, uc) = use_cases();
367        let issued = uc
368            .issue(
369                Uuid::new_v4(),
370                MagicLinkScopeKind::ContractorReport,
371                Uuid::new_v4(),
372                Uuid::new_v4(),
373                3600,
374            )
375            .await
376            .unwrap();
377
378        uc.validate_and_consume(&issued.token).await.unwrap();
379
380        let peeked = uc.peek(&issued.token).await.unwrap();
381        assert!(
382            peeked.is_consumed(),
383            "consumed flag is preserved, informational only"
384        );
385    }
386
387    #[tokio::test]
388    async fn happy_ensure_scope_accepts_matching_kind() {
389        let (subject, issuer, scope) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
390        let (link, _) = MagicLink::issue(
391            subject,
392            MagicLinkScopeKind::ContractorReport,
393            scope,
394            issuer,
395            Duration::hours(1),
396        )
397        .unwrap();
398        assert!(
399            MagicLinkUseCases::ensure_scope(&link, MagicLinkScopeKind::ContractorReport).is_ok()
400        );
401    }
402
403    // ---- @edge (peek — #835) ------------------------------------------------
404
405    #[tokio::test]
406    async fn edge_peek_at_exact_expiry_boundary_matches_validate_and_consume() {
407        let repo = Arc::new(InMemoryRepo::default());
408        let (mut link, clear) = MagicLink::issue(
409            Uuid::new_v4(),
410            MagicLinkScopeKind::ContractorReport,
411            Uuid::new_v4(),
412            Uuid::new_v4(),
413            Duration::hours(1),
414        )
415        .unwrap();
416        link.expires_at = Utc::now() - Duration::seconds(1);
417        repo.rows.lock().unwrap().push(link);
418
419        let uc = MagicLinkUseCases::new(repo as Arc<dyn MagicLinkRepository>);
420        let err = uc.peek(&clear).await.unwrap_err();
421        assert!(matches!(err, AppError::MagicLinkExpired));
422    }
423
424    // ---- @security (peek / ensure_scope — #835) -----------------------------
425
426    #[tokio::test]
427    async fn security_peek_forged_token_returns_invalid() {
428        let (_repo, uc) = use_cases();
429        let err = uc.peek("forged-not-in-db").await.unwrap_err();
430        assert!(matches!(err, AppError::MagicLinkInvalid));
431    }
432
433    #[test]
434    fn security_ensure_scope_rejects_mismatched_kind_uniformly() {
435        let (subject, issuer, scope) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4());
436        let (link, _) = MagicLink::issue(
437            subject,
438            MagicLinkScopeKind::Quote,
439            scope,
440            issuer,
441            Duration::hours(1),
442        )
443        .unwrap();
444        let err = MagicLinkUseCases::ensure_scope(&link, MagicLinkScopeKind::ContractorReport)
445            .unwrap_err();
446        // Uniform with "unknown token" — never a distinguishable "wrong scope"
447        // error that would help an attacker probe which scope a token holds.
448        assert!(matches!(err, AppError::MagicLinkInvalid));
449    }
450
451    // ---- @negative (peek — #835) ---------------------------------------------
452
453    #[tokio::test]
454    async fn negative_peek_empty_token_returns_invalid_without_db_lookup() {
455        let (_repo, uc) = use_cases();
456        let err = uc.peek("   ").await.unwrap_err();
457        assert!(matches!(err, AppError::MagicLinkInvalid));
458    }
459
460    #[tokio::test]
461    async fn negative_expired_link_returns_magic_link_expired() {
462        let repo = Arc::new(InMemoryRepo::default());
463
464        // Manually push an already-expired link bypassing the use case (since
465        // the issue path forbids negative TTL).
466        let (mut link, clear) = MagicLink::issue(
467            Uuid::new_v4(),
468            MagicLinkScopeKind::Ticket,
469            Uuid::new_v4(),
470            Uuid::new_v4(),
471            Duration::hours(1),
472        )
473        .unwrap();
474        link.expires_at = Utc::now() - Duration::seconds(10);
475        repo.rows.lock().unwrap().push(link);
476
477        let uc = MagicLinkUseCases::new(repo.clone() as Arc<dyn MagicLinkRepository>);
478        let err = uc.validate_and_consume(&clear).await.unwrap_err();
479        assert!(matches!(err, AppError::MagicLinkExpired));
480    }
481
482    #[tokio::test]
483    async fn negative_consumed_check_precedes_expired_check() {
484        // If a link is both expired AND consumed, surface the "already consumed"
485        // signal first — it's the more actionable message for the user.
486        let repo = Arc::new(InMemoryRepo::default());
487        let (mut link, clear) = MagicLink::issue(
488            Uuid::new_v4(),
489            MagicLinkScopeKind::Ticket,
490            Uuid::new_v4(),
491            Uuid::new_v4(),
492            Duration::hours(1),
493        )
494        .unwrap();
495        link.consumed_at = Some(Utc::now() - Duration::minutes(10));
496        link.expires_at = Utc::now() - Duration::seconds(10);
497        repo.rows.lock().unwrap().push(link);
498
499        let uc = MagicLinkUseCases::new(repo.clone() as Arc<dyn MagicLinkRepository>);
500        let err = uc.validate_and_consume(&clear).await.unwrap_err();
501        assert!(matches!(err, AppError::MagicLinkAlreadyConsumed));
502    }
503}