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 {
439 out.print_warn("Key rotation is a significant operation.");
440 out.println("All devices will need to re-authorize.");
441 out.newline();
442 }
443 if !confirm_or_refuse(
444 cmd.yes,
445 std::io::stdin().is_terminal(),
446 "key rotation",
447 "ROTATE",
448 )? {
449 out.println("Cancelled - confirmation not matched.");
450 return Ok(());
451 }
452
453 let repo_path = layout::resolve_repo_path(cmd.repo)?;
454 let rotation_config = IdentityRotationConfig {
455 repo_path: repo_path.clone(),
456 identity_key_alias: Some(KeyAlias::new_unchecked(current_alias)),
457 next_key_alias: Some(KeyAlias::new_unchecked(next_alias)),
458 };
459 let auths_ctx = crate::factories::storage::build_auths_context(
460 &repo_path,
461 &ctx.env_config,
462 Some(Arc::clone(&ctx.passphrase_provider)),
463 )?;
464
465 out.print_info("Rotating key...");
466 let rotation_result = auths_sdk::domains::identity::rotation::rotate_identity(
467 rotation_config,
468 &auths_ctx,
469 &auths_sdk::ports::SystemClock,
470 )
471 .map_err(anyhow::Error::new)
472 .context("Key rotation failed")?;
473
474 out.print_success(&format!(
475 "Key rotation complete (new sequence: {})",
476 rotation_result.sequence
477 ));
478 out.newline();
479 out.println("Next steps:");
480 out.println(" 1. Re-authorize your devices: auths pair");
481 out.println(" 2. Update any CI/CD secrets");
482 out.println(" 3. Run `auths doctor` to verify setup");
483
484 Ok(())
485}
486
487#[derive(Debug, PartialEq, Eq)]
492enum ConfirmationMode {
493 Proceed,
495 Prompt,
497 NonInteractive,
499}
500
501fn confirmation_mode(yes: bool, stdin_is_terminal: bool) -> ConfirmationMode {
503 if yes {
504 ConfirmationMode::Proceed
505 } else if stdin_is_terminal {
506 ConfirmationMode::Prompt
507 } else {
508 ConfirmationMode::NonInteractive
509 }
510}
511
512fn confirm_or_refuse(
529 yes: bool,
530 stdin_is_terminal: bool,
531 action: &str,
532 confirm_word: &str,
533) -> Result<bool> {
534 match confirmation_mode(yes, stdin_is_terminal) {
535 ConfirmationMode::Proceed => Ok(true),
536 ConfirmationMode::NonInteractive => Err(anyhow!(
537 "Cannot confirm an emergency {action} in a non-interactive environment. Re-run with \
538 --yes to apply it. The {action} was NOT applied."
539 )),
540 ConfirmationMode::Prompt => {
541 let typed: String = dialoguer::Input::new()
542 .with_prompt(format!("Type {confirm_word} to confirm"))
543 .interact_text()?;
544 Ok(typed == confirm_word)
545 }
546 }
547}
548
549fn handle_freeze(cmd: FreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
551 use auths_sdk::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
552 use auths_sdk::storage_layout::layout;
553
554 let out = Output::new();
555
556 out.print_heading("Identity Freeze");
557 out.newline();
558
559 let duration = parse_duration(&cmd.duration)?;
561 let frozen_at = now;
562 let frozen_until = frozen_at + duration;
563
564 out.println(&format!(
565 "Duration: {} (until {})",
566 out.info(&cmd.duration),
567 out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
568 ));
569 out.newline();
570
571 let repo_path = layout::resolve_repo_path(cmd.repo)?;
573
574 if let Some(existing) = load_active_freeze(&repo_path, now)? {
576 let existing_until = existing.frozen_until;
577 if frozen_until > existing_until {
578 out.print_warn(&format!(
579 "Existing freeze active until {}. Will extend to {}.",
580 existing_until.format("%Y-%m-%d %H:%M UTC"),
581 frozen_until.format("%Y-%m-%d %H:%M UTC"),
582 ));
583 } else {
584 out.print_warn(&format!(
585 "Existing freeze already active until {} (longer than requested).",
586 existing_until.format("%Y-%m-%d %H:%M UTC"),
587 ));
588 out.println("Use a longer duration to extend, or unfreeze first.");
589 return Ok(());
590 }
591 out.newline();
592 }
593
594 if cmd.dry_run {
595 out.print_info("Dry run mode - no changes will be made");
596 out.newline();
597 out.println("Would perform the following actions:");
598 out.println(&format!(
599 " 1. Freeze all signing operations for {}",
600 cmd.duration
601 ));
602 out.println(&format!(
603 " 2. Write freeze state to {}",
604 repo_path.join("freeze.json").display()
605 ));
606 out.println(" 3. auths-sign will refuse to sign until freeze expires");
607 return Ok(());
608 }
609
610 if !confirm_or_refuse(cmd.yes, std::io::stdin().is_terminal(), "freeze", "FREEZE")? {
613 out.println("Cancelled - confirmation not matched.");
614 return Ok(());
615 }
616
617 let state = FreezeState {
618 frozen_at,
619 frozen_until,
620 reason: Some(format!("Emergency freeze for {}", cmd.duration)),
621 };
622
623 store_freeze(&repo_path, &state)?;
624
625 out.print_success(&format!(
626 "Identity frozen until {}",
627 frozen_until.format("%Y-%m-%d %H:%M UTC")
628 ));
629 out.newline();
630 out.println("All signing operations are disabled.");
631 out.println(&format!(
632 "Freeze expires in: {}",
633 out.info(&state.expires_description(now))
634 ));
635 out.newline();
636 out.print_warn(
637 "This freeze is local to this machine — it does not travel to other verifiers, so a \
638 leaked key still verifies elsewhere. To revoke a compromised identity everywhere, \
639 rotate (recover) or abandon it.",
640 );
641 out.newline();
642 out.println("To unfreeze early:");
643 out.println(&format!(" {}", out.dim("auths emergency unfreeze")));
644
645 Ok(())
646}
647
648fn handle_unfreeze(cmd: UnfreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
650 use auths_sdk::freeze::{load_active_freeze, remove_freeze};
651 use auths_sdk::storage_layout::layout;
652
653 let out = Output::new();
654
655 let repo_path = layout::resolve_repo_path(cmd.repo)?;
656
657 match load_active_freeze(&repo_path, now)? {
658 Some(state) => {
659 out.println(&format!(
660 "Active freeze until {}",
661 out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
662 ));
663 out.newline();
664
665 if !cmd.yes {
666 let confirm = Confirm::new()
667 .with_prompt("Remove freeze and restore signing?")
668 .default(false)
669 .interact()?;
670
671 if !confirm {
672 out.println("Cancelled.");
673 return Ok(());
674 }
675 }
676
677 remove_freeze(&repo_path)?;
678 out.print_success("Freeze removed. Signing operations are restored.");
679 }
680 None => {
681 out.print_info("No active freeze found.");
682 }
683 }
684
685 Ok(())
686}
687
688fn handle_report(cmd: ReportCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
690 use auths_sdk::identity::ManagedIdentity;
691 use auths_sdk::ports::IdentityStorage;
692 use auths_sdk::storage::{RegistryAttestationStorage, RegistryIdentityStorage};
693 use auths_sdk::storage_layout::layout;
694
695 let out = Output::new();
696
697 let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
698
699 let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
701 let identity_did = match identity_storage.load_identity() {
702 Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
703 Err(_) => None,
704 };
705
706 let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
708 let enriched = attestation_storage.load_all_enriched().unwrap_or_default();
709 let all_attestations: Vec<_> = enriched.iter().map(|e| e.attestation.clone()).collect();
710
711 let mut seen_devices = std::collections::HashSet::new();
713 let mut devices = Vec::new();
714 for e_att in &enriched {
715 let att = &e_att.attestation;
716 let did_str = att.subject.to_string();
717 if seen_devices.insert(did_str.clone()) {
718 let status = if att.is_revoked() {
719 "revoked"
720 } else if att.expires_at.is_some_and(|exp| exp <= now) {
721 "expired"
722 } else {
723 "active"
724 };
725 devices.push(DeviceInfo {
726 did: did_str,
727 name: att.note.clone(),
728 status: status.to_string(),
729 anchored: e_att.anchor == auths_keri::AnchorStatus::Anchored,
730 last_active: att.timestamp.map(|t| t.to_rfc3339()),
731 });
732 }
733 }
734
735 let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
737 events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
738 let recent_events: Vec<EventInfo> = events
739 .iter()
740 .take(cmd.events)
741 .map(|att| {
742 let event_type = if att.is_revoked() {
743 "device_revocation"
744 } else {
745 "device_authorization"
746 };
747 EventInfo {
748 timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
749 event_type: event_type.to_string(),
750 details: format!("{} for {}", event_type, att.subject),
751 }
752 })
753 .collect();
754
755 let mut recommendations = Vec::new();
757 let active_count = devices.iter().filter(|d| d.status == "active").count();
758 let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
759 let expired_count = devices.iter().filter(|d| d.status == "expired").count();
760
761 if active_count > 0 {
762 recommendations.push(format!(
763 "Review all {} active device authorizations",
764 active_count
765 ));
766 }
767 if expired_count > 0 {
768 recommendations.push(format!(
769 "Clean up {} expired device authorizations",
770 expired_count
771 ));
772 }
773 if revoked_count > 0 {
774 recommendations.push(format!(
775 "{} device(s) already revoked — verify these were intentional",
776 revoked_count
777 ));
778 }
779 recommendations.push("Check for any unexpected signing activity".to_string());
780
781 let report = IncidentReport {
782 generated_at: now.to_rfc3339(),
783 identity_did: identity_did.map(|d| d.to_string()),
784 devices,
785 recent_events,
786 recommendations,
787 };
788
789 if is_json_mode() {
790 let json = serde_json::to_string_pretty(&report)?;
791 if let Some(output_path) = &cmd.output_file {
792 std::fs::write(output_path, &json)
793 .with_context(|| format!("Failed to write report to {:?}", output_path))?;
794 out.print_success(&format!("Report saved to {}", output_path.display()));
795 } else {
796 println!("{}", json);
797 }
798 return Ok(());
799 }
800
801 out.print_heading("Incident Report");
803 out.newline();
804
805 out.println(&format!("Generated: {}", out.info(&report.generated_at)));
806 if let Some(did) = &report.identity_did {
807 out.println(&format!("Identity: {}", out.info(did)));
808 }
809 out.newline();
810
811 out.print_heading(" Devices");
812 for device in &report.devices {
813 let status_icon = if device.status == "active" {
814 out.success("●")
815 } else {
816 out.error("○")
817 };
818 out.println(&format!(
819 " {} {} ({}) - {}",
820 status_icon,
821 device.did,
822 device.name.as_deref().unwrap_or("unnamed"),
823 device.status
824 ));
825 }
826 out.newline();
827
828 out.print_heading(" Recent Events");
829 for event in &report.recent_events {
830 out.println(&format!(
831 " {} [{}] {}",
832 out.dim(&event.timestamp[..19]),
833 event.event_type,
834 event.details
835 ));
836 }
837 out.newline();
838
839 out.print_heading(" Recommendations");
840 for (i, rec) in report.recommendations.iter().enumerate() {
841 out.println(&format!(" {}. {}", i + 1, rec));
842 }
843
844 if let Some(output_path) = &cmd.output_file {
845 let json = serde_json::to_string_pretty(&report)?;
846 std::fs::write(output_path, json)
847 .with_context(|| format!("Failed to write report to {:?}", output_path))?;
848 out.newline();
849 out.print_success(&format!("Report also saved to {}", output_path.display()));
850 }
851
852 Ok(())
853}
854
855use crate::commands::executable::ExecutableCommand;
856use crate::config::CliConfig;
857
858impl ExecutableCommand for EmergencyCommand {
859 #[allow(clippy::disallowed_methods)]
860 fn execute(&self, ctx: &CliConfig) -> Result<()> {
861 handle_emergency(self.clone(), chrono::Utc::now(), ctx)
862 }
863}
864
865#[cfg(test)]
866#[allow(clippy::disallowed_methods)]
867mod tests {
868 use super::*;
869
870 #[test]
871 fn test_incident_report_serialization() {
872 let report = IncidentReport {
873 generated_at: "2024-01-15T10:30:00Z".to_string(),
874 identity_did: Some("did:keri:ETest".to_string()),
875 devices: vec![],
876 recent_events: vec![],
877 recommendations: vec!["Test recommendation".to_string()],
878 };
879
880 let json = serde_json::to_string(&report).unwrap();
881 assert!(json.contains("did:keri:ETest"));
882 assert!(json.contains("Test recommendation"));
883 }
884
885 #[test]
886 fn test_device_info_serialization() {
887 let device = DeviceInfo {
888 did: "did:key:z6MkTest".to_string(),
889 name: Some("Test Device".to_string()),
890 status: "active".to_string(),
891 anchored: true,
892 last_active: None,
893 };
894
895 let json = serde_json::to_string(&device).unwrap();
896 assert!(json.contains("did:key:z6MkTest"));
897 assert!(json.contains("Test Device"));
898 }
899
900 #[test]
901 fn test_freeze_dry_run() {
902 let dir = tempfile::TempDir::new().unwrap();
903 let result = handle_freeze(
904 FreezeCommand {
905 duration: "24h".to_string(),
906 yes: true,
907 dry_run: true,
908 repo: Some(dir.path().to_path_buf()),
909 },
910 chrono::Utc::now(),
911 );
912
913 assert!(result.is_ok());
914 assert!(!dir.path().join("freeze.json").exists());
916 }
917
918 #[test]
919 fn test_freeze_creates_freeze_file() {
920 let dir = tempfile::TempDir::new().unwrap();
921 let result = handle_freeze(
922 FreezeCommand {
923 duration: "1h".to_string(),
924 yes: true,
925 dry_run: false,
926 repo: Some(dir.path().to_path_buf()),
927 },
928 chrono::Utc::now(),
929 );
930
931 assert!(result.is_ok());
932 assert!(dir.path().join("freeze.json").exists());
933
934 let state = auths_sdk::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
936 assert!(state.is_some());
937 }
938
939 #[test]
940 fn test_freeze_invalid_duration() {
941 let dir = tempfile::TempDir::new().unwrap();
942 let result = handle_freeze(
943 FreezeCommand {
944 duration: "invalid".to_string(),
945 yes: true,
946 dry_run: false,
947 repo: Some(dir.path().to_path_buf()),
948 },
949 chrono::Utc::now(),
950 );
951
952 assert!(result.is_err());
953 let err_msg = result.unwrap_err().to_string();
954 assert!(
955 err_msg.contains("Invalid") || err_msg.contains("duration"),
956 "Expected duration parse error, got: {}",
957 err_msg
958 );
959 }
960
961 #[test]
962 fn test_unfreeze_removes_freeze() {
963 let dir = tempfile::TempDir::new().unwrap();
964
965 handle_freeze(
967 FreezeCommand {
968 duration: "24h".to_string(),
969 yes: true,
970 dry_run: false,
971 repo: Some(dir.path().to_path_buf()),
972 },
973 chrono::Utc::now(),
974 )
975 .unwrap();
976 assert!(dir.path().join("freeze.json").exists());
977
978 handle_unfreeze(
980 UnfreezeCommand {
981 yes: true,
982 repo: Some(dir.path().to_path_buf()),
983 },
984 chrono::Utc::now(),
985 )
986 .unwrap();
987 assert!(!dir.path().join("freeze.json").exists());
988 }
989
990 #[test]
991 fn confirmation_fails_closed_without_a_terminal_and_without_yes() {
992 assert_eq!(confirmation_mode(true, false), ConfirmationMode::Proceed);
994 assert_eq!(confirmation_mode(true, true), ConfirmationMode::Proceed);
995 assert_eq!(confirmation_mode(false, true), ConfirmationMode::Prompt);
997 assert_eq!(
1000 confirmation_mode(false, false),
1001 ConfirmationMode::NonInteractive
1002 );
1003 }
1004
1005 #[test]
1006 fn confirm_or_refuse_fails_closed_with_an_actionable_message() {
1007 assert!(confirm_or_refuse(true, false, "key rotation", "ROTATE").unwrap());
1009 let err = confirm_or_refuse(false, false, "key rotation", "ROTATE")
1012 .expect_err("non-interactive without --yes must fail closed");
1013 assert!(
1014 err.to_string().contains("--yes"),
1015 "must point to --yes: {err}"
1016 );
1017 assert!(
1018 err.to_string().contains("rotation"),
1019 "must name the refused action: {err}"
1020 );
1021 }
1022
1023 #[test]
1024 fn handle_freeze_refuses_non_interactive_without_yes() {
1025 let dir = tempfile::TempDir::new().unwrap();
1029 let result = handle_freeze(
1030 FreezeCommand {
1031 duration: "24h".to_string(),
1032 yes: false,
1033 dry_run: false,
1034 repo: Some(dir.path().to_path_buf()),
1035 },
1036 chrono::Utc::now(),
1037 );
1038 let err = result.expect_err("freeze without --yes in a non-interactive env must fail");
1039 assert!(
1040 err.to_string().contains("--yes"),
1041 "the error must direct the operator to --yes, got: {err}"
1042 );
1043 assert!(
1044 !dir.path().join("freeze.json").exists(),
1045 "no freeze must be written"
1046 );
1047 }
1048}