Skip to main content

better_auth_api/plugins/
test_utils.rs

1//! Native test factories, database setup, and authenticated browser cookies.
2
3mod cookies;
4mod organization;
5
6pub use cookies::TestCookie;
7pub use organization::TestOrganizationApi;
8
9use super::endpoint_context::EndpointContext;
10use super::user_admission::{
11    self, UserValidationAction, UserValidationRejection, UserValidationSource, ValidateUserInfo,
12};
13use async_trait::async_trait;
14use better_auth_core::store::database_hooks::{DatabaseHookContext, DatabaseHooks};
15use better_auth_core::wire::{SessionView, UserView, VerificationView};
16use better_auth_core::{
17    AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
18    AuthRoute, AuthSchema, CreateSession, CreateUser, FieldMap,
19};
20use chrono::Utc;
21use serde_json::Value;
22use std::{
23    collections::HashMap,
24    sync::{Arc, RwLock},
25};
26
27/// Enable native test helpers. This plugin exposes privileged database operations.
28#[derive(Debug, Clone, Default)]
29pub struct TestUtilsPlugin {
30    /// Capture OTP values after successful verification creation.
31    pub capture_otp: bool,
32}
33
34/// Per-instance OTP capture. Clearing the sink does not delete verification records.
35#[derive(Default)]
36pub struct OtpCapture(RwLock<HashMap<String, String>>);
37impl OtpCapture {
38    /// Read the last captured OTP for an identifier after prefix normalization.
39    pub fn get(&self, identifier: &str) -> AuthResult<Option<String>> {
40        Ok(self
41            .0
42            .read()
43            .map_err(|_| AuthError::internal("OTP capture lock poisoned"))?
44            .get(identifier)
45            .cloned())
46    }
47    /// Clear captured OTPs without changing persisted verification records.
48    pub fn clear(&self) -> AuthResult<()> {
49        self.0
50            .write()
51            .map_err(|_| AuthError::internal("OTP capture lock poisoned"))?
52            .clear();
53        Ok(())
54    }
55}
56struct TestState {
57    otps: Option<Arc<OtpCapture>>,
58}
59struct CaptureHook(Arc<OtpCapture>);
60#[better_auth_core::database_hooks("plugin:test-utils")]
61impl<S: AuthSchema> DatabaseHooks<S> for CaptureHook {
62    async fn after_create_verification(
63        &self,
64        verification: &VerificationView,
65        _: &DatabaseHookContext<'_, S>,
66    ) -> AuthResult<()> {
67        let value = verification.value.field_value();
68        if !value.is_truthy() {
69            return Ok(());
70        }
71        let identifier = verification.identifier.field_value();
72        if !identifier.is_truthy() {
73            return Ok(());
74        }
75        let value = value
76            .as_str()
77            .ok_or_else(|| AuthError::internal("verification.value.split is not a function"))?;
78        let Some(value) = value.split(':').next().filter(|value| !value.is_empty()) else {
79            return Ok(());
80        };
81        let identifier = identifier
82            .as_str()
83            .ok_or_else(|| AuthError::internal("identifier.startsWith is not a function"))?;
84        let identifier = [
85            "email-verification-otp-",
86            "sign-in-otp-",
87            "forget-password-otp-",
88            "phone-verification-otp-",
89        ]
90        .into_iter()
91        .find_map(|prefix| identifier.strip_prefix(prefix))
92        .unwrap_or(identifier);
93        let _ = self
94            .0
95            .0
96            .write()
97            .map_err(|_| AuthError::internal("OTP capture lock poisoned"))?
98            .insert(identifier.to_owned(), value.to_owned());
99        Ok(())
100    }
101}
102#[async_trait]
103impl<S: AuthSchema> AuthPlugin<S> for TestUtilsPlugin {
104    fn name(&self) -> &'static str {
105        "test-utils"
106    }
107    fn routes(&self) -> Vec<AuthRoute> {
108        Vec::new()
109    }
110    async fn on_init(&self, context: &mut AuthInitContext<S>) -> AuthResult<()> {
111        let otps = self.capture_otp.then(|| Arc::new(OtpCapture::default()));
112        if let Some(sink) = &otps {
113            context.register_database_hook(Arc::new(CaptureHook(sink.clone())));
114        }
115        context.extensions.insert(TestState { otps });
116        Ok(())
117    }
118    async fn on_request(
119        &self,
120        _: &AuthRequest,
121        _: &AuthContext<S>,
122    ) -> AuthResult<Option<AuthResponse>> {
123        Ok(None)
124    }
125}
126
127/// Input shared by the session and cookie helpers. Core session keys are ignored.
128#[derive(Debug, Clone)]
129pub struct TestAuthOptions {
130    /// Existing user ID. `login` checks existence; cookie helpers do not.
131    pub user_id: String,
132    /// Plugin and application session fields.
133    pub session: FieldMap,
134}
135impl TestAuthOptions {
136    /// Use the configured session defaults for this user.
137    pub fn new(user_id: impl Into<String>) -> Self {
138        Self {
139            user_id: user_id.into(),
140            session: FieldMap::new(),
141        }
142    }
143}
144
145/// The session, user, and credentials from one native login operation.
146#[derive(Debug)]
147pub struct TestLogin {
148    /// Session created by this login operation.
149    pub session: SessionView,
150    /// Internal adapter user projection, including fields hidden from public responses.
151    pub user: UserView,
152    /// Raw signed Cookie request header.
153    pub headers: HashMap<String, String>,
154    /// Browser cookie values and attributes for the same session.
155    pub cookies: Vec<TestCookie>,
156    /// Unsigned session token.
157    pub token: String,
158}
159
160/// Native helpers bound to one initialized authentication instance.
161pub struct TestUtilsApi<'a, S: AuthSchema> {
162    auth: &'a AuthContext<S>,
163    state: &'a TestState,
164    endpoint: Option<&'a EndpointContext<'a, S>>,
165}
166impl<'a, S: AuthSchema> TestUtilsApi<'a, S> {
167    /// Access helpers only when `TestUtilsPlugin` is installed on this instance.
168    pub fn from_context(auth: &'a AuthContext<S>) -> AuthResult<Self> {
169        let state = auth
170            .extensions
171            .get::<TestState>()
172            .ok_or_else(|| AuthError::config("TestUtilsPlugin is not enabled"))?;
173        Ok(Self {
174            auth,
175            state,
176            endpoint: None,
177        })
178    }
179    /// Preserve the active transaction when invoking helpers from an endpoint callback.
180    pub fn from_endpoint(endpoint: &'a EndpointContext<'a, S>) -> AuthResult<Self> {
181        let mut api = Self::from_context(endpoint.auth)?;
182        api.endpoint = Some(endpoint);
183        Ok(api)
184    }
185    /// Build a user without persisting it. The configured ID generator still runs for explicit IDs.
186    pub fn create_user(&self, mut overrides: CreateUser) -> AuthResult<CreateUser> {
187        let id = self.generate_id("user")?;
188        let now = better_auth_core::FieldDate::from(Utc::now());
189        let _ = overrides.id.get_or_insert(id);
190        let _ = overrides
191            .email
192            .get_or_insert_with(|| format!("test-{}@example.com", random_lower(8)));
193        if overrides.name.is_undefined() {
194            overrides.name = Some("Test User".into()).into();
195        }
196        let _ = overrides.email_verified.get_or_insert(true);
197        if overrides.image.is_undefined() {
198            overrides.image = None.into();
199        }
200        let _ = overrides.created_at.get_or_insert(now.clone());
201        let _ = overrides.updated_at.get_or_insert(now);
202        Ok(overrides)
203    }
204    /// Persist a user through admission and database hooks. A cancelled before hook returns `None`.
205    pub async fn save_user(&self, mut user: CreateUser) -> AuthResult<Option<UserView>> {
206        if let Some(endpoint) = self.endpoint {
207            user_admission::validate_create(
208                &user,
209                UserValidationSource::new("test", UserValidationAction::CreateUser),
210                endpoint,
211            )
212            .await
213            .map_err(UserValidationRejection::into_auth_error)?;
214        } else if self
215            .auth
216            .extensions
217            .get::<Arc<dyn ValidateUserInfo<S>>>()
218            .is_some()
219        {
220            let current =
221                better_auth_core::hooks::current_request_hook_context().ok_or_else(|| {
222                    UserValidationRejection::new("validation_context_missing")
223                        .with_description("User validation requires an endpoint context")
224                        .into_auth_error()
225                })?;
226            let endpoint = EndpointContext::new(Some(&current.request), current.body, self.auth);
227            user_admission::validate_create(
228                &user,
229                UserValidationSource::new("test", UserValidationAction::CreateUser),
230                &endpoint,
231            )
232            .await
233            .map_err(UserValidationRejection::into_auth_error)?;
234        }
235        if let Some(email) = &mut user.email {
236            *email = email.to_lowercase();
237        }
238        super::helpers::apply_default_role(self.auth, &mut user);
239        match self.endpoint.and_then(|endpoint| endpoint.transaction) {
240            Some(tx) => tx.create_user_optional(user).await,
241            None => self.auth.database.create_user_optional(user).await,
242        }
243    }
244    /// Delete the user through the internal adapter's session and account cleanup.
245    pub async fn delete_user(&self, id: &str) -> AuthResult<()> {
246        match self.endpoint.and_then(|endpoint| endpoint.transaction) {
247            Some(tx) => tx.delete_user(id).await,
248            None => self.auth.database.delete_user(id).await,
249        }
250    }
251    /// Organization factories are available only when the organization plugin is installed.
252    pub fn organization(&self) -> Option<TestOrganizationApi<'_, S>> {
253        (self
254            .auth
255            .metadata
256            .get("organization.enabled")
257            .and_then(Value::as_bool)
258            == Some(true))
259        .then_some(TestOrganizationApi { api: self })
260    }
261    /// OTP capture is absent unless explicitly enabled for this instance.
262    pub fn otps(&self) -> Option<&OtpCapture> {
263        self.state.otps.as_deref()
264    }
265    /// Create one session after confirming that the user exists.
266    pub async fn login(&self, options: TestAuthOptions) -> AuthResult<TestLogin> {
267        let user = match self.endpoint.and_then(|endpoint| endpoint.transaction) {
268            Some(tx) => tx.get_user_by_id(&options.user_id).await?,
269            None => self.auth.database.get_user_by_id(&options.user_id).await?,
270        }
271        .ok_or_else(|| AuthError::internal(format!("User not found: {}", options.user_id)))?;
272        let session = self.create_session(options).await?;
273        let token = session.token.clone();
274        Ok(TestLogin {
275            headers: cookies::headers(self.auth, &token),
276            cookies: cookies::cookies(self.auth, &token, None),
277            token,
278            session,
279            user,
280        })
281    }
282    /// Create a fresh session and its raw signed Cookie request header without a user lookup.
283    pub async fn get_auth_headers(
284        &self,
285        options: TestAuthOptions,
286    ) -> AuthResult<HashMap<String, String>> {
287        let session = self.create_session(options).await?;
288        Ok(cookies::headers(self.auth, &session.token))
289    }
290    /// Create a fresh session and browser-cookie attributes without a user lookup.
291    pub async fn get_cookies(
292        &self,
293        options: TestAuthOptions,
294        domain: Option<&str>,
295    ) -> AuthResult<Vec<TestCookie>> {
296        let session = self.create_session(options).await?;
297        Ok(cookies::cookies(self.auth, &session.token, domain))
298    }
299    fn generate_id(&self, model: &str) -> AuthResult<String> {
300        Ok(self
301            .auth
302            .config
303            .advanced
304            .generate_id(model, None)?
305            .unwrap_or_else(|| better_auth_core::id::random_id(Some(24))))
306    }
307    async fn create_session(&self, mut options: TestAuthOptions) -> AuthResult<SessionView> {
308        for field in [
309            "id",
310            "userId",
311            "createdAt",
312            "updatedAt",
313            "expiresAt",
314            "token",
315            "ipAddress",
316            "userAgent",
317        ] {
318            let _ = options.session.remove(field);
319        }
320        for (name, plugin) in [
321            ("impersonatedBy", "admin.enabled"),
322            ("activeOrganizationId", "organization.enabled"),
323            ("activeTeamId", "organization.teams_enabled"),
324        ] {
325            if self.auth.metadata.get(plugin).and_then(Value::as_bool) != Some(true) {
326                let _ = options.session.remove(name);
327            }
328        }
329        let meta = if let Some(endpoint) = self.endpoint {
330            let request = AuthRequest::new(better_auth_core::HttpMethod::Post, "/")
331                .with_optional_headers(
332                    endpoint
333                        .headers()
334                        .cloned()
335                        .or_else(|| endpoint.request.map(|request| request.headers.clone())),
336                );
337            better_auth_core::RequestMeta::from_request_with_config(
338                &request,
339                &self.auth.config.advanced.ip_address,
340            )
341        } else {
342            better_auth_core::hooks::current_request_hook_context()
343                .map(|context| context.meta)
344                .unwrap_or_default()
345        };
346        let input = CreateSession {
347            user_id: options.user_id.into(),
348            expires_at: (Utc::now() + self.auth.config.session.expires_in()).into(),
349            ip_address: meta.ip_address,
350            user_agent: meta.user_agent,
351            impersonated_by: None,
352            active_organization_id: None,
353            additional_fields: options.session,
354        };
355        match self.endpoint.and_then(|endpoint| endpoint.transaction) {
356            Some(tx) => tx.create_session_optional(input).await?,
357            None => self.auth.database.create_session_optional(input).await?,
358        }
359        .ok_or_else(|| AuthError::internal("Cannot read properties of null (reading 'token')"))
360    }
361}
362
363fn random_lower(length: usize) -> String {
364    use rand::Rng;
365    rand::thread_rng()
366        .sample_iter(rand::distributions::Alphanumeric)
367        .filter(|value| value.is_ascii_lowercase() || value.is_ascii_digit())
368        .take(length)
369        .map(char::from)
370        .collect()
371}