koprogo_api/application/use_cases/
magic_link_use_cases.rs1use 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
28const MIN_TTL_SECONDS: i64 = 60; const MAX_TTL_SECONDS: i64 = 60 * 60 * 24 * 30; #[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct IssuedMagicLinkDto {
35 pub id: Uuid,
36 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 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 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 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 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#[cfg(test)]
171mod tests {
172 use super::*;
173 use async_trait::async_trait;
174 use std::sync::Mutex;
175
176 #[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 #[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 #[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, )
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 #[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 #[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 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 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 #[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 #[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 assert!(matches!(err, AppError::MagicLinkInvalid));
449 }
450
451 #[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 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 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}