//! Application error types and HTTP error response rendering use askama::Template; use axum::{ http::StatusCode, response::{Html, IntoResponse, Response}, }; /// Stashed in response extensions by `AppError::into_response()` so that the /// JSON-error middleware on `/api/…` routes can swap the HTML body for /// `{"error": "…"}` without touching any handler code. #[derive(Clone)] pub struct ApiErrorMessage(pub String); /// Payload carried by `AppError::Validation`. /// /// The `summary` is the user-visible banner shown on the generic error page. /// Per-field error rendering is not currently wired into any template; if /// you find yourself wanting it, build the form re-render path first. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidationError { pub summary: String, } impl ValidationError { pub fn new(summary: impl Into) -> Self { Self { summary: summary.into() } } } impl std::fmt::Display for ValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.summary) } } impl From for ValidationError { fn from(summary: String) -> Self { ValidationError::new(summary) } } impl From<&str> for ValidationError { fn from(summary: &str) -> Self { ValidationError::new(summary.to_string()) } } impl From<&String> for ValidationError { fn from(summary: &String) -> Self { ValidationError::new(summary.clone()) } } /// Application error type that can be converted into an HTTP response #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("Not found")] NotFound, #[error("Unauthorized")] Unauthorized, #[error("Forbidden")] Forbidden, #[error("Bad request: {0}")] BadRequest(String), #[error("Validation error: {0}")] Validation(ValidationError), #[error("Database error: {0}")] Database(#[from] sqlx::Error), #[error("Internal server error")] Internal(#[from] anyhow::Error), #[error("Storage error: {0}")] Storage(String), #[error("Invalid file type: {0}")] InvalidFileType(String), #[error("File too large: {0}")] FileTooLarge(String), #[error("File quarantined: {0}")] MalwareDetected(String), #[error("Service unavailable: {0}")] ServiceUnavailable(String), #[error("Conflict: {0}")] Conflict(String), #[error("Payment required: {0}")] PaymentRequired(String), } impl AppError { /// Construct a validation error with a summary message. pub fn validation(summary: impl Into) -> Self { AppError::Validation(summary.into()) } /// Static tag for Prometheus metric labels (e.g. `kind="database"`) pub fn tag(&self) -> &'static str { match self { AppError::NotFound => "not_found", AppError::Unauthorized => "unauthorized", AppError::Forbidden => "forbidden", AppError::BadRequest(_) => "bad_request", AppError::Validation(_) => "validation", AppError::Database(_) => "database", AppError::Internal(_) => "internal", AppError::Storage(_) => "storage", AppError::InvalidFileType(_) => "invalid_file_type", AppError::FileTooLarge(_) => "file_too_large", AppError::MalwareDetected(_) => "malware_detected", AppError::ServiceUnavailable(_) => "service_unavailable", AppError::Conflict(_) => "conflict", AppError::PaymentRequired(_) => "payment_required", } } /// Get the HTTP status code for this error pub fn status_code(&self) -> StatusCode { match self { AppError::NotFound => StatusCode::NOT_FOUND, AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::Forbidden => StatusCode::FORBIDDEN, AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::Storage(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::InvalidFileType(_) => StatusCode::BAD_REQUEST, AppError::FileTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, AppError::MalwareDetected(_) => StatusCode::UNPROCESSABLE_ENTITY, AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, AppError::Conflict(_) => StatusCode::CONFLICT, AppError::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED, } } /// Get a user-friendly message for this error pub fn user_message(&self) -> String { match self { AppError::NotFound => "The page you're looking for doesn't exist.".to_string(), AppError::Unauthorized => "You need to log in to access this page.".to_string(), AppError::Forbidden => "You don't have permission to access this page.".to_string(), AppError::BadRequest(msg) => msg.clone(), AppError::Validation(v) => v.summary.clone(), AppError::InvalidFileType(msg) => msg.clone(), AppError::FileTooLarge(msg) => msg.clone(), AppError::MalwareDetected(_) => { "This file has been flagged by our security scanner and cannot be uploaded.".to_string() } AppError::ServiceUnavailable(msg) => msg.clone(), AppError::Conflict(msg) => msg.clone(), AppError::PaymentRequired(msg) => msg.clone(), AppError::Database(_) | AppError::Internal(_) | AppError::Storage(_) => { "Something went wrong. Please try again later.".to_string() } } } } impl IntoResponse for AppError { fn into_response(self) -> Response { let status = self.status_code(); let message = self.user_message(); // Increment error counter for Prometheus metrics::counter!("http_errors_total", "kind" => self.tag()).increment(1); // Log server errors with structured fields. // The request_id and user_id are already in the parent tracing span // (set by TraceLayer and AuthUser respectively), so they appear // automatically in these log lines. match &self { AppError::Database(e) => { tracing::error!(error.kind = "database", error.detail = ?e, "request failed"); } AppError::Internal(e) => { tracing::error!(error.kind = "internal", error.detail = ?e, "request failed"); } AppError::Storage(e) => { tracing::error!(error.kind = "storage", error.detail = %e, "request failed"); } AppError::MalwareDetected(detail) => { tracing::warn!(error.kind = "malware_detected", error.detail = %detail, "file quarantined"); } _ => {} } let template = ErrorTemplate { csrf_token: None, // Errors don't need CSRF token status_code: status.as_u16(), status_text: status.canonical_reason().unwrap_or("Error").to_string(), message: message.clone(), }; let mut response = match template.render() { Ok(html) => (status, Html(html)).into_response(), Err(_) => { // Fallback if template rendering fails (status, message.clone()).into_response() } }; // Stash the message so json_error_layer can swap HTML → JSON on API routes response.extensions_mut().insert(ApiErrorMessage(message)); response } } /// Error page template #[derive(Template)] #[template(path = "pages/error.html")] pub struct ErrorTemplate { pub csrf_token: Option, pub status_code: u16, pub status_text: String, pub message: String, } /// Result type alias for handlers pub type Result = std::result::Result; /// Extension trait for adding context to any `Result` where `E` can /// convert into `AppError`. The context string is preserved in the error chain /// via `anyhow::Context`, making it visible in structured error logs. /// /// ```ignore /// use crate::error::ResultExt; /// let user = db::users::get_user_by_id(&db, id) /// .await /// .context("fetch user for checkout")?; /// ``` pub trait ResultExt { fn context(self, msg: &'static str) -> Result; fn with_context String>(self, f: F) -> Result; } impl ResultExt for std::result::Result where E: std::error::Error + Send + Sync + 'static, { fn context(self, msg: &'static str) -> Result { self.map_err(|e| AppError::Internal(anyhow::Error::new(e).context(msg))) } fn with_context String>(self, f: F) -> Result { self.map_err(|e| AppError::Internal(anyhow::Error::new(e).context(f()))) } } #[cfg(test)] mod tests { use super::*; #[test] fn status_code_not_found() { assert_eq!(AppError::NotFound.status_code(), StatusCode::NOT_FOUND); } #[test] fn status_code_unauthorized() { assert_eq!(AppError::Unauthorized.status_code(), StatusCode::UNAUTHORIZED); } #[test] fn status_code_bad_request() { assert_eq!( AppError::BadRequest("test".into()).status_code(), StatusCode::BAD_REQUEST ); } #[test] fn status_code_validation() { assert_eq!( AppError::validation("test").status_code(), StatusCode::UNPROCESSABLE_ENTITY ); } #[test] fn user_message_not_found() { let msg = AppError::NotFound.user_message(); assert!(msg.contains("doesn't exist")); } #[test] fn user_message_internal_is_safe() { let msg = AppError::Storage("s3 connection refused".into()).user_message(); assert!(!msg.contains("s3")); // should not leak internal details assert!(msg.contains("Something went wrong")); } #[test] fn user_message_validation_passes_through() { let msg = AppError::validation("Name too long").user_message(); assert_eq!(msg, "Name too long"); } #[test] fn status_code_file_too_large() { assert_eq!( AppError::FileTooLarge("too big".into()).status_code(), StatusCode::PAYLOAD_TOO_LARGE ); } #[test] fn api_error_message_clone() { let msg = ApiErrorMessage("test error".to_string()); let cloned = msg.clone(); assert_eq!(cloned.0, "test error"); } // ── IntoResponse rendering ────────────────────────────────────────── fn response_status_and_body(err: AppError) -> (StatusCode, String, Option) { let response = err.into_response(); let status = response.status(); let api_msg = response.extensions().get::().cloned(); // We can't easily extract the body synchronously, but we can verify // the status and the stashed ApiErrorMessage extension. (status, api_msg.as_ref().map(|m| m.0.clone()).unwrap_or_default(), api_msg) } #[test] fn into_response_not_found() { let (status, body, ext) = response_status_and_body(AppError::NotFound); assert_eq!(status, StatusCode::NOT_FOUND); assert!(body.contains("doesn't exist")); assert!(ext.is_some()); } #[test] fn into_response_unauthorized() { let (status, body, _) = response_status_and_body(AppError::Unauthorized); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(body.contains("log in")); } #[test] fn into_response_forbidden() { let (status, body, _) = response_status_and_body(AppError::Forbidden); assert_eq!(status, StatusCode::FORBIDDEN); assert!(body.contains("permission")); } #[test] fn into_response_bad_request() { let (status, body, _) = response_status_and_body(AppError::BadRequest("field required".into())); assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body, "field required"); } #[test] fn into_response_validation() { let (status, body, _) = response_status_and_body(AppError::validation("too long")); assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(body, "too long"); } #[test] fn into_response_invalid_file_type() { let (status, body, _) = response_status_and_body(AppError::InvalidFileType("not a PNG".into())); assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body, "not a PNG"); } #[test] fn into_response_file_too_large() { let (status, body, _) = response_status_and_body(AppError::FileTooLarge("over 500 MB".into())); assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(body, "over 500 MB"); } #[test] fn into_response_malware_detected() { let (status, body, _) = response_status_and_body( AppError::MalwareDetected("ClamAV:Eicar-Signature".into()), ); assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); assert!(body.contains("security scanner")); assert!(!body.contains("ClamAV"), "internal scanner detail must not leak"); assert!(!body.contains("Eicar"), "internal signature name must not leak"); } #[test] fn into_response_service_unavailable() { let (status, body, _) = response_status_and_body( AppError::ServiceUnavailable("try again in 5 minutes".into()), ); assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body, "try again in 5 minutes"); } // ── Internal detail leakage ───────────────────────────────────────── #[test] fn into_response_internal_never_leaks_details() { let inner = anyhow::anyhow!("pg connection pool exhausted on host db-primary:5432"); let (status, body, _) = response_status_and_body(AppError::Internal(inner)); assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert!(body.contains("Something went wrong")); assert!(!body.contains("pg connection"), "internal detail must not leak"); assert!(!body.contains("5432"), "host/port must not leak"); } #[test] fn into_response_database_never_leaks_details() { let err = AppError::Database(sqlx::Error::PoolTimedOut); let (status, body, _) = response_status_and_body(err); assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert!(body.contains("Something went wrong")); assert!(!body.contains("PoolTimedOut"), "sqlx variant must not leak"); } #[test] fn into_response_storage_never_leaks_details() { let (status, body, _) = response_status_and_body( AppError::Storage("S3 PutObject failed: AccessDenied on bucket mnw-prod".into()), ); assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert!(body.contains("Something went wrong")); assert!(!body.contains("S3"), "S3 detail must not leak"); assert!(!body.contains("mnw-prod"), "bucket name must not leak"); } // ── ResultExt ─────────────────────────────────────────────────────── #[test] fn result_ext_context_wraps_error() { let original: std::result::Result<(), std::io::Error> = Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file missing")); let wrapped = original.context("loading config"); assert!(wrapped.is_err()); let app_err = wrapped.unwrap_err(); // Should produce an Internal variant assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR); // The context string should appear in the Debug representation let debug = format!("{:?}", app_err); assert!(debug.contains("loading config"), "context string should be in error chain"); } #[test] fn result_ext_with_context_wraps_error() { let original: std::result::Result<(), std::io::Error> = Err(std::io::Error::other("boom")); let wrapped = original.with_context(|| format!("processing item {}", 42)); assert!(wrapped.is_err()); let app_err = wrapped.unwrap_err(); assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR); let debug = format!("{:?}", app_err); assert!(debug.contains("processing item 42")); } #[test] fn result_ext_ok_passes_through() { let original: std::result::Result = Ok(99); let result = original.context("should not matter"); assert_eq!(result.unwrap(), 99); } // ── Tag coverage ──────────────────────────────────────────────────── #[test] fn tag_matches_variant() { assert_eq!(AppError::NotFound.tag(), "not_found"); assert_eq!(AppError::Unauthorized.tag(), "unauthorized"); assert_eq!(AppError::Forbidden.tag(), "forbidden"); assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request"); assert_eq!(AppError::validation("x").tag(), "validation"); assert_eq!(AppError::Storage("x".into()).tag(), "storage"); assert_eq!(AppError::InvalidFileType("x".into()).tag(), "invalid_file_type"); assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large"); assert_eq!(AppError::MalwareDetected("x".into()).tag(), "malware_detected"); assert_eq!(AppError::ServiceUnavailable("x".into()).tag(), "service_unavailable"); assert_eq!(AppError::Internal(anyhow::anyhow!("x")).tag(), "internal"); } // ── User message edge cases ───────────────────────────────────────── #[test] fn user_message_bad_request_preserves_content() { let msg = AppError::BadRequest("".into()).user_message(); assert_eq!(msg, ""); // empty input -> empty output (no crash) } #[test] fn user_message_malware_detected_hides_detail() { let msg = AppError::MalwareDetected("Win.Trojan.Agent-123456".into()).user_message(); assert!(!msg.contains("Win.Trojan")); assert!(msg.contains("security scanner")); } #[test] fn validation_error_from_string() { let v: ValidationError = "boom".to_string().into(); assert_eq!(v.summary, "boom"); } #[test] fn validation_error_from_str() { let v: ValidationError = "boom".into(); assert_eq!(v.summary, "boom"); } #[test] fn validation_constructor_accepts_plain_string() { let e = AppError::validation("nope"); assert_eq!(e.tag(), "validation"); assert_eq!(e.user_message(), "nope"); } #[test] fn validation_constructor_accepts_owned_string() { let e = AppError::validation("nope".to_string()); assert_eq!(e.user_message(), "nope"); } }