better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![expect(
    clippy::unwrap_used,
    clippy::panic_in_result_fn,
    reason = "Admin native regressions inspect exact hook contexts, native identities, and persisted records"
)]

use std::{
    collections::HashMap,
    sync::{Arc, Mutex},
};

use async_trait::async_trait;
use better_auth_core::{
    AuthContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, BeforeRequestAction,
    CreateSession, CreateUser, FieldMap, FieldValue,
    endpoint_dispatch::EndpointDispatcher,
    endpoint_input::EndpointInputPatch,
    observability::{AfterEndpointHook, BeforeEndpointHook, EndpointHooks},
    store::{EphemeralStore, StatelessSchema},
};
use serde_json::{Value, json};

use super::{AdminApi, AdminPlugin, CreateAdminUser};
use crate::plugins::{
    endpoint_context::EndpointContext,
    test_helpers::{create_test_config, initialize_test_context},
    user_admission::{UserValidationData, UserValidationRejection, ValidateUserInfo},
};

struct Probe {
    events: Mutex<Vec<Value>>,
    patch: Option<Value>,
    marker: FieldValue,
    created_at: Mutex<Option<FieldValue>>,
}

impl Probe {
    fn record(&self, phase: &str, request: &AuthRequest) {
        let scope = better_auth_core::hooks::current_request_hook_context().unwrap();
        assert!(!scope.is_http);
        assert!(!request.is_server_only());
        assert_eq!(scope.path.as_deref(), Some("/admin/create-user"));
        assert_eq!(scope.operation_id.as_deref(), Some("createUser"));
        assert_eq!(request.path(), "/admin/create-user");
        assert!(request.original_request().is_none());
        self.events.lock().unwrap().push(json!({
            "phase":phase,
            "headers":request.endpoint_headers(),
        }));
    }

    fn events(&self) -> Vec<Value> {
        self.events.lock().unwrap().clone()
    }
}

#[async_trait]
impl BeforeEndpointHook<StatelessSchema> for Probe {
    async fn before(
        &self,
        request: &AuthRequest,
        _: &AuthContext<StatelessSchema>,
    ) -> AuthResult<Option<BeforeRequestAction>> {
        self.record("before", request);
        Ok(self.patch.clone().map(|body| {
            BeforeRequestAction::MergeContext(EndpointInputPatch {
                body: Some(body),
                ..Default::default()
            })
        }))
    }
}

#[async_trait]
impl AfterEndpointHook<StatelessSchema> for Probe {
    async fn after(
        &self,
        request: &AuthRequest,
        response: &mut AuthResponse,
        _: &AuthContext<StatelessSchema>,
    ) -> AuthResult<()> {
        self.record("after", request);
        if response.status == 200 {
            let result = response.body.field_value()?;
            let mut user = result
                .model_property("user")?
                .as_object()
                .unwrap()
                .snapshot_fields()?;
            let created_at = user.get("createdAt").unwrap();
            assert!(matches!(created_at, FieldValue::Date(_)));
            *self.created_at.lock().unwrap() = Some(created_at.clone());
            let _ = user.insert("hookMarker".into(), self.marker.clone());
            let _ = user.insert("name".into(), "After hook".into());
            response.replace_returned(AuthResponse::native(
                200,
                FieldMap::from([("user".into(), user.into())]).into(),
            ));
        }
        Ok(())
    }
}

#[async_trait]
impl ValidateUserInfo<StatelessSchema> for Probe {
    async fn validate(
        &self,
        _: &UserValidationData,
        context: &EndpointContext<'_, StatelessSchema>,
    ) -> AuthResult<Option<UserValidationRejection>> {
        assert!(context.request.is_none());
        assert_eq!(context.path, Some("/admin/create-user"));
        self.events.lock().unwrap().push(json!({
            "phase":"admission",
            "headers":context.headers(),
            "session":context.session.is_some(),
            "body":context.body.json()?,
        }));
        Ok(None)
    }
}

async fn fixture(patch: Option<Value>) -> AuthResult<(AuthContext<StatelessSchema>, Arc<Probe>)> {
    let plugin = AdminPlugin::new();
    let mut config = create_test_config();
    config.session.disable_session_refresh = Some(true);
    let config = Arc::new(config);
    let mut context = initialize_test_context(
        config.clone(),
        Arc::new(EphemeralStore::new(config)),
        &[&plugin],
    )
    .await?;
    let probe = Arc::new(Probe {
        events: Default::default(),
        patch,
        marker: FieldMap::from([("retained".into(), true.into())]).into(),
        created_at: Default::default(),
    });
    let plugins: Vec<Box<dyn AuthPlugin<StatelessSchema>>> = vec![Box::new(plugin)];
    context.extensions.insert(Arc::new(EndpointDispatcher::new(
        Arc::new(plugins),
        EndpointHooks {
            before: Some(probe.clone()),
            after: Some(probe.clone()),
        },
        [],
    )));
    context
        .extensions
        .insert(probe.clone() as Arc<dyn ValidateUserInfo<StatelessSchema>>);
    Ok((context, probe))
}

fn input(email: &str) -> CreateAdminUser {
    CreateAdminUser {
        email: email.into(),
        name: "Before hook".into(),
        password: None,
        role: None,
        data: None,
    }
}

#[tokio::test]
async fn create_user_runs_validation_and_hooks_without_losing_native_response_values()
-> AuthResult<()> {
    let (context, probe) = fixture(Some(json!({
        "email":"PATCHED@native-admin.test",
        "ignored":"removed by the endpoint validator",
    })))
    .await?;
    let response = AdminApi::from_context(&context)?
        .create_user(&input("initial@native-admin.test"), None)
        .await?;
    assert_eq!(
        response.user.email.field_value(),
        "patched@native-admin.test".into()
    );
    assert_eq!(response.user.name.field_value(), "After hook".into());
    assert!(
        response
            .user
            .additional_fields
            .get("hookMarker")
            .unwrap()
            .strict_equals(&probe.marker)
    );
    assert!(
        response
            .user
            .created_at
            .field_value()
            .strict_equals(probe.created_at.lock().unwrap().as_ref().unwrap())
    );
    assert_eq!(
        probe.events(),
        vec![
            json!({"phase":"before","headers":null}),
            json!({"phase":"admission","headers":null,"session":false,"body":{"email":"PATCHED@native-admin.test","name":"Before hook"}}),
            json!({"phase":"after","headers":null}),
        ]
    );
    assert!(
        context
            .database
            .get_user_by_email("initial@native-admin.test")
            .await?
            .is_none()
    );
    let stored = context
        .database
        .get_user_by_email("patched@native-admin.test")
        .await?
        .unwrap();
    assert_eq!(stored.name.field_value(), "Before hook".into());
    assert!(!stored.additional_fields.contains_key("ignored"));
    assert!(!stored.additional_fields.contains_key("hookMarker"));
    Ok(())
}

#[tokio::test]
async fn create_user_preserves_supplied_headers_and_requires_their_session() -> AuthResult<()> {
    let (context, probe) = fixture(None).await?;
    let empty = HashMap::new();
    let error = AdminApi::from_context(&context)?
        .create_user(&input("unauthenticated@native-admin.test"), Some(&empty))
        .await
        .unwrap_err();
    assert_eq!(error.status_code().unwrap(), 401);
    assert_eq!(
        probe.events(),
        vec![
            json!({"phase":"before","headers":{}}),
            json!({"phase":"after","headers":{}}),
        ]
    );
    assert!(
        context
            .database
            .get_user_by_email("unauthenticated@native-admin.test")
            .await?
            .is_none()
    );
    probe.events.lock().unwrap().clear();
    let owner = context
        .database
        .create_user(
            CreateUser::new()
                .with_email("owner@native-admin.test")
                .with_role("admin"),
        )
        .await?;
    let session = context
        .database
        .create_session(CreateSession {
            user_id: owner.id.clone(),
            expires_at: (chrono::Utc::now() + chrono::Duration::hours(1)).into(),
            inherited_fields: Default::default(),
            additional_fields: Default::default(),
            ip_address: None,
            user_agent: None,
            impersonated_by: None,
            active_organization_id: None,
        })
        .await?;
    let cookie = format!(
        "{}={}",
        context
            .config
            .auth_cookie("session_token", Default::default())
            .name,
        better_auth_core::utils::cookie_utils::sign_cookie_value(
            session.token.typed()?,
            context.config.signing_secret()
        ),
    );
    let headers = HashMap::from([
        ("Cookie".into(), cookie.clone()),
        ("X-Trace".into(), "native".into()),
    ]);
    let response = AdminApi::from_context(&context)?
        .create_user(&input("authenticated@native-admin.test"), Some(&headers))
        .await?;
    assert_eq!(
        response.user.email.field_value(),
        "authenticated@native-admin.test".into()
    );
    let observed_headers = json!({"cookie":cookie,"x-trace":"native"});
    assert_eq!(
        probe.events(),
        vec![
            json!({"phase":"before","headers":observed_headers}),
            json!({"phase":"admission","headers":observed_headers,"session":true,"body":{"email":"authenticated@native-admin.test","name":"Before hook"}}),
            json!({"phase":"after","headers":observed_headers}),
        ]
    );
    Ok(())
}

#[tokio::test]
async fn invalid_hook_input_runs_after_without_admission_or_store_writes() -> AuthResult<()> {
    let (context, probe) = fixture(Some(json!({"email":17}))).await?;
    let error = AdminApi::from_context(&context)?
        .create_user(&input("invalid@native-admin.test"), None)
        .await
        .unwrap_err();
    assert_eq!(error.status_code().unwrap(), 400);
    assert_eq!(
        error.body_value()?.json()?,
        Some(json!({
            "code":"VALIDATION_ERROR",
            "message":"[body.email] Invalid input: expected string, received number",
        }))
    );
    assert_eq!(
        probe.events(),
        vec![
            json!({"phase":"before","headers":null}),
            json!({"phase":"after","headers":null}),
        ]
    );
    assert_eq!(context.database.list_users(Default::default()).await?.1, 0);
    Ok(())
}