test(core): integration tests for generators (balance, BIP39, gate)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 15:49:08 -04:00
parent 08b1735b0e
commit 9cd5924109

View File

@@ -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()));
}
}