Skip to main content

coding_agent_search/pages/
confirmation.rs

1//! Safety confirmation flow for pages export.
2//!
3//! Implements a multi-step confirmation flow that ensures users explicitly
4//! acknowledge the implications of publishing encrypted content to a public site.
5//!
6//! # Confirmation Steps
7//!
8//! 1. **SecretScanAcknowledgment** - If secrets detected, user must type "I understand the risks"
9//! 2. **ContentReview** - User confirms they have reviewed the content summary
10//! 3. **PublicPublishingWarning** - User types the target domain to confirm
11//! 4. **PasswordStrengthWarning** - If password entropy < 60 bits, user chooses action
12//! 5. **RecoveryKeyBackup** - User types the last word of the recovery key
13//! 6. **FinalConfirmation** - User presses Enter twice
14
15use crate::pages::summary::{PrePublishSummary, ScanReportSummary};
16use std::collections::HashSet;
17
18/// Minimum password entropy in bits for full strength.
19pub const MIN_STRONG_PASSWORD_BITS: f64 = 60.0;
20
21const SECRET_ACK_PHRASE: &str = "I understand the risks";
22const SECRET_ACK_PHRASE_NORMALIZED: &str = "i understand the risks";
23
24/// Confirmation step identifiers.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum ConfirmationStep {
27    /// Acknowledge detected secrets (only shown if secrets found).
28    SecretScanAcknowledgment,
29    /// Confirm review of content summary.
30    ContentReview,
31    /// Acknowledge public publishing implications.
32    PublicPublishingWarning,
33    /// Acknowledge weak password (only shown if entropy < threshold).
34    PasswordStrengthWarning,
35    /// Confirm recovery key backup.
36    RecoveryKeyBackup,
37    /// Final double-enter confirmation.
38    FinalConfirmation,
39}
40
41impl ConfirmationStep {
42    /// Get a human-readable label for the step.
43    pub fn label(self) -> &'static str {
44        match self {
45            ConfirmationStep::SecretScanAcknowledgment => "Secret Scan Acknowledgment",
46            ConfirmationStep::ContentReview => "Content Review",
47            ConfirmationStep::PublicPublishingWarning => "Public Publishing Warning",
48            ConfirmationStep::PasswordStrengthWarning => "Password Strength Warning",
49            ConfirmationStep::RecoveryKeyBackup => "Recovery Key Backup",
50            ConfirmationStep::FinalConfirmation => "Final Confirmation",
51        }
52    }
53}
54
55/// Result of a confirmation step validation.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum StepValidation {
58    /// Step passed validation.
59    Passed,
60    /// Step failed validation with error message.
61    Failed(String),
62}
63
64/// Result of processing user input for a confirmation step.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ConfirmationResult {
67    /// Continue with current step (awaiting more input).
68    Continue,
69    /// Step completed, move to next.
70    StepCompleted,
71    /// All steps completed, ready to proceed.
72    Confirmed,
73    /// User aborted the flow.
74    Aborted,
75    /// Skip this step (not applicable).
76    Skip,
77}
78
79/// Password strength action selected by user.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PasswordStrengthAction {
82    /// Set a stronger password.
83    SetStronger,
84    /// Proceed with current password (acknowledged weak).
85    ProceedAnyway,
86    /// Abort the export.
87    Abort,
88}
89
90/// Configuration for the confirmation flow.
91#[derive(Debug, Clone)]
92pub struct ConfirmationConfig {
93    /// Whether secrets were detected.
94    pub has_secrets: bool,
95    /// Whether there are critical secrets.
96    pub has_critical_secrets: bool,
97    /// Number of secret findings.
98    pub secret_count: usize,
99    /// Target domain for publishing (e.g., "username.github.io").
100    pub target_domain: Option<String>,
101    /// Whether publishing to a remote target (GitHub/Cloudflare Pages).
102    pub is_remote_publish: bool,
103    /// Password entropy in bits.
104    pub password_entropy_bits: f64,
105    /// Whether recovery key was generated.
106    pub has_recovery_key: bool,
107    /// The recovery key phrase (for validation).
108    pub recovery_key_phrase: Option<String>,
109    /// Content summary.
110    pub summary: PrePublishSummary,
111}
112
113impl Default for ConfirmationConfig {
114    fn default() -> Self {
115        Self {
116            has_secrets: false,
117            has_critical_secrets: false,
118            secret_count: 0,
119            target_domain: None,
120            is_remote_publish: false,
121            password_entropy_bits: 0.0,
122            has_recovery_key: false,
123            recovery_key_phrase: None,
124            summary: PrePublishSummary {
125                total_conversations: 0,
126                total_messages: 0,
127                total_characters: 0,
128                estimated_size_bytes: 0,
129                earliest_timestamp: None,
130                latest_timestamp: None,
131                date_histogram: Vec::new(),
132                workspaces: Vec::new(),
133                agents: Vec::new(),
134                secret_scan: ScanReportSummary::default(),
135                encryption_config: None,
136                key_slots: Vec::new(),
137                generated_at: chrono::Utc::now(),
138            },
139        }
140    }
141}
142
143/// Manages the multi-step confirmation flow.
144pub struct ConfirmationFlow {
145    /// Current step in the flow.
146    current_step: ConfirmationStep,
147    /// Set of completed steps.
148    completed_steps: HashSet<ConfirmationStep>,
149    /// Configuration for this flow.
150    config: ConfirmationConfig,
151    /// Number of Enter presses for final confirmation.
152    final_enter_count: u8,
153    /// Password strength action if chosen.
154    password_action: Option<PasswordStrengthAction>,
155}
156
157impl ConfirmationFlow {
158    /// Create a new confirmation flow with the given configuration.
159    pub fn new(config: ConfirmationConfig) -> Self {
160        let first_step = Self::determine_first_step(&config);
161        Self {
162            current_step: first_step,
163            completed_steps: HashSet::new(),
164            config,
165            final_enter_count: 0,
166            password_action: None,
167        }
168    }
169
170    /// Get the current step.
171    pub fn current_step(&self) -> ConfirmationStep {
172        self.current_step
173    }
174
175    /// Get the configuration.
176    pub fn config(&self) -> &ConfirmationConfig {
177        &self.config
178    }
179
180    /// Get the password action if one was chosen.
181    pub fn password_action(&self) -> Option<PasswordStrengthAction> {
182        self.password_action
183    }
184
185    /// Determine the first applicable step based on configuration.
186    fn determine_first_step(config: &ConfirmationConfig) -> ConfirmationStep {
187        if config.has_secrets {
188            ConfirmationStep::SecretScanAcknowledgment
189        } else {
190            ConfirmationStep::ContentReview
191        }
192    }
193
194    /// Check if the current step should be skipped.
195    pub fn should_skip_current(&self) -> bool {
196        match self.current_step {
197            ConfirmationStep::SecretScanAcknowledgment => !self.config.has_secrets,
198            ConfirmationStep::PublicPublishingWarning => !self.config.is_remote_publish,
199            ConfirmationStep::PasswordStrengthWarning => {
200                self.config.password_entropy_bits >= MIN_STRONG_PASSWORD_BITS
201            }
202            ConfirmationStep::RecoveryKeyBackup => !self.config.has_recovery_key,
203            _ => false,
204        }
205    }
206
207    /// Validate input for the secret scan acknowledgment step.
208    pub fn validate_secret_ack(&self, input: &str) -> StepValidation {
209        let normalized = input.trim().to_lowercase();
210        if normalized == SECRET_ACK_PHRASE_NORMALIZED {
211            StepValidation::Passed
212        } else {
213            StepValidation::Failed(format!("Please type exactly: \"{SECRET_ACK_PHRASE}\""))
214        }
215    }
216
217    /// Validate input for the content review step.
218    pub fn validate_content_review(&self, input: &str) -> StepValidation {
219        let normalized = input.trim().to_lowercase();
220        if normalized == "y" || normalized == "yes" {
221            StepValidation::Passed
222        } else if normalized == "r" {
223            StepValidation::Failed("Return to summary".to_string())
224        } else {
225            StepValidation::Failed("Press Y to confirm or R to return to summary".to_string())
226        }
227    }
228
229    /// Validate input for the public publishing warning step.
230    pub fn validate_public_warning(&self, input: &str) -> StepValidation {
231        let Some(domain) = &self.config.target_domain else {
232            return StepValidation::Passed;
233        };
234
235        let expected = format!("publish to {}", domain);
236        let normalized = input.trim().to_lowercase();
237
238        if normalized == expected.to_lowercase() {
239            StepValidation::Passed
240        } else {
241            StepValidation::Failed(format!("Please type exactly: \"publish to {}\"", domain))
242        }
243    }
244
245    /// Parse password strength action from input.
246    pub fn parse_password_action(&self, input: &str) -> Option<PasswordStrengthAction> {
247        match input.trim().to_lowercase().as_str() {
248            "s" => Some(PasswordStrengthAction::SetStronger),
249            "p" => Some(PasswordStrengthAction::ProceedAnyway),
250            "a" => Some(PasswordStrengthAction::Abort),
251            _ => None,
252        }
253    }
254
255    /// Validate input for the recovery key backup step.
256    pub fn validate_recovery_key(&self, input: &str) -> StepValidation {
257        let Some(phrase) = &self.config.recovery_key_phrase else {
258            return StepValidation::Passed;
259        };
260
261        // Get the last word from the recovery phrase
262        let last_word = phrase
263            .split('-')
264            .next_back()
265            .or_else(|| phrase.split_whitespace().next_back())
266            .unwrap_or("");
267
268        let normalized = input.trim().to_lowercase();
269        if normalized == last_word.to_lowercase() {
270            StepValidation::Passed
271        } else {
272            StepValidation::Failed(
273                "Incorrect. Please type the LAST word of the recovery key.".to_string(),
274            )
275        }
276    }
277
278    /// Process an Enter keypress for final confirmation.
279    /// Returns true if both Enter presses have been received.
280    pub fn process_final_enter(&mut self) -> bool {
281        self.final_enter_count += 1;
282        self.final_enter_count >= 2
283    }
284
285    /// Reset the final Enter counter (e.g., if user typed something else).
286    pub fn reset_final_enter(&mut self) {
287        self.final_enter_count = 0;
288    }
289
290    /// Get the number of Enter presses received for final confirmation.
291    pub fn final_enter_count(&self) -> u8 {
292        self.final_enter_count
293    }
294
295    /// Mark the current step as completed and advance to the next.
296    pub fn complete_current_step(&mut self) {
297        self.completed_steps.insert(self.current_step);
298        self.advance_to_next_step();
299    }
300
301    /// Advance to the next applicable step.
302    fn advance_to_next_step(&mut self) {
303        let next = match self.current_step {
304            ConfirmationStep::SecretScanAcknowledgment => ConfirmationStep::ContentReview,
305            ConfirmationStep::ContentReview => {
306                if self.config.is_remote_publish {
307                    ConfirmationStep::PublicPublishingWarning
308                } else if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
309                    ConfirmationStep::PasswordStrengthWarning
310                } else if self.config.has_recovery_key {
311                    ConfirmationStep::RecoveryKeyBackup
312                } else {
313                    ConfirmationStep::FinalConfirmation
314                }
315            }
316            ConfirmationStep::PublicPublishingWarning => {
317                if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
318                    ConfirmationStep::PasswordStrengthWarning
319                } else if self.config.has_recovery_key {
320                    ConfirmationStep::RecoveryKeyBackup
321                } else {
322                    ConfirmationStep::FinalConfirmation
323                }
324            }
325            ConfirmationStep::PasswordStrengthWarning => {
326                if self.config.has_recovery_key {
327                    ConfirmationStep::RecoveryKeyBackup
328                } else {
329                    ConfirmationStep::FinalConfirmation
330                }
331            }
332            ConfirmationStep::RecoveryKeyBackup => ConfirmationStep::FinalConfirmation,
333            ConfirmationStep::FinalConfirmation => ConfirmationStep::FinalConfirmation,
334        };
335
336        self.current_step = next;
337
338        // Skip steps that don't apply
339        if self.should_skip_current() && self.current_step != ConfirmationStep::FinalConfirmation {
340            self.advance_to_next_step();
341        }
342    }
343
344    /// Check if all required steps are completed.
345    pub fn is_complete(&self) -> bool {
346        self.completed_steps
347            .contains(&ConfirmationStep::FinalConfirmation)
348    }
349
350    /// Set the password strength action.
351    pub fn set_password_action(&mut self, action: PasswordStrengthAction) {
352        self.password_action = Some(action);
353    }
354
355    /// Get the list of completed steps for display.
356    pub fn completed_steps_summary(&self) -> Vec<(ConfirmationStep, &'static str)> {
357        let mut steps = Vec::new();
358
359        if self.config.has_secrets
360            && self
361                .completed_steps
362                .contains(&ConfirmationStep::SecretScanAcknowledgment)
363        {
364            steps.push((
365                ConfirmationStep::SecretScanAcknowledgment,
366                "Secrets acknowledged",
367            ));
368        }
369
370        if self
371            .completed_steps
372            .contains(&ConfirmationStep::ContentReview)
373        {
374            steps.push((ConfirmationStep::ContentReview, "Content reviewed"));
375        }
376
377        if self.config.is_remote_publish
378            && self
379                .completed_steps
380                .contains(&ConfirmationStep::PublicPublishingWarning)
381        {
382            steps.push((
383                ConfirmationStep::PublicPublishingWarning,
384                "Public URL confirmed",
385            ));
386        }
387
388        if self.config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS
389            && self
390                .completed_steps
391                .contains(&ConfirmationStep::PasswordStrengthWarning)
392        {
393            let label = match self.password_action {
394                Some(PasswordStrengthAction::ProceedAnyway) => "Password warning acknowledged",
395                _ => "Password strength confirmed",
396            };
397            steps.push((ConfirmationStep::PasswordStrengthWarning, label));
398        }
399
400        if self.config.has_recovery_key
401            && self
402                .completed_steps
403                .contains(&ConfirmationStep::RecoveryKeyBackup)
404        {
405            steps.push((ConfirmationStep::RecoveryKeyBackup, "Recovery key saved"));
406        }
407
408        steps
409    }
410}
411
412/// Calculate password entropy using character class analysis.
413///
414/// This is a simple estimate based on character classes:
415/// - Lowercase letters: 26 characters (log2(26) ≈ 4.7 bits each)
416/// - Uppercase letters: 26 characters (log2(26) ≈ 4.7 bits each)
417/// - Digits: 10 characters (log2(10) ≈ 3.3 bits each)
418/// - Symbols: ~32 characters (log2(32) = 5 bits each)
419///
420/// Total entropy = length × log2(pool_size)
421pub fn estimate_password_entropy(password: &str) -> f64 {
422    if password.is_empty() {
423        return 0.0;
424    }
425
426    let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
427    let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
428    let has_digit = password.chars().any(|c| c.is_ascii_digit());
429    let has_special = password.chars().any(|c| !c.is_alphanumeric());
430
431    let mut pool_size = 0u32;
432    if has_lower {
433        pool_size += 26;
434    }
435    if has_upper {
436        pool_size += 26;
437    }
438    if has_digit {
439        pool_size += 10;
440    }
441    if has_special {
442        pool_size += 32;
443    }
444
445    if pool_size == 0 {
446        pool_size = 26; // Assume lowercase if nothing else
447    }
448
449    let bits_per_char = (pool_size as f64).log2();
450    let length = password.chars().count() as f64;
451
452    bits_per_char * length
453}
454
455/// Get a human-readable password strength label.
456pub fn password_strength_label(entropy_bits: f64) -> &'static str {
457    if entropy_bits >= 80.0 {
458        "Very Strong"
459    } else if entropy_bits >= 60.0 {
460        "Strong"
461    } else if entropy_bits >= 40.0 {
462        "Fair"
463    } else if entropy_bits >= 20.0 {
464        "Weak"
465    } else {
466        "Very Weak"
467    }
468}
469
470/// Get the number of required steps for the given configuration.
471pub fn count_required_steps(config: &ConfirmationConfig) -> usize {
472    let mut count = 2; // ContentReview and FinalConfirmation are always required
473
474    if config.has_secrets {
475        count += 1;
476    }
477    if config.is_remote_publish {
478        count += 1;
479    }
480    if config.password_entropy_bits < MIN_STRONG_PASSWORD_BITS {
481        count += 1;
482    }
483    if config.has_recovery_key {
484        count += 1;
485    }
486
487    count
488}
489
490/// Required phrase for unencrypted export acknowledgment.
491pub const UNENCRYPTED_ACK_PHRASE: &str = "I UNDERSTAND AND ACCEPT THE RISKS";
492
493/// Exit code for unconfirmed unencrypted export.
494pub const EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED: i32 = 3;
495
496const UNENCRYPTED_BLOCKED_ERROR_KIND: &str = "unencrypted_blocked";
497const UNENCRYPTED_BLOCKED_MESSAGE: &str = "Unencrypted exports are not allowed in robot mode";
498const UNENCRYPTED_BLOCKED_SUGGESTION: &str =
499    "Use --i-understand-unencrypted-risks flag if you really need this";
500
501/// Result of unencrypted export confirmation.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub enum UnencryptedConfirmResult {
504    /// User confirmed with correct phrase.
505    Confirmed,
506    /// User cancelled (wrong phrase or explicit cancel).
507    Cancelled,
508    /// Blocked in robot mode (no override flag).
509    RobotModeBlocked,
510}
511
512/// Validate unencrypted export acknowledgment phrase.
513///
514/// Requires exact match (case-insensitive) of "I UNDERSTAND AND ACCEPT THE RISKS".
515pub fn validate_unencrypted_ack(input: &str) -> StepValidation {
516    let normalized = input.trim().to_uppercase();
517    if normalized == UNENCRYPTED_ACK_PHRASE {
518        StepValidation::Passed
519    } else {
520        StepValidation::Failed(format!(
521            "Please type exactly: \"{}\"",
522            UNENCRYPTED_ACK_PHRASE
523        ))
524    }
525}
526
527/// Check if robot mode allows unencrypted export.
528///
529/// In robot/JSON mode, unencrypted exports are blocked unless
530/// `--i-understand-unencrypted-risks` flag is provided.
531pub fn check_robot_mode_unencrypted(
532    is_robot_mode: bool,
533    has_override_flag: bool,
534) -> UnencryptedConfirmResult {
535    if is_robot_mode && !has_override_flag {
536        UnencryptedConfirmResult::RobotModeBlocked
537    } else {
538        UnencryptedConfirmResult::Confirmed
539    }
540}
541
542/// Generate the error JSON for blocked robot mode unencrypted export.
543pub fn robot_mode_blocked_error() -> serde_json::Value {
544    serde_json::json!({
545        "error": UNENCRYPTED_BLOCKED_ERROR_KIND,
546        "message": UNENCRYPTED_BLOCKED_MESSAGE,
547        "suggestion": UNENCRYPTED_BLOCKED_SUGGESTION,
548        "exit_code": EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED
549    })
550}
551
552/// Format warning messages for unencrypted export.
553pub fn unencrypted_warning_lines() -> Vec<&'static str> {
554    vec![
555        "You are about to export WITHOUT ENCRYPTION.",
556        "",
557        "This means:",
558        "  • All conversation content will be publicly readable",
559        "  • Anyone with the URL can view your data",
560        "  • Search engines may index your content",
561        "  • There is NO way to restrict access later",
562        "",
563        "This is IRREVERSIBLE once deployed.",
564    ]
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn make_basic_config() -> ConfirmationConfig {
572        ConfirmationConfig {
573            has_secrets: false,
574            has_critical_secrets: false,
575            secret_count: 0,
576            target_domain: None,
577            is_remote_publish: false,
578            password_entropy_bits: 80.0,
579            has_recovery_key: false,
580            recovery_key_phrase: None,
581            ..Default::default()
582        }
583    }
584
585    fn basic_flow_with(configure: impl FnOnce(&mut ConfirmationConfig)) -> ConfirmationFlow {
586        let mut config = make_basic_config();
587        configure(&mut config);
588        ConfirmationFlow::new(config)
589    }
590
591    #[test]
592    fn test_basic_flow_no_secrets() {
593        let config = make_basic_config();
594        let flow = ConfirmationFlow::new(config);
595
596        // Should start at ContentReview (no secrets)
597        assert_eq!(flow.current_step(), ConfirmationStep::ContentReview);
598    }
599
600    #[test]
601    fn test_flow_with_secrets() {
602        let mut config = make_basic_config();
603        config.has_secrets = true;
604
605        let flow = ConfirmationFlow::new(config);
606
607        // Should start at SecretScanAcknowledgment
608        assert_eq!(
609            flow.current_step(),
610            ConfirmationStep::SecretScanAcknowledgment
611        );
612    }
613
614    #[test]
615    fn test_secret_ack_validation() {
616        let flow = basic_flow_with(|config| {
617            config.has_secrets = true;
618        });
619
620        // Wrong phrase
621        assert_eq!(
622            flow.validate_secret_ack("i understand"),
623            StepValidation::Failed("Please type exactly: \"I understand the risks\"".to_string())
624        );
625
626        // Correct phrase (case insensitive)
627        assert_eq!(
628            flow.validate_secret_ack("I UNDERSTAND THE RISKS"),
629            StepValidation::Passed
630        );
631        assert_eq!(
632            flow.validate_secret_ack("i understand the risks"),
633            StepValidation::Passed
634        );
635    }
636
637    #[test]
638    fn test_public_warning_validation() {
639        let flow = basic_flow_with(|config| {
640            config.is_remote_publish = true;
641            config.target_domain = Some("user.github.io".to_string());
642        });
643
644        // Wrong phrase
645        assert!(matches!(
646            flow.validate_public_warning("publish"),
647            StepValidation::Failed(_)
648        ));
649
650        // Correct phrase
651        assert_eq!(
652            flow.validate_public_warning("publish to user.github.io"),
653            StepValidation::Passed
654        );
655    }
656
657    #[test]
658    fn test_recovery_key_validation() {
659        let flow = basic_flow_with(|config| {
660            config.has_recovery_key = true;
661            config.recovery_key_phrase = Some("forge-table-river-cloud-dance".to_string());
662        });
663
664        // Wrong word
665        assert!(matches!(
666            flow.validate_recovery_key("river"),
667            StepValidation::Failed(_)
668        ));
669
670        // Correct last word
671        assert_eq!(flow.validate_recovery_key("dance"), StepValidation::Passed);
672    }
673
674    #[test]
675    fn test_final_confirmation_double_enter() {
676        let config = make_basic_config();
677        let mut flow = ConfirmationFlow::new(config);
678
679        // First Enter
680        assert!(!flow.process_final_enter());
681        assert_eq!(flow.final_enter_count(), 1);
682
683        // Second Enter
684        assert!(flow.process_final_enter());
685        assert_eq!(flow.final_enter_count(), 2);
686    }
687
688    #[test]
689    fn test_step_advancement() {
690        let mut config = make_basic_config();
691        config.has_secrets = true;
692        config.is_remote_publish = true;
693        config.target_domain = Some("test.github.io".to_string());
694        config.has_recovery_key = true;
695        config.recovery_key_phrase = Some("word1-word2-word3".to_string());
696
697        let mut flow = ConfirmationFlow::new(config);
698
699        // Start at SecretScanAcknowledgment
700        assert_eq!(
701            flow.current_step(),
702            ConfirmationStep::SecretScanAcknowledgment
703        );
704
705        flow.complete_current_step();
706        assert_eq!(flow.current_step(), ConfirmationStep::ContentReview);
707
708        flow.complete_current_step();
709        assert_eq!(
710            flow.current_step(),
711            ConfirmationStep::PublicPublishingWarning
712        );
713
714        flow.complete_current_step();
715        // Skips PasswordStrengthWarning (entropy >= 60)
716        assert_eq!(flow.current_step(), ConfirmationStep::RecoveryKeyBackup);
717
718        flow.complete_current_step();
719        assert_eq!(flow.current_step(), ConfirmationStep::FinalConfirmation);
720    }
721
722    #[test]
723    fn test_password_entropy_estimation() {
724        // Empty password
725        assert_eq!(estimate_password_entropy(""), 0.0);
726
727        // Simple lowercase
728        let entropy = estimate_password_entropy("password");
729        assert!(entropy > 30.0 && entropy < 40.0); // ~37.6 bits
730
731        // Mixed case + digits + symbols
732        let entropy = estimate_password_entropy("P@ssw0rd!");
733        assert!(entropy > 50.0); // Higher due to larger character pool
734    }
735
736    #[test]
737    fn test_password_strength_label() {
738        for (entropy_bits, expected_label) in [
739            (10.0, "Very Weak"),
740            (30.0, "Weak"),
741            (50.0, "Fair"),
742            (70.0, "Strong"),
743            (90.0, "Very Strong"),
744        ] {
745            assert_eq!(
746                password_strength_label(entropy_bits),
747                expected_label,
748                "entropy_bits={entropy_bits}"
749            );
750        }
751    }
752
753    #[test]
754    fn test_count_required_steps() {
755        let config = make_basic_config();
756        assert_eq!(count_required_steps(&config), 2); // ContentReview + FinalConfirmation
757
758        let mut config = make_basic_config();
759        config.has_secrets = true;
760        config.is_remote_publish = true;
761        config.password_entropy_bits = 30.0;
762        config.has_recovery_key = true;
763        assert_eq!(count_required_steps(&config), 6); // All steps
764    }
765
766    #[test]
767    fn test_content_review_validation() {
768        let config = make_basic_config();
769        let flow = ConfirmationFlow::new(config);
770
771        for input in ["y", "Y", "yes"] {
772            assert_eq!(
773                flow.validate_content_review(input),
774                StepValidation::Passed,
775                "input={input}"
776            );
777        }
778        assert!(matches!(
779            flow.validate_content_review("n"),
780            StepValidation::Failed(_)
781        ));
782    }
783
784    #[test]
785    fn test_password_action_parsing() {
786        let config = make_basic_config();
787        let flow = ConfirmationFlow::new(config);
788
789        for (input, expected) in [
790            ("s", Some(PasswordStrengthAction::SetStronger)),
791            ("P", Some(PasswordStrengthAction::ProceedAnyway)),
792            ("a", Some(PasswordStrengthAction::Abort)),
793        ] {
794            assert_eq!(flow.parse_password_action(input), expected, "input={input}");
795        }
796        assert_eq!(flow.parse_password_action("x"), None);
797    }
798
799    #[test]
800    fn test_completed_steps_summary() {
801        let mut config = make_basic_config();
802        config.has_secrets = true;
803        config.is_remote_publish = true;
804        config.target_domain = Some("test.github.io".to_string());
805
806        let mut flow = ConfirmationFlow::new(config);
807
808        // Complete secret ack
809        flow.complete_current_step();
810
811        // Complete content review
812        flow.complete_current_step();
813
814        let summary = flow.completed_steps_summary();
815        assert_eq!(summary.len(), 2);
816        assert_eq!(summary[0].1, "Secrets acknowledged");
817        assert_eq!(summary[1].1, "Content reviewed");
818    }
819
820    #[test]
821    fn test_unencrypted_ack_validation() {
822        // Correct phrase (exact match)
823        assert_eq!(
824            validate_unencrypted_ack("I UNDERSTAND AND ACCEPT THE RISKS"),
825            StepValidation::Passed
826        );
827
828        // Correct phrase (case insensitive)
829        assert_eq!(
830            validate_unencrypted_ack("i understand and accept the risks"),
831            StepValidation::Passed
832        );
833
834        // Correct phrase with whitespace
835        assert_eq!(
836            validate_unencrypted_ack("  I UNDERSTAND AND ACCEPT THE RISKS  "),
837            StepValidation::Passed
838        );
839
840        // Incorrect phrases
841        assert!(matches!(
842            validate_unencrypted_ack("I understand"),
843            StepValidation::Failed(_)
844        ));
845        assert!(matches!(
846            validate_unencrypted_ack("yes"),
847            StepValidation::Failed(_)
848        ));
849        assert!(matches!(
850            validate_unencrypted_ack("I ACCEPT THE RISKS"),
851            StepValidation::Failed(_)
852        ));
853    }
854
855    #[test]
856    fn test_robot_mode_unencrypted_check() {
857        // Not robot mode - always allowed
858        assert_eq!(
859            check_robot_mode_unencrypted(false, false),
860            UnencryptedConfirmResult::Confirmed
861        );
862
863        // Robot mode with override flag - allowed
864        assert_eq!(
865            check_robot_mode_unencrypted(true, true),
866            UnencryptedConfirmResult::Confirmed
867        );
868
869        // Robot mode without override flag - blocked
870        assert_eq!(
871            check_robot_mode_unencrypted(true, false),
872            UnencryptedConfirmResult::RobotModeBlocked
873        );
874    }
875
876    #[test]
877    fn test_robot_mode_blocked_error() {
878        let error = robot_mode_blocked_error();
879        assert_eq!(
880            error,
881            serde_json::json!({
882                "error": "unencrypted_blocked",
883                "message": "Unencrypted exports are not allowed in robot mode",
884                "suggestion": "Use --i-understand-unencrypted-risks flag if you really need this",
885                "exit_code": EXIT_CODE_UNENCRYPTED_NOT_CONFIRMED
886            })
887        );
888    }
889
890    #[test]
891    fn test_unencrypted_warning_lines() {
892        let lines = unencrypted_warning_lines();
893        assert!(!lines.is_empty());
894        assert!(lines[0].contains("WITHOUT ENCRYPTION"));
895    }
896}