diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c26966d..70d40fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable, beta] steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ matrix.rust }} components: clippy, rustfmt - name: Run tests run: cargo test --verbose @@ -28,12 +26,12 @@ jobs: - name: Check formatting run: cargo fmt -- --check - name: Code coverage - if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + if: matrix.os == 'ubuntu-latest' run: | cargo install cargo-tarpaulin - cargo tarpaulin --out Xml --output-dir coverage + cargo tarpaulin --tests --out Xml --output-dir coverage - name: Upload to codecov - if: matrix.os == 'ubuntu-latest' && matrix.rust == 'stable' + if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: files: ./coverage/cobertura.xml diff --git a/coverage-test/cobertura.xml b/coverage-test/cobertura.xml new file mode 100644 index 0000000..3ab9380 --- /dev/null +++ b/coverage-test/cobertura.xml @@ -0,0 +1 @@ +/Users/jmachen/code/rpg \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 15bf6f4..a738655 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1218,4 +1218,265 @@ mod tests { print_columns(passwords.clone(), 1, false); print_columns(passwords, 3, false); } + + #[test] + fn test_parse_exclude_chars_non_printable_start() { + // Test range with start character < 32 (non-printable) + // This should skip the range logic and treat as individual chars + let result = parse_exclude_chars(vec!["\x1f-9".to_string()]); + // Should succeed but treat as individual characters, not a range + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should contain the characters from the string, not a range expansion + assert!(chars.len() >= 2); // At least \x1f, -, and 9 + } + + #[test] + fn test_parse_exclude_chars_non_printable_end() { + // Test range with end character >= 127 (non-printable) + // This should skip the range logic + let result = parse_exclude_chars(vec!["a-\x7f".to_string()]); + // Should succeed but treat as individual characters + assert!(result.is_ok()); + // Should not expand as a range + let chars = result.unwrap(); + // The range logic should be skipped due to end >= 127 + // So it should treat as individual characters + assert!(chars.contains(&'a')); + } + + #[test] + fn test_parse_exclude_chars_duplicate_handling() { + // Test that duplicates are properly handled + let result = parse_exclude_chars(vec!["a".to_string(), "a".to_string(), "b".to_string()]); + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should only contain one 'a' and one 'b' + assert_eq!(chars.iter().filter(|&&c| c == 'a').count(), 1); + assert_eq!(chars.iter().filter(|&&c| c == 'b').count(), 1); + } + + #[test] + fn test_parse_exclude_chars_duplicate_in_range_and_individual() { + // Test that characters in ranges are not duplicated when also specified individually + let result = parse_exclude_chars(vec!["a-c".to_string(), "b".to_string()]); + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should contain a, b, c each once + assert_eq!(chars.iter().filter(|&&c| c == 'a').count(), 1); + assert_eq!(chars.iter().filter(|&&c| c == 'b').count(), 1); + assert_eq!(chars.iter().filter(|&&c| c == 'c').count(), 1); + } + + #[test] + fn test_parse_exclude_chars_boundary_conditions() { + // Test range exactly at printable ASCII boundaries + // Space (32) to ~ (126) should work + let result = parse_exclude_chars(vec![" -~".to_string()]); + assert!(result.is_ok()); + let chars = result.unwrap(); + // Should expand to all printable ASCII + assert!(chars.contains(&' ')); + assert!(chars.contains(&'~')); + } + + #[test] + fn test_build_char_set_all_types_disabled_lowercase_available() { + // Test with all character types disabled, but lowercase still available + let args = create_test_args(true, true, true, vec![]); + let char_set = build_char_set(&args).unwrap(); + // Should only contain lowercase letters + assert!(!char_set.is_empty()); + assert!(char_set.contains(&b'a')); + assert!(char_set.contains(&b'z')); + assert!(!char_set.contains(&b'A')); + assert!(!char_set.contains(&b'0')); + assert!(!char_set.contains(&b'!')); + } + + #[test] + fn test_build_char_set_include_chars_empty_after_exclude() { + // Test include_chars where exclude_chars removes all characters + let mut args = create_test_args(false, false, false, vec!['a', 'b', 'c']); + args.include_chars = Some(vec!['a', 'b', 'c']); + let result = build_char_set(&args); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + PasswordError::EmptyCharacterSet + )); + } + + #[test] + fn test_build_char_set_include_chars_with_exclusions_partial() { + // Test include_chars with exclude_chars that removes some but not all + let mut args = create_test_args(false, false, false, vec!['a']); + args.include_chars = Some(vec!['a', 'b', 'c', 'd', 'e']); + let char_set = build_char_set(&args).unwrap(); + assert_eq!(char_set.len(), 4); // b, c, d, e + assert!(!char_set.contains(&b'a')); + assert!(char_set.contains(&b'b')); + assert!(char_set.contains(&b'c')); + assert!(char_set.contains(&b'd')); + assert!(char_set.contains(&b'e')); + } + + #[test] + fn test_password_error_display_all_variants() { + // Ensure all error variants are tested for Display implementation + let err = PasswordError::InvalidLength; + let msg = err.to_string(); + assert!(msg.contains("Password length must be greater than 0")); + + let err = PasswordError::InvalidLengthTooLong; + let msg = err.to_string(); + assert!(msg.contains("exceeds maximum of 10,000")); + + let err = PasswordError::InvalidCount; + let msg = err.to_string(); + assert!(msg.contains("Password count must be greater than 0")); + + let err = PasswordError::EmptyCharacterSet; + let msg = err.to_string(); + assert!(msg.contains("All characters have been excluded")); + assert!(msg.contains("Hint")); + + let err = PasswordError::AllTypesDisabled; + let msg = err.to_string(); + assert!(msg.contains("All character types are disabled")); + assert!(msg.contains("Hint")); + } + + #[test] + fn test_password_error_source() { + // Test that PasswordError implements std::error::Error + let err = PasswordError::InvalidLength; + // Should be able to use as Error trait object + let _err_ref: &dyn std::error::Error = &err; + } + + #[test] + fn test_generate_password_with_minimums_exceeding_length() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@']; + // Request 5 minimums but length is only 4 + // Minimums take precedence, so password will be length 5 + let mut rng = StdRng::seed_from_u64(1001); + let password = generate_password_with_minimums(&char_set, 4, Some(5), None, None, &mut rng); + + // Should generate a password with at least 5 capitals (minimum takes precedence) + assert!(password.len() >= 5); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + assert!(capitals >= 5); // Minimum requirement is met + } + + #[test] + fn test_generate_password_with_minimums_sum_exceeds_length() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![ + b'a', b'b', b'c', b'A', b'B', b'C', b'0', b'1', b'2', b'!', b'@', b'#', + ]; + // Request min_capitals=3, min_numerals=3, min_symbols=3, but length=6 + // Minimums take precedence, so password will be at least length 9 + let mut rng = StdRng::seed_from_u64(1002); + let password = + generate_password_with_minimums(&char_set, 6, Some(3), Some(3), Some(3), &mut rng); + + // Password length should be at least 9 (sum of minimums) + // May be more if minimums are applied then filled up to length + assert!(password.len() >= 9); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); + let symbols = password.chars().filter(|c| !c.is_alphanumeric()).count(); + + // Should meet all minimum requirements + assert!(capitals >= 3); + assert!(numerals >= 3); + assert!(symbols >= 3); + } + + #[test] + fn test_generate_password_with_minimums_exact_length() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@']; + // Request min_capitals=2, min_numerals=2, length=4 + let mut rng = StdRng::seed_from_u64(1003); + let password = + generate_password_with_minimums(&char_set, 4, Some(2), Some(2), None, &mut rng); + + assert_eq!(password.len(), 4); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); + + assert!(capitals >= 2); + assert!(numerals >= 2); + } + + #[test] + fn test_generate_password_from_pattern_all_same_type() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'c', b'A', b'B', b'C', b'0', b'1', b'2']; + let pattern = vec![ + PatternChar::Lowercase, + PatternChar::Lowercase, + PatternChar::Lowercase, + ]; + + let mut rng = StdRng::seed_from_u64(2001); + let password = generate_password_from_pattern(&char_set, &pattern, &mut rng); + + assert_eq!(password.len(), 3); + for c in password.chars() { + assert!(c.is_ascii_lowercase()); + } + } + + #[test] + fn test_generate_password_from_pattern_very_long() { + use rand::{SeedableRng, rngs::StdRng}; + + let char_set = vec![b'a', b'b', b'A', b'B', b'0', b'1', b'!', b'@']; + // Create a pattern of length 100 + let pattern: Vec = (0..100) + .map(|i| match i % 4 { + 0 => PatternChar::Lowercase, + 1 => PatternChar::Uppercase, + 2 => PatternChar::Numeric, + _ => PatternChar::Symbol, + }) + .collect(); + + let mut rng = StdRng::seed_from_u64(2002); + let password = generate_password_from_pattern(&char_set, &pattern, &mut rng); + + assert_eq!(password.len(), 100); + } + + #[test] + fn test_print_columns_very_long_passwords() { + let passwords = vec!["a".repeat(100), "b".repeat(50), "c".repeat(150)]; + // Test width calculation with very long passwords + print_columns(passwords.clone(), 1, false); + print_columns(passwords.clone(), 2, false); + print_columns(passwords, 3, true); + } + + #[test] + fn test_print_columns_single_password_multi_column() { + // Test single password with multiple columns (should still print it) + let passwords = vec!["single".to_string()]; + print_columns(passwords, 5, false); + } + + #[test] + fn test_validate_args_all_types_disabled_lowercase_available() { + // Test validate_args when all types are disabled but lowercase available + let args = create_test_args(true, true, true, vec![]); + let result = validate_args(&args); + assert!(result.is_ok()); // Should be valid since lowercase is still available + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 08f2c0e..511ab8c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -92,3 +92,427 @@ fn test_seed_reproducibility() { assert_eq!(pass1, pass2); } + +#[test] +fn test_cli_invalid_exclude_chars() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--exclude-chars", "z-a", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with invalid range"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Invalid range") || stderr.contains("Error parsing exclude characters") + ); +} + +#[test] +fn test_cli_invalid_include_chars() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--include-chars", "z-a", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with invalid range"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Invalid range") || stderr.contains("Error parsing include characters") + ); +} + +#[test] +fn test_cli_invalid_pattern() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "LLX", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with invalid pattern"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Invalid pattern character") || stderr.contains("Error parsing pattern") + ); +} + +#[test] +fn test_cli_invalid_length_zero() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--length", "0", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with length 0"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Password length must be greater than 0")); +} + +#[test] +fn test_cli_invalid_length_too_long() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--length", "10001", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(!output.status.success(), "Should fail with length > 10000"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("exceeds maximum of 10,000")); +} + +#[test] +fn test_cli_json_output() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["2", "--length", "10", "--format", "json", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Should be valid JSON + let json: serde_json::Value = serde_json::from_str(&stdout).expect("Should be valid JSON"); + assert!(json.get("passwords").is_some()); + assert!(json.get("count").is_some()); + assert!(json.get("length").is_some()); + assert!(json.get("entropy_bits").is_some()); + + let passwords = json.get("passwords").unwrap().as_array().unwrap(); + assert_eq!(passwords.len(), 2); + assert_eq!(passwords[0].as_str().unwrap().len(), 10); +} + +#[test] +fn test_cli_table_output() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["6", "--table", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + + // Should have passwords in table format (may be on multiple lines in columns) + // With 6 passwords and 2 columns, we'd expect at least 3 lines + assert!( + lines.len() >= 3, + "Expected at least 3 lines in table format, got {}", + lines.len() + ); + // Verify passwords are present by checking non-empty content + assert!(!stdout.trim().is_empty(), "Output should contain passwords"); +} + +#[test] +fn test_cli_table_with_header() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["6", "--table"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Should contain header when not in quiet mode + assert!(stdout.contains("Printing")); +} + +#[test] +fn test_cli_quiet_mode() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["3", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // Should not contain banner or header in quiet mode + assert!(!stdout.contains("RPG v")); + assert!(!stdout.contains("Printing")); + + // Should only have passwords + let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 3); +} + +#[test] +fn test_cli_pattern_generation() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "LLLNNNSSS", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + assert_eq!(password.len(), 9); + // Verify pattern was followed (can't predict exact chars but can verify types) + let chars: Vec = password.chars().collect(); + assert!(chars[0].is_ascii_lowercase()); + assert!(chars[1].is_ascii_lowercase()); + assert!(chars[2].is_ascii_lowercase()); + assert!(chars[3].is_ascii_digit()); + assert!(chars[4].is_ascii_digit()); + assert!(chars[5].is_ascii_digit()); + assert!(!chars[6].is_alphanumeric()); + assert!(!chars[7].is_alphanumeric()); + assert!(!chars[8].is_alphanumeric()); +} + +#[test] +fn test_cli_pattern_case_insensitive() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "lllununss", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + assert_eq!(password.len(), 9); +} + +#[test] +fn test_cli_minimum_requirements() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&[ + "1", + "--length", + "10", + "--min-capitals", + "2", + "--min-numerals", + "2", + "--min-symbols", + "2", + "--quiet", + ]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + assert_eq!(password.len(), 10); + let capitals = password.chars().filter(|c| c.is_ascii_uppercase()).count(); + let numerals = password.chars().filter(|c| c.is_ascii_digit()).count(); + let symbols = password.chars().filter(|c| !c.is_alphanumeric()).count(); + + assert!(capitals >= 2); + assert!(numerals >= 2); + assert!(symbols >= 2); +} + +#[test] +fn test_cli_seed_reproducibility_with_options() { + let output1 = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&[ + "1", + "--seed", + "999", + "--length", + "20", + "--min-capitals", + "3", + "--quiet", + ]) + .output() + .expect("Failed to execute command"); + + let output2 = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&[ + "1", + "--seed", + "999", + "--length", + "20", + "--min-capitals", + "3", + "--quiet", + ]) + .output() + .expect("Failed to execute command"); + + assert!(output1.status.success()); + assert!(output2.status.success()); + + let stdout1 = String::from_utf8(output1.stdout).unwrap(); + let stdout2 = String::from_utf8(output2.stdout).unwrap(); + let pass1: Vec<&str> = stdout1.lines().filter(|l| !l.is_empty()).collect(); + let pass2: Vec<&str> = stdout2.lines().filter(|l| !l.is_empty()).collect(); + + assert_eq!(pass1, pass2); +} + +#[test] +fn test_cli_include_chars() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&[ + "1", + "--include-chars", + "a,b,c,1,2,3", + "--length", + "10", + "--quiet", + ]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + assert_eq!(password.len(), 10); + // All characters should be from the include set + for c in password.chars() { + assert!(matches!(c, 'a' | 'b' | 'c' | '1' | '2' | '3')); + } +} + +#[test] +fn test_cli_include_chars_with_exclude() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&[ + "1", + "--include-chars", + "a,b,c", + "--exclude-chars", + "a", + "--length", + "10", + "--quiet", + ]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + assert_eq!(password.len(), 10); + // Should only contain 'b' or 'c' + for c in password.chars() { + assert!(matches!(c, 'b' | 'c')); + } +} + +#[test] +fn test_cli_include_chars_with_range() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&[ + "1", + "--include-chars", + "a-z,0-9", + "--length", + "10", + "--quiet", + ]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + assert_eq!(password.len(), 10); + // All characters should be lowercase or digit + for c in password.chars() { + assert!(c.is_ascii_lowercase() || c.is_ascii_digit()); + } +} + +#[test] +fn test_cli_character_type_combinations() { + // Test with capitals off + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--capitals-off", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + // Should not contain uppercase letters + assert!(!password.chars().any(|c| c.is_ascii_uppercase())); +} + +#[test] +fn test_cli_pattern_overrides_length() { + // Pattern length should override --length option + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--pattern", "LLL", "--length", "20", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + // Should be length 3 (pattern length), not 20 + assert_eq!(password.len(), 3); +} + +#[test] +fn test_cli_exclude_chars_with_range() { + let output = Command::new(env!("CARGO_BIN_EXE_rpg")) + .args(&["1", "--exclude-chars", "a-z", "--quiet"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success(), "Command failed: {:?}", output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let password = stdout + .lines() + .filter(|l| !l.is_empty()) + .next() + .unwrap() + .trim(); + + // Should not contain lowercase letters + assert!(!password.chars().any(|c| c.is_ascii_lowercase())); +}