90 lines
3.2 KiB
Rust
90 lines
3.2 KiB
Rust
//! 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()));
|
||
}
|
||
}
|