1mod 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#[derive(Debug, Clone, Default)]
29pub struct TestUtilsPlugin {
30 pub capture_otp: bool,
32}
33
34#[derive(Default)]
36pub struct OtpCapture(RwLock<HashMap<String, String>>);
37impl OtpCapture {
38 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 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#[derive(Debug, Clone)]
129pub struct TestAuthOptions {
130 pub user_id: String,
132 pub session: FieldMap,
134}
135impl TestAuthOptions {
136 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#[derive(Debug)]
147pub struct TestLogin {
148 pub session: SessionView,
150 pub user: UserView,
152 pub headers: HashMap<String, String>,
154 pub cookies: Vec<TestCookie>,
156 pub token: String,
158}
159
160pub 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 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 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 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 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(¤t.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 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 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 pub fn otps(&self) -> Option<&OtpCapture> {
263 self.state.otps.as_deref()
264 }
265 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 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 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}