1use crate::application::error::AppError;
29use crate::domain::entities::UserRole;
30use chrono::{DateTime, Duration, Utc};
31use serde::{Deserialize, Serialize};
32use uuid::Uuid;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum MandateKind {
41 Lawyer,
42 Notary,
43 Amo,
44 Architect,
45 Bet,
46 Warden,
47}
48
49impl MandateKind {
50 pub fn required_user_role(&self) -> UserRole {
53 match self {
54 MandateKind::Lawyer => UserRole::Lawyer,
55 MandateKind::Notary => UserRole::Notary,
56 MandateKind::Amo => UserRole::Amo,
57 MandateKind::Architect => UserRole::Architect,
58 MandateKind::Bet => UserRole::Bet,
59 MandateKind::Warden => UserRole::Warden,
60 }
61 }
62
63 pub fn requires_ag_decision(&self) -> bool {
68 false
69 }
70}
71
72impl std::fmt::Display for MandateKind {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 MandateKind::Lawyer => write!(f, "lawyer"),
76 MandateKind::Notary => write!(f, "notary"),
77 MandateKind::Amo => write!(f, "amo"),
78 MandateKind::Architect => write!(f, "architect"),
79 MandateKind::Bet => write!(f, "bet"),
80 MandateKind::Warden => write!(f, "warden"),
81 }
82 }
83}
84
85impl std::str::FromStr for MandateKind {
86 type Err = AppError;
87
88 fn from_str(s: &str) -> Result<Self, Self::Err> {
89 match s.trim().to_lowercase().as_str() {
90 "lawyer" => Ok(MandateKind::Lawyer),
91 "notary" => Ok(MandateKind::Notary),
92 "amo" => Ok(MandateKind::Amo),
93 "architect" => Ok(MandateKind::Architect),
94 "bet" => Ok(MandateKind::Bet),
95 "warden" => Ok(MandateKind::Warden),
96 other => Err(AppError::Validation(format!(
97 "Invalid mandate kind: {}",
98 other
99 ))),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107pub enum MandateScope {
108 Building(Uuid),
109 Acp(Uuid),
110}
111
112impl MandateScope {
113 pub fn kind_str(&self) -> &'static str {
114 match self {
115 MandateScope::Building(_) => "building",
116 MandateScope::Acp(_) => "acp",
117 }
118 }
119
120 pub fn id(&self) -> Uuid {
121 match self {
122 MandateScope::Building(id) | MandateScope::Acp(id) => *id,
123 }
124 }
125
126 pub fn from_parts(kind: &str, id: Uuid) -> Result<Self, AppError> {
129 match kind.trim().to_lowercase().as_str() {
130 "building" => Ok(MandateScope::Building(id)),
131 "acp" => Ok(MandateScope::Acp(id)),
132 other => Err(AppError::Validation(format!(
133 "Invalid mandate scope_kind: {}",
134 other
135 ))),
136 }
137 }
138}
139
140pub const MAX_MANDATE_DURATION_DAYS: i64 = 365 * 5;
143
144pub const MIN_REASON_LEN: usize = 10;
147
148pub const MAX_REASON_LEN: usize = 500;
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct Mandate {
153 pub id: Uuid,
154 pub subject_user_id: Uuid,
155 pub kind: MandateKind,
156 pub scope: MandateScope,
157 pub issued_by: Uuid,
158 pub reason: String,
159 pub valid_from: DateTime<Utc>,
160 pub valid_until: DateTime<Utc>,
161 pub revoked_at: Option<DateTime<Utc>>,
162 pub created_at: DateTime<Utc>,
163 pub updated_at: DateTime<Utc>,
164}
165
166impl Mandate {
167 #[allow(clippy::too_many_arguments)]
169 pub fn issue(
170 subject_user_id: Uuid,
171 kind: MandateKind,
172 scope: MandateScope,
173 issued_by: Uuid,
174 reason: String,
175 valid_from: DateTime<Utc>,
176 valid_until: DateTime<Utc>,
177 ) -> Result<Self, AppError> {
178 if subject_user_id == issued_by {
179 return Err(AppError::Validation(
180 "Mandate subject and issuer must differ".to_string(),
181 ));
182 }
183 if valid_until <= valid_from {
184 return Err(AppError::Validation(
185 "Mandate valid_until must be strictly after valid_from".to_string(),
186 ));
187 }
188 let duration = valid_until - valid_from;
189 if duration > Duration::days(MAX_MANDATE_DURATION_DAYS) {
190 return Err(AppError::Validation(format!(
191 "Mandate duration exceeds {} days (anti-abuse)",
192 MAX_MANDATE_DURATION_DAYS
193 )));
194 }
195 let trimmed_reason = reason.trim().to_string();
196 if trimmed_reason.len() < MIN_REASON_LEN {
197 return Err(AppError::Validation(format!(
198 "Mandate reason must be at least {} chars",
199 MIN_REASON_LEN
200 )));
201 }
202 if trimmed_reason.len() > MAX_REASON_LEN {
203 return Err(AppError::Validation(format!(
204 "Mandate reason must be at most {} chars",
205 MAX_REASON_LEN
206 )));
207 }
208 if subject_user_id.is_nil() || issued_by.is_nil() || scope.id().is_nil() {
209 return Err(AppError::Validation(
210 "Mandate references must not be nil UUIDs".to_string(),
211 ));
212 }
213
214 let now = Utc::now();
215 Ok(Self {
216 id: Uuid::new_v4(),
217 subject_user_id,
218 kind,
219 scope,
220 issued_by,
221 reason: trimmed_reason,
222 valid_from,
223 valid_until,
224 revoked_at: None,
225 created_at: now,
226 updated_at: now,
227 })
228 }
229
230 pub fn is_revoked(&self) -> bool {
231 self.revoked_at.is_some()
232 }
233
234 pub fn is_expired_at(&self, t: DateTime<Utc>) -> bool {
235 t >= self.valid_until
236 }
237
238 pub fn is_expired(&self) -> bool {
239 self.is_expired_at(Utc::now())
240 }
241
242 pub fn is_active_at(&self, t: DateTime<Utc>) -> bool {
243 !self.is_revoked() && t >= self.valid_from && t < self.valid_until
244 }
245
246 pub fn is_currently_active(&self) -> bool {
247 self.is_active_at(Utc::now())
248 }
249
250 pub fn revoke(&mut self) {
253 let now = Utc::now();
254 if self.revoked_at.is_none() {
255 self.revoked_at = Some(now);
256 }
257 self.updated_at = now;
258 }
259}
260
261#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn fixture_ids() -> (Uuid, Uuid, Uuid) {
270 (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4())
271 }
272
273 fn fixture_reason() -> String {
274 "Cession unité C12 — acte authentique".to_string()
275 }
276
277 fn fixture_valid_window() -> (DateTime<Utc>, DateTime<Utc>) {
278 let now = Utc::now();
279 (now, now + Duration::days(60))
280 }
281
282 #[test]
285 fn happy_issue_notary_mandate_is_currently_active() {
286 let (subject, issuer, scope_id) = fixture_ids();
287 let (from, until) = fixture_valid_window();
288 let m = Mandate::issue(
289 subject,
290 MandateKind::Notary,
291 MandateScope::Building(scope_id),
292 issuer,
293 fixture_reason(),
294 from,
295 until,
296 )
297 .expect("valid mandate should be issued");
298
299 assert_eq!(m.subject_user_id, subject);
300 assert_eq!(m.issued_by, issuer);
301 assert_eq!(m.kind, MandateKind::Notary);
302 assert!(matches!(m.scope, MandateScope::Building(_)));
303 assert!(m.is_currently_active());
304 assert!(!m.is_expired());
305 assert!(!m.is_revoked());
306 }
307
308 #[test]
309 fn happy_revoke_sets_revoked_at_and_invalidates() {
310 let (subject, issuer, scope_id) = fixture_ids();
311 let (from, until) = fixture_valid_window();
312 let mut m = Mandate::issue(
313 subject,
314 MandateKind::Lawyer,
315 MandateScope::Acp(scope_id),
316 issuer,
317 fixture_reason(),
318 from,
319 until,
320 )
321 .unwrap();
322
323 m.revoke();
324 assert!(m.is_revoked());
325 assert!(!m.is_currently_active());
326 }
327
328 #[test]
329 fn happy_kind_maps_to_corresponding_user_role() {
330 assert_eq!(MandateKind::Lawyer.required_user_role(), UserRole::Lawyer);
331 assert_eq!(MandateKind::Notary.required_user_role(), UserRole::Notary);
332 assert_eq!(MandateKind::Amo.required_user_role(), UserRole::Amo);
333 assert_eq!(
334 MandateKind::Architect.required_user_role(),
335 UserRole::Architect
336 );
337 assert_eq!(MandateKind::Bet.required_user_role(), UserRole::Bet);
338 assert_eq!(MandateKind::Warden.required_user_role(), UserRole::Warden);
339 }
340
341 #[test]
342 fn happy_kind_roundtrips_via_display_and_from_str() {
343 use std::str::FromStr;
344 for k in [
345 MandateKind::Lawyer,
346 MandateKind::Notary,
347 MandateKind::Amo,
348 MandateKind::Architect,
349 MandateKind::Bet,
350 MandateKind::Warden,
351 ] {
352 let s = k.to_string();
353 assert_eq!(MandateKind::from_str(&s).unwrap(), k);
354 }
355 }
356
357 #[test]
360 fn edge_minimum_window_one_second_is_accepted() {
361 let (subject, issuer, scope_id) = fixture_ids();
362 let from = Utc::now();
363 let until = from + Duration::seconds(1);
364 let m = Mandate::issue(
365 subject,
366 MandateKind::Warden,
367 MandateScope::Building(scope_id),
368 issuer,
369 fixture_reason(),
370 from,
371 until,
372 );
373 assert!(m.is_ok());
374 }
375
376 #[test]
377 fn edge_exactly_five_years_is_accepted_boundary() {
378 let (subject, issuer, scope_id) = fixture_ids();
379 let from = Utc::now();
380 let until = from + Duration::days(MAX_MANDATE_DURATION_DAYS);
381 let m = Mandate::issue(
382 subject,
383 MandateKind::Architect,
384 MandateScope::Acp(scope_id),
385 issuer,
386 fixture_reason(),
387 from,
388 until,
389 );
390 assert!(
391 m.is_ok(),
392 "exactly 5 years must remain inside the allowed bound"
393 );
394 }
395
396 #[test]
397 fn edge_at_valid_until_is_considered_expired() {
398 let (subject, issuer, scope_id) = fixture_ids();
399 let from = Utc::now() - Duration::days(2);
400 let until = Utc::now() - Duration::seconds(1);
401 let m = Mandate::issue(
402 subject,
403 MandateKind::Notary,
404 MandateScope::Building(scope_id),
405 issuer,
406 fixture_reason(),
407 from,
408 until,
409 )
410 .unwrap();
411 assert!(m.is_expired());
412 assert!(!m.is_currently_active());
413 }
414
415 #[test]
416 fn edge_double_revoke_is_idempotent() {
417 let (subject, issuer, scope_id) = fixture_ids();
418 let (from, until) = fixture_valid_window();
419 let mut m = Mandate::issue(
420 subject,
421 MandateKind::Bet,
422 MandateScope::Building(scope_id),
423 issuer,
424 fixture_reason(),
425 from,
426 until,
427 )
428 .unwrap();
429 m.revoke();
430 let first = m.revoked_at.expect("first revoke sets timestamp");
431 m.revoke();
432 assert_eq!(m.revoked_at, Some(first));
433 }
434
435 #[test]
438 fn security_subject_equals_issuer_is_rejected() {
439 let (subject, _, scope_id) = fixture_ids();
440 let (from, until) = fixture_valid_window();
441 let err = Mandate::issue(
442 subject,
443 MandateKind::Lawyer,
444 MandateScope::Building(scope_id),
445 subject, fixture_reason(),
447 from,
448 until,
449 )
450 .unwrap_err();
451 assert!(matches!(err, AppError::Validation(_)));
452 }
453
454 #[test]
455 fn security_building_and_acp_scopes_are_distinct() {
456 let id = Uuid::new_v4();
457 let b = MandateScope::Building(id);
458 let a = MandateScope::Acp(id);
459 assert_ne!(b, a, "Building and Acp scopes must not compare equal");
460 assert_eq!(b.kind_str(), "building");
461 assert_eq!(a.kind_str(), "acp");
462 }
463
464 #[test]
465 fn security_nil_uuids_are_rejected() {
466 let (from, until) = fixture_valid_window();
467 let err = Mandate::issue(
468 Uuid::nil(),
469 MandateKind::Notary,
470 MandateScope::Building(Uuid::new_v4()),
471 Uuid::new_v4(),
472 fixture_reason(),
473 from,
474 until,
475 )
476 .unwrap_err();
477 assert!(matches!(err, AppError::Validation(_)));
478 }
479
480 #[test]
481 fn security_revoked_mandate_inside_window_is_not_active() {
482 let (subject, issuer, scope_id) = fixture_ids();
483 let (from, until) = fixture_valid_window();
484 let mut m = Mandate::issue(
485 subject,
486 MandateKind::Amo,
487 MandateScope::Acp(scope_id),
488 issuer,
489 fixture_reason(),
490 from,
491 until,
492 )
493 .unwrap();
494 m.revoke();
495 assert!(!m.is_currently_active());
497 }
498
499 #[test]
502 fn negative_valid_until_before_valid_from_is_rejected() {
503 let (subject, issuer, scope_id) = fixture_ids();
504 let from = Utc::now();
505 let until = from - Duration::seconds(1);
506 let err = Mandate::issue(
507 subject,
508 MandateKind::Notary,
509 MandateScope::Building(scope_id),
510 issuer,
511 fixture_reason(),
512 from,
513 until,
514 )
515 .unwrap_err();
516 assert!(matches!(err, AppError::Validation(_)));
517 }
518
519 #[test]
520 fn negative_window_equal_zero_is_rejected() {
521 let (subject, issuer, scope_id) = fixture_ids();
522 let now = Utc::now();
523 let err = Mandate::issue(
524 subject,
525 MandateKind::Warden,
526 MandateScope::Building(scope_id),
527 issuer,
528 fixture_reason(),
529 now,
530 now,
531 )
532 .unwrap_err();
533 assert!(matches!(err, AppError::Validation(_)));
534 }
535
536 #[test]
537 fn negative_duration_above_max_is_rejected() {
538 let (subject, issuer, scope_id) = fixture_ids();
539 let from = Utc::now();
540 let until = from + Duration::days(MAX_MANDATE_DURATION_DAYS + 1);
541 let err = Mandate::issue(
542 subject,
543 MandateKind::Lawyer,
544 MandateScope::Acp(scope_id),
545 issuer,
546 fixture_reason(),
547 from,
548 until,
549 )
550 .unwrap_err();
551 assert!(matches!(err, AppError::Validation(_)));
552 }
553
554 #[test]
555 fn negative_reason_too_short_is_rejected() {
556 let (subject, issuer, scope_id) = fixture_ids();
557 let (from, until) = fixture_valid_window();
558 let err = Mandate::issue(
559 subject,
560 MandateKind::Notary,
561 MandateScope::Building(scope_id),
562 issuer,
563 "court".to_string(), from,
565 until,
566 )
567 .unwrap_err();
568 assert!(matches!(err, AppError::Validation(_)));
569 }
570
571 #[test]
572 fn negative_reason_too_long_is_rejected() {
573 let (subject, issuer, scope_id) = fixture_ids();
574 let (from, until) = fixture_valid_window();
575 let too_long = "x".repeat(MAX_REASON_LEN + 1);
576 let err = Mandate::issue(
577 subject,
578 MandateKind::Architect,
579 MandateScope::Building(scope_id),
580 issuer,
581 too_long,
582 from,
583 until,
584 )
585 .unwrap_err();
586 assert!(matches!(err, AppError::Validation(_)));
587 }
588
589 #[test]
590 fn negative_invalid_kind_string_is_rejected() {
591 use std::str::FromStr;
592 let err = MandateKind::from_str("plombier").unwrap_err();
593 assert!(matches!(err, AppError::Validation(_)));
594 }
595
596 #[test]
597 fn negative_invalid_scope_kind_string_is_rejected() {
598 let err = MandateScope::from_parts("unit", Uuid::new_v4()).unwrap_err();
599 assert!(matches!(err, AppError::Validation(_)));
600 }
601}