better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
#![expect(
    clippy::unwrap_used,
    clippy::panic_in_result_fn,
    reason = "Admin regressions compare exact permission, callback, and storage outcomes"
)]

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

use better_auth_core::{
    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, CreateSession,
    CreateUser, FieldMap, FieldValue, HttpMethod,
    config::CookieCacheConfig,
    store::{EphemeralStore, StatelessSchema, StoreCapabilities},
    wire::{SessionView, UserView},
};
use chrono::{Duration, Utc};
use serde_json::{Value, json};

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

struct Fixture {
    ctx: AuthContext<StatelessSchema>,
    session: SessionView,
    owner: UserView,
    hook: NativeSessionHook,
}

impl Fixture {
    async fn new(plugin: &AdminPlugin) -> AuthResult<Self> {
        let mut config = create_test_config();
        config.session.disable_session_refresh = Some(true);
        config.session.cookie_cache = Some(CookieCacheConfig {
            enabled: Some(true),
            ..Default::default()
        });
        let config = Arc::new(config);
        let mut ctx = initialize_test_context(
            config.clone(),
            Arc::new(EphemeralStore::new(config)),
            &[plugin],
        )
        .await?;
        ctx.extensions.insert(StoreCapabilities {
            database: false,
            secondary: false,
        });
        let hook =
            NativeSessionHook::install(&mut ctx, AuthPlugin::<StatelessSchema>::routes(plugin));
        let owner = ctx
            .database
            .create_user(CreateUser {
                id: Some("17".into()),
                email: Some("owner@native-admin.test".into()),
                role: Some("admin".into()),
                ..Default::default()
            })
            .await?;
        let session = ctx
            .database
            .create_session(CreateSession {
                user_id: owner.id.clone(),
                expires_at: (Utc::now() + 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?;
        Ok(Self {
            ctx,
            session,
            owner,
            hook,
        })
    }

    fn request(&self, user: FieldValue, path: &str, body: Value) -> AuthResult<AuthRequest> {
        self.hook
            .request(&self.ctx, &self.session, user, HttpMethod::Post, path, body)
    }

    async fn dispatch(
        &self,
        request: &AuthRequest,
        plugin: &AdminPlugin,
    ) -> AuthResult<AuthResponse> {
        Ok(dispatch_http_plugin(request, &self.ctx, plugin)
            .await?
            .unwrap())
    }
}

fn body(response: &AuthResponse) -> Value {
    let value = if response.is_api_error().unwrap() {
        better_auth_core::ResponseBody::Native(response.api_error_body().unwrap())
    } else {
        response.body.clone()
    };
    serde_json::from_slice(&value.bytes().unwrap()).unwrap()
}

fn assert_http_error(response: &AuthResponse, status: u16, expected: Value) {
    assert_eq!(response.status, status);
    assert!(!response.is_api_error().unwrap());
    assert_eq!(body(response), expected);
}

#[tokio::test]
async fn admin_permission_reads_reject_null_and_deny_primitive_users_before_writes()
-> AuthResult<()> {
    let plugin = AdminPlugin::new();
    let fixture = Fixture::new(&plugin).await?;
    for user in [FieldValue::Null, false.into(), 0.0.into(), "".into()] {
        let request = fixture.request(
            user.clone(),
            "/admin/update-user",
            json!({"userId":fixture.owner.id,"data":{"name":"must not persist"}}),
        )?;
        let result = fixture.dispatch(&request, &plugin).await;
        if user.is_null() {
            let error = result.unwrap_err();
            assert!(matches!(error, AuthError::TypeError(ref message)
                if message == "Cannot read properties of null (reading 'id')"));
        } else {
            assert_http_error(
                &result?,
                403,
                json!({
                    "code":"YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS",
                    "message":"You are not allowed to update users"
                }),
            );
        }
        assert_eq!(
            fixture
                .ctx
                .database
                .get_user_by_id(fixture.owner.id.typed()?)
                .await?,
            Some(fixture.owner.clone())
        );
        assert_eq!(
            fixture
                .ctx
                .database
                .get_session(fixture.session.token.typed()?)
                .await?,
            Some(fixture.session.clone())
        );
        assert!(request.new_session()?.is_none());
    }
    Ok(())
}

#[derive(Default)]
struct Admission(Mutex<Vec<FieldValue>>);

#[async_trait::async_trait]
impl ValidateUserInfo<StatelessSchema> for Admission {
    async fn validate(
        &self,
        _data: &UserValidationData,
        context: &EndpointContext<'_, StatelessSchema>,
    ) -> AuthResult<Option<UserValidationRejection>> {
        self.0
            .lock()
            .unwrap()
            .push(context.session.as_ref().unwrap().user_value()?);
        Ok(None)
    }
}

#[tokio::test]
async fn admin_create_retains_primitive_session_values_for_http_and_native_admission()
-> AuthResult<()> {
    let plugin = AdminPlugin::with_config(AdminConfig {
        default_role: "admin".into(),
        ..Default::default()
    });
    let mut fixture = Fixture::new(&plugin).await?;
    let admission = Arc::new(Admission::default());
    fixture
        .ctx
        .extensions
        .insert(admission.clone() as Arc<dyn ValidateUserInfo<StatelessSchema>>);
    for (index, user) in [FieldValue::Bool(false), 0.0.into(), "".into()]
        .into_iter()
        .enumerate()
    {
        for native in [false, true] {
            let email = format!("created-{index}-{native}@native-admin.test");
            let input =
                json!({"email":email,"name":"Created","role":"user","data":{"banned":false}});
            let request = fixture.request(user.clone(), "/admin/create-user", input.clone())?;
            if native {
                let input: CreateAdminUser = serde_json::from_value(input)?;
                let response = AdminApi::from_context(&fixture.ctx)?
                    .create_user(&input, Some(&request.headers))
                    .await?;
                assert_eq!(
                    response.user.email.field_value(),
                    FieldValue::from(email.clone())
                );
            } else {
                let response = fixture.dispatch(&request, &plugin).await?;
                assert_eq!(body(&response)["user"]["email"], email);
                assert!(request.new_session()?.is_none());
            }
            assert_eq!(admission.0.lock().unwrap().last(), Some(&user));
            let persisted = fixture
                .ctx
                .database
                .get_user_by_email(&email)
                .await?
                .unwrap();
            assert_eq!(persisted.role.field_value(), FieldValue::from("user"));
            assert_eq!(persisted.banned.field_value(), FieldValue::Bool(false));
        }
    }
    assert_eq!(admission.0.lock().unwrap().len(), 6);
    Ok(())
}

#[tokio::test]
async fn admin_has_permission_uses_user_truthiness_before_request_fallback() -> AuthResult<()> {
    let plugin = AdminPlugin::new();
    let fixture = Fixture::new(&plugin).await?;
    for user in [
        FieldValue::Null,
        false.into(),
        0.0.into(),
        "".into(),
        FieldMap::new().into(),
    ] {
        let request = fixture.request(
            user.clone(),
            "/admin/has-permission",
            json!({"role":"admin","permissions":{"user":["create"]}}),
        )?;
        let response = fixture.dispatch(&request, &plugin).await?;
        assert_eq!(
            body(&response),
            json!({"error":null,"success":!user.is_truthy()})
        );
        if !user.is_truthy() {
            let request = fixture.request(
                user.clone(),
                "/admin/has-permission",
                json!({"userId":fixture.owner.id,"permissions":{"user":["create"]}}),
            )?;
            assert_eq!(
                body(&fixture.dispatch(&request, &plugin).await?)["success"],
                true
            );
            let request = fixture.request(
                user,
                "/admin/has-permission",
                json!({"permissions":{"user":["create"]}}),
            )?;
            assert_http_error(
                &fixture.dispatch(&request, &plugin).await?,
                400,
                json!({"message":"user not found"}),
            );
        }
    }
    Ok(())
}

#[tokio::test]
async fn admin_self_ban_compares_native_ids_without_string_coercion() -> AuthResult<()> {
    let plugin = AdminPlugin::new();
    for actor_id in [FieldValue::from("17"), FieldValue::from(17.0)] {
        let fixture = Fixture::new(&plugin).await?;
        let request = fixture.request(
            FieldMap::from([
                ("id".into(), actor_id.clone()),
                ("role".into(), "admin".into()),
            ])
            .into(),
            "/admin/update-user",
            json!({"userId":"17","data":{"banned":true}}),
        )?;
        let result = fixture.dispatch(&request, &plugin).await;
        let same_id = actor_id.strict_equals(&FieldValue::from("17"));
        if same_id {
            assert_http_error(
                &result?,
                400,
                json!({
                    "code":"YOU_CANNOT_BAN_YOURSELF",
                    "message":"You cannot ban yourself"
                }),
            );
        } else {
            assert_eq!(body(&result?)["banned"], true);
        }
        let stored = fixture.ctx.database.get_user_by_id("17").await?.unwrap();
        if same_id {
            assert_eq!(stored, fixture.owner);
        } else {
            assert_eq!(stored.banned.field_value(), FieldValue::Bool(true));
        }
        assert_eq!(
            fixture
                .ctx
                .database
                .get_session(fixture.session.token.typed()?)
                .await?
                .is_some(),
            same_id
        );
    }
    Ok(())
}

#[tokio::test]
async fn admin_impersonation_keeps_native_actor_ids_and_allows_authorized_self_targets()
-> AuthResult<()> {
    for allow_admins in [false, true] {
        let plugin = AdminPlugin::with_config(AdminConfig {
            allow_impersonating_admins: allow_admins,
            ..Default::default()
        });
        let fixture = Fixture::new(&plugin).await?;
        for actor_id in [FieldValue::from("17"), FieldValue::from(17.0)] {
            let request = fixture.request(
                FieldMap::from([
                    ("id".into(), actor_id.clone()),
                    ("role".into(), "admin".into()),
                ])
                .into(),
                "/admin/impersonate-user",
                json!({"userId":"17"}),
            )?;
            let result = fixture.dispatch(&request, &plugin).await;
            if allow_admins {
                let response = result?;
                let output = body(&response);
                assert_eq!(output["user"]["id"], "17");
                assert_eq!(
                    output["session"]["impersonatedBy"],
                    actor_id.json()?.unwrap()
                );
                let session = fixture
                    .ctx
                    .database
                    .get_session(output["session"]["token"].as_str().unwrap())
                    .await?
                    .unwrap();
                assert_eq!(session.user_id.field_value(), FieldValue::from("17"));
                assert_eq!(session.impersonated_by.field_value(), actor_id);
                assert_eq!(
                    request.new_session()?.unwrap().session_view()?.token,
                    session.token
                );
            } else {
                assert_http_error(
                    &result?,
                    403,
                    json!({
                        "code":"YOU_CANNOT_IMPERSONATE_ADMINS",
                        "message":"You cannot impersonate admins"
                    }),
                );
                assert!(request.new_session()?.is_none());
                assert_eq!(fixture.ctx.database.get_user_sessions("17").await?.len(), 1);
            }
            assert_eq!(
                fixture
                    .ctx
                    .database
                    .get_session(fixture.session.token.typed()?)
                    .await?,
                Some(fixture.session.clone())
            );
        }
    }
    Ok(())
}