Skip to main content

alibi_core/error/
mod.rs

1pub mod codes;
2
3pub mod page;
4
5use crate::types::{AuthRequest, AuthResponse, ErrorCodeMessageResponse};
6use codes::upstream_code;
7use thiserror::Error;
8
9/// Authentication framework error types.
10///
11/// Each variant maps to an HTTP status code via [`AuthError::status_code`].
12/// Use [`AuthError::to_auth_response`] to produce a standardized JSON response
13/// matching the better-auth `OpenAPI` spec: `{ "message": "..." }`.
14#[derive(Error, Debug)]
15pub enum AuthError {
16    /// An intentional public API error from application policy or input validation.
17    ///
18    /// The supplied message is returned verbatim, including for a 500 status.
19    /// Use internal error variants for database failures or other private details.
20    #[error("{message}")]
21    Api {
22        status: u16,
23        code: Option<String>,
24        message: String,
25    },
26
27    /// A documented upstream API error whose message is safe to return publicly.
28    #[error("{message}")]
29    Upstream {
30        /// HTTP response status defined by the upstream endpoint.
31        status: u16,
32        /// Stable upstream error code.
33        code: &'static str,
34        /// Documented public error message. Never include internal failure details.
35        message: &'static str,
36    },
37
38    #[error("{0}")]
39    BadRequest(String),
40
41    #[error("Invalid request: {0}")]
42    InvalidRequest(String),
43
44    #[error("Validation error: {0}")]
45    Validation(String),
46
47    #[error("Invalid email or password")]
48    InvalidCredentials,
49
50    #[error("Authentication required")]
51    Unauthenticated,
52
53    #[error("{0}")]
54    AuthenticationFailed(String),
55
56    #[error("Session not found or expired")]
57    SessionNotFound,
58
59    #[error("{0}")]
60    Forbidden(String),
61
62    /// A session-create lifecycle hook explicitly cancelled creation.
63    /// Callers may apply endpoint-specific null-session behavior; the default
64    /// public response retains the existing cancellation status and message.
65    #[error("session creation cancelled by database hook")]
66    SessionCreationCancelled,
67
68    /// A user-create database hook explicitly cancelled creation.
69    /// Endpoint owners may map the absent user differently from a genuine
70    /// application Forbidden error with the same text.
71    #[error("user creation cancelled by database hook")]
72    UserCreationCancelled,
73
74    #[error("{0}")]
75    BannedUser(String),
76
77    #[error("Insufficient permissions")]
78    Unauthorized,
79
80    #[error("User not found")]
81    UserNotFound,
82
83    #[error("{0}")]
84    NotFound(String),
85
86    #[error("{0}")]
87    Conflict(String),
88
89    #[error("{0}")]
90    MethodNotAllowed(String),
91
92    #[error("{0}")]
93    PayloadTooLarge(String),
94
95    #[error("{0}")]
96    UnprocessableEntity(String),
97
98    /// `retry_after` is the number of seconds until the next request is
99    /// admitted; responses publish it as `X-Retry-After`.
100    #[error("Too many requests")]
101    RateLimited { retry_after: Option<f64> },
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    #[error("Serialization error: {0}")]
113    Serialization(#[from] serde_json::Error),
114
115    #[error("Plugin error: {plugin} - {message}")]
116    Plugin { plugin: String, message: String },
117
118    #[error("Internal server error: {0}")]
119    Internal(String),
120
121    /// Authentication or encoding of encrypted persistence data failed.
122    /// HTTP callers receive an empty 500; private details are logged.
123    #[error("Encrypted authentication data failed: {0}")]
124    Encryption(String),
125
126    /// An ordinary application callback failure whose HTTP contract is an empty 500.
127    /// The private cause is logged, never sent to the client. Explicit API errors
128    /// retain their own response and must not be wrapped in this variant.
129    #[error("Application callback failed: {0}")]
130    CallbackFailure(Box<Self>),
131
132    #[error("Password hashing error: {0}")]
133    PasswordHash(String),
134
135    #[error("JWT error: {0}")]
136    Jwt(#[from] jsonwebtoken::errors::Error),
137}
138
139impl AuthError {
140    /// HTTP status code for this error.
141    #[must_use]
142    pub const fn status_code(&self) -> u16 {
143        match self {
144            Self::Api { status, .. } | Self::Upstream { status, .. } => *status,
145            Self::BadRequest(_) | Self::InvalidRequest(_) | Self::Validation(_) => 400,
146            Self::InvalidCredentials
147            | Self::Unauthenticated
148            | Self::AuthenticationFailed(_)
149            | Self::SessionNotFound => 401,
150            Self::Forbidden(_)
151            | Self::SessionCreationCancelled
152            | Self::UserCreationCancelled
153            | Self::BannedUser(_)
154            | Self::Unauthorized => 403,
155            Self::UserNotFound | Self::NotFound(_) => 404,
156            Self::Conflict(_) => 409,
157            Self::MethodNotAllowed(_) => 405,
158            Self::PayloadTooLarge(_) => 413,
159            Self::UnprocessableEntity(_) => 422,
160            Self::RateLimited { .. } => 429,
161            Self::NotImplemented(_) => 501,
162            Self::Config(_)
163            | Self::Database(_)
164            | Self::Serialization(_)
165            | Self::Plugin { .. }
166            | Self::Internal(_)
167            | Self::Encryption(_)
168            | Self::CallbackFailure(_)
169            | Self::PasswordHash(_)
170            | Self::Jwt(_) => 500,
171        }
172    }
173
174    /// Mark an ordinary application callback failure, whose HTTP contract is an
175    /// empty 500. Explicit API errors keep their own public response.
176    pub(crate) fn into_callback_failure(self) -> Self {
177        match self {
178            Self::Api { .. } | Self::Upstream { .. } | Self::CallbackFailure(_) => self,
179            other => Self::CallbackFailure(Box::new(other)),
180        }
181    }
182
183    /// Resolve the wire error code for a message, if upstream defines one.
184    ///
185    /// better-auth 1.5 replaced message-derived codes with explicit constants,
186    /// and only errors built from one of those constants carry a `code` at all
187    /// (`APIError.from` sets it; a plain `new APIError(status, { message })`
188    /// does not). So an unknown message yields `None` and the field is omitted,
189    /// rather than being back-derived from the text.
190    pub fn code_from_message(message: &str) -> Option<String> {
191        upstream_code(message).map(str::to_owned)
192    }
193
194    /// Compute the HTTP status, error code, and user-facing message.
195    ///
196    /// The code is `None` for messages upstream has no constant for; those
197    /// responses carry only a `message`.
198    ///
199    /// Internal errors (500) are logged and replaced with a generic message
200    /// to avoid leaking details.
201    pub fn error_payload(&self) -> (u16, Option<String>, String) {
202        let status = self.status_code();
203        let (code, message) = match self {
204            Self::Api { code, message, .. } => (code.clone(), message.clone()),
205            Self::Upstream { code, message, .. } => {
206                (Some((*code).to_owned()), (*message).to_owned())
207            }
208            Self::BannedUser(message) => (Some(codes::BANNED_USER.to_owned()), message.clone()),
209            _ => {
210                let message = match status {
211                    500 => {
212                        tracing::error!(error = %self, "Internal server error");
213                        "Internal server error".to_owned()
214                    }
215                    _ => self.to_string(),
216                };
217                let code = Self::code_from_message(&message);
218                (code, message)
219            }
220        };
221        (status, code, message)
222    }
223
224    /// Convert this error into a standardized [`AuthResponse`] matching the
225    /// better-auth spec: `{ "code": "...", "message": "..." }`.
226    ///
227    /// Named `to_auth_response` to avoid collision with Axum's
228    /// `IntoResponse::into_response` when the `axum` feature is enabled.
229    #[must_use]
230    pub fn to_auth_response(self) -> AuthResponse {
231        if matches!(&self, Self::CallbackFailure(_) | Self::Encryption(_)) {
232            tracing::error!(error = %self, "Authentication operation failed");
233            return AuthResponse::new(500);
234        }
235        let ordinary = self.status_code() == 500 && !crate::endpoint::is_endpoint_api_error(&self);
236        let retry_after = self.retry_after_header();
237        let (status, code, message) = self.error_payload();
238        let response = AuthResponse::json(
239            status,
240            &ErrorCodeMessageResponse {
241                code,
242                message: message.clone(),
243            },
244        )
245        .unwrap_or_else(|_| AuthResponse::text(status, &message));
246        let response = if ordinary {
247            response.with_header("Content-Type", "application/json;charset=utf-8")
248        } else {
249            response
250        };
251        match retry_after {
252            Some(seconds) => response.with_header("X-Retry-After", seconds),
253            None => response,
254        }
255    }
256
257    /// The `X-Retry-After` value of a rate-limit rejection, formatted as the
258    /// rate-limit middleware publishes it.
259    fn retry_after_header(&self) -> Option<String> {
260        match self {
261            Self::RateLimited {
262                retry_after: Some(seconds),
263            } => Some(ryu_js::Buffer::new().format(*seconds).to_owned()),
264            _ => None,
265        }
266    }
267
268    /// A `429` rejection that tells the client to retry after `retry_after` seconds,
269    /// such as [`RateLimitDecision::Blocked`](crate::middleware::RateLimitDecision::Blocked)'s.
270    #[must_use]
271    pub const fn rate_limited(retry_after: f64) -> Self {
272        Self::RateLimited {
273            retry_after: Some(retry_after),
274        }
275    }
276
277    #[must_use]
278    pub fn bad_request(message: impl Into<String>) -> Self {
279        Self::BadRequest(message.into())
280    }
281
282    #[must_use]
283    pub fn forbidden(message: impl Into<String>) -> Self {
284        Self::Forbidden(message.into())
285    }
286
287    #[must_use]
288    pub fn banned_user(message: impl Into<String>) -> Self {
289        Self::BannedUser(message.into())
290    }
291
292    #[must_use]
293    pub fn not_found(message: impl Into<String>) -> Self {
294        Self::NotFound(message.into())
295    }
296
297    #[must_use]
298    pub fn conflict(message: impl Into<String>) -> Self {
299        Self::Conflict(message.into())
300    }
301
302    #[must_use]
303    pub fn method_not_allowed(message: impl Into<String>) -> Self {
304        Self::MethodNotAllowed(message.into())
305    }
306
307    #[must_use]
308    pub fn payload_too_large(message: impl Into<String>) -> Self {
309        Self::PayloadTooLarge(message.into())
310    }
311
312    #[must_use]
313    pub fn not_implemented(message: impl Into<String>) -> Self {
314        Self::NotImplemented(message.into())
315    }
316
317    #[must_use]
318    pub fn plugin(plugin: &str, message: impl Into<String>) -> Self {
319        Self::Plugin {
320            plugin: plugin.to_owned(),
321            message: message.into(),
322        }
323    }
324
325    #[must_use]
326    pub fn config(message: impl Into<String>) -> Self {
327        Self::Config(message.into())
328    }
329
330    #[must_use]
331    pub fn internal(message: impl Into<String>) -> Self {
332        Self::Internal(message.into())
333    }
334
335    #[must_use]
336    pub fn validation(message: impl Into<String>) -> Self {
337        Self::Validation(message.into())
338    }
339
340    #[must_use]
341    pub fn authentication_failed(message: impl Into<String>) -> Self {
342        Self::AuthenticationFailed(message.into())
343    }
344}
345
346#[derive(Error, Debug)]
347pub enum DatabaseError {
348    /// Multiple physical rows share a provider identity; ownership is ambiguous.
349    #[error(
350        "Multiple accounts match the same accountId for provider {provider:?}. Resolve duplicate account identities before continuing."
351    )]
352    AmbiguousAccount { provider: String },
353
354    #[error("Connection error: {0}")]
355    Connection(String),
356
357    #[error("Query error: {0}")]
358    Query(String),
359
360    #[error("Migration error: {0}")]
361    Migration(String),
362
363    #[error("Constraint violation: {0}")]
364    Constraint(String),
365
366    #[error("Transaction error: {0}")]
367    Transaction(String),
368}
369
370pub type AuthResult<T> = Result<T, AuthError>;
371
372#[cfg(feature = "axum")]
373impl axum::response::IntoResponse for AuthError {
374    fn into_response(self) -> axum::response::Response {
375        self.to_auth_response().into_response()
376    }
377}
378
379/// Convert `validator::ValidationErrors` into a standardized error response body.
380///
381/// Returns a 400 response with `{ "code": "VALIDATION_ERROR", "message": "[body.field] ..." }`
382/// matching the TS better-auth error shape.
383#[must_use]
384pub fn validation_error_response(errors: &validator::ValidationErrors) -> AuthResponse {
385    let messages: Vec<String> = errors
386        .field_errors()
387        .into_iter()
388        .flat_map(|(field, errs)| {
389            errs.iter().map(move |e| {
390                let msg = e
391                    .message
392                    .as_ref()
393                    .map_or_else(|| format!("Invalid value for {field}"), ToString::to_string);
394                format!("[body.{field}] {msg}")
395            })
396        })
397        .collect();
398    let message = messages.join("; ");
399    let body = ErrorCodeMessageResponse {
400        code: Some(codes::VALIDATION_ERROR.to_owned()),
401        message,
402    };
403
404    // Upstream answers 400, not 422, for validation failures.
405    AuthResponse::json(400, &body).unwrap_or_else(|_| AuthResponse::text(400, "Validation failed"))
406}
407
408/// Validate a request body, returning a parsed + validated value or an error response.
409///
410/// # Errors
411///
412/// Returns an error response if the request body is missing, malformed, or fails validation.
413pub fn validate_request_body<T>(req: &AuthRequest) -> Result<T, AuthResponse>
414where
415    T: serde::de::DeserializeOwned + validator::Validate + 'static,
416{
417    let value: T = req.body_as_json().map_err(|e| {
418        let message = format!("Invalid JSON: {e}");
419        let code = AuthError::code_from_message(&message);
420        AuthResponse::json(400, &ErrorCodeMessageResponse { code, message })
421            .unwrap_or_else(|_| AuthResponse::text(400, "Invalid JSON"))
422    })?;
423
424    value
425        .validate()
426        .map_err(|e| validation_error_response(&e))?;
427    Ok(value)
428}
429
430// LCOV_EXCL_START
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
436    #[test]
437    fn bad_request_is_400() {
438        assert_eq!(AuthError::bad_request("oops").status_code(), 400);
439    }
440
441    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
442    #[test]
443    fn invalid_request_is_400() {
444        assert_eq!(AuthError::InvalidRequest("x".into()).status_code(), 400);
445    }
446
447    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
448    #[test]
449    fn validation_is_400() {
450        assert_eq!(AuthError::validation("x").status_code(), 400);
451    }
452
453    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
454    #[test]
455    fn invalid_credentials_is_401() {
456        assert_eq!(AuthError::InvalidCredentials.status_code(), 401);
457    }
458
459    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
460    #[test]
461    fn unauthenticated_is_401() {
462        assert_eq!(AuthError::Unauthenticated.status_code(), 401);
463    }
464
465    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
466    #[test]
467    fn session_not_found_is_401() {
468        assert_eq!(AuthError::SessionNotFound.status_code(), 401);
469    }
470
471    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
472    #[test]
473    fn forbidden_is_403() {
474        assert_eq!(AuthError::forbidden("nope").status_code(), 403);
475    }
476
477    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
478    #[test]
479    fn unauthorized_is_403() {
480        assert_eq!(AuthError::Unauthorized.status_code(), 403);
481    }
482
483    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
484    #[test]
485    fn not_found_is_404() {
486        assert_eq!(AuthError::not_found("gone").status_code(), 404);
487        assert_eq!(AuthError::UserNotFound.status_code(), 404);
488    }
489
490    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
491    #[test]
492    fn conflict_is_409() {
493        assert_eq!(AuthError::conflict("dup").status_code(), 409);
494    }
495
496    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
497    #[test]
498    fn unprocessable_entity_is_422() {
499        assert_eq!(
500            AuthError::UnprocessableEntity("x".into()).status_code(),
501            422
502        );
503    }
504
505    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
506    #[test]
507    fn rate_limited_is_429() {
508        assert_eq!(
509            AuthError::RateLimited { retry_after: None }.status_code(),
510            429
511        );
512    }
513
514    // Application routes answer with the built-in middleware's retry header.
515    #[test]
516    fn rate_limited_responses_publish_retry_after() {
517        for (seconds, header) in [(9.0, "9"), (2.5, "2.5")] {
518            let response = AuthError::rate_limited(seconds).to_auth_response();
519            assert_eq!(response.status, 429);
520            assert_eq!(
521                response.headers.get("X-Retry-After").map(String::as_str),
522                Some(header)
523            );
524            #[cfg(feature = "axum")]
525            {
526                let response =
527                    axum::response::IntoResponse::into_response(AuthError::rate_limited(seconds));
528                assert_eq!(response.status(), 429);
529                assert_eq!(response.headers()["x-retry-after"], header);
530            }
531        }
532    }
533
534    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
535    #[test]
536    fn not_implemented_is_501() {
537        assert_eq!(AuthError::not_implemented("todo").status_code(), 501);
538    }
539
540    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
541    #[test]
542    fn internal_errors_are_500() {
543        assert_eq!(AuthError::config("bad").status_code(), 500);
544        assert_eq!(AuthError::internal("fail").status_code(), 500);
545        assert_eq!(AuthError::plugin("p", "m").status_code(), 500);
546        assert_eq!(AuthError::PasswordHash("h".into()).status_code(), 500);
547        assert_eq!(
548            AuthError::Database(DatabaseError::Connection("c".into())).status_code(),
549            500
550        );
551    }
552
553    // Upstream reference: BASE_ERROR_CODES in @better-auth/core — a message
554    // upstream defines a constant for carries that constant.
555    #[test]
556    fn code_from_message_returns_the_upstream_constant() {
557        assert_eq!(
558            AuthError::code_from_message("User not found").as_deref(),
559            Some("USER_NOT_FOUND")
560        );
561    }
562
563    // Upstream reference: packages/core/src/error/index.ts :: `APIError.from`
564    // attaches a code, while `new APIError(status, { message })` does not — so
565    // a message outside every upstream table has no code on the wire.
566    #[test]
567    fn code_from_message_is_none_for_unknown_messages() {
568        assert_eq!(AuthError::code_from_message("invalid email!"), None);
569        assert_eq!(AuthError::code_from_message("Email is the same"), None);
570        assert_eq!(AuthError::code_from_message(""), None);
571    }
572
573    // Upstream reference: plugin tables built with `defineErrorCodes` are part
574    // of the same vocabulary, not just the base set.
575    #[test]
576    fn code_from_message_covers_plugin_tables() {
577        assert_eq!(
578            AuthError::code_from_message("Username is already taken. Please try another.")
579                .as_deref(),
580            Some("USERNAME_IS_ALREADY_TAKEN")
581        );
582    }
583
584    // Upstream reference: BASE_ERROR_CODES in @better-auth/core ::
585    // packages/core/src/error/codes.ts — codes are explicit constants since
586    // better-auth 1.5, so these must not be re-derived from the message.
587    #[test]
588    fn base_error_codes_are_not_derived_from_the_message() {
589        for (message, expected) in [
590            ("Invalid callbackURL", "INVALID_CALLBACK_URL"),
591            ("Invalid redirectURL", "INVALID_REDIRECT_URL"),
592            ("Invalid errorCallbackURL", "INVALID_ERROR_CALLBACK_URL"),
593            (
594                "Invalid newUserCallbackURL",
595                "INVALID_NEW_USER_CALLBACK_URL",
596            ),
597            ("Email is already verified", "EMAIL_ALREADY_VERIFIED"),
598            (
599                "Verification email isn't enabled",
600                "VERIFICATION_EMAIL_NOT_ENABLED",
601            ),
602            (
603                "Session expired. Re-authenticate to perform this action.",
604                "SESSION_EXPIRED",
605            ),
606            (
607                "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
608                "CROSS_SITE_NAVIGATION_LOGIN_BLOCKED",
609            ),
610        ] {
611            assert_eq!(
612                AuthError::code_from_message(message).as_deref(),
613                Some(expected),
614                "message {message:?} must carry the upstream constant"
615            );
616        }
617    }
618
619    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
620    #[test]
621    fn error_payload_for_client_error() {
622        // Not an upstream message, so it goes out without a code.
623        let (status, code, message) = AuthError::bad_request("Missing field").error_payload();
624        assert_eq!(status, 400);
625        assert_eq!(code, None);
626        assert_eq!(message, "Missing field");
627
628        let (status_2, code_2, message_2) =
629            AuthError::bad_request("Field is required").error_payload();
630        assert_eq!(status_2, 400);
631        assert_eq!(code_2.as_deref(), Some("MISSING_FIELD"));
632        assert_eq!(message_2, "Field is required");
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 error_payload_for_internal_error_hides_details() {
638        let (status, _code, message) = AuthError::internal("secret detail").error_payload();
639        assert_eq!(status, 500);
640        assert_eq!(message, "Internal server error");
641    }
642
643    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
644    #[test]
645    fn to_auth_response_returns_correct_status() {
646        let resp = AuthError::bad_request("oops").to_auth_response();
647        assert_eq!(resp.status, 400);
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 to_auth_response_body_contains_code_and_message() {
653        let resp = AuthError::UserNotFound.to_auth_response();
654        assert_eq!(resp.status, 404);
655        let body: serde_json::Value =
656            serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
657        assert_eq!(
658            (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
659            "USER_NOT_FOUND"
660        );
661        assert_eq!(
662            (*(body).get("message").unwrap_or(&serde_json::Value::Null)),
663            "User not found"
664        );
665    }
666
667    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
668    #[test]
669    fn constructor_helpers_produce_correct_variants() {
670        // Each helper should produce the expected Display output
671        assert_eq!(AuthError::bad_request("x").to_string(), "x");
672        assert_eq!(AuthError::forbidden("x").to_string(), "x");
673        assert_eq!(AuthError::not_found("x").to_string(), "x");
674        assert_eq!(AuthError::conflict("x").to_string(), "x");
675        assert_eq!(AuthError::not_implemented("x").to_string(), "x");
676        assert_eq!(AuthError::config("x").to_string(), "Configuration error: x");
677        assert_eq!(
678            AuthError::internal("x").to_string(),
679            "Internal server error: x"
680        );
681        assert_eq!(
682            AuthError::validation("x").to_string(),
683            "Validation error: x"
684        );
685        assert_eq!(
686            AuthError::plugin("p", "m").to_string(),
687            "Plugin error: p - m"
688        );
689    }
690
691    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
692    #[test]
693    fn database_error_display() {
694        let e = DatabaseError::Connection("timeout".into());
695        assert_eq!(e.to_string(), "Connection error: timeout");
696    }
697
698    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
699    #[test]
700    fn database_error_converts_to_auth_error() {
701        let db_err = DatabaseError::Query("bad sql".into());
702        let auth_err: AuthError = db_err.into();
703        assert_eq!(auth_err.status_code(), 500);
704    }
705
706    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
707    #[test]
708    fn validation_error_response_serializes_field_errors() {
709        let mut errors = validator::ValidationErrors::new();
710        let mut error = validator::ValidationError::new("email");
711        error.message = Some("Email is invalid".into());
712        errors.add("email", error);
713
714        let resp = validation_error_response(&errors);
715        assert_eq!(resp.status, 400);
716        let body: serde_json::Value =
717            serde_json::from_slice(&resp.body).expect("response body should be valid JSON");
718        assert_eq!(
719            (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
720            "VALIDATION_ERROR"
721        );
722        assert_eq!(
723            (*(body).get("message").unwrap_or(&serde_json::Value::Null)),
724            "[body.email] Email is invalid"
725        );
726    }
727
728    // Rust-specific surface: `AuthError` and Rust-side response/error conversion behavior are public Rust library APIs with no direct TS analogue.
729    #[test]
730    fn fixed_message_variants_display() {
731        assert_eq!(
732            AuthError::InvalidCredentials.to_string(),
733            "Invalid email or password"
734        );
735        assert_eq!(
736            AuthError::Unauthenticated.to_string(),
737            "Authentication required"
738        );
739        assert_eq!(
740            AuthError::SessionNotFound.to_string(),
741            "Session not found or expired"
742        );
743        assert_eq!(
744            AuthError::Unauthorized.to_string(),
745            "Insufficient permissions"
746        );
747        assert_eq!(AuthError::UserNotFound.to_string(), "User not found");
748        assert_eq!(
749            AuthError::RateLimited { retry_after: None }.to_string(),
750            "Too many requests"
751        );
752    }
753
754    // The framework-neutral response conversion is distinct from Axum's HTTP path.
755    #[test]
756    fn callback_failure_transport_preserves_empty_body_and_public_api_errors() {
757        let response = AuthError::CallbackFailure(Box::new(AuthError::internal("private cause")))
758            .to_auth_response();
759        assert_eq!(response.status, 500);
760        assert_eq!(response.body, Vec::<u8>::new());
761        assert!(response.headers.get("content-type").is_none());
762
763        let internal = AuthError::internal("private cause").to_auth_response();
764        assert_eq!(internal.status, 500);
765        assert_eq!(
766            serde_json::from_slice::<serde_json::Value>(&internal.body).unwrap(),
767            serde_json::json!({"message":"Internal server error"})
768        );
769        let explicit = AuthError::Api {
770            status: 500,
771            code: Some("APPLICATION_ERROR".into()),
772            message: "public error".into(),
773        }
774        .to_auth_response();
775        assert_eq!(explicit.status, 500);
776        assert_eq!(
777            serde_json::from_slice::<serde_json::Value>(&explicit.body).unwrap(),
778            serde_json::json!({"code":"APPLICATION_ERROR","message":"public error"})
779        );
780    }
781}
782// LCOV_EXCL_STOP