Skip to main content

alibi_core/error/
mod.rs

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