1use crate::ux::format::{Output, is_json_mode};
11use anyhow::{Context, Result, anyhow};
12use clap::{Parser, Subcommand};
13use dialoguer::{Confirm, Input, Select};
14use serde::{Deserialize, Serialize};
15use std::io::IsTerminal;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19#[derive(Parser, Debug, Clone)]
21#[command(name = "emergency", about = "Emergency incident response commands")]
22pub struct EmergencyCommand {
23 #[command(subcommand)]
24 pub command: Option<EmergencySubcommand>,
25}
26
27#[derive(Subcommand, Debug, Clone)]
28pub enum EmergencySubcommand {
29 #[command(name = "revoke-device")]
31 RevokeDevice(RevokeDeviceCommand),
32
33 #[command(name = "rotate-now")]
35 RotateNow(RotateNowCommand),
36
37 Freeze(FreezeCommand),
39
40 Unfreeze(UnfreezeCommand),
42
43 Report(ReportCommand),
45}
46
47#[derive(Parser, Debug, Clone)]
49pub struct RevokeDeviceCommand {
50 #[arg(long)]
52 pub device: Option<String>,
53
54 #[arg(long)]
56 pub key: Option<String>,
57
58 #[arg(long)]
60 pub note: Option<String>,
61
62 #[arg(long, short = 'y')]
64 pub yes: bool,
65
66 #[arg(long)]
68 pub dry_run: bool,
69
70 #[arg(long)]
72 pub repo: Option<PathBuf>,
73}
74
75#[derive(Parser, Debug, Clone)]
77pub struct RotateNowCommand {
78 #[arg(long)]
80 pub current_alias: Option<String>,
81
82 #[arg(long)]
84 pub next_alias: Option<String>,
85
86 #[arg(long, short = 'y')]
88 pub yes: bool,
89
90 #[arg(long)]
92 pub dry_run: bool,
93
94 #[arg(long)]
96 pub reason: Option<String>,
97
98 #[arg(long)]
100 pub repo: Option<PathBuf>,
101}
102
103#[derive(Parser, Debug, Clone)]
105pub struct FreezeCommand {
106 #[arg(long, default_value = "24h")]
108 pub duration: String,
109
110 #[arg(long, short = 'y')]
112 pub yes: bool,
113
114 #[arg(long)]
116 pub dry_run: bool,
117
118 #[arg(long)]
120 pub repo: Option<PathBuf>,
121}
122
123#[derive(Parser, Debug, Clone)]
125pub struct UnfreezeCommand {
126 #[arg(long, short = 'y')]
128 pub yes: bool,
129
130 #[arg(long)]
132 pub repo: Option<PathBuf>,
133}
134
135#[derive(Parser, Debug, Clone)]
137pub struct ReportCommand {
138 #[arg(long, default_value = "100")]
140 pub events: usize,
141
142 #[arg(long = "output", visible_alias = "file", short = 'o')]
144 pub output_file: Option<PathBuf>,
145
146 #[arg(long)]
148 pub repo: Option<PathBuf>,
149}
150
151#[derive(Debug, Serialize, Deserialize)]
153pub struct IncidentReport {
154 pub generated_at: String,
155 pub identity_did: Option<String>,
156 pub devices: Vec<DeviceInfo>,
157 pub recent_events: Vec<EventInfo>,
158 pub recommendations: Vec<String>,
159}
160
161#[derive(Debug, Serialize, Deserialize)]
162pub struct DeviceInfo {
163 pub did: String,
164 pub name: Option<String>,
165 pub status: String,
166 pub anchored: bool,
167 pub last_active: Option<String>,
168}
169
170#[derive(Debug, Serialize, Deserialize)]
171pub struct EventInfo {
172 pub timestamp: String,
173 pub event_type: String,
174 pub details: String,
175}
176
177pub fn handle_emergency(
179 cmd: EmergencyCommand,
180 now: chrono::DateTime<chrono::Utc>,
181 ctx: &crate::config::CliConfig,
182) -> Result<()> {
183 match cmd.command {
184 Some(EmergencySubcommand::RevokeDevice(c)) => handle_revoke_device(c, now, ctx),
185 Some(EmergencySubcommand::RotateNow(c)) => handle_rotate_now(c, now, ctx),
186 Some(EmergencySubcommand::Freeze(c)) => handle_freeze(c, now),
187 Some(EmergencySubcommand::Unfreeze(c)) => handle_unfreeze(c, now),
188 Some(EmergencySubcommand::Report(c)) => handle_report(c, now),
189 None => handle_interactive_flow(ctx),
190 }
191}
192
193fn handle_interactive_flow(ctx: &crate::config::CliConfig) -> Result<()> {
195 #[allow(clippy::disallowed_methods)]
196 let now = chrono::Utc::now();
197 let out = Output::new();
198
199 if !std::io::stdin().is_terminal() {
200 return Err(anyhow!(
201 "Interactive mode requires a terminal. Use subcommands for non-interactive use."
202 ));
203 }
204
205 out.newline();
206 out.println(&format!(
207 " {} {}",
208 out.error("🚨"),
209 out.bold("Emergency Response")
210 ));
211 out.newline();
212
213 let options = [
214 "Device lost or stolen",
215 "Key may have been exposed",
216 "Freeze everything immediately",
217 "Generate incident report",
218 "Cancel",
219 ];
220
221 let selection = Select::new()
222 .with_prompt("What happened?")
223 .items(options)
224 .default(0)
225 .interact()?;
226
227 match selection {
228 0 => {
229 out.print_info("Starting device revocation flow...");
231 handle_revoke_device(
232 RevokeDeviceCommand {
233 device: None,
234 key: None,
235 note: None,
236 yes: false,
237 dry_run: false,
238 repo: None,
239 },
240 now,
241 ctx,
242 )
243 }
244 1 => {
245 out.print_info("Starting key rotation flow...");
247 handle_rotate_now(
248 RotateNowCommand {
249 current_alias: None,
250 next_alias: None,
251 yes: false,
252 dry_run: false,
253 reason: Some("Potential key exposure".to_string()),
254 repo: None,
255 },
256 now,
257 ctx,
258 )
259 }
260 2 => {
261 out.print_warn("Starting freeze flow...");
263 handle_freeze(
264 FreezeCommand {
265 duration: "24h".to_string(),
266 yes: false,
267 dry_run: false,
268 repo: None,
269 },
270 now,
271 )
272 }
273 3 => {
274 handle_report(
276 ReportCommand {
277 events: 100,
278 output_file: None,
279 repo: None,
280 },
281 now,
282 )
283 }
284 _ => {
285 out.println("Cancelled.");
286 Ok(())
287 }
288 }
289}
290
291fn handle_revoke_device(
293 cmd: RevokeDeviceCommand,
294 _now: chrono::DateTime<chrono::Utc>,
295 ctx: &crate::config::CliConfig,
296) -> Result<()> {
297 use auths_sdk::keychain::KeyAlias;
298 use auths_sdk::storage_layout::layout;
299
300 let out = Output::new();
301
302 out.print_heading("Device Revocation");
303 out.newline();
304
305 let device_did = if let Some(did) = cmd.device {
307 did
308 } else if std::io::stdin().is_terminal() {
309 Input::new()
310 .with_prompt("Enter device ID to revoke")
311 .interact_text()?
312 } else {
313 return Err(anyhow!("--device is required in non-interactive mode"));
314 };
315
316 let identity_key_alias = if let Some(alias) = cmd.key {
318 alias
319 } else if std::io::stdin().is_terminal() {
320 Input::new()
321 .with_prompt("Enter identity key alias")
322 .interact_text()?
323 } else {
324 return Err(anyhow!("--key is required in non-interactive mode"));
325 };
326
327 out.println(&format!("Device to revoke: {}", out.info(&device_did)));
328 out.newline();
329
330 if cmd.dry_run {
331 out.print_info("Dry run mode - no changes will be made");
332 out.newline();
333 out.println("Would perform the following actions:");
334 out.println(&format!(
335 " 1. Revoke device authorization for {}",
336 device_did
337 ));
338 out.println(" 2. Create signed revocation attestation");
339 out.println(" 3. Store revocation in Git repository");
340 return Ok(());
341 }
342
343 if !cmd.yes {
345 let confirm = Confirm::new()
346 .with_prompt(format!("Revoke device {}?", device_did))
347 .default(false)
348 .interact()?;
349
350 if !confirm {
351 out.println("Cancelled.");
352 return Ok(());
353 }
354 }
355
356 let repo_path = layout::resolve_repo_path(cmd.repo)?;
357 let auths_ctx = crate::factories::storage::build_auths_context(
358 &repo_path,
359 &ctx.env_config,
360 Some(Arc::clone(&ctx.passphrase_provider)),
361 )?;
362 let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias);
363
364 out.print_info("Creating signed revocation attestation...");
365 auths_sdk::domains::device::service::revoke_device(
366 &device_did,
367 &identity_key_alias,
368 &auths_ctx,
369 cmd.note,
370 &auths_sdk::ports::SystemClock,
371 )
372 .map_err(anyhow::Error::new)
373 .context("Failed to revoke device")?;
374
375 out.print_success(&format!("Device {} has been revoked", device_did));
376 out.newline();
377 out.println("The device can no longer sign on behalf of your identity.");
378
379 Ok(())
380}
381
382fn handle_rotate_now(
384 cmd: RotateNowCommand,
385 _now: chrono::DateTime<chrono::Utc>,
386 ctx: &crate::config::CliConfig,
387) -> Result<()> {
388 use auths_sdk::domains::identity::types::IdentityRotationConfig;
389 use auths_sdk::keychain::KeyAlias;
390 use auths_sdk::storage_layout::layout;
391
392 let out = Output::new();
393
394 out.print_heading("Emergency Key Rotation");
395 out.newline();
396
397 let reason = cmd
398 .reason
399 .unwrap_or_else(|| "Manual emergency rotation".to_string());
400 out.println(&format!("Reason: {}", out.info(&reason)));
401 out.newline();
402
403 let current_alias = if let Some(alias) = cmd.current_alias {
405 alias
406 } else if std::io::stdin().is_terminal() {
407 Input::new()
408 .with_prompt("Enter current signing key alias")
409 .interact_text()?
410 } else {
411 return Err(anyhow!(
412 "--current-alias is required in non-interactive mode"
413 ));
414 };
415
416 let next_alias = if let Some(alias) = cmd.next_alias {
417 alias
418 } else if std::io::stdin().is_terminal() {
419 Input::new()
420 .with_prompt("Enter alias for the new signing key")
421 .interact_text()?
422 } else {
423 return Err(anyhow!("--next-alias is required in non-interactive mode"));
424 };
425
426 if cmd.dry_run {
427 out.print_info("Dry run mode - no changes will be made");
428 out.newline();
429 out.println("Would perform the following actions:");
430 out.println(" 1. Generate new signing key");
431 out.println(" 2. Create rotation event in identity log");
432 out.println(" 3. Update key alias mappings");
433 return Ok(());
434 }
435
436 if !cmd.yes {
438 out.print_warn("Key rotation is a significant operation.");
439 out.println("All devices will need to re-authorize.");
440 out.newline();
441
442 let confirmation: String = Input::new()
443 .with_prompt("Type ROTATE to confirm")
444 .interact_text()?;
445
446 if confirmation != "ROTATE" {
447 out.println("Cancelled - confirmation not matched.");
448 return Ok(());
449 }
450 }
451
452 let repo_path = layout::resolve_repo_path(cmd.repo)?;
453 let rotation_config = IdentityRotationConfig {
454 repo_path: repo_path.clone(),
455 identity_key_alias: Some(KeyAlias::new_unchecked(current_alias)),
456 next_key_alias: Some(KeyAlias::new_unchecked(next_alias)),
457 };
458 let auths_ctx = crate::factories::storage::build_auths_context(
459 &repo_path,
460 &ctx.env_config,
461 Some(Arc::clone(&ctx.passphrase_provider)),
462 )?;
463
464 out.print_info("Rotating key...");
465 let rotation_result = auths_sdk::domains::identity::rotation::rotate_identity(
466 rotation_config,
467 &auths_ctx,
468 &auths_sdk::ports::SystemClock,
469 )
470 .map_err(anyhow::Error::new)
471 .context("Key rotation failed")?;
472
473 out.print_success(&format!(
474 "Key rotation complete (new sequence: {})",
475 rotation_result.sequence
476 ));
477 out.newline();
478 out.println("Next steps:");
479 out.println(" 1. Re-authorize your devices: auths pair");
480 out.println(" 2. Update any CI/CD secrets");
481 out.println(" 3. Run `auths doctor` to verify setup");
482
483 Ok(())
484}
485
486fn handle_freeze(cmd: FreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
488 use auths_sdk::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
489 use auths_sdk::storage_layout::layout;
490
491 let out = Output::new();
492
493 out.print_heading("Identity Freeze");
494 out.newline();
495
496 let duration = parse_duration(&cmd.duration)?;
498 let frozen_at = now;
499 let frozen_until = frozen_at + duration;
500
501 out.println(&format!(
502 "Duration: {} (until {})",
503 out.info(&cmd.duration),
504 out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
505 ));
506 out.newline();
507
508 let repo_path = layout::resolve_repo_path(cmd.repo)?;
510
511 if let Some(existing) = load_active_freeze(&repo_path, now)? {
513 let existing_until = existing.frozen_until;
514 if frozen_until > existing_until {
515 out.print_warn(&format!(
516 "Existing freeze active until {}. Will extend to {}.",
517 existing_until.format("%Y-%m-%d %H:%M UTC"),
518 frozen_until.format("%Y-%m-%d %H:%M UTC"),
519 ));
520 } else {
521 out.print_warn(&format!(
522 "Existing freeze already active until {} (longer than requested).",
523 existing_until.format("%Y-%m-%d %H:%M UTC"),
524 ));
525 out.println("Use a longer duration to extend, or unfreeze first.");
526 return Ok(());
527 }
528 out.newline();
529 }
530
531 if cmd.dry_run {
532 out.print_info("Dry run mode - no changes will be made");
533 out.newline();
534 out.println("Would perform the following actions:");
535 out.println(&format!(
536 " 1. Freeze all signing operations for {}",
537 cmd.duration
538 ));
539 out.println(&format!(
540 " 2. Write freeze state to {}",
541 repo_path.join("freeze.json").display()
542 ));
543 out.println(" 3. auths-sign will refuse to sign until freeze expires");
544 return Ok(());
545 }
546
547 if !cmd.yes {
549 let confirmation: String = dialoguer::Input::new()
550 .with_prompt("Type FREEZE to confirm")
551 .interact_text()?;
552
553 if confirmation != "FREEZE" {
554 out.println("Cancelled - confirmation not matched.");
555 return Ok(());
556 }
557 }
558
559 let state = FreezeState {
560 frozen_at,
561 frozen_until,
562 reason: Some(format!("Emergency freeze for {}", cmd.duration)),
563 };
564
565 store_freeze(&repo_path, &state)?;
566
567 out.print_success(&format!(
568 "Identity frozen until {}",
569 frozen_until.format("%Y-%m-%d %H:%M UTC")
570 ));
571 out.newline();
572 out.println("All signing operations are disabled.");
573 out.println(&format!(
574 "Freeze expires in: {}",
575 out.info(&state.expires_description(now))
576 ));
577 out.newline();
578 out.println("To unfreeze early:");
579 out.println(&format!(" {}", out.dim("auths emergency unfreeze")));
580
581 Ok(())
582}
583
584fn handle_unfreeze(cmd: UnfreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
586 use auths_sdk::freeze::{load_active_freeze, remove_freeze};
587 use auths_sdk::storage_layout::layout;
588
589 let out = Output::new();
590
591 let repo_path = layout::resolve_repo_path(cmd.repo)?;
592
593 match load_active_freeze(&repo_path, now)? {
594 Some(state) => {
595 out.println(&format!(
596 "Active freeze until {}",
597 out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
598 ));
599 out.newline();
600
601 if !cmd.yes {
602 let confirm = Confirm::new()
603 .with_prompt("Remove freeze and restore signing?")
604 .default(false)
605 .interact()?;
606
607 if !confirm {
608 out.println("Cancelled.");
609 return Ok(());
610 }
611 }
612
613 remove_freeze(&repo_path)?;
614 out.print_success("Freeze removed. Signing operations are restored.");
615 }
616 None => {
617 out.print_info("No active freeze found.");
618 }
619 }
620
621 Ok(())
622}
623
624fn handle_report(cmd: ReportCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
626 use auths_sdk::identity::ManagedIdentity;
627 use auths_sdk::ports::IdentityStorage;
628 use auths_sdk::storage::{RegistryAttestationStorage, RegistryIdentityStorage};
629 use auths_sdk::storage_layout::layout;
630
631 let out = Output::new();
632
633 let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
634
635 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
637 let identity_did = match identity_storage.load_identity() {
638 Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
639 Err(_) => None,
640 };
641
642 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
644 let enriched = attestation_storage.load_all_enriched().unwrap_or_default();
645 let all_attestations: Vec<_> = enriched.iter().map(|e| e.attestation.clone()).collect();
646
647 let mut seen_devices = std::collections::HashSet::new();
649 let mut devices = Vec::new();
650 for e_att in &enriched {
651 let att = &e_att.attestation;
652 let did_str = att.subject.to_string();
653 if seen_devices.insert(did_str.clone()) {
654 let status = if att.is_revoked() {
655 "revoked"
656 } else if att.expires_at.is_some_and(|exp| exp <= now) {
657 "expired"
658 } else {
659 "active"
660 };
661 devices.push(DeviceInfo {
662 did: did_str,
663 name: att.note.clone(),
664 status: status.to_string(),
665 anchored: e_att.anchor == auths_keri::AnchorStatus::Anchored,
666 last_active: att.timestamp.map(|t| t.to_rfc3339()),
667 });
668 }
669 }
670
671 let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
673 events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
674 let recent_events: Vec<EventInfo> = events
675 .iter()
676 .take(cmd.events)
677 .map(|att| {
678 let event_type = if att.is_revoked() {
679 "device_revocation"
680 } else {
681 "device_authorization"
682 };
683 EventInfo {
684 timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
685 event_type: event_type.to_string(),
686 details: format!("{} for {}", event_type, att.subject),
687 }
688 })
689 .collect();
690
691 let mut recommendations = Vec::new();
693 let active_count = devices.iter().filter(|d| d.status == "active").count();
694 let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
695 let expired_count = devices.iter().filter(|d| d.status == "expired").count();
696
697 if active_count > 0 {
698 recommendations.push(format!(
699 "Review all {} active device authorizations",
700 active_count
701 ));
702 }
703 if expired_count > 0 {
704 recommendations.push(format!(
705 "Clean up {} expired device authorizations",
706 expired_count
707 ));
708 }
709 if revoked_count > 0 {
710 recommendations.push(format!(
711 "{} device(s) already revoked — verify these were intentional",
712 revoked_count
713 ));
714 }
715 recommendations.push("Check for any unexpected signing activity".to_string());
716
717 let report = IncidentReport {
718 generated_at: now.to_rfc3339(),
719 identity_did: identity_did.map(|d| d.to_string()),
720 devices,
721 recent_events,
722 recommendations,
723 };
724
725 if is_json_mode() {
726 let json = serde_json::to_string_pretty(&report)?;
727 if let Some(output_path) = &cmd.output_file {
728 std::fs::write(output_path, &json)
729 .with_context(|| format!("Failed to write report to {:?}", output_path))?;
730 out.print_success(&format!("Report saved to {}", output_path.display()));
731 } else {
732 println!("{}", json);
733 }
734 return Ok(());
735 }
736
737 out.print_heading("Incident Report");
739 out.newline();
740
741 out.println(&format!("Generated: {}", out.info(&report.generated_at)));
742 if let Some(did) = &report.identity_did {
743 out.println(&format!("Identity: {}", out.info(did)));
744 }
745 out.newline();
746
747 out.print_heading(" Devices");
748 for device in &report.devices {
749 let status_icon = if device.status == "active" {
750 out.success("●")
751 } else {
752 out.error("○")
753 };
754 out.println(&format!(
755 " {} {} ({}) - {}",
756 status_icon,
757 device.did,
758 device.name.as_deref().unwrap_or("unnamed"),
759 device.status
760 ));
761 }
762 out.newline();
763
764 out.print_heading(" Recent Events");
765 for event in &report.recent_events {
766 out.println(&format!(
767 " {} [{}] {}",
768 out.dim(&event.timestamp[..19]),
769 event.event_type,
770 event.details
771 ));
772 }
773 out.newline();
774
775 out.print_heading(" Recommendations");
776 for (i, rec) in report.recommendations.iter().enumerate() {
777 out.println(&format!(" {}. {}", i + 1, rec));
778 }
779
780 if let Some(output_path) = &cmd.output_file {
781 let json = serde_json::to_string_pretty(&report)?;
782 std::fs::write(output_path, json)
783 .with_context(|| format!("Failed to write report to {:?}", output_path))?;
784 out.newline();
785 out.print_success(&format!("Report also saved to {}", output_path.display()));
786 }
787
788 Ok(())
789}
790
791use crate::commands::executable::ExecutableCommand;
792use crate::config::CliConfig;
793
794impl ExecutableCommand for EmergencyCommand {
795 #[allow(clippy::disallowed_methods)]
796 fn execute(&self, ctx: &CliConfig) -> Result<()> {
797 handle_emergency(self.clone(), chrono::Utc::now(), ctx)
798 }
799}
800
801#[cfg(test)]
802#[allow(clippy::disallowed_methods)]
803mod tests {
804 use super::*;
805
806 #[test]
807 fn test_incident_report_serialization() {
808 let report = IncidentReport {
809 generated_at: "2024-01-15T10:30:00Z".to_string(),
810 identity_did: Some("did:keri:ETest".to_string()),
811 devices: vec![],
812 recent_events: vec![],
813 recommendations: vec!["Test recommendation".to_string()],
814 };
815
816 let json = serde_json::to_string(&report).unwrap();
817 assert!(json.contains("did:keri:ETest"));
818 assert!(json.contains("Test recommendation"));
819 }
820
821 #[test]
822 fn test_device_info_serialization() {
823 let device = DeviceInfo {
824 did: "did:key:z6MkTest".to_string(),
825 name: Some("Test Device".to_string()),
826 status: "active".to_string(),
827 anchored: true,
828 last_active: None,
829 };
830
831 let json = serde_json::to_string(&device).unwrap();
832 assert!(json.contains("did:key:z6MkTest"));
833 assert!(json.contains("Test Device"));
834 }
835
836 #[test]
837 fn test_freeze_dry_run() {
838 let dir = tempfile::TempDir::new().unwrap();
839 let result = handle_freeze(
840 FreezeCommand {
841 duration: "24h".to_string(),
842 yes: true,
843 dry_run: true,
844 repo: Some(dir.path().to_path_buf()),
845 },
846 chrono::Utc::now(),
847 );
848
849 assert!(result.is_ok());
850 assert!(!dir.path().join("freeze.json").exists());
852 }
853
854 #[test]
855 fn test_freeze_creates_freeze_file() {
856 let dir = tempfile::TempDir::new().unwrap();
857 let result = handle_freeze(
858 FreezeCommand {
859 duration: "1h".to_string(),
860 yes: true,
861 dry_run: false,
862 repo: Some(dir.path().to_path_buf()),
863 },
864 chrono::Utc::now(),
865 );
866
867 assert!(result.is_ok());
868 assert!(dir.path().join("freeze.json").exists());
869
870 let state = auths_sdk::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
872 assert!(state.is_some());
873 }
874
875 #[test]
876 fn test_freeze_invalid_duration() {
877 let dir = tempfile::TempDir::new().unwrap();
878 let result = handle_freeze(
879 FreezeCommand {
880 duration: "invalid".to_string(),
881 yes: true,
882 dry_run: false,
883 repo: Some(dir.path().to_path_buf()),
884 },
885 chrono::Utc::now(),
886 );
887
888 assert!(result.is_err());
889 let err_msg = result.unwrap_err().to_string();
890 assert!(
891 err_msg.contains("Invalid") || err_msg.contains("duration"),
892 "Expected duration parse error, got: {}",
893 err_msg
894 );
895 }
896
897 #[test]
898 fn test_unfreeze_removes_freeze() {
899 let dir = tempfile::TempDir::new().unwrap();
900
901 handle_freeze(
903 FreezeCommand {
904 duration: "24h".to_string(),
905 yes: true,
906 dry_run: false,
907 repo: Some(dir.path().to_path_buf()),
908 },
909 chrono::Utc::now(),
910 )
911 .unwrap();
912 assert!(dir.path().join("freeze.json").exists());
913
914 handle_unfreeze(
916 UnfreezeCommand {
917 yes: true,
918 repo: Some(dir.path().to_path_buf()),
919 },
920 chrono::Utc::now(),
921 )
922 .unwrap();
923 assert!(!dir.path().join("freeze.json").exists());
924 }
925}