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