1use crate::application::error::AppError;
24use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
25use chrono::{DateTime, Duration, Utc};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use uuid::Uuid;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum MagicLinkScopeKind {
33 Ticket,
34 Quote,
35 Invoice,
36 ContractorEvaluation,
37 ContractorReport,
40}
41
42impl std::fmt::Display for MagicLinkScopeKind {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 MagicLinkScopeKind::Ticket => write!(f, "ticket"),
46 MagicLinkScopeKind::Quote => write!(f, "quote"),
47 MagicLinkScopeKind::Invoice => write!(f, "invoice"),
48 MagicLinkScopeKind::ContractorEvaluation => write!(f, "contractor_evaluation"),
49 MagicLinkScopeKind::ContractorReport => write!(f, "contractor_report"),
50 }
51 }
52}
53
54impl std::str::FromStr for MagicLinkScopeKind {
55 type Err = AppError;
56
57 fn from_str(s: &str) -> Result<Self, Self::Err> {
58 match s.to_lowercase().as_str() {
59 "ticket" => Ok(MagicLinkScopeKind::Ticket),
60 "quote" => Ok(MagicLinkScopeKind::Quote),
61 "invoice" => Ok(MagicLinkScopeKind::Invoice),
62 "contractor_evaluation" => Ok(MagicLinkScopeKind::ContractorEvaluation),
63 "contractor_report" => Ok(MagicLinkScopeKind::ContractorReport),
64 other => Err(AppError::Validation(format!(
65 "Invalid magic link scope_kind: {}",
66 other
67 ))),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct MagicLink {
74 pub id: Uuid,
75 pub token_hash: String,
77 pub subject_user_id: Uuid,
79 pub scope_kind: MagicLinkScopeKind,
80 pub scope_id: Uuid,
81 pub issued_by: Uuid,
83 pub expires_at: DateTime<Utc>,
84 pub consumed_at: Option<DateTime<Utc>>,
85 pub created_at: DateTime<Utc>,
86 pub updated_at: DateTime<Utc>,
87}
88
89impl MagicLink {
90 pub fn issue(
97 subject_user_id: Uuid,
98 scope_kind: MagicLinkScopeKind,
99 scope_id: Uuid,
100 issued_by: Uuid,
101 ttl: Duration,
102 ) -> Result<(Self, String), AppError> {
103 if ttl <= Duration::zero() {
104 return Err(AppError::Validation(
105 "MagicLink ttl must be strictly positive".to_string(),
106 ));
107 }
108 if scope_id.is_nil() {
109 return Err(AppError::Validation(
110 "MagicLink scope_id must not be nil".to_string(),
111 ));
112 }
113 if subject_user_id == issued_by {
114 return Err(AppError::Validation(
115 "MagicLink subject and issuer must differ".to_string(),
116 ));
117 }
118
119 let clear_token = Self::generate_clear_token();
120 let token_hash = Self::hash_token(&clear_token);
121 let now = Utc::now();
122 let expires_at = now + ttl;
123
124 let entity = Self {
125 id: Uuid::new_v4(),
126 token_hash,
127 subject_user_id,
128 scope_kind,
129 scope_id,
130 issued_by,
131 expires_at,
132 consumed_at: None,
133 created_at: now,
134 updated_at: now,
135 };
136
137 Ok((entity, clear_token))
138 }
139
140 pub fn hash_token(clear_token: &str) -> String {
143 let mut hasher = Sha256::new();
144 hasher.update(clear_token.as_bytes());
145 format!("{:x}", hasher.finalize())
146 }
147
148 fn generate_clear_token() -> String {
151 let mut bytes = [0u8; 32];
152 for b in bytes.iter_mut() {
153 *b = rand::random::<u8>();
154 }
155 URL_SAFE_NO_PAD.encode(bytes)
156 }
157
158 pub fn is_expired(&self) -> bool {
159 Utc::now() > self.expires_at
160 }
161
162 pub fn is_consumed(&self) -> bool {
163 self.consumed_at.is_some()
164 }
165
166 pub fn is_valid(&self) -> bool {
167 !self.is_expired() && !self.is_consumed()
168 }
169
170 pub fn consume(&mut self) {
173 let now = Utc::now();
174 if self.consumed_at.is_none() {
175 self.consumed_at = Some(now);
176 }
177 self.updated_at = now;
178 }
179}
180
181#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn fixture_pair() -> (Uuid, Uuid, Uuid) {
190 (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4())
191 }
192
193 #[test]
198 fn happy_issue_returns_valid_pair() {
199 let (subject, issuer, scope) = fixture_pair();
200 let (link, clear) = MagicLink::issue(
201 subject,
202 MagicLinkScopeKind::Ticket,
203 scope,
204 issuer,
205 Duration::days(7),
206 )
207 .expect("issue should succeed for valid inputs");
208
209 assert_eq!(link.subject_user_id, subject);
210 assert_eq!(link.issued_by, issuer);
211 assert_eq!(link.scope_id, scope);
212 assert_eq!(link.scope_kind, MagicLinkScopeKind::Ticket);
213 assert!(link.is_valid());
214 assert!(!link.is_consumed());
215 assert!(!link.is_expired());
216 assert!(clear.len() >= 40, "clear token too short: {}", clear.len());
218 }
219
220 #[test]
221 fn happy_consume_sets_consumed_at_and_invalidates() {
222 let (subject, issuer, scope) = fixture_pair();
223 let (mut link, _) = MagicLink::issue(
224 subject,
225 MagicLinkScopeKind::Quote,
226 scope,
227 issuer,
228 Duration::hours(1),
229 )
230 .unwrap();
231
232 assert!(link.is_valid());
233 link.consume();
234 assert!(link.is_consumed());
235 assert!(!link.is_valid());
236 assert!(link.consumed_at.is_some());
237 }
238
239 #[test]
240 fn happy_hash_token_is_deterministic_hex64() {
241 let h1 = MagicLink::hash_token("hello");
242 let h2 = MagicLink::hash_token("hello");
243 assert_eq!(h1, h2);
244 assert_eq!(h1.len(), 64); assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
246 }
247
248 #[test]
249 fn happy_scope_kind_roundtrips_via_display_and_from_str() {
250 use std::str::FromStr;
251 for kind in [
252 MagicLinkScopeKind::Ticket,
253 MagicLinkScopeKind::Quote,
254 MagicLinkScopeKind::Invoice,
255 MagicLinkScopeKind::ContractorEvaluation,
256 MagicLinkScopeKind::ContractorReport,
259 ] {
260 let s = kind.to_string();
261 let parsed = MagicLinkScopeKind::from_str(&s).expect("roundtrip");
262 assert_eq!(parsed, kind);
263 }
264 }
265
266 #[test]
267 fn happy_contractor_report_scope_kind_string_is_stable() {
268 assert_eq!(
272 MagicLinkScopeKind::ContractorReport.to_string(),
273 "contractor_report"
274 );
275 }
276
277 #[test]
282 fn edge_token_at_exact_expiry_is_invalid_after_now() {
283 let (subject, issuer, scope) = fixture_pair();
284 let (mut link, _) = MagicLink::issue(
285 subject,
286 MagicLinkScopeKind::Invoice,
287 scope,
288 issuer,
289 Duration::seconds(1),
290 )
291 .unwrap();
292 link.expires_at = Utc::now() - Duration::seconds(1);
294 assert!(link.is_expired());
295 assert!(!link.is_valid());
296 }
297
298 #[test]
299 fn edge_double_consume_is_idempotent() {
300 let (subject, issuer, scope) = fixture_pair();
301 let (mut link, _) = MagicLink::issue(
302 subject,
303 MagicLinkScopeKind::Ticket,
304 scope,
305 issuer,
306 Duration::minutes(5),
307 )
308 .unwrap();
309 link.consume();
310 let first_consumed_at = link.consumed_at.expect("first consume sets timestamp");
311 link.consume();
312 assert_eq!(link.consumed_at, Some(first_consumed_at));
314 }
315
316 #[test]
317 fn edge_min_ttl_one_second_is_valid_immediately() {
318 let (subject, issuer, scope) = fixture_pair();
319 let (link, _) = MagicLink::issue(
320 subject,
321 MagicLinkScopeKind::ContractorEvaluation,
322 scope,
323 issuer,
324 Duration::seconds(1),
325 )
326 .unwrap();
327 assert!(link.is_valid());
328 }
329
330 #[test]
335 fn security_each_issue_returns_distinct_token_and_hash() {
336 let (subject, issuer, scope) = fixture_pair();
337 let (link_a, clear_a) = MagicLink::issue(
338 subject,
339 MagicLinkScopeKind::Ticket,
340 scope,
341 issuer,
342 Duration::hours(1),
343 )
344 .unwrap();
345 let (link_b, clear_b) = MagicLink::issue(
346 subject,
347 MagicLinkScopeKind::Ticket,
348 scope,
349 issuer,
350 Duration::hours(1),
351 )
352 .unwrap();
353 assert_ne!(clear_a, clear_b, "tokens must be unique per issue");
354 assert_ne!(
355 link_a.token_hash, link_b.token_hash,
356 "hashes must differ since tokens differ"
357 );
358 assert_ne!(link_a.id, link_b.id);
359 }
360
361 #[test]
362 fn security_clear_token_is_never_equal_to_stored_hash() {
363 let (subject, issuer, scope) = fixture_pair();
364 let (link, clear) = MagicLink::issue(
365 subject,
366 MagicLinkScopeKind::Quote,
367 scope,
368 issuer,
369 Duration::hours(1),
370 )
371 .unwrap();
372 assert_ne!(clear, link.token_hash);
373 assert_eq!(MagicLink::hash_token(&clear), link.token_hash);
375 }
376
377 #[test]
378 fn security_contractor_report_link_keeps_its_own_scope_kind() {
379 let (subject, issuer, scope) = fixture_pair();
383 let (link, _) = MagicLink::issue(
384 subject,
385 MagicLinkScopeKind::ContractorReport,
386 scope,
387 issuer,
388 Duration::hours(72),
389 )
390 .unwrap();
391 assert_eq!(link.scope_kind, MagicLinkScopeKind::ContractorReport);
392 assert_ne!(link.scope_kind, MagicLinkScopeKind::Quote);
393 }
394
395 #[test]
396 fn security_different_inputs_produce_different_hashes() {
397 let h1 = MagicLink::hash_token("token-A");
398 let h2 = MagicLink::hash_token("token-B");
399 assert_ne!(h1, h2);
400 }
401
402 #[test]
403 fn security_expired_link_reports_invalid_even_if_not_consumed() {
404 let (subject, issuer, scope) = fixture_pair();
405 let (mut link, _) = MagicLink::issue(
406 subject,
407 MagicLinkScopeKind::Ticket,
408 scope,
409 issuer,
410 Duration::hours(1),
411 )
412 .unwrap();
413 link.expires_at = Utc::now() - Duration::seconds(10);
414 assert!(!link.is_consumed());
415 assert!(!link.is_valid());
416 }
417
418 #[test]
423 fn negative_zero_ttl_is_rejected() {
424 let (subject, issuer, scope) = fixture_pair();
425 let err = MagicLink::issue(
426 subject,
427 MagicLinkScopeKind::Ticket,
428 scope,
429 issuer,
430 Duration::zero(),
431 )
432 .unwrap_err();
433 match err {
434 AppError::Validation(_) => {}
435 other => panic!("expected Validation, got {:?}", other),
436 }
437 }
438
439 #[test]
440 fn negative_negative_ttl_is_rejected() {
441 let (subject, issuer, scope) = fixture_pair();
442 let err = MagicLink::issue(
443 subject,
444 MagicLinkScopeKind::Ticket,
445 scope,
446 issuer,
447 Duration::seconds(-1),
448 )
449 .unwrap_err();
450 assert!(matches!(err, AppError::Validation(_)));
451 }
452
453 #[test]
454 fn negative_nil_scope_id_is_rejected() {
455 let (subject, issuer, _) = fixture_pair();
456 let err = MagicLink::issue(
457 subject,
458 MagicLinkScopeKind::Quote,
459 Uuid::nil(),
460 issuer,
461 Duration::hours(1),
462 )
463 .unwrap_err();
464 assert!(matches!(err, AppError::Validation(_)));
465 }
466
467 #[test]
468 fn negative_subject_equals_issuer_is_rejected() {
469 let (subject, _, scope) = fixture_pair();
470 let err = MagicLink::issue(
471 subject,
472 MagicLinkScopeKind::Ticket,
473 scope,
474 subject, Duration::hours(1),
476 )
477 .unwrap_err();
478 assert!(matches!(err, AppError::Validation(_)));
479 }
480
481 #[test]
482 fn negative_invalid_scope_kind_string_is_rejected() {
483 use std::str::FromStr;
484 let err = MagicLinkScopeKind::from_str("payment").unwrap_err();
485 assert!(matches!(err, AppError::Validation(_)));
486 }
487}