rustauth-plugins 0.3.0

Official RustAuth plugin modules.
Documentation
use std::sync::Arc;

use rustauth_core::db::MemoryAdapter;
use rustauth_core::user::DbUserStore;
use rustauth_plugins::email_otp::{ChangeEmailOptions, EmailOtpOptions};

use super::common::*;

#[tokio::test]
async fn sign_in_email_otp_allows_only_one_concurrent_redeem() {
    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::default()).unwrap();
    router
        .handle_async(
            json_request(
                "/email-otp/send-verification-otp",
                r#"{"email":"ada@example.com","type":"sign-in"}"#,
                None,
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    let body = format!(r#"{{"email":"ada@example.com","otp":"{otp}"}}"#);
    let first_request = json_request("/sign-in/email-otp", &body, None).unwrap();
    let second_request = json_request("/sign-in/email-otp", &body, None).unwrap();

    let (first, second) = tokio::join!(
        router.handle_async(first_request),
        router.handle_async(second_request),
    );
    let first = first.unwrap();
    let second = second.unwrap();
    let successes = [first.status(), second.status()]
        .into_iter()
        .filter(|status| *status == StatusCode::OK)
        .count();

    assert_eq!(
        successes,
        1,
        "exactly one concurrent sign-in may succeed: {:?} {:?}",
        first.status(),
        second.status()
    );
    let failures = [first.status(), second.status()]
        .into_iter()
        .filter(|status| *status == StatusCode::BAD_REQUEST)
        .count();
    assert_eq!(failures, 1);
    let failed_body: Value = if first.status() == StatusCode::BAD_REQUEST {
        serde_json::from_slice(first.body()).unwrap()
    } else {
        serde_json::from_slice(second.body()).unwrap()
    };
    assert_eq!(failed_body["code"], "INVALID_OTP");
    assert_eq!(adapter.len("session").await, 1);
}

#[tokio::test]
async fn reset_password_email_otp_allows_only_one_concurrent_redeem() {
    let adapter = Arc::new(MemoryAdapter::new());
    let user = create_user(&adapter, "ada@example.com", false).await;
    create_credential(&adapter, &user.id, "old-password").await;
    let sender = CaptureSender::default();
    let router = router(adapter.clone(), sender.clone(), EmailOtpOptions::default()).unwrap();
    router
        .handle_async(
            json_request(
                "/email-otp/request-password-reset",
                r#"{"email":"ada@example.com"}"#,
                None,
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    let body = format!(r#"{{"email":"ada@example.com","otp":"{otp}","password":"new-password"}}"#);
    let first_request = json_request("/email-otp/reset-password", &body, None).unwrap();
    let second_request = json_request("/email-otp/reset-password", &body, None).unwrap();

    let (first, second) = tokio::join!(
        router.handle_async(first_request),
        router.handle_async(second_request),
    );
    let first = first.unwrap();
    let second = second.unwrap();
    let successes = [first.status(), second.status()]
        .into_iter()
        .filter(|status| *status == StatusCode::OK)
        .count();

    assert_eq!(
        successes,
        1,
        "exactly one concurrent reset may succeed: {:?} {:?}",
        first.status(),
        second.status()
    );
    let failures = [first.status(), second.status()]
        .into_iter()
        .filter(|status| *status == StatusCode::BAD_REQUEST)
        .count();
    assert_eq!(failures, 1);
    let failed_body: Value = if first.status() == StatusCode::BAD_REQUEST {
        serde_json::from_slice(first.body()).unwrap()
    } else {
        serde_json::from_slice(second.body()).unwrap()
    };
    assert_eq!(failed_body["code"], "INVALID_OTP");

    let account = DbUserStore::new(adapter.as_ref())
        .find_credential_account(&user.id)
        .await
        .unwrap()
        .unwrap();
    assert!(fast_verify_password(account.password.as_deref().unwrap(), "new-password").unwrap());
}

#[tokio::test]
async fn change_email_otp_allows_only_one_concurrent_redeem() {
    let adapter = Arc::new(MemoryAdapter::new());
    let user = create_user(&adapter, "ada@example.com", true).await;
    let cookie = session_cookie(&adapter, &user.id).await;
    let sender = CaptureSender::default();
    let router = router(
        adapter.clone(),
        sender.clone(),
        EmailOtpOptions {
            change_email: ChangeEmailOptions {
                enabled: true,
                verify_current_email: false,
            },
            ..EmailOtpOptions::default()
        },
    )
    .unwrap();
    router
        .handle_async(
            json_request(
                "/email-otp/request-email-change",
                r#"{"newEmail":"new@example.com"}"#,
                Some(&cookie),
            )
            .unwrap(),
        )
        .await
        .unwrap();
    let otp = sender.last_otp().await;
    let body = format!(r#"{{"newEmail":"new@example.com","otp":"{otp}"}}"#);
    let first_request = json_request("/email-otp/change-email", &body, Some(&cookie)).unwrap();
    let second_request = json_request("/email-otp/change-email", &body, Some(&cookie)).unwrap();

    let (first, second) = tokio::join!(
        router.handle_async(first_request),
        router.handle_async(second_request),
    );
    let first = first.unwrap();
    let second = second.unwrap();
    let successes = [first.status(), second.status()]
        .into_iter()
        .filter(|status| *status == StatusCode::OK)
        .count();

    assert_eq!(
        successes,
        1,
        "exactly one concurrent change-email may succeed: {:?} {:?}",
        first.status(),
        second.status()
    );
    let failures = [first.status(), second.status()]
        .into_iter()
        .filter(|status| *status == StatusCode::BAD_REQUEST)
        .count();
    assert_eq!(failures, 1);
    let failed_body: Value = if first.status() == StatusCode::BAD_REQUEST {
        serde_json::from_slice(first.body()).unwrap()
    } else {
        serde_json::from_slice(second.body()).unwrap()
    };
    assert_eq!(failed_body["code"], "INVALID_OTP");

    let updated = DbUserStore::new(adapter.as_ref())
        .find_user_by_id(&user.id)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(updated.email, "new@example.com");
}