Skip to main content

auths_cli/commands/
emergency.rs

1//! Emergency response commands for incident handling.
2//!
3//! Commands:
4//! - `auths emergency` - Interactive emergency response flow
5//! - `auths emergency revoke-device` - Revoke a compromised device
6//! - `auths emergency rotate-now` - Force key rotation
7//! - `auths emergency freeze` - Freeze all operations
8//! - `auths emergency report` - Generate incident report
9
10use 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/// Emergency incident response commands.
19#[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    /// Revoke a compromised device immediately.
29    #[command(name = "revoke-device")]
30    RevokeDevice(RevokeDeviceCommand),
31
32    /// Force immediate key rotation.
33    #[command(name = "rotate-now")]
34    RotateNow(RotateNowCommand),
35
36    /// Freeze all signing operations.
37    Freeze(FreezeCommand),
38
39    /// Unfreeze (cancel an active freeze early).
40    Unfreeze(UnfreezeCommand),
41
42    /// Generate an incident report.
43    Report(ReportCommand),
44}
45
46/// Revoke a compromised device.
47#[derive(Parser, Debug, Clone)]
48pub struct RevokeDeviceCommand {
49    /// Device DID to revoke.
50    #[arg(long)]
51    pub device: Option<String>,
52
53    /// Local alias of the identity's key (used for signing the revocation).
54    #[arg(long)]
55    pub identity_key_alias: Option<String>,
56
57    /// Optional note explaining the revocation.
58    #[arg(long)]
59    pub note: Option<String>,
60
61    /// Skip confirmation prompt.
62    #[arg(long, short = 'y')]
63    pub yes: bool,
64
65    /// Preview actions without making changes.
66    #[arg(long)]
67    pub dry_run: bool,
68
69    /// Path to the Auths repository.
70    #[arg(long)]
71    pub repo: Option<PathBuf>,
72}
73
74/// Force immediate key rotation.
75#[derive(Parser, Debug, Clone)]
76pub struct RotateNowCommand {
77    /// Local alias of the current signing key.
78    #[arg(long)]
79    pub current_alias: Option<String>,
80
81    /// Local alias for the new signing key after rotation.
82    #[arg(long)]
83    pub next_alias: Option<String>,
84
85    /// Skip confirmation prompt (requires typing ROTATE).
86    #[arg(long, short = 'y')]
87    pub yes: bool,
88
89    /// Preview actions without making changes.
90    #[arg(long)]
91    pub dry_run: bool,
92
93    /// Reason for rotation.
94    #[arg(long)]
95    pub reason: Option<String>,
96
97    /// Path to the Auths repository.
98    #[arg(long)]
99    pub repo: Option<PathBuf>,
100}
101
102/// Freeze all signing operations.
103#[derive(Parser, Debug, Clone)]
104pub struct FreezeCommand {
105    /// Duration to freeze (e.g., "24h", "7d").
106    #[arg(long, default_value = "24h")]
107    pub duration: String,
108
109    /// Skip confirmation prompt (requires typing identity name).
110    #[arg(long, short = 'y')]
111    pub yes: bool,
112
113    /// Preview actions without making changes.
114    #[arg(long)]
115    pub dry_run: bool,
116
117    /// Path to the Auths repository.
118    #[arg(long)]
119    pub repo: Option<PathBuf>,
120}
121
122/// Cancel an active freeze early.
123#[derive(Parser, Debug, Clone)]
124pub struct UnfreezeCommand {
125    /// Skip confirmation prompt.
126    #[arg(long, short = 'y')]
127    pub yes: bool,
128
129    /// Path to the Auths repository.
130    #[arg(long)]
131    pub repo: Option<PathBuf>,
132}
133
134/// Generate an incident report.
135#[derive(Parser, Debug, Clone)]
136pub struct ReportCommand {
137    /// Include last N events in report.
138    #[arg(long, default_value = "100")]
139    pub events: usize,
140
141    /// Output file path (defaults to stdout).
142    #[arg(long = "output", visible_alias = "file", short = 'o')]
143    pub output_file: Option<PathBuf>,
144
145    /// Path to the Auths repository.
146    #[arg(long)]
147    pub repo: Option<PathBuf>,
148}
149
150/// Incident report output.
151#[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
175/// Handle the emergency command.
176pub 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
187/// Handle interactive emergency flow.
188fn 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            // Device lost/stolen
222            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            // Key exposed
234            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            // Freeze everything
246            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            // Generate report
256            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
269/// Handle device revocation using the real revocation code path.
270fn 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    // Get device to revoke
289    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    // Get identity key alias
300    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    // Confirmation
329    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    // Resolve repository and load identity
342    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    // Look up the device's public key from existing attestations
355    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    // Create passphrase provider from terminal
366    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
404/// Handle emergency key rotation using the real rotation code path.
405fn 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    // Get key aliases
422    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    // Extra confirmation for rotation
455    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    // Resolve repository
471    let repo_path = layout::resolve_repo_path(cmd.repo)?;
472    let config = StorageLayoutConfig::default();
473
474    // Create passphrase provider from terminal
475    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        &current_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
506/// Handle freeze operation — temporarily disables all signing.
507fn 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    // Parse duration
517    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    // Resolve repository
529    let repo_path = layout::resolve_repo_path(cmd.repo)?;
530
531    // Check for existing freeze
532    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    // Confirmation
568    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
604/// Handle unfreeze — cancel an active freeze early.
605fn 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
644/// Handle incident report generation.
645fn 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    // Load real identity
657    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    // Load real device attestations
664    let attestation_storage = RegistryAttestationStorage::new(repo_path);
665    let all_attestations = attestation_storage
666        .load_all_attestations()
667        .unwrap_or_default();
668
669    // Build device list from attestations (deduplicate by subject DID)
670    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    // Build recent events from attestation history (most recent first, capped)
692    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    // Generate recommendations based on actual state
712    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    // Text output
758    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        // Dry run should NOT create the freeze file
865        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        // Verify the freeze is active
882        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        // Create a freeze
910        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        // Unfreeze
920        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}