Skip to main content

better_auth_core/
error.rs

1use thiserror::Error;
2
3/// An endpoint error response whose body and headers are excluded from diagnostics.
4pub struct ApiErrorResponse(Box<crate::types::AuthResponse>, bool);
5
6impl std::fmt::Debug for ApiErrorResponse {
7    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
8        f.debug_struct("ApiErrorResponse")
9            .field("status", &self.0.status)
10            .finish()
11    }
12}
13
14impl std::fmt::Display for ApiErrorResponse {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        write!(f, "Endpoint rejected request (HTTP {})", self.0.status)
17    }
18}
19
20/// Authentication framework error types.
21///
22/// Each variant maps to an HTTP status code via [`AuthError::status_code`].
23/// Use [`AuthError::to_auth_response`] to produce a standardized JSON response
24/// matching the better-auth OpenAPI spec: `{ "message": "..." }`.
25#[derive(Error, Debug)]
26pub enum AuthError {
27    /// Runtime schema failures reject the host entry point before endpoint error handling.
28    #[error(transparent)]
29    SchemaCheck(std::sync::Arc<crate::store::schema::SchemaCheckError>),
30    /// Preserve an endpoint's complete error body and repeated response headers.
31    #[error("{0}")]
32    Response(ApiErrorResponse),
33    /// Public schema error with a field-specific message.
34    #[error("{message}")]
35    FieldInput {
36        /// Upstream schema error code.
37        code: &'static str,
38        /// Validation message safe to return to the caller.
39        message: String,
40    },
41    /// A documented upstream API error whose message is safe to return publicly.
42    #[error("{message}")]
43    Upstream {
44        /// HTTP response status defined by the upstream endpoint.
45        status: u16,
46        /// Stable upstream error code.
47        code: &'static str,
48        /// Documented public error message. Never include internal failure details.
49        message: &'static str,
50    },
51
52    #[error("{0}")]
53    BadRequest(String),
54
55    #[error("Invalid request: {0}")]
56    InvalidRequest(String),
57
58    #[error("Validation error: {0}")]
59    Validation(String),
60
61    #[error("Invalid email or password")]
62    InvalidCredentials,
63
64    #[error("Unauthorized")]
65    Unauthenticated,
66
67    #[error("{0}")]
68    AuthenticationFailed(String),
69
70    #[error("Session not found or expired")]
71    SessionNotFound,
72
73    #[error("{0}")]
74    Forbidden(String),
75
76    #[error("{0}")]
77    BannedUser(String),
78
79    #[error("Insufficient permissions")]
80    Unauthorized,
81
82    #[error("User not found")]
83    UserNotFound,
84
85    #[error("{0}")]
86    NotFound(String),
87
88    #[error("{0}")]
89    Conflict(String),
90
91    #[error("{0}")]
92    MethodNotAllowed(String),
93
94    #[error("{0}")]
95    PayloadTooLarge(String),
96
97    #[error("{0}")]
98    UnprocessableEntity(String),
99
100    #[error("Too many requests")]
101    RateLimited,
102
103    #[error("{0}")]
104    NotImplemented(String),
105
106    #[error("Configuration error: {0}")]
107    Config(String),
108
109    #[error("Database error: {0}")]
110    Database(#[from] DatabaseError),
111
112    /// Redis cache failure with the original source error.
113    #[cfg(feature = "redis-cache")]
114    #[error("Redis cache error: {0}")]
115    Redis(#[from] redis::RedisError),
116
117    #[error("Serialization error: {0}")]
118    Serialization(#[from] serde_json::Error),
119
120    #[error("Plugin error: {plugin} - {message}")]
121    Plugin { plugin: String, message: String },
122
123    #[error("Internal server error: {0}")]
124    Internal(String),
125
126    #[error("Password hashing error: {0}")]
127    PasswordHash(String),
128
129    #[error("JWT error: {0}")]
130    Jwt(#[from] jsonwebtoken::errors::Error),
131}
132
133impl AuthError {
134    /// Diagnostic message for tracing. Response headers are never included.
135    pub fn instrumentation_message(&self) -> String {
136        match self {
137            Self::Response(response) => response
138                .0
139                .body
140                .json()
141                .ok()
142                .flatten()
143                .and_then(|body| {
144                    body.get("message")
145                        .and_then(serde_json::Value::as_str)
146                        .map(str::to_owned)
147                })
148                .unwrap_or_else(|| self.to_string()),
149            Self::Internal(message) | Self::Config(message) | Self::PasswordHash(message) => {
150                message.clone()
151            }
152            _ => self.to_string(),
153        }
154    }
155
156    /// Create a redirect that bypasses the HTTP error callback, like upstream `FOUND`.
157    /// Numeric 302 errors created from `AuthResponse` retain the ordinary API error policy.
158    pub fn redirect(location: impl Into<String>) -> Self {
159        Self::Response(ApiErrorResponse(
160            Box::new(
161                crate::AuthResponse::new(302)
162                    .with_header("location", location)
163                    .with_header("content-type", "application/json"),
164            ),
165            true,
166        ))
167    }
168
169    /// Whether this error is an explicit `FOUND` redirect.
170    pub fn is_found_redirect(&self) -> bool {
171        matches!(self, Self::Response(ApiErrorResponse(_, true)))
172    }
173
174    /// Attach endpoint headers while preserving the redirect's error-policy behavior.
175    pub fn capture_endpoint_headers(self, headers: crate::Headers) -> Self {
176        match self {
177            Self::Response(mut response) => {
178                response.0.capture_error_headers(headers);
179                Self::Response(response)
180            }
181            other => {
182                let mut response = other.to_auth_response();
183                response.capture_error_headers(headers);
184                response.into()
185            }
186        }
187    }
188
189    /// Whether the error is an intentional endpoint rejection, including redirects and server API errors.
190    pub fn is_api_error(&self) -> bool {
191        match self {
192            Self::Response(_)
193            | Self::FieldInput { .. }
194            | Self::Upstream { .. }
195            | Self::BadRequest(_)
196            | Self::InvalidRequest(_)
197            | Self::Validation(_)
198            | Self::InvalidCredentials
199            | Self::Unauthenticated
200            | Self::AuthenticationFailed(_)
201            | Self::SessionNotFound
202            | Self::Forbidden(_)
203            | Self::BannedUser(_)
204            | Self::Unauthorized
205            | Self::UserNotFound
206            | Self::NotFound(_)
207            | Self::Conflict(_)
208            | Self::MethodNotAllowed(_)
209            | Self::PayloadTooLarge(_)
210            | Self::UnprocessableEntity(_)
211            | Self::RateLimited
212            | Self::NotImplemented(_) => true,
213            Self::SchemaCheck(_)
214            | Self::Config(_)
215            | Self::Database(_)
216            | Self::Serialization(_)
217            | Self::Plugin { .. }
218            | Self::Internal(_)
219            | Self::PasswordHash(_)
220            | Self::Jwt(_) => false,
221            #[cfg(feature = "redis-cache")]
222            Self::Redis(_) => false,
223        }
224    }
225
226    /// Serialize an HTTP endpoint failure without exposing ordinary runtime errors.
227    pub fn to_http_response(self) -> crate::AuthResponse {
228        if self.is_api_error() {
229            self.to_auth_response()
230        } else {
231            crate::observability::logger::current().error(
232                "Authentication request failed",
233                &[crate::observability::LogArgument::Error(&self)],
234            );
235            crate::AuthResponse::new(500)
236        }
237    }
238
239    /// HTTP status code for this error.
240    pub fn status_code(&self) -> u16 {
241        match self {
242            Self::Response(response) => response.0.status,
243            Self::FieldInput { .. } => 400,
244            Self::Upstream { status, .. } => *status,
245            // 400
246            Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
247            // 401
248            Self::InvalidCredentials
249            | Self::Unauthenticated
250            | Self::AuthenticationFailed(_)
251            | Self::SessionNotFound => 401,
252            // 403
253            Self::Forbidden(_) | Self::BannedUser(_) | Self::Unauthorized => 403,
254            // 404
255            Self::UserNotFound | Self::NotFound(_) => 404,
256            // 409
257            Self::Conflict(_) => 409,
258            // 405
259            Self::MethodNotAllowed(_) => 405,
260            // 413
261            Self::PayloadTooLarge(_) => 413,
262            // 422
263            Self::UnprocessableEntity(_) => 422,
264            // 429
265            Self::RateLimited => 429,
266            // 501
267            Self::NotImplemented(_) => 501,
268            // 500
269            #[cfg(feature = "redis-cache")]
270            Self::Redis(_) => 500,
271            Self::SchemaCheck(_)
272            | Self::Config(_)
273            | Self::Database(_)
274            | Self::Serialization(_)
275            | Self::Plugin { .. }
276            | Self::Internal(_)
277            | Self::PasswordHash(_)
278            | Self::Jwt(_) => 500,
279        }
280    }
281
282    /// Resolve the wire error code for a message, if upstream defines one.
283    ///
284    /// better-auth 1.5 replaced message-derived codes with explicit constants,
285    /// and only errors built from one of those constants carry a `code` at all
286    /// (`APIError.from` sets it; a plain `new APIError(status, { message })`
287    /// does not). So an unknown message yields `None` and the field is omitted,
288    /// rather than being back-derived from the text.
289    pub fn code_from_message(message: &str) -> Option<String> {
290        crate::error_codes::upstream_code(message).map(str::to_string)
291    }
292
293    /// Compute the HTTP status, error code, and user-facing message.
294    ///
295    /// The code is `None` for messages upstream has no constant for; those
296    /// responses carry only a `message`.
297    ///
298    /// Internal errors (500) are logged and replaced with a generic message
299    /// to avoid leaking details.
300    pub fn error_payload(&self) -> (u16, Option<String>, String) {
301        let status = self.status_code();
302        let (code, message) = match self {
303            Self::FieldInput { code, message } => (Some((*code).to_owned()), message.clone()),
304            Self::Upstream { code, message, .. } => {
305                (Some((*code).to_owned()), (*message).to_owned())
306            }
307            Self::BannedUser(message) => (Some("BANNED_USER".to_string()), message.clone()),
308            _ => {
309                let message = match status {
310                    500 => {
311                        crate::observability::logger::current().error(
312                            "Internal server error",
313                            &[crate::observability::LogArgument::Error(&self)],
314                        );
315                        "Internal server error".to_string()
316                    }
317                    _ => self.to_string(),
318                };
319                let code = Self::code_from_message(&message);
320                (code, message)
321            }
322        };
323        (status, code, message)
324    }
325
326    /// Convert this error into a standardized [`AuthResponse`](crate::types::AuthResponse) matching the
327    /// better-auth spec: `{ "code": "...", "message": "..." }`.
328    ///
329    /// Named `to_auth_response` to avoid collision with Axum's
330    /// `IntoResponse::into_response` when the `axum` feature is enabled.
331    pub fn to_auth_response(self) -> crate::types::AuthResponse {
332        if let Self::Response(response) = self {
333            return (*response.0).into_api_error();
334        }
335        if let Self::PasswordHash(error) = self {
336            crate::observability::logger::current().error(
337                "Password hashing failed",
338                &[crate::observability::LogArgument::Value(
339                    &serde_json::json!(error),
340                )],
341            );
342            return crate::types::AuthResponse::new(500);
343        }
344        let is_api_error = self.is_api_error();
345        let (status, code, message) = self.error_payload();
346        let response = crate::types::AuthResponse::json(
347            status,
348            &crate::types::ErrorCodeMessageResponse {
349                code,
350                message: message.clone(),
351            },
352        )
353        .unwrap_or_else(|_| crate::types::AuthResponse::text(status, &message));
354        if is_api_error {
355            response.into_api_error()
356        } else {
357            response
358        }
359    }
360
361    pub fn bad_request(message: impl Into<String>) -> Self {
362        Self::BadRequest(message.into())
363    }
364
365    pub fn forbidden(message: impl Into<String>) -> Self {
366        Self::Forbidden(message.into())
367    }
368
369    pub fn banned_user(message: impl Into<String>) -> Self {
370        Self::BannedUser(message.into())
371    }
372
373    pub fn not_found(message: impl Into<String>) -> Self {
374        Self::NotFound(message.into())
375    }
376
377    pub fn conflict(message: impl Into<String>) -> Self {
378        Self::Conflict(message.into())
379    }
380
381    pub fn method_not_allowed(message: impl Into<String>) -> Self {
382        Self::MethodNotAllowed(message.into())
383    }
384
385    pub fn payload_too_large(message: impl Into<String>) -> Self {
386        Self::PayloadTooLarge(message.into())
387    }
388
389    pub fn not_implemented(message: impl Into<String>) -> Self {
390        Self::NotImplemented(message.into())
391    }
392
393    pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
394        Self::Plugin {
395            plugin: plugin.to_string(),
396            message: message.into(),
397        }
398    }
399
400    pub fn config(message: impl Into<String>) -> Self {
401        Self::Config(message.into())
402    }
403
404    pub fn internal(message: impl Into<String>) -> Self {
405        Self::Internal(message.into())
406    }
407
408    pub fn validation(message: impl Into<String>) -> Self {
409        Self::Validation(message.into())
410    }
411
412    pub fn authentication_failed(message: impl Into<String>) -> Self {
413        Self::AuthenticationFailed(message.into())
414    }
415}
416
417#[derive(Error, Debug)]
418pub enum DatabaseError {
419    #[error("Connection error: {0}")]
420    Connection(String),
421
422    #[error("Query error: {0}")]
423    Query(String),
424
425    #[error("Migration error: {0}")]
426    Migration(String),
427
428    #[error("Constraint violation: {0}")]
429    Constraint(String),
430
431    /// A uniqueness conflict that callers may resolve by generating another value.
432    #[error("Unique constraint violation: {0}")]
433    UniqueConstraint(String),
434
435    #[error("Transaction error: {0}")]
436    Transaction(String),
437}
438
439pub type AuthResult<T> = Result<T, AuthError>;
440
441impl From<crate::types::AuthResponse> for AuthError {
442    fn from(response: crate::types::AuthResponse) -> Self {
443        Self::Response(ApiErrorResponse(Box::new(response), false))
444    }
445}
446
447#[cfg(feature = "axum")]
448impl axum::response::IntoResponse for AuthError {
449    fn into_response(self) -> axum::response::Response {
450        let response = self.to_auth_response();
451        let body = match response.body.into_bytes() {
452            Ok(body) => body,
453            Err(error) => return error.into_response(),
454        };
455        let status = axum::http::StatusCode::from_u16(response.status)
456            .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
457        let mut output = axum::response::Response::new(axum::body::Body::from(body));
458        *output.status_mut() = status;
459        for (name, value) in response.headers {
460            if let (Ok(name), Ok(value)) = (
461                axum::http::HeaderName::from_bytes(name.as_bytes()),
462                axum::http::HeaderValue::from_str(&value),
463            ) {
464                let _ = output.headers_mut().append(name, value);
465            }
466        }
467        output
468    }
469}
470
471/// Convert `validator::ValidationErrors` into a standardized error response body.
472///
473/// Returns a 400 response with `{ "code": "VALIDATION_ERROR", "message": "[body.field] ..." }`
474/// matching the TS better-auth error shape.
475pub fn validation_error_response(
476    errors: &validator::ValidationErrors,
477) -> crate::types::AuthResponse {
478    // Build a TS-compatible message: "[body.field] message; [body.field2] message2"
479    let messages: Vec<String> = errors
480        .field_errors()
481        .into_iter()
482        .flat_map(|(field, errs)| {
483            errs.iter().map(move |e| {
484                let msg = e
485                    .message
486                    .as_ref()
487                    .map(|m| m.to_string())
488                    .unwrap_or_else(|| format!("Invalid value for {}", field));
489                format!("[body.{}] {}", field, msg)
490            })
491        })
492        .collect();
493    let message = messages.join("; ");
494
495    let body = crate::types::ErrorCodeMessageResponse {
496        code: Some("VALIDATION_ERROR".to_string()),
497        message,
498    };
499
500    // Validation errors return 400 (not 422) per the TS spec
501    crate::types::AuthResponse::json(400, &body)
502        .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Validation failed"))
503}
504
505/// Validate a request body, returning a parsed + validated value or an error response.
506pub fn validate_request_body<T>(
507    req: &crate::types::AuthRequest,
508) -> Result<T, crate::types::AuthResponse>
509where
510    T: serde::de::DeserializeOwned + validator::Validate,
511{
512    let value: T = req.body_as_json().map_err(|e| {
513        let message = format!("Invalid JSON: {}", e);
514        let code = AuthError::code_from_message(&message);
515        crate::types::AuthResponse::json(
516            400,
517            &crate::types::ErrorCodeMessageResponse { code, message },
518        )
519        .unwrap_or_else(|_| crate::types::AuthResponse::text(400, "Invalid JSON"))
520    })?;
521
522    value
523        .validate()
524        .map_err(|e| validation_error_response(&e))?;
525
526    Ok(value)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn endpoint_error_preserves_body_headers_and_redacts_diagnostics() {
535        let body = serde_json::json!({"code":"RATE_LIMITED", "message":"Rate limit exceeded.", "details":{"tryAgainIn":1234}});
536        let mut response = crate::types::AuthResponse::json(429, &body).unwrap();
537        response.headers.append("Set-Cookie", "sensitive=first");
538        response.headers.append("Set-Cookie", "sensitive=second");
539        let error = AuthError::from(response);
540        assert_eq!(error.status_code(), 429);
541        assert!(!format!("{error:?} {error}").contains("sensitive"));
542        assert!(!format!("{error:?} {error}").contains("tryAgainIn"));
543        let response = error.to_auth_response();
544        assert_eq!(
545            serde_json::from_slice::<serde_json::Value>(&response.body.bytes().unwrap()).unwrap(),
546            body
547        );
548        assert_eq!(
549            response.headers.get_all("set-cookie").collect::<Vec<_>>(),
550            ["sensitive=first", "sensitive=second"]
551        );
552    }
553
554    #[cfg(feature = "axum")]
555    #[tokio::test]
556    async fn endpoint_error_into_axum_preserves_json_and_repeated_headers() {
557        use axum::response::IntoResponse;
558        let mut response = crate::types::AuthResponse::json(
559            429,
560            &serde_json::json!({"details":{"tryAgainIn":1234}}),
561        )
562        .unwrap();
563        response.headers.append("Set-Cookie", "first=1");
564        response.headers.append("Set-Cookie", "second=2");
565        let output = AuthError::from(response).into_response();
566        assert_eq!(output.status(), 429);
567        assert_eq!(output.headers().get_all("set-cookie").iter().count(), 2);
568        assert_eq!(output.headers()["content-type"], "application/json");
569        let body = axum::body::to_bytes(output.into_body(), usize::MAX)
570            .await
571            .unwrap();
572        assert_eq!(
573            serde_json::from_slice::<serde_json::Value>(&body).unwrap()["details"]["tryAgainIn"],
574            1234
575        );
576    }
577
578    // ── status_code ─────────────────────────────────────────────────────
579
580    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
581    #[test]
582    fn bad_request_is_400() {
583        assert_eq!(AuthError::bad_request("oops").status_code(), 400);
584    }
585
586    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
587    #[test]
588    fn invalid_request_is_400() {
589        assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
590    }
591
592    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
593    #[test]
594    fn validation_is_400() {
595        assert_eq!(AuthError::validation("x").status_code(), 400);
596    }
597
598    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
599    #[test]
600    fn invalid_credentials_is_401() {
601        assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
602    }
603
604    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
605    #[test]
606    fn unauthenticated_is_401() {
607        assert_eq!(AuthError::Unauthenticated.status_code(), 401);
608    }
609
610    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
611    #[test]
612    fn session_not_found_is_401() {
613        assert_eq!(AuthError::SessionNotFound.status_code(), 401);
614    }
615
616    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
617    #[test]
618    fn forbidden_is_403() {
619        assert_eq!(AuthError::forbidden("nope").status_code(), 403);
620    }
621
622    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
623    #[test]
624    fn unauthorized_is_403() {
625        assert_eq!(AuthError::Unauthorized.status_code(), 403);
626    }
627
628    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
629    #[test]
630    fn not_found_is_404() {
631        assert_eq!(AuthError::not_found("gone").status_code(), 404);
632        assert_eq!(AuthError::UserNotFound.status_code(), 404);
633    }
634
635    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
636    #[test]
637    fn conflict_is_409() {
638        assert_eq!(AuthError::conflict("dup").status_code(), 409);
639    }
640
641    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
642    #[test]
643    fn unprocessable_entity_is_422() {
644        assert_eq!(
645            AuthError::UnprocessableEntity("x".into()).status_code(),
646            422
647        );
648    }
649
650    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
651    #[test]
652    fn rate_limited_is_429() {
653        assert_eq!(AuthError::RateLimited.status_code(), 429);
654    }
655
656    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
657    #[test]
658    fn not_implemented_is_501() {
659        assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
660    }
661
662    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
663    #[test]
664    fn internal_errors_are_500() {
665        assert_eq!(AuthError::config("bad").status_code(), 500);
666        assert_eq!(AuthError::internal("fail").status_code(), 500);
667        assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
668        assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
669        assert_eq!(
670            AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
671            500
672        );
673    }
674
675    // ── code_from_message ───────────────────────────────────────────────
676
677    // Upstream reference: BASE_ERROR_CODES in @better-auth/core — a message
678    // upstream defines a constant for carries that constant.
679    #[test]
680    fn code_from_message_returns_the_upstream_constant() {
681        assert_eq!(
682            AuthError::code_from_message("User not found").as_deref(),
683            Some("USER_NOT_FOUND")
684        );
685    }
686
687    // Upstream reference: packages/core/src/error/index.ts :: `APIError.from`
688    // attaches a code, while `new APIError(status, { message })` does not — so
689    // a message outside every upstream table has no code on the wire.
690    #[test]
691    fn code_from_message_is_none_for_unknown_messages() {
692        assert_eq!(AuthError::code_from_message("invalid email!"), None);
693        assert_eq!(AuthError::code_from_message("Email is the same"), None);
694        assert_eq!(AuthError::code_from_message(""), None);
695    }
696
697    // Upstream reference: plugin tables built with `defineErrorCodes` are part
698    // of the same vocabulary, not just the base set.
699    #[test]
700    fn code_from_message_covers_plugin_tables() {
701        assert_eq!(
702            AuthError::code_from_message("Username is already taken. Please try another.")
703                .as_deref(),
704            Some("USERNAME_IS_ALREADY_TAKEN")
705        );
706    }
707
708    // Upstream reference: BASE_ERROR_CODES in @better-auth/core ::
709    // packages/core/src/error/codes.ts — codes are explicit constants since
710    // better-auth 1.5, so these must not be re-derived from the message.
711    #[test]
712    fn base_error_codes_are_not_derived_from_the_message() {
713        for (message, expected) in [
714            ("Invalid callbackURL", "INVALID_CALLBACK_URL"),
715            ("Invalid redirectURL", "INVALID_REDIRECT_URL"),
716            ("Invalid errorCallbackURL", "INVALID_ERROR_CALLBACK_URL"),
717            (
718                "Invalid newUserCallbackURL",
719                "INVALID_NEW_USER_CALLBACK_URL",
720            ),
721            ("Email is already verified", "EMAIL_ALREADY_VERIFIED"),
722            (
723                "Verification email isn't enabled",
724                "VERIFICATION_EMAIL_NOT_ENABLED",
725            ),
726            (
727                "Session expired. Re-authenticate to perform this action.",
728                "SESSION_EXPIRED",
729            ),
730            (
731                "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
732                "CROSS_SITE_NAVIGATION_LOGIN_BLOCKED",
733            ),
734        ] {
735            assert_eq!(
736                AuthError::code_from_message(message).as_deref(),
737                Some(expected),
738                "message {message:?} must carry the upstream constant"
739            );
740        }
741    }
742
743    // ── error_payload ───────────────────────────────────────────────────
744
745    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
746    #[test]
747    fn error_payload_for_client_error() {
748        // Not an upstream message, so it goes out without a code.
749        let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
750        assert_eq!(status, 400);
751        assert_eq!(code, None);
752        assert_eq!(message, "Missing field");
753
754        let (status, code, message) = AuthError::bad_request("Field is required").error_payload();
755        assert_eq!(status, 400);
756        assert_eq!(code.as_deref(), Some("MISSING_FIELD"));
757        assert_eq!(message, "Field is required");
758    }
759
760    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
761    #[test]
762    fn error_payload_for_internal_error_hides_details() {
763        let (status, _code, message) = AuthError::internal("secret detail").error_payload();
764        assert_eq!(status, 500);
765        assert_eq!(message, "Internal server error");
766    }
767
768    // ── to_auth_response ────────────────────────────────────────────────
769
770    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
771    #[test]
772    fn to_auth_response_returns_correct_status() {
773        let resp = AuthError::bad_request("oops").to_auth_response();
774        assert_eq!(resp.status, 400);
775    }
776
777    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
778    #[test]
779    fn to_auth_response_body_contains_code_and_message() {
780        let resp = AuthError::UserNotFound.to_auth_response();
781        assert_eq!(resp.status, 404);
782        let body: serde_json::Value = serde_json::from_slice(&resp.body.bytes().unwrap())
783            .expect("response body should be valid JSON");
784        assert_eq!(body["code"], "USER_NOT_FOUND");
785        assert_eq!(body["message"], "User not found");
786    }
787
788    // ── constructor helpers ──────────────────────────────────────────────
789
790    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
791    #[test]
792    fn constructor_helpers_produce_correct_variants() {
793        // Each helper should produce the expected Display output
794        assert_eq!(AuthError::bad_request("x").to_string(), "x");
795        assert_eq!(AuthError::forbidden("x").to_string(), "x");
796        assert_eq!(AuthError::not_found("x").to_string(), "x");
797        assert_eq!(AuthError::conflict("x").to_string(), "x");
798        assert_eq!(AuthError::not_implemented("x").to_string(), "x");
799        assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
800        assert_eq!(
801            AuthError::internal("x").to_string(),
802            "Internal server error: x"
803        );
804        assert_eq!(
805            AuthError::validation("x").to_string(),
806            "Validation error: x"
807        );
808        assert_eq!(
809            AuthError::plugin("p", "m").to_string(),
810            "Plugin error: p - m"
811        );
812    }
813
814    // ── DatabaseError ───────────────────────────────────────────────────
815
816    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
817    #[test]
818    fn database_error_display() {
819        let e = DatabaseError::Connection("timeout".into());
820        assert_eq!(e.to_string(), "Connection error: timeout");
821    }
822
823    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
824    #[test]
825    fn database_error_converts_to_auth_error() {
826        let db_err = DatabaseError::Query("bad sql".into());
827        let auth_err: AuthError = db_err.into();
828        assert_eq!(auth_err.status_code(), 500);
829    }
830
831    // ── validation_error_response ───────────────────────────────────────
832
833    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
834    #[test]
835    fn validation_error_response_serializes_field_errors() {
836        let mut errors = validator::ValidationErrors::new();
837        let mut error = validator::ValidationError::new("email");
838        error.message = Some("Email is invalid".into());
839        errors.add("email", error);
840
841        let resp = validation_error_response(&errors);
842        assert_eq!(resp.status, 400);
843        let body: serde_json::Value = serde_json::from_slice(&resp.body.bytes().unwrap())
844            .expect("response body should be valid JSON");
845        assert_eq!(body["code"], "VALIDATION_ERROR");
846        assert_eq!(body["message"], "[body.email] Email is invalid");
847    }
848
849    // ── Display for fixed-message variants ──────────────────────────────
850
851    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
852    #[test]
853    fn fixed_message_variants_display() {
854        assert_eq!(
855            AuthError::InvalidCredentials.to_string(),
856            "Invalid email or password"
857        );
858        assert_eq!(AuthError::Unauthenticated.to_string(), "Unauthorized");
859        assert_eq!(
860            AuthError::SessionNotFound.to_string(),
861            "Session not found or expired"
862        );
863        assert_eq!(
864            AuthError::Unauthorized.to_string(),
865            "Insufficient permissions"
866        );
867        assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
868        assert_eq!(AuthError::RateLimited.to_string(), "Too many requests");
869    }
870}