rustauth-plugins 0.3.0

Official RustAuth plugin modules.
Documentation
use super::*;
use time::Duration;

#[test]
fn missing_send_otp_callback_fails_at_plugin_factory() {
    assert!(phone_number(PhoneNumberOptions::default()).is_err());
}

#[tokio::test]
async fn validator_rejects_invalid_phone_number() -> Result<(), Box<dyn std::error::Error>> {
    let options = PhoneNumberOptions::default().phone_number_validator(|phone| Ok(phone == PHONE));
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, true).await?;
    let router = router_with_options(options, adapter)?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-in/phone-number",
            r#"{"phoneNumber":"bad","password":"secret123"}"#,
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "INVALID_PHONE_NUMBER");
    Ok(())
}

#[tokio::test]
async fn verify_accepts_only_latest_otp_after_multiple_sends(
) -> Result<(), Box<dyn std::error::Error>> {
    let sent = Arc::new(Mutex::new(Vec::new()));
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, false).await?;
    let router = router_with_options(
        PhoneNumberOptions::default().send_otp({
            let sent = Arc::clone(&sent);
            move |phone_number, code| {
                sent.lock()
                    .map_err(|_| RustAuthError::Api("lock poisoned".to_owned()))?
                    .push((phone_number.to_owned(), code.to_owned()));
                Ok(())
            }
        }),
        adapter,
    )?;

    for _ in 0..3 {
        let response = router
            .handle_async(json_request(
                Method::POST,
                "/api/auth/phone-number/send-otp",
                &format!(r#"{{"phoneNumber":"{PHONE}"}}"#),
                None,
            )?)
            .await?;
        assert_eq!(response.status(), StatusCode::OK);
    }

    wait_for_sent_pairs(&sent, 3).await;
    let (first_code, last_code) = {
        let codes = sent.lock().map_err(|_| "lock poisoned")?;
        (
            codes
                .first()
                .map(|(_, code)| code.clone())
                .ok_or("first code")?,
            codes
                .last()
                .map(|(_, code)| code.clone())
                .ok_or("last code")?,
        )
    };

    let stale = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{PHONE}","code":"{first_code}","disableSession":true}}"#),
            None,
        )?)
        .await?;
    assert_eq!(stale.status(), StatusCode::BAD_REQUEST);
    let stale_body: Value = serde_json::from_slice(stale.body())?;
    assert_eq!(stale_body["code"], "INVALID_OTP");

    let verified = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{PHONE}","code":"{last_code}","disableSession":true}}"#),
            None,
        )?)
        .await?;
    assert_eq!(verified.status(), StatusCode::OK);
    Ok(())
}

#[tokio::test]
async fn verify_rejects_reused_otp_code() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, false).await?;
    seed_otp(&adapter, PHONE, "123456", 0, 300).await?;
    let router = router_with_options(PhoneNumberOptions::default(), adapter.clone())?;

    let first = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{PHONE}","code":"123456","disableSession":true}}"#),
            None,
        )?)
        .await?;
    assert_eq!(first.status(), StatusCode::OK);

    let reused = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{PHONE}","code":"123456","disableSession":true}}"#),
            None,
        )?)
        .await?;
    assert_eq!(reused.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(reused.body())?;
    assert_eq!(body["code"], "INVALID_OTP");
    Ok(())
}

#[tokio::test]
async fn verify_rejects_missing_and_expired_otp() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    let router = router_with_options(PhoneNumberOptions::default(), adapter.clone())?;

    let missing = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{PHONE}","code":"123456"}}"#),
            None,
        )?)
        .await?;
    assert_eq!(missing.status(), StatusCode::BAD_REQUEST);
    let missing_body: Value = serde_json::from_slice(missing.body())?;
    assert_eq!(missing_body["code"], "INVALID_OTP");

    seed_otp(&adapter, PHONE, "123456", 0, -1).await?;
    let expired = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{PHONE}","code":"123456"}}"#),
            None,
        )?)
        .await?;
    assert_eq!(expired.status(), StatusCode::BAD_REQUEST);
    let expired_body: Value = serde_json::from_slice(expired.body())?;
    assert_eq!(expired_body["code"], "OTP_EXPIRED");
    Ok(())
}

#[tokio::test]
async fn update_phone_number_rejects_duplicate_with_session(
) -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, true).await?;
    seed_user_with_phone_id(&adapter, "user_2", NEW_PHONE, true).await?;
    seed_otp(&adapter, NEW_PHONE, "123456", 0, 300).await?;
    let session = DbSessionStore::new(adapter.as_ref())
        .create_session(CreateSessionInput::new(
            "user_1",
            OffsetDateTime::now_utc() + Duration::hours(1),
        ))
        .await?;
    let router = router_with_options(PhoneNumberOptions::default(), adapter)?;
    let cookie = signed_session_cookie(&session.token)?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{NEW_PHONE}","code":"123456","updatePhoneNumber":true}}"#),
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "PHONE_NUMBER_EXIST");
    Ok(())
}

#[tokio::test]
async fn update_user_rejects_non_null_phone_number() -> Result<(), Box<dyn std::error::Error>> {
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, true).await?;
    let session = DbSessionStore::new(adapter.as_ref())
        .create_session(CreateSessionInput::new(
            "user_1",
            OffsetDateTime::now_utc() + Duration::hours(1),
        ))
        .await?;
    let router = router_with_options(PhoneNumberOptions::default(), adapter)?;
    let cookie = signed_session_cookie(&session.token)?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/update-user",
            &format!(r#"{{"phoneNumber":"{NEW_PHONE}"}}"#),
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    let body: Value = serde_json::from_slice(response.body())?;
    assert_eq!(body["code"], "PHONE_NUMBER_CANNOT_BE_UPDATED");
    Ok(())
}

#[tokio::test]
async fn require_verification_blocks_sign_in_and_sends_otp(
) -> Result<(), Box<dyn std::error::Error>> {
    let sent = Arc::new(Mutex::new(0));
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, false).await?;
    DbUserStore::new(adapter.as_ref())
        .create_credential_account(CreateCredentialAccountInput::new(
            "user_1",
            fast_hash_password("secret123")?,
        ))
        .await?;
    let options = PhoneNumberOptions::default()
        .require_verification(true)
        .send_otp({
            let sent = Arc::clone(&sent);
            move |_phone, _code| {
                *sent
                    .lock()
                    .map_err(|_| RustAuthError::Api("lock poisoned".to_owned()))? += 1;
                Ok(())
            }
        });
    let router = router_with_options(options, adapter.clone())?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/sign-in/phone-number",
            &format!(r#"{{"phoneNumber":"{PHONE}","password":"secret123"}}"#),
            None,
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    wait_for_sent_count(&sent, 1).await;
    assert_eq!(*sent.lock().map_err(|_| "lock poisoned")?, 1);
    assert!(find_verification(&adapter, PHONE).await?.is_some());
    Ok(())
}

#[tokio::test]
async fn callback_on_verification_receives_updated_phone_and_user_id(
) -> Result<(), Box<dyn std::error::Error>> {
    let captured = Arc::new(Mutex::new(Vec::new()));
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, true).await?;
    seed_otp(&adapter, NEW_PHONE, "123456", 0, 300).await?;
    let session = DbSessionStore::new(adapter.as_ref())
        .create_session(CreateSessionInput::new(
            "user_1",
            OffsetDateTime::now_utc() + Duration::hours(1),
        ))
        .await?;
    let options = PhoneNumberOptions::default().callback_on_verification({
        let captured = Arc::clone(&captured);
        move |phone_number, user_id| {
            captured
                .lock()
                .map_err(|_| RustAuthError::Api("lock poisoned".to_owned()))?
                .push((phone_number.to_owned(), user_id.to_owned()));
            Ok(())
        }
    });
    let router = router_with_options(options, adapter.clone())?;
    let cookie = signed_session_cookie(&session.token)?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(r#"{{"phoneNumber":"{NEW_PHONE}","code":"123456","updatePhoneNumber":true}}"#),
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(
        captured.lock().map_err(|_| "lock poisoned")?.clone(),
        vec![(NEW_PHONE.to_owned(), "user_1".to_owned())]
    );
    Ok(())
}

#[tokio::test]
async fn update_phone_number_uses_custom_verify_otp_without_internal_record(
) -> Result<(), Box<dyn std::error::Error>> {
    let called = Arc::new(Mutex::new(false));
    let adapter = Arc::new(MemoryAdapter::new());
    seed_user_with_phone(&adapter, PHONE, true).await?;
    let session = DbSessionStore::new(adapter.as_ref())
        .create_session(CreateSessionInput::new(
            "user_1",
            OffsetDateTime::now_utc() + Duration::hours(1),
        ))
        .await?;
    let options = PhoneNumberOptions::default().verify_otp({
        let called = Arc::clone(&called);
        move |phone_number, code| {
            *called
                .lock()
                .map_err(|_| RustAuthError::Api("lock poisoned".to_owned()))? =
                phone_number == NEW_PHONE && code == "external";
            Ok(phone_number == NEW_PHONE && code == "external")
        }
    });
    let router = router_with_options(options, adapter.clone())?;
    let cookie = signed_session_cookie(&session.token)?;

    let response = router
        .handle_async(json_request(
            Method::POST,
            "/api/auth/phone-number/verify",
            &format!(
                r#"{{"phoneNumber":"{NEW_PHONE}","code":"external","updatePhoneNumber":true}}"#
            ),
            Some(&cookie),
        )?)
        .await?;

    assert_eq!(response.status(), StatusCode::OK);
    assert!(*called.lock().map_err(|_| "lock poisoned")?);
    assert!(find_user_by_phone(&adapter, NEW_PHONE).await?.is_some());
    assert!(find_verification(&adapter, NEW_PHONE).await?.is_none());
    Ok(())
}