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 = "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 )
490 .context("Key rotation failed")?;
491
492 out.print_success(&format!(
493 "Key rotation complete (new sequence: {})",
494 rotation_info.sequence
495 ));
496 out.newline();
497 out.println("Next steps:");
498 out.println(" 1. Re-authorize your devices: auths device link");
499 out.println(" 2. Update any CI/CD secrets");
500 out.println(" 3. Run `auths doctor` to verify setup");
501
502 Ok(())
503}
504
505fn handle_freeze(cmd: FreezeCommand) -> Result<()> {
507 use auths_id::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
508 use auths_id::storage::layout;
509
510 let out = Output::new();
511
512 out.print_heading("Identity Freeze");
513 out.newline();
514
515 let duration = parse_duration(&cmd.duration)?;
517 let frozen_at = chrono::Utc::now();
518 let frozen_until = frozen_at + duration;
519
520 out.println(&format!(
521 "Duration: {} (until {})",
522 out.info(&cmd.duration),
523 out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
524 ));
525 out.newline();
526
527 let repo_path = layout::resolve_repo_path(cmd.repo)?;
529
530 if let Some(existing) = load_active_freeze(&repo_path, chrono::Utc::now())? {
532 let existing_until = existing.frozen_until;
533 if frozen_until > existing_until {
534 out.print_warn(&format!(
535 "Existing freeze active until {}. Will extend to {}.",
536 existing_until.format("%Y-%m-%d %H:%M UTC"),
537 frozen_until.format("%Y-%m-%d %H:%M UTC"),
538 ));
539 } else {
540 out.print_warn(&format!(
541 "Existing freeze already active until {} (longer than requested).",
542 existing_until.format("%Y-%m-%d %H:%M UTC"),
543 ));
544 out.println("Use a longer duration to extend, or unfreeze first.");
545 return Ok(());
546 }
547 out.newline();
548 }
549
550 if cmd.dry_run {
551 out.print_info("Dry run mode - no changes will be made");
552 out.newline();
553 out.println("Would perform the following actions:");
554 out.println(&format!(
555 " 1. Freeze all signing operations for {}",
556 cmd.duration
557 ));
558 out.println(&format!(
559 " 2. Write freeze state to {}",
560 repo_path.join("freeze.json").display()
561 ));
562 out.println(" 3. auths-sign will refuse to sign until freeze expires");
563 return Ok(());
564 }
565
566 if !cmd.yes {
568 let confirmation: String = dialoguer::Input::new()
569 .with_prompt("Type FREEZE to confirm")
570 .interact_text()?;
571
572 if confirmation != "FREEZE" {
573 out.println("Cancelled - confirmation not matched.");
574 return Ok(());
575 }
576 }
577
578 let state = FreezeState {
579 frozen_at,
580 frozen_until,
581 reason: Some(format!("Emergency freeze for {}", cmd.duration)),
582 };
583
584 store_freeze(&repo_path, &state)?;
585
586 out.print_success(&format!(
587 "Identity frozen until {}",
588 frozen_until.format("%Y-%m-%d %H:%M UTC")
589 ));
590 out.newline();
591 out.println("All signing operations are disabled.");
592 out.println(&format!(
593 "Freeze expires in: {}",
594 out.info(&state.expires_description(chrono::Utc::now()))
595 ));
596 out.newline();
597 out.println("To unfreeze early:");
598 out.println(&format!(" {}", out.dim("auths emergency unfreeze")));
599
600 Ok(())
601}
602
603fn handle_unfreeze(cmd: UnfreezeCommand) -> Result<()> {
605 use auths_id::freeze::{load_active_freeze, remove_freeze};
606 use auths_id::storage::layout;
607
608 let out = Output::new();
609
610 let repo_path = layout::resolve_repo_path(cmd.repo)?;
611
612 match load_active_freeze(&repo_path, chrono::Utc::now())? {
613 Some(state) => {
614 out.println(&format!(
615 "Active freeze until {}",
616 out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
617 ));
618 out.newline();
619
620 if !cmd.yes {
621 let confirm = Confirm::new()
622 .with_prompt("Remove freeze and restore signing?")
623 .default(false)
624 .interact()?;
625
626 if !confirm {
627 out.println("Cancelled.");
628 return Ok(());
629 }
630 }
631
632 remove_freeze(&repo_path)?;
633 out.print_success("Freeze removed. Signing operations are restored.");
634 }
635 None => {
636 out.print_info("No active freeze found.");
637 }
638 }
639
640 Ok(())
641}
642
643fn handle_report(cmd: ReportCommand) -> Result<()> {
645 use auths_id::identity::helpers::ManagedIdentity;
646 use auths_id::storage::attestation::AttestationSource;
647 use auths_id::storage::identity::IdentityStorage;
648 use auths_id::storage::layout;
649 use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage};
650
651 let out = Output::new();
652
653 let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
654
655 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
657 let identity_did = match identity_storage.load_identity() {
658 Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
659 Err(_) => None,
660 };
661
662 let attestation_storage = RegistryAttestationStorage::new(repo_path);
664 let all_attestations = attestation_storage
665 .load_all_attestations()
666 .unwrap_or_default();
667
668 let mut seen_devices = std::collections::HashSet::new();
670 let mut devices = Vec::new();
671 for att in &all_attestations {
672 let did_str = att.subject.to_string();
673 if seen_devices.insert(did_str.clone()) {
674 let status = if att.is_revoked() {
675 "revoked"
676 } else if att.expires_at.is_some_and(|exp| exp <= chrono::Utc::now()) {
677 "expired"
678 } else {
679 "active"
680 };
681 devices.push(DeviceInfo {
682 did: did_str,
683 name: att.note.clone(),
684 status: status.to_string(),
685 last_active: att.timestamp.map(|t| t.to_rfc3339()),
686 });
687 }
688 }
689
690 let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
692 events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
693 let recent_events: Vec<EventInfo> = events
694 .iter()
695 .take(cmd.events)
696 .map(|att| {
697 let event_type = if att.is_revoked() {
698 "device_revocation"
699 } else {
700 "device_authorization"
701 };
702 EventInfo {
703 timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
704 event_type: event_type.to_string(),
705 details: format!("{} for {}", event_type, att.subject),
706 }
707 })
708 .collect();
709
710 let mut recommendations = Vec::new();
712 let active_count = devices.iter().filter(|d| d.status == "active").count();
713 let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
714 let expired_count = devices.iter().filter(|d| d.status == "expired").count();
715
716 if active_count > 0 {
717 recommendations.push(format!(
718 "Review all {} active device authorizations",
719 active_count
720 ));
721 }
722 if expired_count > 0 {
723 recommendations.push(format!(
724 "Clean up {} expired device authorizations",
725 expired_count
726 ));
727 }
728 if revoked_count > 0 {
729 recommendations.push(format!(
730 "{} device(s) already revoked — verify these were intentional",
731 revoked_count
732 ));
733 }
734 recommendations.push("Check for any unexpected signing activity".to_string());
735
736 let report = IncidentReport {
737 generated_at: chrono::Utc::now().to_rfc3339(),
738 identity_did: identity_did.map(|d| d.to_string()),
739 devices,
740 recent_events,
741 recommendations,
742 };
743
744 if is_json_mode() {
745 let json = serde_json::to_string_pretty(&report)?;
746 if let Some(output_path) = &cmd.output_file {
747 std::fs::write(output_path, &json)
748 .with_context(|| format!("Failed to write report to {:?}", output_path))?;
749 out.print_success(&format!("Report saved to {}", output_path.display()));
750 } else {
751 println!("{}", json);
752 }
753 return Ok(());
754 }
755
756 out.print_heading("Incident Report");
758 out.newline();
759
760 out.println(&format!("Generated: {}", out.info(&report.generated_at)));
761 if let Some(did) = &report.identity_did {
762 out.println(&format!("Identity: {}", out.info(did)));
763 }
764 out.newline();
765
766 out.print_heading(" Devices");
767 for device in &report.devices {
768 let status_icon = if device.status == "active" {
769 out.success("●")
770 } else {
771 out.error("○")
772 };
773 out.println(&format!(
774 " {} {} ({}) - {}",
775 status_icon,
776 device.did,
777 device.name.as_deref().unwrap_or("unnamed"),
778 device.status
779 ));
780 }
781 out.newline();
782
783 out.print_heading(" Recent Events");
784 for event in &report.recent_events {
785 out.println(&format!(
786 " {} [{}] {}",
787 out.dim(&event.timestamp[..19]),
788 event.event_type,
789 event.details
790 ));
791 }
792 out.newline();
793
794 out.print_heading(" Recommendations");
795 for (i, rec) in report.recommendations.iter().enumerate() {
796 out.println(&format!(" {}. {}", i + 1, rec));
797 }
798
799 if let Some(output_path) = &cmd.output_file {
800 let json = serde_json::to_string_pretty(&report)?;
801 std::fs::write(output_path, json)
802 .with_context(|| format!("Failed to write report to {:?}", output_path))?;
803 out.newline();
804 out.print_success(&format!("Report also saved to {}", output_path.display()));
805 }
806
807 Ok(())
808}
809
810use crate::commands::executable::ExecutableCommand;
811use crate::config::CliConfig;
812
813impl ExecutableCommand for EmergencyCommand {
814 fn execute(&self, _ctx: &CliConfig) -> Result<()> {
815 handle_emergency(self.clone())
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn test_incident_report_serialization() {
825 let report = IncidentReport {
826 generated_at: "2024-01-15T10:30:00Z".to_string(),
827 identity_did: Some("did:keri:ETest".to_string()),
828 devices: vec![],
829 recent_events: vec![],
830 recommendations: vec!["Test recommendation".to_string()],
831 };
832
833 let json = serde_json::to_string(&report).unwrap();
834 assert!(json.contains("did:keri:ETest"));
835 assert!(json.contains("Test recommendation"));
836 }
837
838 #[test]
839 fn test_device_info_serialization() {
840 let device = DeviceInfo {
841 did: "did:key:z6MkTest".to_string(),
842 name: Some("Test Device".to_string()),
843 status: "active".to_string(),
844 last_active: None,
845 };
846
847 let json = serde_json::to_string(&device).unwrap();
848 assert!(json.contains("did:key:z6MkTest"));
849 assert!(json.contains("Test Device"));
850 }
851
852 #[test]
853 fn test_freeze_dry_run() {
854 let dir = tempfile::TempDir::new().unwrap();
855 let result = handle_freeze(FreezeCommand {
856 duration: "24h".to_string(),
857 yes: true,
858 dry_run: true,
859 repo: Some(dir.path().to_path_buf()),
860 });
861
862 assert!(result.is_ok());
863 assert!(!dir.path().join("freeze.json").exists());
865 }
866
867 #[test]
868 fn test_freeze_creates_freeze_file() {
869 let dir = tempfile::TempDir::new().unwrap();
870 let result = handle_freeze(FreezeCommand {
871 duration: "1h".to_string(),
872 yes: true,
873 dry_run: false,
874 repo: Some(dir.path().to_path_buf()),
875 });
876
877 assert!(result.is_ok());
878 assert!(dir.path().join("freeze.json").exists());
879
880 let state = auths_id::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
882 assert!(state.is_some());
883 }
884
885 #[test]
886 fn test_freeze_invalid_duration() {
887 let dir = tempfile::TempDir::new().unwrap();
888 let result = handle_freeze(FreezeCommand {
889 duration: "invalid".to_string(),
890 yes: true,
891 dry_run: false,
892 repo: Some(dir.path().to_path_buf()),
893 });
894
895 assert!(result.is_err());
896 let err_msg = result.unwrap_err().to_string();
897 assert!(
898 err_msg.contains("Invalid") || err_msg.contains("duration"),
899 "Expected duration parse error, got: {}",
900 err_msg
901 );
902 }
903
904 #[test]
905 fn test_unfreeze_removes_freeze() {
906 let dir = tempfile::TempDir::new().unwrap();
907
908 handle_freeze(FreezeCommand {
910 duration: "24h".to_string(),
911 yes: true,
912 dry_run: false,
913 repo: Some(dir.path().to_path_buf()),
914 })
915 .unwrap();
916 assert!(dir.path().join("freeze.json").exists());
917
918 handle_unfreeze(UnfreezeCommand {
920 yes: true,
921 repo: Some(dir.path().to_path_buf()),
922 })
923 .unwrap();
924 assert!(!dir.path().join("freeze.json").exists());
925 }
926}