diff --git a/crates/idfoto-core/tests/generators.rs b/crates/idfoto-core/tests/generators.rs new file mode 100644 index 0000000..8b2b6a7 --- /dev/null +++ b/crates/idfoto-core/tests/generators.rs @@ -0,0 +1,89 @@ +//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity, +//! zxcvbn strength gate. +//! +//! # Note on length cap +//! +//! `generate_password` enforces `length <= 128`. The task originally specified +//! `length: 10_000` in a single call, but that would error at runtime. +//! +//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with +//! `length: 128` to gather 10,240 characters total, then aggregate per-class +//! counts before asserting proportions. The ±5pp tolerance is unchanged because +//! sample size is the same (~10k chars). + +use idfoto_core::{ + Capitalization, CharClasses, GeneratorRequest, SymbolCharset, + generate_passphrase, generate_password, validate_passphrase_strength, +}; + +#[test] +fn random_password_class_balance_is_reasonable() { + // Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics. + // (generate_password caps at length 128, so we cannot do a single 10,000-char call.) + let req = GeneratorRequest::Random { + length: 128, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + }; + + let mut lower = 0usize; + let mut upper = 0usize; + let mut digits = 0usize; + let mut total = 0usize; + + for _ in 0..80 { + let pw = generate_password(&req).unwrap(); + lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count(); + upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count(); + digits += pw.chars().filter(|c| c.is_ascii_digit()).count(); + total += pw.len(); + } + let symbols = total - lower - upper - digits; + + // Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74 + // Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2% + // Allow ±5pp slop. + let t = total as f64; + let assert_pct = |label: &str, actual: usize, expected_pct: f64| { + let pct = (actual as f64) / t * 100.0; + assert!( + (pct - expected_pct).abs() < 5.0, + "{label}: actual {pct:.1}% vs expected {expected_pct:.1}%" + ); + }; + assert_pct("lower", lower, 26.0 / 74.0 * 100.0); + assert_pct("upper", upper, 26.0 / 74.0 * 100.0); + assert_pct("digits", digits, 10.0 / 74.0 * 100.0); + assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0); +} + +#[test] +fn bip39_5_word_passphrase_passes_zxcvbn_gate() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3"); +} + +#[test] +fn common_weak_passphrases_fail_gate() { + for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] { + assert!( + validate_passphrase_strength(weak).is_err(), + "expected '{weak}' to fail gate" + ); + } +} + +#[test] +fn random_passwords_are_unique_across_calls() { + let req = GeneratorRequest::default(); + let mut seen = std::collections::HashSet::new(); + for _ in 0..1000 { + let pw = generate_password(&req).unwrap(); + assert!(seen.insert(pw.as_str().to_owned())); + } +}