mod cookies;
mod organization;
pub use cookies::TestCookie;
pub use organization::TestOrganizationApi;
use super::endpoint_context::EndpointContext;
use super::user_admission::{
self, UserValidationAction, UserValidationRejection, UserValidationSource, ValidateUserInfo,
};
use async_trait::async_trait;
use better_auth_core::store::database_hooks::{DatabaseHookContext, DatabaseHooks};
use better_auth_core::wire::{SessionView, UserView, VerificationView};
use better_auth_core::{
AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
AuthRoute, AuthSchema, CreateSession, CreateUser, FieldMap,
};
use chrono::Utc;
use serde_json::Value;
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
#[derive(Debug, Clone, Default)]
pub struct TestUtilsPlugin {
pub capture_otp: bool,
}
#[derive(Default)]
pub struct OtpCapture(RwLock<HashMap<String, String>>);
impl OtpCapture {
pub fn get(&self, identifier: &str) -> AuthResult<Option<String>> {
Ok(self
.0
.read()
.map_err(|_| AuthError::internal("OTP capture lock poisoned"))?
.get(identifier)
.cloned())
}
pub fn clear(&self) -> AuthResult<()> {
self.0
.write()
.map_err(|_| AuthError::internal("OTP capture lock poisoned"))?
.clear();
Ok(())
}
}
struct TestState {
otps: Option<Arc<OtpCapture>>,
}
struct CaptureHook(Arc<OtpCapture>);
#[better_auth_core::database_hooks("plugin:test-utils")]
impl<S: AuthSchema> DatabaseHooks<S> for CaptureHook {
async fn after_create_verification(
&self,
verification: Option<&VerificationView>,
_: &DatabaseHookContext<'_, S>,
) -> AuthResult<()> {
let Some(verification) = verification else {
return Ok(());
};
let value = verification.value.field_value();
if !value.is_truthy() {
return Ok(());
}
let identifier = verification.identifier.field_value();
if !identifier.is_truthy() {
return Ok(());
}
let value = value
.as_str()
.ok_or_else(|| AuthError::internal("verification.value.split is not a function"))?;
let Some(value) = value.split(':').next().filter(|value| !value.is_empty()) else {
return Ok(());
};
let identifier = identifier
.as_str()
.ok_or_else(|| AuthError::internal("identifier.startsWith is not a function"))?;
let identifier = [
"email-verification-otp-",
"sign-in-otp-",
"forget-password-otp-",
"phone-verification-otp-",
]
.into_iter()
.find_map(|prefix| identifier.strip_prefix(prefix))
.unwrap_or(identifier);
let _ = self
.0
.0
.write()
.map_err(|_| AuthError::internal("OTP capture lock poisoned"))?
.insert(identifier.to_owned(), value.to_owned());
Ok(())
}
}
#[async_trait]
impl<S: AuthSchema> AuthPlugin<S> for TestUtilsPlugin {
fn name(&self) -> &'static str {
"test-utils"
}
fn routes(&self) -> Vec<AuthRoute> {
Vec::new()
}
async fn on_init(&self, context: &mut AuthInitContext<S>) -> AuthResult<()> {
let otps = self.capture_otp.then(|| Arc::new(OtpCapture::default()));
if let Some(sink) = &otps {
context.register_database_hook(Arc::new(CaptureHook(sink.clone())));
}
context.extensions.insert(TestState { otps });
Ok(())
}
async fn on_request(
&self,
_: &AuthRequest,
_: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
Ok(None)
}
}
#[derive(Debug, Clone)]
pub struct TestAuthOptions {
pub user_id: String,
pub session: FieldMap,
}
impl TestAuthOptions {
pub fn new(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
session: FieldMap::new(),
}
}
}
#[derive(Debug)]
pub struct TestLogin {
pub session: SessionView,
pub user: UserView,
pub headers: HashMap<String, String>,
pub cookies: Vec<TestCookie>,
pub token: better_auth_core::SchemaValue<String>,
}
pub struct TestUtilsApi<'a, S: AuthSchema> {
auth: &'a AuthContext<S>,
state: &'a TestState,
endpoint: Option<&'a EndpointContext<'a, S>>,
}
impl<'a, S: AuthSchema> TestUtilsApi<'a, S> {
pub fn from_context(auth: &'a AuthContext<S>) -> AuthResult<Self> {
let state = auth
.extensions
.get::<TestState>()
.ok_or_else(|| AuthError::config("TestUtilsPlugin is not enabled"))?;
Ok(Self {
auth,
state,
endpoint: None,
})
}
pub fn from_endpoint(endpoint: &'a EndpointContext<'a, S>) -> AuthResult<Self> {
let mut api = Self::from_context(endpoint.auth)?;
api.endpoint = Some(endpoint);
Ok(api)
}
pub fn create_user(&self, mut overrides: CreateUser) -> AuthResult<CreateUser> {
let id = self.generate_id("user")?;
let now = better_auth_core::FieldDate::from(Utc::now());
let _ = overrides.id.get_or_insert(id);
let _ = overrides
.email
.get_or_insert_with(|| format!("test-{}@example.com", random_lower(8)));
if overrides.name.is_undefined() {
overrides.name = Some("Test User".into()).into();
}
let _ = overrides.email_verified.get_or_insert(true);
if overrides.image.is_undefined() {
overrides.image = None.into();
}
let _ = overrides.created_at.get_or_insert(now.clone());
let _ = overrides.updated_at.get_or_insert(now);
Ok(overrides)
}
pub async fn save_user(&self, user: CreateUser) -> AuthResult<Option<UserView>> {
let mut user = user.into_user_fields()?;
if let Some(endpoint) = self.endpoint {
if let Some(rejection) = user_admission::validate_create(
&user,
UserValidationSource::new("test", UserValidationAction::CreateUser),
endpoint,
)
.await?
{
return Err(rejection.into_auth_error());
}
} else if self
.auth
.extensions
.get::<Arc<dyn ValidateUserInfo<S>>>()
.is_some()
{
let current =
better_auth_core::hooks::current_request_hook_context().ok_or_else(|| {
UserValidationRejection::new("validation_context_missing")
.with_description("User validation requires an endpoint context")
.into_auth_error()
})?;
let endpoint = EndpointContext::new(Some(¤t.request), current.body, self.auth);
if let Some(rejection) = user_admission::validate_create(
&user,
UserValidationSource::new("test", UserValidationAction::CreateUser),
&endpoint,
)
.await?
{
return Err(rejection.into_auth_error());
}
}
super::helpers::apply_default_role(self.auth, &mut user);
match self.endpoint.and_then(|endpoint| endpoint.transaction) {
Some(tx) => tx.create_user_fields_optional(user).await,
None => self.auth.database.create_user_fields_optional(user).await,
}
}
pub async fn delete_user(&self, id: &str) -> AuthResult<()> {
match self.endpoint.and_then(|endpoint| endpoint.transaction) {
Some(tx) => tx.delete_user(id).await,
None => self.auth.database.delete_user(id).await,
}
}
pub fn organization(&self) -> Option<TestOrganizationApi<'_, S>> {
(self
.auth
.metadata
.get("organization.enabled")
.and_then(Value::as_bool)
== Some(true))
.then_some(TestOrganizationApi { api: self })
}
pub fn otps(&self) -> Option<&OtpCapture> {
self.state.otps.as_deref()
}
pub async fn login(&self, options: TestAuthOptions) -> AuthResult<TestLogin> {
let user = match self.endpoint.and_then(|endpoint| endpoint.transaction) {
Some(tx) => tx.get_user_by_id(&options.user_id).await?,
None => self.auth.database.get_user_by_id(&options.user_id).await?,
}
.ok_or_else(|| AuthError::internal(format!("User not found: {}", options.user_id)))?;
let session = self.create_session(options).await?;
let token = session.token.clone();
Ok(TestLogin {
headers: cookies::headers(self.auth, &token.field_value())?,
cookies: cookies::cookies(self.auth, &token.field_value(), None)?,
token,
session,
user,
})
}
pub async fn get_auth_headers(
&self,
options: TestAuthOptions,
) -> AuthResult<HashMap<String, String>> {
let session = self.create_session(options).await?;
cookies::headers(self.auth, &session.token.field_value())
}
pub async fn get_cookies(
&self,
options: TestAuthOptions,
domain: Option<&str>,
) -> AuthResult<Vec<TestCookie>> {
let session = self.create_session(options).await?;
cookies::cookies(self.auth, &session.token.field_value(), domain)
}
fn generate_id(&self, model: &str) -> AuthResult<String> {
Ok(self
.auth
.config
.advanced
.generate_id(model, None)?
.unwrap_or_else(|| better_auth_core::id::random_id(Some(24))))
}
async fn create_session(&self, mut options: TestAuthOptions) -> AuthResult<SessionView> {
for field in [
"id",
"userId",
"createdAt",
"updatedAt",
"expiresAt",
"token",
"ipAddress",
"userAgent",
] {
let _ = options.session.remove(field);
}
for (name, plugin) in [
("impersonatedBy", "admin.enabled"),
("activeOrganizationId", "organization.enabled"),
("activeTeamId", "organization.teams_enabled"),
] {
if self.auth.metadata.get(plugin).and_then(Value::as_bool) != Some(true) {
let _ = options.session.remove(name);
}
}
let meta = if let Some(endpoint) = self.endpoint {
let request = AuthRequest::new(better_auth_core::HttpMethod::Post, "/")
.with_optional_headers(
endpoint
.headers()
.cloned()
.or_else(|| endpoint.request.map(|request| request.headers.clone())),
);
better_auth_core::RequestMeta::from_request_with_config(
&request,
&self.auth.config.advanced.ip_address,
)
} else {
better_auth_core::hooks::current_request_hook_context()
.map(|context| context.meta)
.unwrap_or_default()
};
let input = CreateSession {
inherited_fields: Default::default(),
user_id: options.user_id.into(),
expires_at: (Utc::now() + self.auth.config.session.expires_in()).into(),
ip_address: meta.ip_address,
user_agent: meta.user_agent,
impersonated_by: None,
active_organization_id: None,
additional_fields: options.session,
};
match self.endpoint.and_then(|endpoint| endpoint.transaction) {
Some(tx) => tx.create_session_optional(input).await?,
None => self.auth.database.create_session_optional(input).await?,
}
.ok_or_else(|| AuthError::internal("Cannot read properties of null (reading 'token')"))
}
}
fn random_lower(length: usize) -> String {
use rand::Rng;
rand::thread_rng()
.sample_iter(rand::distributions::Alphanumeric)
.filter(|value| value.is_ascii_lowercase() || value.is_ascii_digit())
.take(length)
.map(char::from)
.collect()
}