Skip to main content

fraiseql_server/encryption/
rotation_api.rs

1// Phase 12.4 Cycle 3: Rotation API Endpoints - GREEN
2//! Credential rotation REST API endpoints for status, manual rotation,
3//! history retrieval, and configuration management.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Rotation status levels
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum RotationStatus {
12    /// Less than 70% TTL consumed
13    Healthy,
14    /// 70-85% TTL consumed
15    ExpiringSoon,
16    /// 85%+ TTL consumed or refresh triggered
17    NeedsRotation,
18    /// Over 100% TTL consumed
19    Overdue,
20}
21
22impl std::fmt::Display for RotationStatus {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            Self::Healthy => write!(f, "healthy"),
26            Self::ExpiringSoon => write!(f, "expiring_soon"),
27            Self::NeedsRotation => write!(f, "needs_rotation"),
28            Self::Overdue => write!(f, "overdue"),
29        }
30    }
31}
32
33/// Rotation status response
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RotationStatusResponse {
36    /// Current active version number
37    pub current_version:           u16,
38    /// TTL for each version in days
39    pub ttl_days:                  u32,
40    /// Last rotation timestamp
41    pub last_rotation:             Option<DateTime<Utc>>,
42    /// Estimated next rotation time
43    pub next_rotation:             Option<DateTime<Utc>>,
44    /// Current status level
45    pub status:                    RotationStatus,
46    /// Is automatic refresh enabled
47    pub auto_refresh_enabled:      bool,
48    /// Total versions for this key
49    pub versions_total:            usize,
50    /// Active versions count
51    pub versions_active:           usize,
52    /// Expired versions count
53    pub versions_expired:          usize,
54    /// Last rotation duration in milliseconds
55    pub last_rotation_duration_ms: u64,
56    /// Total auto-refresh checks performed
57    pub auto_refresh_checks:       u64,
58}
59
60impl RotationStatusResponse {
61    /// Create new rotation status response
62    pub fn new(current_version: u16, ttl_days: u32) -> Self {
63        Self {
64            current_version,
65            ttl_days,
66            last_rotation: None,
67            next_rotation: None,
68            status: RotationStatus::Healthy,
69            auto_refresh_enabled: true,
70            versions_total: 1,
71            versions_active: 1,
72            versions_expired: 0,
73            last_rotation_duration_ms: 0,
74            auto_refresh_checks: 0,
75        }
76    }
77
78    /// Set last rotation timestamp
79    pub fn with_last_rotation(mut self, timestamp: DateTime<Utc>) -> Self {
80        self.last_rotation = Some(timestamp);
81        self
82    }
83
84    /// Set next rotation timestamp
85    pub fn with_next_rotation(mut self, timestamp: DateTime<Utc>) -> Self {
86        self.next_rotation = Some(timestamp);
87        self
88    }
89
90    /// Set status level
91    pub fn with_status(mut self, status: RotationStatus) -> Self {
92        self.status = status;
93        self
94    }
95}
96
97/// Manual rotation request
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ManualRotationRequest {
100    /// Key ID to rotate (optional, defaults to primary)
101    pub key_id:  Option<String>,
102    /// Reason for rotation
103    pub reason:  Option<String>,
104    /// Dry-run mode (validate without applying)
105    pub dry_run: Option<bool>,
106}
107
108/// Manual rotation response
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ManualRotationResponse {
111    /// New version number
112    pub new_version: u16,
113    /// Old version number
114    pub old_version: u16,
115    /// Rotation status: "success" or "failed"
116    pub status:      String,
117    /// Rotation duration in milliseconds
118    pub duration_ms: u64,
119    /// Error message if failed
120    pub error:       Option<String>,
121}
122
123impl ManualRotationResponse {
124    /// Create successful rotation response
125    pub fn success(old_version: u16, new_version: u16, duration_ms: u64) -> Self {
126        Self {
127            new_version,
128            old_version,
129            status: "success".to_string(),
130            duration_ms,
131            error: None,
132        }
133    }
134
135    /// Create failed rotation response
136    pub fn failure(old_version: u16, error: impl Into<String>) -> Self {
137        Self {
138            new_version: old_version,
139            old_version,
140            status: "failed".to_string(),
141            duration_ms: 0,
142            error: Some(error.into()),
143        }
144    }
145}
146
147/// Rotation history record
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RotationHistoryRecord {
150    /// When rotation occurred
151    pub timestamp:    DateTime<Utc>,
152    /// Previous version
153    pub old_version:  u16,
154    /// New version
155    pub new_version:  u16,
156    /// Rotation reason
157    pub reason:       Option<String>,
158    /// Operation duration in milliseconds
159    pub duration_ms:  u64,
160    /// "auto" or "manual"
161    pub triggered_by: String,
162    /// User ID if manually triggered
163    pub user_id:      Option<String>,
164}
165
166/// Rotation history response
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RotationHistoryResponse {
169    /// Pagination: total count
170    pub total_count: usize,
171    /// Pagination: offset used
172    pub offset:      usize,
173    /// Pagination: limit used
174    pub limit:       usize,
175    /// History records
176    pub records:     Vec<RotationHistoryRecord>,
177}
178
179impl RotationHistoryResponse {
180    /// Create new history response
181    pub fn new(offset: usize, limit: usize) -> Self {
182        Self {
183            total_count: 0,
184            offset,
185            limit,
186            records: Vec::new(),
187        }
188    }
189
190    /// Add record to history
191    pub fn with_record(mut self, record: RotationHistoryRecord) -> Self {
192        self.records.push(record);
193        self
194    }
195
196    /// Set total count
197    pub fn with_total_count(mut self, count: usize) -> Self {
198        self.total_count = count;
199        self
200    }
201}
202
203/// Rotation configuration
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct RotationConfigResponse {
206    /// Is auto-refresh enabled
207    pub auto_refresh_enabled: bool,
208    /// Check interval in hours
209    pub refresh_check_interval_hours: u32,
210    /// Refresh threshold percentage
211    pub refresh_threshold_percent: u32,
212    /// TTL in days
213    pub ttl_days: u32,
214    /// Quiet hours start (0-23, None = disabled)
215    pub quiet_hours_start: Option<u32>,
216    /// Quiet hours end (0-23)
217    pub quiet_hours_end: Option<u32>,
218    /// Manual rotation cooldown in minutes
219    pub manual_rotation_cooldown_minutes: u32,
220}
221
222impl RotationConfigResponse {
223    /// Create default config
224    pub fn default() -> Self {
225        Self {
226            auto_refresh_enabled: true,
227            refresh_check_interval_hours: 24,
228            refresh_threshold_percent: 80,
229            ttl_days: 365,
230            quiet_hours_start: None,
231            quiet_hours_end: None,
232            manual_rotation_cooldown_minutes: 60,
233        }
234    }
235}
236
237/// Rotation config update request
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct RotationConfigUpdateRequest {
240    /// Is auto-refresh enabled
241    pub auto_refresh_enabled:         Option<bool>,
242    /// Check interval in hours
243    pub refresh_check_interval_hours: Option<u32>,
244    /// Refresh threshold percentage
245    pub refresh_threshold_percent:    Option<u32>,
246    /// TTL in days
247    pub ttl_days:                     Option<u32>,
248    /// Quiet hours start (0-23)
249    pub quiet_hours_start:            Option<u32>,
250    /// Quiet hours end (0-23)
251    pub quiet_hours_end:              Option<u32>,
252}
253
254impl RotationConfigUpdateRequest {
255    /// Validate configuration values
256    pub fn validate(&self) -> Result<(), String> {
257        if let Some(threshold) = self.refresh_threshold_percent {
258            if threshold < 1 || threshold > 99 {
259                return Err("Threshold must be 1-99".to_string());
260            }
261        }
262
263        if let Some(ttl) = self.ttl_days {
264            if ttl < 1 || ttl > 365 {
265                return Err("TTL must be 1-365 days".to_string());
266            }
267        }
268
269        if let Some(interval) = self.refresh_check_interval_hours {
270            if interval < 1 || interval > 720 {
271                return Err("Interval must be 1-720 hours".to_string());
272            }
273        }
274
275        Ok(())
276    }
277}
278
279/// Rotation schedule types
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "snake_case")]
282pub enum ScheduleType {
283    /// Manual rotation only
284    Manual,
285    /// Cron-based schedule
286    Cron,
287    /// Interval-based (every N days)
288    Interval,
289}
290
291impl std::fmt::Display for ScheduleType {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        match self {
294            Self::Manual => write!(f, "manual"),
295            Self::Cron => write!(f, "cron"),
296            Self::Interval => write!(f, "interval"),
297        }
298    }
299}
300
301/// Rotation schedule response
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct RotationScheduleResponse {
304    /// Schedule type
305    pub schedule_type:       ScheduleType,
306    /// Schedule value (cron expression or interval in days)
307    pub schedule_value:      String,
308    /// Next scheduled rotation time
309    pub next_scheduled_time: Option<DateTime<Utc>>,
310    /// Is schedule enabled
311    pub enabled:             bool,
312}
313
314impl RotationScheduleResponse {
315    /// Create manual schedule (default)
316    pub fn manual() -> Self {
317        Self {
318            schedule_type:       ScheduleType::Manual,
319            schedule_value:      "manual".to_string(),
320            next_scheduled_time: None,
321            enabled:             false,
322        }
323    }
324
325    /// Create cron schedule
326    pub fn cron(expression: impl Into<String>, next_time: DateTime<Utc>) -> Self {
327        Self {
328            schedule_type:       ScheduleType::Cron,
329            schedule_value:      expression.into(),
330            next_scheduled_time: Some(next_time),
331            enabled:             true,
332        }
333    }
334
335    /// Create interval schedule
336    pub fn interval(days: u32, next_time: DateTime<Utc>) -> Self {
337        Self {
338            schedule_type:       ScheduleType::Interval,
339            schedule_value:      format!("{} days", days),
340            next_scheduled_time: Some(next_time),
341            enabled:             true,
342        }
343    }
344}
345
346/// Rotation schedule update request
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct RotationScheduleUpdateRequest {
349    /// Schedule type
350    pub schedule_type:  ScheduleType,
351    /// Schedule value
352    pub schedule_value: String,
353}
354
355/// Test schedule response (next N times)
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct TestScheduleResponse {
358    /// Validation status
359    pub valid:      bool,
360    /// Error message if invalid
361    pub error:      Option<String>,
362    /// Next 10 scheduled times
363    pub next_times: Vec<DateTime<Utc>>,
364}
365
366impl TestScheduleResponse {
367    /// Create valid schedule test
368    pub fn valid(next_times: Vec<DateTime<Utc>>) -> Self {
369        Self {
370            valid: true,
371            error: None,
372            next_times,
373        }
374    }
375
376    /// Create invalid schedule test
377    pub fn invalid(error: impl Into<String>) -> Self {
378        Self {
379            valid:      false,
380            error:      Some(error.into()),
381            next_times: Vec::new(),
382        }
383    }
384}
385
386/// API error response
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct ApiErrorResponse {
389    /// Error code
390    pub error:   String,
391    /// Error message
392    pub message: String,
393    /// Additional error details
394    pub code:    Option<String>,
395}
396
397impl ApiErrorResponse {
398    /// Create new error response
399    pub fn new(error: impl Into<String>, message: impl Into<String>) -> Self {
400        Self {
401            error:   error.into(),
402            message: message.into(),
403            code:    None,
404        }
405    }
406
407    /// Add error code
408    pub fn with_code(mut self, code: impl Into<String>) -> Self {
409        self.code = Some(code.into());
410        self
411    }
412}
413
414// ========== REFACTOR ENHANCEMENTS ==========
415
416/// Configuration preset type
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
418#[serde(rename_all = "snake_case")]
419pub enum ConfigPreset {
420    /// HIPAA compliance preset
421    Hipaa,
422    /// PCI-DSS compliance preset
423    PciDss,
424    /// GDPR compliance preset
425    Gdpr,
426    /// SOC 2 compliance preset
427    Soc2,
428    /// Custom preset
429    Custom,
430}
431
432impl std::fmt::Display for ConfigPreset {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        match self {
435            Self::Hipaa => write!(f, "hipaa"),
436            Self::PciDss => write!(f, "pci_dss"),
437            Self::Gdpr => write!(f, "gdpr"),
438            Self::Soc2 => write!(f, "soc2"),
439            Self::Custom => write!(f, "custom"),
440        }
441    }
442}
443
444impl ConfigPreset {
445    /// Get default config for this preset
446    pub fn get_config(&self) -> RotationConfigResponse {
447        match self {
448            Self::Hipaa => RotationConfigResponse {
449                auto_refresh_enabled: true,
450                refresh_check_interval_hours: 24,
451                refresh_threshold_percent: 80,
452                ttl_days: 365,
453                quiet_hours_start: Some(2),
454                quiet_hours_end: Some(4),
455                manual_rotation_cooldown_minutes: 60,
456            },
457            Self::PciDss => RotationConfigResponse {
458                auto_refresh_enabled: true,
459                refresh_check_interval_hours: 24,
460                refresh_threshold_percent: 80,
461                ttl_days: 365,
462                quiet_hours_start: Some(2),
463                quiet_hours_end: Some(4),
464                manual_rotation_cooldown_minutes: 60,
465            },
466            Self::Gdpr => RotationConfigResponse {
467                auto_refresh_enabled: true,
468                refresh_check_interval_hours: 24,
469                refresh_threshold_percent: 75,
470                ttl_days: 90,
471                quiet_hours_start: None,
472                quiet_hours_end: None,
473                manual_rotation_cooldown_minutes: 30,
474            },
475            Self::Soc2 => RotationConfigResponse {
476                auto_refresh_enabled: true,
477                refresh_check_interval_hours: 24,
478                refresh_threshold_percent: 80,
479                ttl_days: 365,
480                quiet_hours_start: Some(2),
481                quiet_hours_end: Some(4),
482                manual_rotation_cooldown_minutes: 60,
483            },
484            Self::Custom => RotationConfigResponse::default(),
485        }
486    }
487}
488
489/// Compliance preset list response
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct CompliancePresetsResponse {
492    /// Available presets
493    pub presets: Vec<PresetInfo>,
494}
495
496/// Preset information
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct PresetInfo {
499    /// Preset name
500    pub name:                 ConfigPreset,
501    /// Description
502    pub description:          String,
503    /// TTL days for this preset
504    pub ttl_days:             u32,
505    /// Refresh check interval hours
506    pub check_interval_hours: u32,
507    /// Refresh threshold percentage
508    pub threshold_percent:    u32,
509}
510
511impl CompliancePresetsResponse {
512    /// Create default presets response
513    pub fn default() -> Self {
514        Self {
515            presets: vec![
516                PresetInfo {
517                    name:                 ConfigPreset::Hipaa,
518                    description:          "HIPAA compliance requirements".to_string(),
519                    ttl_days:             365,
520                    check_interval_hours: 24,
521                    threshold_percent:    80,
522                },
523                PresetInfo {
524                    name:                 ConfigPreset::PciDss,
525                    description:          "PCI-DSS compliance requirements".to_string(),
526                    ttl_days:             365,
527                    check_interval_hours: 24,
528                    threshold_percent:    80,
529                },
530                PresetInfo {
531                    name:                 ConfigPreset::Gdpr,
532                    description:          "GDPR data minimization requirements".to_string(),
533                    ttl_days:             90,
534                    check_interval_hours: 24,
535                    threshold_percent:    75,
536                },
537                PresetInfo {
538                    name:                 ConfigPreset::Soc2,
539                    description:          "SOC 2 compliance requirements".to_string(),
540                    ttl_days:             365,
541                    check_interval_hours: 24,
542                    threshold_percent:    80,
543                },
544            ],
545        }
546    }
547}
548
549/// Query parameters for history endpoint
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct RotationHistoryQuery {
552    /// Limit (default 100, max 1000)
553    pub limit:        Option<usize>,
554    /// Offset for pagination
555    pub offset:       Option<usize>,
556    /// From date filter (ISO format)
557    pub from:         Option<String>,
558    /// To date filter (ISO format)
559    pub to:           Option<String>,
560    /// Reason filter
561    pub reason:       Option<String>,
562    /// Triggered by filter (auto or manual)
563    pub triggered_by: Option<String>,
564    /// Export format (json, csv, json-lines)
565    pub format:       Option<String>,
566}
567
568impl RotationHistoryQuery {
569    /// Get effective limit (capped at 1000)
570    pub fn effective_limit(&self) -> usize {
571        self.limit.unwrap_or(100).min(1000)
572    }
573
574    /// Get effective offset
575    pub fn effective_offset(&self) -> usize {
576        self.offset.unwrap_or(0)
577    }
578}
579
580/// Rotation status display with urgency indicator
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct RotationStatusDisplay {
583    /// Status response
584    pub status:             RotationStatusResponse,
585    /// Urgency score (0-100)
586    pub urgency_score:      u32,
587    /// Recommended action
588    pub recommended_action: String,
589}
590
591impl RotationStatusDisplay {
592    /// Create new display from status
593    pub fn from_status(status: RotationStatusResponse) -> Self {
594        // Calculate urgency based on TTL consumed
595        let urgency_score = match status.status {
596            RotationStatus::Healthy => 10,
597            RotationStatus::ExpiringSoon => 50,
598            RotationStatus::NeedsRotation => 85,
599            RotationStatus::Overdue => 100,
600        };
601
602        let recommended_action = match status.status {
603            RotationStatus::Healthy => "Monitor key health".to_string(),
604            RotationStatus::ExpiringSoon => "Prepare for upcoming rotation".to_string(),
605            RotationStatus::NeedsRotation => "Trigger manual rotation immediately".to_string(),
606            RotationStatus::Overdue => "CRITICAL: Rotate immediately to prevent expiry".to_string(),
607        };
608
609        Self {
610            status,
611            urgency_score,
612            recommended_action,
613        }
614    }
615}
616
617impl RotationStatusResponse {
618    /// Convert to display with urgency
619    pub fn to_display(&self) -> RotationStatusDisplay {
620        RotationStatusDisplay::from_status(self.clone())
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn test_rotation_status_response_creation() {
630        let response = RotationStatusResponse::new(1, 365);
631        assert_eq!(response.current_version, 1);
632        assert_eq!(response.ttl_days, 365);
633        assert_eq!(response.status, RotationStatus::Healthy);
634    }
635
636    #[test]
637    fn test_rotation_status_response_builder() {
638        let now = Utc::now();
639        let response = RotationStatusResponse::new(2, 365)
640            .with_last_rotation(now)
641            .with_status(RotationStatus::NeedsRotation);
642        assert!(response.last_rotation.is_some());
643        assert_eq!(response.status, RotationStatus::NeedsRotation);
644    }
645
646    #[test]
647    fn test_manual_rotation_response_success() {
648        let response = ManualRotationResponse::success(1, 2, 100);
649        assert_eq!(response.status, "success");
650        assert_eq!(response.old_version, 1);
651        assert_eq!(response.new_version, 2);
652        assert!(response.error.is_none());
653    }
654
655    #[test]
656    fn test_manual_rotation_response_failure() {
657        let response = ManualRotationResponse::failure(1, "Vault error");
658        assert_eq!(response.status, "failed");
659        assert!(response.error.is_some());
660    }
661
662    #[test]
663    fn test_rotation_history_response_creation() {
664        let response = RotationHistoryResponse::new(0, 100);
665        assert_eq!(response.offset, 0);
666        assert_eq!(response.limit, 100);
667        assert_eq!(response.total_count, 0);
668    }
669
670    #[test]
671    fn test_rotation_history_record_creation() {
672        let now = Utc::now();
673        let record = RotationHistoryRecord {
674            timestamp:    now,
675            old_version:  1,
676            new_version:  2,
677            reason:       Some("test".to_string()),
678            duration_ms:  50,
679            triggered_by: "manual".to_string(),
680            user_id:      Some("user123".to_string()),
681        };
682        assert_eq!(record.old_version, 1);
683        assert_eq!(record.new_version, 2);
684    }
685
686    #[test]
687    fn test_rotation_config_update_validation() {
688        let update = RotationConfigUpdateRequest {
689            auto_refresh_enabled:         Some(true),
690            refresh_check_interval_hours: Some(24),
691            refresh_threshold_percent:    Some(80),
692            ttl_days:                     Some(365),
693            quiet_hours_start:            None,
694            quiet_hours_end:              None,
695        };
696        assert!(update.validate().is_ok());
697    }
698
699    #[test]
700    fn test_rotation_config_update_invalid_threshold() {
701        let update = RotationConfigUpdateRequest {
702            auto_refresh_enabled:         None,
703            refresh_check_interval_hours: None,
704            refresh_threshold_percent:    Some(100), // Invalid
705            ttl_days:                     None,
706            quiet_hours_start:            None,
707            quiet_hours_end:              None,
708        };
709        assert!(update.validate().is_err());
710    }
711
712    #[test]
713    fn test_rotation_config_update_invalid_ttl() {
714        let update = RotationConfigUpdateRequest {
715            auto_refresh_enabled:         None,
716            refresh_check_interval_hours: None,
717            refresh_threshold_percent:    None,
718            ttl_days:                     Some(400), // Invalid
719            quiet_hours_start:            None,
720            quiet_hours_end:              None,
721        };
722        assert!(update.validate().is_err());
723    }
724
725    #[test]
726    fn test_rotation_schedule_response_manual() {
727        let schedule = RotationScheduleResponse::manual();
728        assert_eq!(schedule.schedule_type, ScheduleType::Manual);
729        assert!(!schedule.enabled);
730    }
731
732    #[test]
733    fn test_rotation_schedule_response_cron() {
734        let now = Utc::now();
735        let schedule = RotationScheduleResponse::cron("0 2 1 * *", now);
736        assert_eq!(schedule.schedule_type, ScheduleType::Cron);
737        assert!(schedule.enabled);
738    }
739
740    #[test]
741    fn test_rotation_schedule_response_interval() {
742        let now = Utc::now();
743        let schedule = RotationScheduleResponse::interval(30, now);
744        assert_eq!(schedule.schedule_type, ScheduleType::Interval);
745        assert!(schedule.enabled);
746    }
747
748    #[test]
749    fn test_test_schedule_response_valid() {
750        let times = vec![Utc::now(), Utc::now()];
751        let response = TestScheduleResponse::valid(times.clone());
752        assert!(response.valid);
753        assert_eq!(response.next_times.len(), 2);
754    }
755
756    #[test]
757    fn test_test_schedule_response_invalid() {
758        let response = TestScheduleResponse::invalid("Invalid cron");
759        assert!(!response.valid);
760        assert!(response.error.is_some());
761    }
762
763    #[test]
764    fn test_api_error_response_creation() {
765        let error = ApiErrorResponse::new("rotation_failed", "Vault unreachable");
766        assert_eq!(error.error, "rotation_failed");
767        assert_eq!(error.message, "Vault unreachable");
768    }
769
770    #[test]
771    fn test_api_error_response_with_code() {
772        let error = ApiErrorResponse::new("rotation_failed", "Vault unreachable")
773            .with_code("VAULT_UNAVAILABLE");
774        assert!(error.code.is_some());
775    }
776
777    // ========== REFACTOR ENHANCEMENT TESTS ==========
778
779    #[test]
780    fn test_config_preset_hipaa() {
781        let config = ConfigPreset::Hipaa.get_config();
782        assert_eq!(config.ttl_days, 365);
783        assert!(config.auto_refresh_enabled);
784    }
785
786    #[test]
787    fn test_config_preset_gdpr() {
788        let config = ConfigPreset::Gdpr.get_config();
789        assert_eq!(config.ttl_days, 90);
790        assert_eq!(config.refresh_threshold_percent, 75);
791    }
792
793    #[test]
794    fn test_compliance_presets_response() {
795        let presets = CompliancePresetsResponse::default();
796        assert_eq!(presets.presets.len(), 4);
797    }
798
799    #[test]
800    fn test_rotation_history_query_effective_limit() {
801        let query1 = RotationHistoryQuery {
802            limit:        Some(50),
803            offset:       None,
804            from:         None,
805            to:           None,
806            reason:       None,
807            triggered_by: None,
808            format:       None,
809        };
810        assert_eq!(query1.effective_limit(), 50);
811
812        let query2 = RotationHistoryQuery {
813            limit:        Some(5000),
814            offset:       None,
815            from:         None,
816            to:           None,
817            reason:       None,
818            triggered_by: None,
819            format:       None,
820        };
821        assert_eq!(query2.effective_limit(), 1000); // Capped
822    }
823
824    #[test]
825    fn test_rotation_status_display_healthy() {
826        let status = RotationStatusResponse::new(1, 365);
827        let display = status.to_display();
828        assert_eq!(display.urgency_score, 10);
829        assert!(display.recommended_action.contains("Monitor"));
830    }
831
832    #[test]
833    fn test_rotation_status_display_urgent() {
834        let status = RotationStatusResponse::new(1, 365).with_status(RotationStatus::Overdue);
835        let display = status.to_display();
836        assert_eq!(display.urgency_score, 100);
837        assert!(display.recommended_action.contains("CRITICAL"));
838    }
839}