better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![allow(
    clippy::expect_used,
    reason = "Fixture setup and response assertions require successful fallible construction."
)]
use super::*;
use crate::plugins::{
    test_helpers,
    user_admission::{UserValidationData, UserValidationRejection, ValidateUserInfo},
};
use async_trait::async_trait;
use better_auth_core::{
    AuthInitContext, AuthPlugin, FieldMap, FieldValue, HttpMethod,
    plugin_runtime::AdapterUserFields,
    store::database_hooks::{DatabaseHookContext, DatabaseHookUpdate, DatabaseHooks},
    store::schema::SchemaConfiguration,
    store::{AuthStore, EphemeralStore, StatelessSchema},
    user_fields::{FieldValidators, UserConfig, UserFieldConfig, UserFieldType},
};
use std::sync::Mutex;

const PHONE: &str = "+15551230001";
const EMAIL: &str = "phone@admission.test";

#[derive(Default)]
struct Trace(Mutex<Vec<(&'static str, FieldMap)>>);

impl Trace {
    fn push(&self, phase: &'static str, fields: FieldMap) {
        self.0.lock().unwrap().push((phase, fields));
    }
}

#[async_trait]
impl<S: AuthSchema> ValidateUserInfo<S> for Trace {
    async fn validate(
        &self,
        data: &UserValidationData,
        _: &EndpointContext<'_, S>,
    ) -> AuthResult<Option<UserValidationRejection>> {
        assert_eq!(
            serde_json::to_value(&data.source)?,
            json!({"method":"phone-number", "action":"create-user"}),
        );
        self.push("admission", data.user.clone());
        Ok(None)
    }
}

#[better_auth_core::database_hooks()]
impl<S: AuthSchema> DatabaseHooks<S> for Trace {
    async fn before_create_user(
        &self,
        input: &mut FieldMap,
        _: &DatabaseHookContext<'_, S>,
    ) -> AuthResult<DatabaseHookUpdate<FieldMap>> {
        self.push("before", input.clone());
        Ok(DatabaseHookUpdate::Continue)
    }

    async fn after_create_user(
        &self,
        user: Option<&UserView>,
        _: &DatabaseHookContext<'_, S>,
    ) -> AuthResult<()> {
        self.push("after", user.unwrap().clone().into());
        Ok(())
    }
}

async fn check_signup<S: AuthSchema>(
    store: Arc<dyn AuthStore<S>>,
    sqlite: bool,
    custom_name: bool,
) -> AuthResult<()> {
    let trace = Arc::new(Trace::default());
    let mut config = test_helpers::create_test_config();
    for name in ["email", "name"] {
        let observer = trace.clone();
        let _ = config.user.fields_mut().insert(
            name.into(),
            UserFieldConfig {
                required: Some(false),
                validator: Some(FieldValidators {
                    input: Some(Arc::new(move |value| {
                        observer.push(name, FieldMap::from([("input".into(), value)]));
                        Ok(FieldMap::from([("source".into(), "parsed".into())]).into())
                    })),
                    ..Default::default()
                }),
                ..Default::default()
            },
        );
    }
    let _ = config.user.fields_mut().insert(
        "phoneNumberVerified".into(),
        UserFieldConfig {
            field_type: UserFieldType::Boolean,
            required: Some(false),
            input: Some(false),
            default_value: Some(false.into()),
            ..Default::default()
        },
    );
    let observer = trace.clone();
    let _ = config.user.fields_mut().insert(
        "metadata".into(),
        UserFieldConfig {
            field_type: UserFieldType::Json,
            required: Some(false),
            validator: Some(FieldValidators {
                input: Some(Arc::new(move |value| {
                    observer.push(
                        "metadata",
                        FieldMap::from([("input".into(), value.clone())]),
                    );
                    Ok(FieldMap::from([
                        ("source".into(), "parsed".into()),
                        ("input".into(), value),
                    ])
                    .into())
                })),
                ..Default::default()
            }),
            ..Default::default()
        },
    );
    for (name, field_name, field_type) in [
        ("phoneNumber", None, UserFieldType::String),
        ("code", Some("username"), UserFieldType::String),
        (
            "disableSession",
            Some("displayUsername"),
            UserFieldType::Boolean,
        ),
        (
            "updatePhoneNumber",
            Some("banReason"),
            UserFieldType::Boolean,
        ),
    ] {
        let _ = config.user.fields_mut().insert(
            name.into(),
            UserFieldConfig {
                field_type,
                required: Some(false),
                field_name: field_name.map(str::to_owned),
                validator: Some(FieldValidators {
                    input: Some(Arc::new(move |_| {
                        panic!("Reserved route field {name} reached the user validator")
                    })),
                    ..Default::default()
                }),
                ..Default::default()
            },
        );
    }
    let config = Arc::new(config);
    let verifier = trace.clone();
    let email = trace.clone();
    let verified = trace.clone();
    let mut plugin = PhoneNumberPlugin::new()
        .send_otp(|_, _| async { Err(AuthError::internal("Verification must not send a new OTP")) })
        .verify_otp(move |otp, _| {
            assert_eq!(otp.phone_number, PHONE);
            assert_eq!(otp.code, "123456");
            verifier.push("verify", FieldMap::new());
            async { Ok(true) }
        })
        .sign_up_on_verification(move |phone| {
            assert_eq!(phone, PHONE);
            email.push("temp-email", FieldMap::new());
            EMAIL.to_uppercase()
        })
        .callback_on_verification(move |data, _| {
            assert_eq!(data.phone_number, PHONE);
            verified.push("verified", data.user.into());
            async { Ok(()) }
        });
    if custom_name {
        let name = trace.clone();
        plugin = plugin.temporary_name(move |phone| {
            assert_eq!(phone, PHONE);
            name.push("temp-name", FieldMap::new());
            "Phone Owner".into()
        });
    }
    let mut model_fields = better_auth_core::plugin_runtime::ModelFields::default();
    let mut init =
        AuthInitContext::new(config.clone(), store.clone()).expect("fixture context initializes");
    better_auth_core::AuthPlugin::<S>::schema(&plugin, &mut model_fields)?;
    plugin.on_init(&mut init).await?;
    let mut replacement_fields = UserConfig::default();
    for name in ["phoneNumber", "phoneNumberVerified"] {
        let _ = replacement_fields
            .fields_mut()
            .insert(name.into(), config.user.fields()[name].clone());
    }
    model_fields.register_user_fields(replacement_fields);
    let parts = init.into_parts();
    let (adapter, endpoint, mut fields) = model_fields.resolve(&config);
    let adapter_fields = adapter.user.clone();
    let adapter = Arc::new(adapter);
    fields.set_schema_configuration(&SchemaConfiguration {
        config: adapter.clone(),
        plugins: vec!["phone-number", "phone-signup-field-contract"],
        metadata: parts.metadata.clone(),
        secondary_storage: false,
        rate_limit: Default::default(),
    });
    let store = store.with_runtime(adapter, vec![trace.clone()], fields)?;
    let mut context =
        AuthContext::new(Arc::new(endpoint), store).expect("fixture context initializes");
    context.metadata = parts.metadata;
    context.extensions = parts.extensions;
    context.extensions.insert(AdapterUserFields(adapter_fields));
    context
        .extensions
        .insert(trace.clone() as Arc<dyn ValidateUserInfo<S>>);
    let request = test_helpers::create_auth_request_no_query(
        HttpMethod::Post,
        "/phone-number/verify",
        None,
        Some(
            json!({
                "phoneNumber":PHONE, "code":"123456", "disableSession":true,
                "updatePhoneNumber":false, "email":"client@admission.test", "name":"Client",
                "metadata":{"source":"client"},
            })
            .to_string()
            .into_bytes(),
        ),
    );
    let response = plugin.on_request(&request, &context).await?.unwrap();
    assert_eq!(response.status, 200);
    let events = trace.0.lock().unwrap().clone();
    let mut phases = vec!["verify", "email", "name", "metadata", "temp-email"];
    if custom_name {
        phases.push("temp-name");
    }
    phases.extend(["admission", "before", "after", "verified"]);
    assert_eq!(
        events.iter().map(|event| event.0).collect::<Vec<_>>(),
        phases
    );
    assert_eq!(
        events[1].1,
        FieldMap::from([("input".into(), "client@admission.test".into())])
    );
    assert_eq!(
        events[2].1,
        FieldMap::from([("input".into(), "Client".into())])
    );
    let input_metadata = FieldMap::from([("source".into(), "client".into())]);
    assert_eq!(
        events[3].1,
        FieldMap::from([("input".into(), input_metadata.clone().into())])
    );
    let metadata: FieldValue = FieldMap::from([
        ("source".into(), "parsed".into()),
        ("input".into(), input_metadata.into()),
    ])
    .into();
    let admitted = &events[events.len() - 4].1;
    assert!(matches!(admitted["createdAt"], FieldValue::Date(_)));
    assert!(matches!(admitted["updatedAt"], FieldValue::Date(_)));
    let name = if custom_name { "Phone Owner" } else { PHONE };
    let expected_input = FieldMap::from([
        ("createdAt".into(), admitted["createdAt"].clone()),
        ("updatedAt".into(), admitted["updatedAt"].clone()),
        ("email".into(), EMAIL.into()),
        ("name".into(), name.into()),
        ("phoneNumberVerified".into(), true.into()),
        ("metadata".into(), metadata.clone()),
        ("phoneNumber".into(), PHONE.into()),
    ]);
    assert_eq!(admitted, &expected_input);
    assert_eq!(
        admitted.keys().collect::<Vec<_>>(),
        expected_input.keys().collect::<Vec<_>>()
    );
    assert_eq!(events[events.len() - 3].1, expected_input);
    let stored = context.database.get_user_by_email(EMAIL).await?.unwrap();
    assert!(!stored.id.typed()?.is_empty());
    let absent = if sqlite {
        FieldValue::Null
    } else {
        FieldValue::Undefined
    };
    let expected_stored = FieldMap::from([
        ("id".into(), stored.id.field_value()),
        ("name".into(), name.into()),
        ("email".into(), EMAIL.into()),
        ("emailVerified".into(), false.into()),
        ("image".into(), absent.clone()),
        ("createdAt".into(), admitted["createdAt"].clone()),
        ("updatedAt".into(), admitted["updatedAt"].clone()),
        ("phoneNumberVerified".into(), true.into()),
        ("metadata".into(), metadata),
        ("phoneNumber".into(), PHONE.into()),
        ("code".into(), absent.clone()),
        ("disableSession".into(), absent.clone()),
        ("updatePhoneNumber".into(), absent),
    ]);
    assert_eq!(FieldMap::from(stored.clone()), expected_stored);
    assert_eq!(events[events.len() - 2].1, expected_stored);
    assert_eq!(events[events.len() - 1].1, expected_stored);
    assert_eq!(
        response.body.json()?,
        Some(json!({"status":true, "token":null, "user":expected_stored.json()?}))
    );
    assert_eq!(context.database.list_users(Default::default()).await?.1, 1);
    assert!(
        context
            .database
            .get_user_accounts(stored.id.typed()?)
            .await?
            .is_empty()
    );
    assert!(
        context
            .database
            .get_user_sessions(stored.id.typed()?)
            .await?
            .is_empty()
    );
    assert!(
        context
            .database
            .get_verification_by_identifier(PHONE)
            .await?
            .is_none()
    );
    Ok(())
}

#[tokio::test]
async fn phone_signup_parses_fields_before_temporary_identity_and_verified_proof() -> AuthResult<()>
{
    for custom_name in [false, true] {
        let memory: Arc<dyn AuthStore<StatelessSchema>> = Arc::new(EphemeralStore::new(Arc::new(
            test_helpers::create_test_config(),
        )));
        check_signup(memory, false, custom_name).await?;
        check_signup(
            test_helpers::create_test_database().await,
            true,
            custom_name,
        )
        .await?;
    }
    Ok(())
}