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;
17use std::sync::Arc;
18
19/// Emergency incident response commands.
20#[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    /// Revoke a compromised device immediately.
30    #[command(name = "revoke-device")]
31    RevokeDevice(RevokeDeviceCommand),
32
33    /// Force immediate key rotation.
34    #[command(name = "rotate-now")]
35    RotateNow(RotateNowCommand),
36
37    /// Freeze all signing operations.
38    Freeze(FreezeCommand),
39
40    /// Unfreeze (cancel an active freeze early).
41    Unfreeze(UnfreezeCommand),
42
43    /// Generate an incident report.
44    Report(ReportCommand),
45}
46
47/// Revoke a compromised device.
48#[derive(Parser, Debug, Clone)]
49pub struct RevokeDeviceCommand {
50    /// Device ID to revoke.
51    #[arg(long)]
52    pub device: Option<String>,
53
54    /// Local alias of the identity's key (used for signing the revocation).
55    #[arg(long)]
56    pub key: Option<String>,
57
58    /// Optional note explaining the revocation.
59    #[arg(long)]
60    pub note: Option<String>,
61
62    /// Skip confirmation prompt.
63    #[arg(long, short = 'y')]
64    pub yes: bool,
65
66    /// Preview actions without making changes.
67    #[arg(long)]
68    pub dry_run: bool,
69
70    /// Path to the Auths repository.
71    #[arg(long)]
72    pub repo: Option<PathBuf>,
73}
74
75/// Force immediate key rotation.
76#[derive(Parser, Debug, Clone)]
77pub struct RotateNowCommand {
78    /// Local alias of the current signing key.
79    #[arg(long)]
80    pub current_alias: Option<String>,
81
82    /// Local alias for the new signing key after rotation.
83    #[arg(long)]
84    pub next_alias: Option<String>,
85
86    /// Skip confirmation prompt (requires typing ROTATE).
87    #[arg(long, short = 'y')]
88    pub yes: bool,
89
90    /// Preview actions without making changes.
91    #[arg(long)]
92    pub dry_run: bool,
93
94    /// Reason for rotation.
95    #[arg(long)]
96    pub reason: Option<String>,
97
98    /// Path to the Auths repository.
99    #[arg(long)]
100    pub repo: Option<PathBuf>,
101}
102
103/// Freeze all signing operations.
104#[derive(Parser, Debug, Clone)]
105pub struct FreezeCommand {
106    /// Duration to freeze (e.g., "24h", "7d").
107    #[arg(long, default_value = "24h")]
108    pub duration: String,
109
110    /// Skip confirmation prompt (requires typing identity name).
111    #[arg(long, short = 'y')]
112    pub yes: bool,
113
114    /// Preview actions without making changes.
115    #[arg(long)]
116    pub dry_run: bool,
117
118    /// Path to the Auths repository.
119    #[arg(long)]
120    pub repo: Option<PathBuf>,
121}
122
123/// Cancel an active freeze early.
124#[derive(Parser, Debug, Clone)]
125pub struct UnfreezeCommand {
126    /// Skip confirmation prompt.
127    #[arg(long, short = 'y')]
128    pub yes: bool,
129
130    /// Path to the Auths repository.
131    #[arg(long)]
132    pub repo: Option<PathBuf>,
133}
134
135/// Generate an incident report.
136#[derive(Parser, Debug, Clone)]
137pub struct ReportCommand {
138    /// Include last N events in report.
139    #[arg(long, default_value = "100")]
140    pub events: usize,
141
142    /// Output file path (defaults to stdout).
143    #[arg(long = "output", visible_alias = "file", short = 'o')]
144    pub output_file: Option<PathBuf>,
145
146    /// Path to the Auths repository.
147    #[arg(long)]
148    pub repo: Option<PathBuf>,
149}
150
151/// Incident report output.
152#[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
177/// Handle the emergency command.
178pub 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
193/// Handle interactive emergency flow.
194fn 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            // Device lost/stolen
230            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            // Key exposed
246            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            // Freeze everything
262            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            // Generate report
275            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
291/// Handle device revocation using the real revocation code path.
292fn 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    // Get device to revoke
306    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    // Get identity key alias
317    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    // Confirmation
344    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
382/// Handle emergency key rotation using the real rotation code path.
383fn 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    // Get key aliases
404    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    // Extra confirmation for rotation
437    if !cmd.yes {
438        out.print_warn("Key rotation is a significant operation.");
439        out.println("All devices will need to re-authorize.");
440        out.newline();
441
442        let confirmation: String = Input::new()
443            .with_prompt("Type ROTATE to confirm")
444            .interact_text()?;
445
446        if confirmation != "ROTATE" {
447            out.println("Cancelled - confirmation not matched.");
448            return Ok(());
449        }
450    }
451
452    let repo_path = layout::resolve_repo_path(cmd.repo)?;
453    let rotation_config = IdentityRotationConfig {
454        repo_path: repo_path.clone(),
455        identity_key_alias: Some(KeyAlias::new_unchecked(current_alias)),
456        next_key_alias: Some(KeyAlias::new_unchecked(next_alias)),
457    };
458    let auths_ctx = crate::factories::storage::build_auths_context(
459        &repo_path,
460        &ctx.env_config,
461        Some(Arc::clone(&ctx.passphrase_provider)),
462    )?;
463
464    out.print_info("Rotating key...");
465    let rotation_result = auths_sdk::domains::identity::rotation::rotate_identity(
466        rotation_config,
467        &auths_ctx,
468        &auths_sdk::ports::SystemClock,
469    )
470    .map_err(anyhow::Error::new)
471    .context("Key rotation failed")?;
472
473    out.print_success(&format!(
474        "Key rotation complete (new sequence: {})",
475        rotation_result.sequence
476    ));
477    out.newline();
478    out.println("Next steps:");
479    out.println("  1. Re-authorize your devices: auths pair");
480    out.println("  2. Update any CI/CD secrets");
481    out.println("  3. Run `auths doctor` to verify setup");
482
483    Ok(())
484}
485
486/// Handle freeze operation — temporarily disables all signing.
487fn handle_freeze(cmd: FreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
488    use auths_sdk::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
489    use auths_sdk::storage_layout::layout;
490
491    let out = Output::new();
492
493    out.print_heading("Identity Freeze");
494    out.newline();
495
496    // Parse duration
497    let duration = parse_duration(&cmd.duration)?;
498    let frozen_at = now;
499    let frozen_until = frozen_at + duration;
500
501    out.println(&format!(
502        "Duration: {} (until {})",
503        out.info(&cmd.duration),
504        out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
505    ));
506    out.newline();
507
508    // Resolve repository
509    let repo_path = layout::resolve_repo_path(cmd.repo)?;
510
511    // Check for existing freeze
512    if let Some(existing) = load_active_freeze(&repo_path, now)? {
513        let existing_until = existing.frozen_until;
514        if frozen_until > existing_until {
515            out.print_warn(&format!(
516                "Existing freeze active until {}. Will extend to {}.",
517                existing_until.format("%Y-%m-%d %H:%M UTC"),
518                frozen_until.format("%Y-%m-%d %H:%M UTC"),
519            ));
520        } else {
521            out.print_warn(&format!(
522                "Existing freeze already active until {} (longer than requested).",
523                existing_until.format("%Y-%m-%d %H:%M UTC"),
524            ));
525            out.println("Use a longer duration to extend, or unfreeze first.");
526            return Ok(());
527        }
528        out.newline();
529    }
530
531    if cmd.dry_run {
532        out.print_info("Dry run mode - no changes will be made");
533        out.newline();
534        out.println("Would perform the following actions:");
535        out.println(&format!(
536            "  1. Freeze all signing operations for {}",
537            cmd.duration
538        ));
539        out.println(&format!(
540            "  2. Write freeze state to {}",
541            repo_path.join("freeze.json").display()
542        ));
543        out.println("  3. auths-sign will refuse to sign until freeze expires");
544        return Ok(());
545    }
546
547    // Confirmation
548    if !cmd.yes {
549        let confirmation: String = dialoguer::Input::new()
550            .with_prompt("Type FREEZE to confirm")
551            .interact_text()?;
552
553        if confirmation != "FREEZE" {
554            out.println("Cancelled - confirmation not matched.");
555            return Ok(());
556        }
557    }
558
559    let state = FreezeState {
560        frozen_at,
561        frozen_until,
562        reason: Some(format!("Emergency freeze for {}", cmd.duration)),
563    };
564
565    store_freeze(&repo_path, &state)?;
566
567    out.print_success(&format!(
568        "Identity frozen until {}",
569        frozen_until.format("%Y-%m-%d %H:%M UTC")
570    ));
571    out.newline();
572    out.println("All signing operations are disabled.");
573    out.println(&format!(
574        "Freeze expires in: {}",
575        out.info(&state.expires_description(now))
576    ));
577    out.newline();
578    out.println("To unfreeze early:");
579    out.println(&format!("  {}", out.dim("auths emergency unfreeze")));
580
581    Ok(())
582}
583
584/// Handle unfreeze — cancel an active freeze early.
585fn handle_unfreeze(cmd: UnfreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
586    use auths_sdk::freeze::{load_active_freeze, remove_freeze};
587    use auths_sdk::storage_layout::layout;
588
589    let out = Output::new();
590
591    let repo_path = layout::resolve_repo_path(cmd.repo)?;
592
593    match load_active_freeze(&repo_path, now)? {
594        Some(state) => {
595            out.println(&format!(
596                "Active freeze until {}",
597                out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
598            ));
599            out.newline();
600
601            if !cmd.yes {
602                let confirm = Confirm::new()
603                    .with_prompt("Remove freeze and restore signing?")
604                    .default(false)
605                    .interact()?;
606
607                if !confirm {
608                    out.println("Cancelled.");
609                    return Ok(());
610                }
611            }
612
613            remove_freeze(&repo_path)?;
614            out.print_success("Freeze removed. Signing operations are restored.");
615        }
616        None => {
617            out.print_info("No active freeze found.");
618        }
619    }
620
621    Ok(())
622}
623
624/// Handle incident report generation.
625fn handle_report(cmd: ReportCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
626    use auths_sdk::identity::ManagedIdentity;
627    use auths_sdk::ports::IdentityStorage;
628    use auths_sdk::storage::{RegistryAttestationStorage, RegistryIdentityStorage};
629    use auths_sdk::storage_layout::layout;
630
631    let out = Output::new();
632
633    let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
634
635    // Load real identity
636    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
637    let identity_did = match identity_storage.load_identity() {
638        Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
639        Err(_) => None,
640    };
641
642    // Load real device attestations + anchor status
643    let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
644    let enriched = attestation_storage.load_all_enriched().unwrap_or_default();
645    let all_attestations: Vec<_> = enriched.iter().map(|e| e.attestation.clone()).collect();
646
647    // Build device list from attestations (deduplicate by subject DID)
648    let mut seen_devices = std::collections::HashSet::new();
649    let mut devices = Vec::new();
650    for e_att in &enriched {
651        let att = &e_att.attestation;
652        let did_str = att.subject.to_string();
653        if seen_devices.insert(did_str.clone()) {
654            let status = if att.is_revoked() {
655                "revoked"
656            } else if att.expires_at.is_some_and(|exp| exp <= now) {
657                "expired"
658            } else {
659                "active"
660            };
661            devices.push(DeviceInfo {
662                did: did_str,
663                name: att.note.clone(),
664                status: status.to_string(),
665                anchored: e_att.anchor == auths_keri::AnchorStatus::Anchored,
666                last_active: att.timestamp.map(|t| t.to_rfc3339()),
667            });
668        }
669    }
670
671    // Build recent events from attestation history (most recent first, capped)
672    let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
673    events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
674    let recent_events: Vec<EventInfo> = events
675        .iter()
676        .take(cmd.events)
677        .map(|att| {
678            let event_type = if att.is_revoked() {
679                "device_revocation"
680            } else {
681                "device_authorization"
682            };
683            EventInfo {
684                timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
685                event_type: event_type.to_string(),
686                details: format!("{} for {}", event_type, att.subject),
687            }
688        })
689        .collect();
690
691    // Generate recommendations based on actual state
692    let mut recommendations = Vec::new();
693    let active_count = devices.iter().filter(|d| d.status == "active").count();
694    let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
695    let expired_count = devices.iter().filter(|d| d.status == "expired").count();
696
697    if active_count > 0 {
698        recommendations.push(format!(
699            "Review all {} active device authorizations",
700            active_count
701        ));
702    }
703    if expired_count > 0 {
704        recommendations.push(format!(
705            "Clean up {} expired device authorizations",
706            expired_count
707        ));
708    }
709    if revoked_count > 0 {
710        recommendations.push(format!(
711            "{} device(s) already revoked — verify these were intentional",
712            revoked_count
713        ));
714    }
715    recommendations.push("Check for any unexpected signing activity".to_string());
716
717    let report = IncidentReport {
718        generated_at: now.to_rfc3339(),
719        identity_did: identity_did.map(|d| d.to_string()),
720        devices,
721        recent_events,
722        recommendations,
723    };
724
725    if is_json_mode() {
726        let json = serde_json::to_string_pretty(&report)?;
727        if let Some(output_path) = &cmd.output_file {
728            std::fs::write(output_path, &json)
729                .with_context(|| format!("Failed to write report to {:?}", output_path))?;
730            out.print_success(&format!("Report saved to {}", output_path.display()));
731        } else {
732            println!("{}", json);
733        }
734        return Ok(());
735    }
736
737    // Text output
738    out.print_heading("Incident Report");
739    out.newline();
740
741    out.println(&format!("Generated: {}", out.info(&report.generated_at)));
742    if let Some(did) = &report.identity_did {
743        out.println(&format!("Identity: {}", out.info(did)));
744    }
745    out.newline();
746
747    out.print_heading("  Devices");
748    for device in &report.devices {
749        let status_icon = if device.status == "active" {
750            out.success("●")
751        } else {
752            out.error("○")
753        };
754        out.println(&format!(
755            "    {} {} ({}) - {}",
756            status_icon,
757            device.did,
758            device.name.as_deref().unwrap_or("unnamed"),
759            device.status
760        ));
761    }
762    out.newline();
763
764    out.print_heading("  Recent Events");
765    for event in &report.recent_events {
766        out.println(&format!(
767            "    {} [{}] {}",
768            out.dim(&event.timestamp[..19]),
769            event.event_type,
770            event.details
771        ));
772    }
773    out.newline();
774
775    out.print_heading("  Recommendations");
776    for (i, rec) in report.recommendations.iter().enumerate() {
777        out.println(&format!("    {}. {}", i + 1, rec));
778    }
779
780    if let Some(output_path) = &cmd.output_file {
781        let json = serde_json::to_string_pretty(&report)?;
782        std::fs::write(output_path, json)
783            .with_context(|| format!("Failed to write report to {:?}", output_path))?;
784        out.newline();
785        out.print_success(&format!("Report also saved to {}", output_path.display()));
786    }
787
788    Ok(())
789}
790
791use crate::commands::executable::ExecutableCommand;
792use crate::config::CliConfig;
793
794impl ExecutableCommand for EmergencyCommand {
795    #[allow(clippy::disallowed_methods)]
796    fn execute(&self, ctx: &CliConfig) -> Result<()> {
797        handle_emergency(self.clone(), chrono::Utc::now(), ctx)
798    }
799}
800
801#[cfg(test)]
802#[allow(clippy::disallowed_methods)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn test_incident_report_serialization() {
808        let report = IncidentReport {
809            generated_at: "2024-01-15T10:30:00Z".to_string(),
810            identity_did: Some("did:keri:ETest".to_string()),
811            devices: vec![],
812            recent_events: vec![],
813            recommendations: vec!["Test recommendation".to_string()],
814        };
815
816        let json = serde_json::to_string(&report).unwrap();
817        assert!(json.contains("did:keri:ETest"));
818        assert!(json.contains("Test recommendation"));
819    }
820
821    #[test]
822    fn test_device_info_serialization() {
823        let device = DeviceInfo {
824            did: "did:key:z6MkTest".to_string(),
825            name: Some("Test Device".to_string()),
826            status: "active".to_string(),
827            anchored: true,
828            last_active: None,
829        };
830
831        let json = serde_json::to_string(&device).unwrap();
832        assert!(json.contains("did:key:z6MkTest"));
833        assert!(json.contains("Test Device"));
834    }
835
836    #[test]
837    fn test_freeze_dry_run() {
838        let dir = tempfile::TempDir::new().unwrap();
839        let result = handle_freeze(
840            FreezeCommand {
841                duration: "24h".to_string(),
842                yes: true,
843                dry_run: true,
844                repo: Some(dir.path().to_path_buf()),
845            },
846            chrono::Utc::now(),
847        );
848
849        assert!(result.is_ok());
850        // Dry run should NOT create the freeze file
851        assert!(!dir.path().join("freeze.json").exists());
852    }
853
854    #[test]
855    fn test_freeze_creates_freeze_file() {
856        let dir = tempfile::TempDir::new().unwrap();
857        let result = handle_freeze(
858            FreezeCommand {
859                duration: "1h".to_string(),
860                yes: true,
861                dry_run: false,
862                repo: Some(dir.path().to_path_buf()),
863            },
864            chrono::Utc::now(),
865        );
866
867        assert!(result.is_ok());
868        assert!(dir.path().join("freeze.json").exists());
869
870        // Verify the freeze is active
871        let state = auths_sdk::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
872        assert!(state.is_some());
873    }
874
875    #[test]
876    fn test_freeze_invalid_duration() {
877        let dir = tempfile::TempDir::new().unwrap();
878        let result = handle_freeze(
879            FreezeCommand {
880                duration: "invalid".to_string(),
881                yes: true,
882                dry_run: false,
883                repo: Some(dir.path().to_path_buf()),
884            },
885            chrono::Utc::now(),
886        );
887
888        assert!(result.is_err());
889        let err_msg = result.unwrap_err().to_string();
890        assert!(
891            err_msg.contains("Invalid") || err_msg.contains("duration"),
892            "Expected duration parse error, got: {}",
893            err_msg
894        );
895    }
896
897    #[test]
898    fn test_unfreeze_removes_freeze() {
899        let dir = tempfile::TempDir::new().unwrap();
900
901        // Create a freeze
902        handle_freeze(
903            FreezeCommand {
904                duration: "24h".to_string(),
905                yes: true,
906                dry_run: false,
907                repo: Some(dir.path().to_path_buf()),
908            },
909            chrono::Utc::now(),
910        )
911        .unwrap();
912        assert!(dir.path().join("freeze.json").exists());
913
914        // Unfreeze
915        handle_unfreeze(
916            UnfreezeCommand {
917                yes: true,
918                repo: Some(dir.path().to_path_buf()),
919            },
920            chrono::Utc::now(),
921        )
922        .unwrap();
923        assert!(!dir.path().join("freeze.json").exists());
924    }
925}