use super::*;
use crate::authn::factor::{
EmailOtpConfig, HotpConfig, OtpAlgorithm, PasswordConfig, ZeroizedString,
};
use crate::validation::MAX_PASSWORD_BYTES;
use chrono::Utc;
use std::sync::Arc;
const RFC_4226_SECRET_B32: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
#[test]
fn apply_hotp_failure_zero_max_attempts_increments_without_burn() {
let prior = HotpConfig {
counter: 7,
attempt_count: 100,
max_attempts: 0,
..Default::default()
};
let next = apply_hotp_failure(&prior);
assert_eq!(next.counter, 7);
assert_eq!(next.attempt_count, 101);
}
#[test]
fn apply_hotp_failure_below_max_increments_without_burn() {
let prior = HotpConfig {
counter: 7,
attempt_count: 2,
max_attempts: 5,
..Default::default()
};
let next = apply_hotp_failure(&prior);
assert_eq!(next.counter, 7);
assert_eq!(next.attempt_count, 3);
}
#[test]
fn apply_hotp_failure_burn_advances_counter_by_lookahead_plus_one() {
let prior = HotpConfig {
counter: 100,
attempt_count: 5,
max_attempts: 5,
lookahead_window: 4,
..Default::default()
};
let next = apply_hotp_failure(&prior);
assert_eq!(next.counter, 105);
assert_eq!(next.attempt_count, 0);
}
#[test]
fn apply_email_otp_failure_zero_max_attempts_increments_without_burn() {
let prior = EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new("hash")),
pending_until: Some(Utc::now()),
attempt_count: 100,
max_attempts: 0,
..Default::default()
};
let next = apply_email_otp_failure(&prior);
assert!(next.pending_hash.is_some());
assert_eq!(next.attempt_count, 101);
}
#[test]
fn hotp_attempt_limit_burns_lookahead_window() {
let cfg = FactorConfig::Hotp(HotpConfig {
secret: ZeroizedString::new(RFC_4226_SECRET_B32),
counter: 100,
attempt_count: 5,
max_attempts: 5,
lookahead_window: 4,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from("123456"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Hotp, Utc::now());
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::Hotp(updated)) => {
assert_eq!(updated.counter, 105);
assert_eq!(updated.attempt_count, 0);
}
_ => panic!("expected FailWithUpdate burn"),
}
}
#[test]
fn hotp_zero_max_attempts_does_not_burn() {
let cfg = FactorConfig::Hotp(HotpConfig {
secret: ZeroizedString::new(RFC_4226_SECRET_B32),
counter: 100,
attempt_count: 0,
max_attempts: 0,
lookahead_window: 4,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from("999999"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Hotp, Utc::now());
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::Hotp(updated)) => {
assert_eq!(updated.counter, 100);
assert_eq!(updated.attempt_count, 1);
}
other => panic!(
"expected FailWithUpdate without burn, got {:?}",
match other {
VerifyOutcome::Fail => "Fail",
VerifyOutcome::Pass => "Pass",
VerifyOutcome::PassWithUpdate(_) => "PassWithUpdate",
_ => "other",
}
),
}
}
#[test]
fn hotp_success_advances_counter_by_one() {
let cfg = FactorConfig::Hotp(HotpConfig {
secret: ZeroizedString::new(RFC_4226_SECRET_B32),
digits: 6,
algorithm: OtpAlgorithm::Sha1,
counter: 0,
lookahead_window: 0,
attempt_count: 0,
max_attempts: 5,
});
let cred = FactorCredential::OtpCode(Arc::from("755224"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Hotp, Utc::now());
match outcome {
VerifyOutcome::PassWithUpdate(FactorConfig::Hotp(updated)) => {
assert_eq!(updated.counter, 1);
assert_eq!(updated.attempt_count, 0);
}
_ => panic!("expected PassWithUpdate"),
}
}
#[test]
fn email_otp_zero_max_attempts_does_not_burn() {
let hash = axess_factors::generate_password_hash("12345678");
let future = Utc::now() + chrono::Duration::seconds(300);
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new(&hash)),
pending_until: Some(future),
attempt_count: 0,
max_attempts: 0,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from("00000000"));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, Utc::now());
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::EmailOtp(updated)) => {
assert!(updated.pending_hash.is_some());
assert_eq!(updated.attempt_count, 1);
}
_ => panic!("expected FailWithUpdate without burn"),
}
}
#[test]
fn email_otp_at_expiry_boundary_still_valid() {
let code = "12345678";
let hash = axess_factors::generate_password_hash(code);
let now = Utc::now();
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new(&hash)),
pending_until: Some(now),
attempt_count: 0,
max_attempts: 5,
..Default::default()
});
let cred = FactorCredential::OtpCode(Arc::from(code));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, now);
assert!(matches!(outcome, VerifyOutcome::PassWithUpdate(_)));
}
#[test]
fn password_at_max_length_succeeds() {
let max_password = "a".repeat(MAX_PASSWORD_BYTES);
let hash = axess_factors::generate_password_hash(&max_password);
let cfg = FactorConfig::Password(PasswordConfig {
hash: ZeroizedString::new(&hash),
rules: Default::default(),
});
let cred = FactorCredential::Password(ZeroizedString::new(&max_password));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Password, Utc::now());
assert!(matches!(outcome, VerifyOutcome::Pass));
}
#[test]
fn totp_success_passes_replay_guard_and_returns_update() {
use crate::authn::factor::TotpConfig;
use chrono::TimeZone;
let cfg = FactorConfig::Totp(TotpConfig {
secret: ZeroizedString::new(RFC_4226_SECRET_B32),
digits: 8,
period_secs: 30,
algorithm: OtpAlgorithm::Sha1,
past_window: 0,
future_window: 0,
last_step: Some(0),
});
let cred = FactorCredential::OtpCode(Arc::from("94287082"));
let now = Utc.timestamp_opt(59, 0).unwrap();
let outcome = verify_credential(&cred, &cfg, &FactorKind::Totp, now);
match outcome {
VerifyOutcome::PassWithUpdate(FactorConfig::Totp(updated)) => {
assert_eq!(updated.last_step, Some(1));
}
_ => panic!("expected TOTP PassWithUpdate at RFC 6238 vector"),
}
}
#[test]
fn hotp_code_at_max_length_continues_to_verify_with_update() {
use crate::validation::MAX_OTP_CODE_BYTES;
let cfg = FactorConfig::Hotp(HotpConfig {
secret: ZeroizedString::new(RFC_4226_SECRET_B32),
counter: 100,
attempt_count: 0,
max_attempts: 5,
..Default::default()
});
let at_max = "1".repeat(MAX_OTP_CODE_BYTES);
let cred = FactorCredential::OtpCode(Arc::from(at_max.as_str()));
let outcome = verify_credential(&cred, &cfg, &FactorKind::Hotp, Utc::now());
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::Hotp(updated)) => {
assert_eq!(updated.attempt_count, 1);
}
_ => panic!("expected FailWithUpdate when continuing through length guard"),
}
}
struct FixedBytesRng {
sequence: Vec<u64>,
idx: std::sync::Mutex<usize>,
}
impl axess_rng::SecureRng for FixedBytesRng {
fn fill_bytes(&self, dest: &mut [u8]) {
let mut idx = self.idx.lock().expect("FixedBytesRng mutex poisoned");
let value = self.sequence[*idx];
*idx += 1;
let bytes = value.to_le_bytes();
let len = dest.len().min(8);
dest[..len].copy_from_slice(&bytes[..len]);
}
}
#[test]
fn generate_otp_code_accepts_value_just_under_original_max_fair() {
let v1: u64 = 18_446_730_000_000_000_000;
let rng = FixedBytesRng {
sequence: vec![v1, 1],
idx: std::sync::Mutex::new(0),
};
let code = generate_otp_code(&rng, 6);
assert_eq!(code, "000000");
}
#[test]
fn generate_otp_code_rejects_value_equal_to_max_fair() {
let modulus: u64 = 1_000_000;
let max_fair: u64 = u64::MAX - (u64::MAX % modulus);
let rng = FixedBytesRng {
sequence: vec![max_fair, 0],
idx: std::sync::Mutex::new(0),
};
let code = generate_otp_code(&rng, 6);
assert_eq!(code, "000000");
}
#[test]
fn email_otp_code_at_max_length_continues_to_verify_with_update() {
use crate::validation::MAX_OTP_CODE_BYTES;
let hash = axess_factors::generate_password_hash("12345678");
let future = Utc::now() + chrono::Duration::seconds(300);
let cfg = FactorConfig::EmailOtp(EmailOtpConfig {
email: "test@example.com".into(),
pending_hash: Some(ZeroizedString::new(&hash)),
pending_until: Some(future),
attempt_count: 0,
max_attempts: 5,
..Default::default()
});
let at_max = "1".repeat(MAX_OTP_CODE_BYTES);
let cred = FactorCredential::OtpCode(Arc::from(at_max.as_str()));
let outcome = verify_credential(&cred, &cfg, &FactorKind::EmailOtp, Utc::now());
match outcome {
VerifyOutcome::FailWithUpdate(FactorConfig::EmailOtp(updated)) => {
assert_eq!(updated.attempt_count, 1);
assert!(updated.pending_hash.is_some());
}
_ => panic!("expected FailWithUpdate when continuing through length guard"),
}
}