#![cfg(feature = "password")]
use secure_data::password::{
hash_password, verify_password, Argon2Hasher, PasswordError, PasswordHasher,
};
use secure_data::secret::SecretString;
#[test]
fn hash_a_password_returns_phc_format() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash = hash_password(&password).expect("hashing should succeed");
assert!(
hash.expose_hash().starts_with("$argon2id$"),
"hash should start with $argon2id$, got: {}",
hash.expose_hash()
);
}
#[test]
fn verify_correct_password_returns_true() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash = hash_password(&password).expect("hashing should succeed");
let result = verify_password(&password, &hash).expect("verify should succeed");
assert!(result, "correct password should verify as true");
}
#[test]
fn verify_wrong_password_returns_false() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash = hash_password(&password).expect("hashing should succeed");
let wrong = SecretString::new("wrong-password".to_string());
let result = verify_password(&wrong, &hash).expect("verify should not error");
assert!(!result, "wrong password should verify as false");
}
#[test]
fn hash_is_unique_per_call() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash1 = hash_password(&password).expect("first hash");
let hash2 = hash_password(&password).expect("second hash");
assert_ne!(
hash1.expose_hash(),
hash2.expose_hash(),
"two hashes of the same password should differ due to random salt"
);
}
#[test]
fn empty_password_rejected() {
let password = SecretString::new(String::new());
let result = hash_password(&password);
assert!(result.is_err(), "empty password should be rejected");
let err = result.unwrap_err();
assert!(
matches!(err, PasswordError::EmptyPassword),
"error should be EmptyPassword, got: {err:?}"
);
}
#[test]
fn password_hash_redacted_in_debug() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash = hash_password(&password).expect("hashing should succeed");
let debug_output = format!("{:?}", hash);
assert!(
debug_output.contains("[REDACTED]"),
"Debug should contain [REDACTED], got: {debug_output}"
);
assert!(
!debug_output.contains("$argon2id$"),
"Debug should not contain the raw hash"
);
}
#[test]
fn password_hash_redacted_in_json() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash = hash_password(&password).expect("hashing should succeed");
let json = serde_json::to_string(&hash).expect("serialization should succeed");
assert_eq!(
json, "\"[REDACTED]\"",
"JSON should be [REDACTED], got: {json}"
);
}
#[test]
fn timing_consistency_verify_password() {
let password = SecretString::new("correct-horse-battery".to_string());
let hash = hash_password(&password).expect("hashing should succeed");
let wrong = SecretString::new("wrong-password-guess".to_string());
let _ = verify_password(&password, &hash);
let _ = verify_password(&wrong, &hash);
let iterations = 5;
let mut correct_total = std::time::Duration::ZERO;
let mut wrong_total = std::time::Duration::ZERO;
for _ in 0..iterations {
let start = std::time::Instant::now();
let _ = verify_password(&password, &hash);
correct_total += start.elapsed();
let start = std::time::Instant::now();
let _ = verify_password(&wrong, &hash);
wrong_total += start.elapsed();
}
let correct_avg_ms = correct_total.as_millis() as f64 / iterations as f64;
let wrong_avg_ms = wrong_total.as_millis() as f64 / iterations as f64;
let diff_ms = (correct_avg_ms - wrong_avg_ms).abs();
assert!(
diff_ms < 20.0,
"timing difference should be < 20ms, got {diff_ms:.2}ms \
(correct avg: {correct_avg_ms:.2}ms, wrong avg: {wrong_avg_ms:.2}ms)"
);
}
#[test]
fn trait_hash_verify_matches_free_functions() {
let hasher = Argon2Hasher::default();
let password = SecretString::new("trait-test-password".to_string());
let hash = hasher
.hash_password(&password)
.expect("trait hash should succeed");
assert!(
verify_password(&password, &hash).expect("free fn verify should succeed"),
"free function should verify trait-produced hash"
);
}
#[test]
fn free_function_hash_verified_by_trait() {
let password = SecretString::new("cross-verify-test".to_string());
let hash = hash_password(&password).expect("free fn hash should succeed");
let hasher = Argon2Hasher::default();
assert!(
hasher
.verify_password(&password, &hash)
.expect("trait verify should succeed"),
"trait should verify free-function-produced hash"
);
}
#[test]
fn argon2_hasher_rejects_empty_password() {
let hasher = Argon2Hasher::default();
let password = SecretString::new(String::new());
let result = hasher.hash_password(&password);
assert!(matches!(result.unwrap_err(), PasswordError::EmptyPassword));
}
#[test]
fn password_hash_clone_works() {
let password = SecretString::new("clone-test".to_string());
let hash = hash_password(&password).expect("should hash");
let cloned = hash.clone();
assert_eq!(hash.expose_hash(), cloned.expose_hash());
}