better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
//! Replay frozen endpoint values through the shared runtime observer.
//! JavaScript reflection and host diagnostics remain in the source fixtures and coverage ledger.

use super::*;
use crate::plugins::test_helpers::native_observation::{NativeObservation, assert_replay};
use better_auth_core::{
    FieldDate, UpdateAccount,
    store::schema::EntityRole,
    user_fields::{UserFieldFactory, UserFieldType},
};
use serde_json::Value as Json;
use std::sync::atomic::{AtomicUsize, Ordering};

mod native_boundary;
mod runtime;
#[path = "../../../../../../tests/support/secondary_native_source.rs"]
mod source;

const NOW: f64 = 2_000_000_000_000.0;

struct Trace {
    native: Mutex<NativeObservation>,
    projected: Mutex<FieldValue>,
    projections: AtomicUsize,
    second_false: bool,
    verified: FieldValue,
    mutation: Option<String>,
    database: Arc<dyn AuthStore<StatelessSchema>>,
    store: Arc<EphemeralStore>,
}

impl Trace {
    fn record(&self, phase: &str, name: &str, value: &FieldValue) -> AuthResult<()> {
        let mut native = self.native.lock().unwrap();
        let value = native.observe(value)?;
        native.record(phase, [(name.into(), value)]);
        Ok(())
    }

    fn storage(&self) -> AuthResult<FieldValue> {
        let mut tables = FieldMap::new();
        for (name, role) in [
            ("user", EntityRole::User),
            ("session", EntityRole::Session),
            ("account", EntityRole::Account),
            ("verification", EntityRole::Verification),
            ("twoFactor", EntityRole::TwoFactor),
        ] {
            let _ = tables.insert(
                name.into(),
                self.store
                    .storage_rows(role)?
                    .into_iter()
                    .map(FieldValue::from)
                    .collect::<Vec<_>>()
                    .into(),
            );
        }
        Ok(tables.into())
    }
}

#[async_trait]
impl PasswordHasher for Trace {
    async fn hash(&self, password: &str) -> AuthResult<String> {
        assert_eq!(password, PASSWORD);
        self.record("hash", "value", &password.into())?;
        Ok("new-hash".into())
    }

    async fn verify(&self, _: &str, _: &str) -> AuthResult<bool> {
        Err(AuthError::internal(
            "The fixture must reach the native verifier",
        ))
    }

    async fn verify_native(&self, hash: &FieldValue, password: &str) -> AuthResult<FieldValue> {
        assert_eq!(password, PASSWORD);
        let same_hash = hash.strict_equals(&self.projected.lock().unwrap());
        assert!(same_hash);
        {
            let mut native = self.native.lock().unwrap();
            let arguments = native.observe(
                &FieldMap::from([
                    ("hash".into(), hash.clone()),
                    ("password".into(), password.into()),
                ])
                .into(),
            )?;
            native.record(
                "verify",
                [
                    ("arguments".into(), arguments),
                    ("sameHash".into(), same_hash.into()),
                ],
            );
        }
        if let Some(mutation) = self
            .mutation
            .as_deref()
            .filter(|mutation| *mutation != "none")
        {
            let update = match mutation {
                "provider" => UpdateAccount {
                    provider_id: "changed-provider".into(),
                    ..Default::default()
                },
                "replacement" => UpdateAccount {
                    id: "replacement".into(),
                    password: Some("replacement-hash".to_owned()).into(),
                    ..Default::default()
                },
                _ => return Err(AuthError::internal("Unknown captured Account mutation")),
            };
            let _ = self
                .database
                .update_account_by_id_value(&"credential".into(), update)
                .await?;
            let stored = self
                .store
                .storage_rows(EntityRole::Account)?
                .into_iter()
                .map(FieldValue::from)
                .collect::<Vec<_>>();
            self.record(
                if mutation == "provider" {
                    "mutate-provider"
                } else {
                    "replace-account"
                },
                "stored",
                &stored.into(),
            )?;
        }
        Ok(self.verified.clone())
    }
}

fn hash(shape: &str, trace: &Arc<Trace>) -> AuthResult<FieldValue> {
    Ok(match shape {
        "false" => false.into(),
        "zero" => 0.into(),
        "nan" => f64::NAN.into(),
        "empty" => "".into(),
        "null" => FieldValue::Null,
        "undefined" => FieldValue::Undefined,
        "string" => "projected-hash".into(),
        "number" => 17.into(),
        "array" => vec![FieldValue::from("projected-hash")].into(),
        "object" => {
            let trace = Arc::downgrade(trace);
            FieldMap::from([
                ("marker".into(), "projected-hash".into()),
                (
                    "toString".into(),
                    FieldFunction::contextual(move |_, _| {
                        let trace = trace.upgrade().ok_or_else(|| {
                            AuthError::internal("The password trace must outlive its callback")
                        })?;
                        trace.native.lock().unwrap().record("hash.toString", []);
                        Err(AuthError::internal(
                            "The password consumer must not stringify the hash",
                        ))
                    })
                    .into(),
                ),
            ])
            .into()
        }
        _ => return Err(AuthError::internal("Unknown captured password shape")),
    })
}

fn tables(query_order: bool) -> Vec<(String, Vec<FieldMap>)> {
    let date = || FieldValue::from(FieldDate::from_milliseconds(NOW));
    let mut user = FieldMap::from([
        ("id".into(), "owner".into()),
        ("name".into(), "Owner".into()),
        (
            "email".into(),
            if query_order {
                "owner@password-query.test"
            } else {
                "owner@password-native.test"
            }
            .into(),
        ),
        ("emailVerified".into(), true.into()),
        ("image".into(), FieldValue::Null),
        ("twoFactorEnabled".into(), false.into()),
    ]);
    if query_order {
        let _ = user.insert("phoneNumber".into(), "+12025550123".into());
        let _ = user.insert("phoneNumberVerified".into(), true.into());
    }
    let _ = user.insert("createdAt".into(), date());
    let _ = user.insert("updatedAt".into(), date());
    vec![
        ("user".into(), vec![user]),
        (
            "session".into(),
            vec![FieldMap::from([
                ("id".into(), "session".into()),
                ("token".into(), "session-token".into()),
                ("userId".into(), "owner".into()),
                (
                    "expiresAt".into(),
                    FieldDate::from_milliseconds(4_070_908_800_000.0).into(),
                ),
                ("createdAt".into(), date()),
                ("updatedAt".into(), date()),
                ("ipAddress".into(), FieldValue::Null),
                ("userAgent".into(), FieldValue::Null),
            ])],
        ),
        (
            "account".into(),
            vec![FieldMap::from([
                ("id".into(), "credential".into()),
                ("userId".into(), "owner".into()),
                ("accountId".into(), "owner".into()),
                ("providerId".into(), "credential".into()),
                ("password".into(), "stored-hash".into()),
                ("createdAt".into(), date()),
                ("updatedAt".into(), date()),
            ])],
        ),
        ("verification".into(), Vec::new()),
        ("twoFactor".into(), Vec::new()),
    ]
}

fn config() -> better_auth_core::AuthConfig {
    let mut config = test_helpers::create_test_config().disable_session_refresh(true);
    let _ = config.account.additional_fields.insert(
        "updatedAt".into(),
        UserFieldConfig {
            field_type: UserFieldType::Date,
            on_update: Some(UserFieldFactory::new(|| {
                Ok(FieldDate::from_milliseconds(NOW).into())
            })),
            ..Default::default()
        },
    );
    config
}

fn install_projection(config: &mut better_auth_core::AuthConfig, trace: &Arc<Trace>) {
    let trace = trace.clone();
    let _ = config.account.additional_fields.insert(
        "password".into(),
        UserFieldConfig {
            required: Some(false),
            returned: Some(false),
            transform: Some(FieldTransforms {
                input: None,
                output: Some(UserFieldTransform::new(move |value| {
                    assert!(value == "stored-hash".into() || value == "new-hash".into());
                    trace.record("projection", "input", &value)?;
                    let count = trace.projections.fetch_add(1, Ordering::SeqCst) + 1;
                    Ok(if trace.second_false && count == 2 {
                        false.into()
                    } else {
                        trace.projected.lock().unwrap().clone()
                    })
                })),
            }),
            ..Default::default()
        },
    );
}

async fn query_case(case: &Json) -> AuthResult<()> {
    let mut config = config();
    let store = Arc::new(EphemeralStore::from_storage(
        Arc::new(config.clone()),
        tables(true),
    )?);
    let raw =
        test_helpers::initialize_test_context(Arc::new(config.clone()), store.clone(), &[]).await?;
    let trace = Arc::new(Trace {
        native: Mutex::new(NativeObservation::default()),
        projected: Mutex::new(FieldValue::Undefined),
        projections: AtomicUsize::new(0),
        second_false: case["secondHash"] == "false",
        verified: (case["operation"] != "phone").into(),
        mutation: case["mutation"].as_str().map(str::to_owned),
        database: raw.database,
        store: store.clone(),
    });
    *trace.projected.lock().unwrap() = hash(case["hash"].as_str().unwrap_or("object"), &trace)?;
    install_projection(&mut config, &trace);
    let email = EmailPasswordPlugin::new().password_hasher(trace.clone());
    let password = PasswordManagementPlugin::new();
    let phone = PhoneNumberPlugin::new();
    let two_factor = TwoFactorPlugin::new()
        .allow_passwordless(case["allowPasswordless"].as_bool().unwrap_or(false))
        .totp_disabled(true);
    let plugins: [&dyn AuthPlugin<StatelessSchema>; 4] = [&email, &password, &phone, &two_factor];
    let context = test_helpers::initialize_test_context(Arc::new(config), store, &plugins).await?;
    let (plugin, path, body): (&dyn AuthPlugin<StatelessSchema>, _, _) = match case["operation"]
        .as_str()
        .unwrap()
    {
        "phone" => (
            &phone,
            "/sign-in/phone-number",
            json!({"phoneNumber":"+12025550123","password":PASSWORD}),
        ),
        "change" => (
            &password,
            "/change-password",
            json!({"currentPassword":PASSWORD,"newPassword":PASSWORD}),
        ),
        "two-factor" => (
            &two_factor,
            "/two-factor/enable",
            json!({"password":match case["password"].as_str().unwrap() {
                "long" => "x".repeat(129), "empty" => String::new(), "provided" => PASSWORD.to_owned(), _ => return Err(AuthError::internal("Unknown captured password input")),
            }}),
        ),
        _ => return Err(AuthError::internal("Unknown captured password operation")),
    };
    let request = test_helpers::create_auth_json_request_no_query(
        HttpMethod::Post,
        path,
        Some("session-token"),
        Some(body),
    );
    let result = plugin
        .on_request(
            &request,
            context.initialize_request_context().await?.as_ref(),
        )
        .await
        .and_then(|response| {
            response.ok_or_else(|| {
                AuthError::internal("The password fixture route must return a response")
            })
        })
        .and_then(|response| response.body.field_value());
    let stored = trace.storage()?;
    let mut native = trace.native.lock().unwrap();
    let projected = native.observe(&trace.projected.lock().unwrap())?;
    let outcome = runtime::outcome(result, &mut native, None)?;
    let actual = json!({"projected":projected, "outcome":outcome, "events":native.events,
        "functionIds":native.function_ids(), "errorFunctions":[], "stored":native.observe(&stored)?});
    let expected = runtime::query_expected(case)?;
    assert_replay(&actual, &expected, case["name"].as_str().unwrap());
    Ok(())
}

#[tokio::test]
async fn query_order_fixture_replays_all_runtime_paths() -> AuthResult<()> {
    let fixture: Json = serde_json::from_str(include_str!(
        "../../../../../../tests/fixtures/password-query-order-1.7.6.json"
    ))?;
    assert_eq!(fixture["version"], "1.7.6");
    assert_eq!(fixture["now"], NOW);
    let cases = fixture["cases"].as_array().unwrap();
    assert_eq!(cases.len(), 18);
    for case in cases {
        query_case(case).await?;
    }
    Ok(())
}