use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use rustauth_core::db::MemoryAdapter;
use rustauth_core::error::RustAuthError;
use rustauth_plugins::email_otp::{
EmailOtpEncryptor, EmailOtpGenerator, EmailOtpHasher, EmailOtpOptions, EmailOtpType,
OtpStorage, ResendStrategy,
};
use super::common::*;
#[tokio::test]
async fn encrypted_storage_is_not_plain_and_can_be_reused() {
let adapter = Arc::new(MemoryAdapter::new());
create_user(&adapter, "ada@example.com", false).await;
let sender = CaptureSender::default();
let router = router(
adapter.clone(),
sender.clone(),
EmailOtpOptions {
store_otp: OtpStorage::Encrypted,
resend_strategy: ResendStrategy::Reuse,
..EmailOtpOptions::default()
},
)
.unwrap();
for _ in 0..2 {
router
.handle_async(
json_request(
"/email-otp/send-verification-otp",
r#"{"email":"ada@example.com","type":"email-verification"}"#,
None,
)
.unwrap(),
)
.await
.unwrap();
}
let otp = sender.last_otp().await;
let stored = verification_value(&adapter, "email-verification-otp-ada@example.com")
.await
.unwrap();
let response = router
.handle_async(
json_request(
"/email-otp/check-verification-otp",
&format!(
r#"{{"email":"ada@example.com","type":"email-verification","otp":"{otp}"}}"#
),
None,
)
.unwrap(),
)
.await
.unwrap();
assert_eq!(sender.count(), 2);
assert!(!stored.starts_with(&otp));
assert_eq!(response.status(), StatusCode::OK);
}
struct PrefixEncryptor;
impl EmailOtpEncryptor for PrefixEncryptor {
fn encrypt_otp(&self, otp: &str) -> Result<String, RustAuthError> {
Ok(format!("enc:{otp}"))
}
fn decrypt_otp(&self, stored: &str) -> Result<String, RustAuthError> {
stored
.strip_prefix("enc:")
.map(str::to_owned)
.ok_or_else(|| RustAuthError::Crypto("invalid custom encrypted OTP".to_owned()))
}
}
struct PrefixHasher;
impl EmailOtpHasher for PrefixHasher {
fn hash_otp(&self, otp: &str) -> Result<String, RustAuthError> {
Ok(format!("hashed:{otp}"))
}
}
struct CountingGenerator(Arc<AtomicUsize>);
impl EmailOtpGenerator for CountingGenerator {
fn generate_otp(&self, _email: &str, _otp_type: EmailOtpType, _length: usize) -> String {
format!("otp-{}", self.0.fetch_add(1, Ordering::SeqCst))
}
}
#[tokio::test]
async fn custom_hash_storage_verifies_and_is_not_reused() {
let adapter = Arc::new(MemoryAdapter::new());
create_user(&adapter, "ada@example.com", false).await;
let sender = CaptureSender::default();
let router = router(
adapter.clone(),
sender.clone(),
EmailOtpOptions {
store_otp: OtpStorage::CustomHash(Arc::new(PrefixHasher)),
resend_strategy: ResendStrategy::Reuse,
..EmailOtpOptions::default()
},
)
.unwrap();
for _ in 0..2 {
router
.handle_async(
json_request(
"/email-otp/send-verification-otp",
r#"{"email":"ada@example.com","type":"email-verification"}"#,
None,
)
.unwrap(),
)
.await
.unwrap();
}
let otp = sender.last_otp().await;
let stored = verification_value(&adapter, "email-verification-otp-ada@example.com")
.await
.unwrap();
let response = router
.handle_async(
json_request(
"/email-otp/check-verification-otp",
&format!(
r#"{{"email":"ada@example.com","type":"email-verification","otp":"{otp}"}}"#
),
None,
)
.unwrap(),
)
.await
.unwrap();
assert_eq!(sender.count(), 2);
assert!(stored.starts_with("hashed:"));
assert!(!stored.contains(&format!("{}:0", otp)));
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn custom_encrypt_storage_verifies_and_can_be_retrieved() {
let adapter = Arc::new(MemoryAdapter::new());
let router = router(
adapter,
CaptureSender::default(),
EmailOtpOptions {
store_otp: OtpStorage::CustomEncrypt(Arc::new(PrefixEncryptor)),
..EmailOtpOptions::default()
},
)
.unwrap();
let create = router
.handle_async(
json_request(
"/email-otp/create-verification-otp",
r#"{"email":"ada@example.com","type":"email-verification"}"#,
None,
)
.unwrap(),
)
.await
.unwrap();
let otp: String = serde_json::from_slice(create.body()).unwrap();
let get = router
.handle_async(
get_json_request(
"/email-otp/get-verification-otp?email=ada%40example.com&type=email-verification",
"",
None,
)
.unwrap(),
)
.await
.unwrap();
let get_body: Value = serde_json::from_slice(get.body()).unwrap();
assert_eq!(get.status(), StatusCode::OK);
assert_eq!(get_body["otp"], otp);
}
#[tokio::test]
async fn rotate_strategy_always_generates_new_otp() {
let adapter = Arc::new(MemoryAdapter::new());
create_user(&adapter, "ada@example.com", false).await;
let sender = CaptureSender::default();
let counter = Arc::new(AtomicUsize::new(0));
let router = router(
adapter,
sender.clone(),
EmailOtpOptions {
generator: Some(Arc::new(CountingGenerator(Arc::clone(&counter)))),
resend_strategy: ResendStrategy::Rotate,
..EmailOtpOptions::default()
},
)
.unwrap();
for _ in 0..2 {
router
.handle_async(
json_request(
"/email-otp/send-verification-otp",
r#"{"email":"ada@example.com","type":"email-verification"}"#,
None,
)
.unwrap(),
)
.await
.unwrap();
}
assert_eq!(sender.count_after_dispatch(2).await, 2);
assert_eq!(sender.otps().await, vec!["otp-0", "otp-1"]);
}
#[tokio::test]
async fn reuse_strategy_generates_fresh_otp_after_expiry() {
let adapter = Arc::new(MemoryAdapter::new());
create_user(&adapter, "ada@example.com", false).await;
let sender = CaptureSender::default();
let counter = Arc::new(AtomicUsize::new(0));
let router = router(
adapter,
sender.clone(),
EmailOtpOptions {
expires_in: time::Duration::ZERO,
generator: Some(Arc::new(CountingGenerator(Arc::clone(&counter)))),
resend_strategy: ResendStrategy::Reuse,
..EmailOtpOptions::default()
},
)
.unwrap();
for _ in 0..2 {
router
.handle_async(
json_request(
"/email-otp/send-verification-otp",
r#"{"email":"ada@example.com","type":"email-verification"}"#,
None,
)
.unwrap(),
)
.await
.unwrap();
}
assert_eq!(sender.otps().await, vec!["otp-0", "otp-1"]);
}