Skip to main content

max / makenotwork

19.1 KB · 530 lines History Blame Raw
1 //! Application error types and HTTP error response rendering
2
3 use askama::Template;
4 use axum::{
5 http::StatusCode,
6 response::{Html, IntoResponse, Response},
7 };
8
9 /// Stashed in response extensions by `AppError::into_response()` so that the
10 /// JSON-error middleware on `/api/…` routes can swap the HTML body for
11 /// `{"error": "…"}` without touching any handler code.
12 #[derive(Clone)]
13 pub struct ApiErrorMessage(pub String);
14
15 /// Payload carried by `AppError::Validation`.
16 ///
17 /// The `summary` is the user-visible banner shown on the generic error page.
18 /// Per-field error rendering is not currently wired into any template; if
19 /// you find yourself wanting it, build the form re-render path first.
20 #[derive(Debug, Clone, PartialEq, Eq)]
21 pub struct ValidationError {
22 pub summary: String,
23 }
24
25 impl ValidationError {
26 pub fn new(summary: impl Into<String>) -> Self {
27 Self { summary: summary.into() }
28 }
29 }
30
31 impl std::fmt::Display for ValidationError {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 f.write_str(&self.summary)
34 }
35 }
36
37 impl From<String> for ValidationError {
38 fn from(summary: String) -> Self {
39 ValidationError::new(summary)
40 }
41 }
42
43 impl From<&str> for ValidationError {
44 fn from(summary: &str) -> Self {
45 ValidationError::new(summary.to_string())
46 }
47 }
48
49 impl From<&String> for ValidationError {
50 fn from(summary: &String) -> Self {
51 ValidationError::new(summary.clone())
52 }
53 }
54
55 /// Application error type that can be converted into an HTTP response
56 #[derive(Debug, thiserror::Error)]
57 pub enum AppError {
58 #[error("Not found")]
59 NotFound,
60
61 #[error("Unauthorized")]
62 Unauthorized,
63
64 #[error("Forbidden")]
65 Forbidden,
66
67 #[error("Bad request: {0}")]
68 BadRequest(String),
69
70 #[error("Validation error: {0}")]
71 Validation(ValidationError),
72
73 #[error("Database error: {0}")]
74 Database(#[from] sqlx::Error),
75
76 #[error("Internal server error")]
77 Internal(#[from] anyhow::Error),
78
79 #[error("Storage error: {0}")]
80 Storage(String),
81
82 #[error("Invalid file type: {0}")]
83 InvalidFileType(String),
84
85 #[error("File too large: {0}")]
86 FileTooLarge(String),
87
88 #[error("File quarantined: {0}")]
89 MalwareDetected(String),
90
91 #[error("Service unavailable: {0}")]
92 ServiceUnavailable(String),
93
94 #[error("Conflict: {0}")]
95 Conflict(String),
96
97 #[error("Payment required: {0}")]
98 PaymentRequired(String),
99 }
100
101 impl AppError {
102 /// Construct a validation error with a summary message.
103 pub fn validation(summary: impl Into<ValidationError>) -> Self {
104 AppError::Validation(summary.into())
105 }
106
107 /// Static tag for Prometheus metric labels (e.g. `kind="database"`)
108 pub fn tag(&self) -> &'static str {
109 match self {
110 AppError::NotFound => "not_found",
111 AppError::Unauthorized => "unauthorized",
112 AppError::Forbidden => "forbidden",
113 AppError::BadRequest(_) => "bad_request",
114 AppError::Validation(_) => "validation",
115 AppError::Database(_) => "database",
116 AppError::Internal(_) => "internal",
117 AppError::Storage(_) => "storage",
118 AppError::InvalidFileType(_) => "invalid_file_type",
119 AppError::FileTooLarge(_) => "file_too_large",
120 AppError::MalwareDetected(_) => "malware_detected",
121 AppError::ServiceUnavailable(_) => "service_unavailable",
122 AppError::Conflict(_) => "conflict",
123 AppError::PaymentRequired(_) => "payment_required",
124 }
125 }
126
127 /// Get the HTTP status code for this error
128 pub fn status_code(&self) -> StatusCode {
129 match self {
130 AppError::NotFound => StatusCode::NOT_FOUND,
131 AppError::Unauthorized => StatusCode::UNAUTHORIZED,
132 AppError::Forbidden => StatusCode::FORBIDDEN,
133 AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
134 AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
135 AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
136 AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
137 AppError::Storage(_) => StatusCode::INTERNAL_SERVER_ERROR,
138 AppError::InvalidFileType(_) => StatusCode::BAD_REQUEST,
139 AppError::FileTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
140 AppError::MalwareDetected(_) => StatusCode::UNPROCESSABLE_ENTITY,
141 AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
142 AppError::Conflict(_) => StatusCode::CONFLICT,
143 AppError::PaymentRequired(_) => StatusCode::PAYMENT_REQUIRED,
144 }
145 }
146
147 /// Get a user-friendly message for this error
148 pub fn user_message(&self) -> String {
149 match self {
150 AppError::NotFound => "The page you're looking for doesn't exist.".to_string(),
151 AppError::Unauthorized => "You need to log in to access this page.".to_string(),
152 AppError::Forbidden => "You don't have permission to access this page.".to_string(),
153 AppError::BadRequest(msg) => msg.clone(),
154 AppError::Validation(v) => v.summary.clone(),
155 AppError::InvalidFileType(msg) => msg.clone(),
156 AppError::FileTooLarge(msg) => msg.clone(),
157 AppError::MalwareDetected(_) => {
158 "This file has been flagged by our security scanner and cannot be uploaded.".to_string()
159 }
160 AppError::ServiceUnavailable(msg) => msg.clone(),
161 AppError::Conflict(msg) => msg.clone(),
162 AppError::PaymentRequired(msg) => msg.clone(),
163 AppError::Database(_) | AppError::Internal(_) | AppError::Storage(_) => {
164 "Something went wrong. Please try again later.".to_string()
165 }
166 }
167 }
168 }
169
170 impl IntoResponse for AppError {
171 fn into_response(self) -> Response {
172 let status = self.status_code();
173 let message = self.user_message();
174
175 // Increment error counter for Prometheus
176 metrics::counter!("http_errors_total", "kind" => self.tag()).increment(1);
177
178 // Log server errors with structured fields.
179 // The request_id and user_id are already in the parent tracing span
180 // (set by TraceLayer and AuthUser respectively), so they appear
181 // automatically in these log lines.
182 match &self {
183 AppError::Database(e) => {
184 tracing::error!(error.kind = "database", error.detail = ?e, "request failed");
185 }
186 AppError::Internal(e) => {
187 tracing::error!(error.kind = "internal", error.detail = ?e, "request failed");
188 }
189 AppError::Storage(e) => {
190 tracing::error!(error.kind = "storage", error.detail = %e, "request failed");
191 }
192 AppError::MalwareDetected(detail) => {
193 tracing::warn!(error.kind = "malware_detected", error.detail = %detail, "file quarantined");
194 }
195 _ => {}
196 }
197
198 let template = ErrorTemplate {
199 csrf_token: None, // Errors don't need CSRF token
200 status_code: status.as_u16(),
201 status_text: status.canonical_reason().unwrap_or("Error").to_string(),
202 message: message.clone(),
203 };
204
205 let mut response = match template.render() {
206 Ok(html) => (status, Html(html)).into_response(),
207 Err(_) => {
208 // Fallback if template rendering fails
209 (status, message.clone()).into_response()
210 }
211 };
212
213 // Stash the message so json_error_layer can swap HTML → JSON on API routes
214 response.extensions_mut().insert(ApiErrorMessage(message));
215
216 response
217 }
218 }
219
220 /// Error page template
221 #[derive(Template)]
222 #[template(path = "pages/error.html")]
223 pub struct ErrorTemplate {
224 pub csrf_token: Option<String>,
225 pub status_code: u16,
226 pub status_text: String,
227 pub message: String,
228 }
229
230 /// Result type alias for handlers
231 pub type Result<T> = std::result::Result<T, AppError>;
232
233 /// Extension trait for adding context to any `Result<T, E>` where `E` can
234 /// convert into `AppError`. The context string is preserved in the error chain
235 /// via `anyhow::Context`, making it visible in structured error logs.
236 ///
237 /// ```ignore
238 /// use crate::error::ResultExt;
239 /// let user = db::users::get_user_by_id(&db, id)
240 /// .await
241 /// .context("fetch user for checkout")?;
242 /// ```
243 pub trait ResultExt<T> {
244 fn context(self, msg: &'static str) -> Result<T>;
245 fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T>;
246 }
247
248 impl<T, E> ResultExt<T> for std::result::Result<T, E>
249 where
250 E: std::error::Error + Send + Sync + 'static,
251 {
252 fn context(self, msg: &'static str) -> Result<T> {
253 self.map_err(|e| AppError::Internal(anyhow::Error::new(e).context(msg)))
254 }
255
256 fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T> {
257 self.map_err(|e| AppError::Internal(anyhow::Error::new(e).context(f())))
258 }
259 }
260
261 #[cfg(test)]
262 mod tests {
263 use super::*;
264
265 #[test]
266 fn status_code_not_found() {
267 assert_eq!(AppError::NotFound.status_code(), StatusCode::NOT_FOUND);
268 }
269
270 #[test]
271 fn status_code_unauthorized() {
272 assert_eq!(AppError::Unauthorized.status_code(), StatusCode::UNAUTHORIZED);
273 }
274
275 #[test]
276 fn status_code_bad_request() {
277 assert_eq!(
278 AppError::BadRequest("test".into()).status_code(),
279 StatusCode::BAD_REQUEST
280 );
281 }
282
283 #[test]
284 fn status_code_validation() {
285 assert_eq!(
286 AppError::validation("test").status_code(),
287 StatusCode::UNPROCESSABLE_ENTITY
288 );
289 }
290
291 #[test]
292 fn user_message_not_found() {
293 let msg = AppError::NotFound.user_message();
294 assert!(msg.contains("doesn't exist"));
295 }
296
297 #[test]
298 fn user_message_internal_is_safe() {
299 let msg = AppError::Storage("s3 connection refused".into()).user_message();
300 assert!(!msg.contains("s3")); // should not leak internal details
301 assert!(msg.contains("Something went wrong"));
302 }
303
304 #[test]
305 fn user_message_validation_passes_through() {
306 let msg = AppError::validation("Name too long").user_message();
307 assert_eq!(msg, "Name too long");
308 }
309
310 #[test]
311 fn status_code_file_too_large() {
312 assert_eq!(
313 AppError::FileTooLarge("too big".into()).status_code(),
314 StatusCode::PAYLOAD_TOO_LARGE
315 );
316 }
317
318 #[test]
319 fn api_error_message_clone() {
320 let msg = ApiErrorMessage("test error".to_string());
321 let cloned = msg.clone();
322 assert_eq!(cloned.0, "test error");
323 }
324
325 // ── IntoResponse rendering ──────────────────────────────────────────
326
327 fn response_status_and_body(err: AppError) -> (StatusCode, String, Option<ApiErrorMessage>) {
328 let response = err.into_response();
329 let status = response.status();
330 let api_msg = response.extensions().get::<ApiErrorMessage>().cloned();
331 // We can't easily extract the body synchronously, but we can verify
332 // the status and the stashed ApiErrorMessage extension.
333 (status, api_msg.as_ref().map(|m| m.0.clone()).unwrap_or_default(), api_msg)
334 }
335
336 #[test]
337 fn into_response_not_found() {
338 let (status, body, ext) = response_status_and_body(AppError::NotFound);
339 assert_eq!(status, StatusCode::NOT_FOUND);
340 assert!(body.contains("doesn't exist"));
341 assert!(ext.is_some());
342 }
343
344 #[test]
345 fn into_response_unauthorized() {
346 let (status, body, _) = response_status_and_body(AppError::Unauthorized);
347 assert_eq!(status, StatusCode::UNAUTHORIZED);
348 assert!(body.contains("log in"));
349 }
350
351 #[test]
352 fn into_response_forbidden() {
353 let (status, body, _) = response_status_and_body(AppError::Forbidden);
354 assert_eq!(status, StatusCode::FORBIDDEN);
355 assert!(body.contains("permission"));
356 }
357
358 #[test]
359 fn into_response_bad_request() {
360 let (status, body, _) = response_status_and_body(AppError::BadRequest("field required".into()));
361 assert_eq!(status, StatusCode::BAD_REQUEST);
362 assert_eq!(body, "field required");
363 }
364
365 #[test]
366 fn into_response_validation() {
367 let (status, body, _) = response_status_and_body(AppError::validation("too long"));
368 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
369 assert_eq!(body, "too long");
370 }
371
372 #[test]
373 fn into_response_invalid_file_type() {
374 let (status, body, _) = response_status_and_body(AppError::InvalidFileType("not a PNG".into()));
375 assert_eq!(status, StatusCode::BAD_REQUEST);
376 assert_eq!(body, "not a PNG");
377 }
378
379 #[test]
380 fn into_response_file_too_large() {
381 let (status, body, _) = response_status_and_body(AppError::FileTooLarge("over 500 MB".into()));
382 assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
383 assert_eq!(body, "over 500 MB");
384 }
385
386 #[test]
387 fn into_response_malware_detected() {
388 let (status, body, _) = response_status_and_body(
389 AppError::MalwareDetected("ClamAV:Eicar-Signature".into()),
390 );
391 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
392 assert!(body.contains("security scanner"));
393 assert!(!body.contains("ClamAV"), "internal scanner detail must not leak");
394 assert!(!body.contains("Eicar"), "internal signature name must not leak");
395 }
396
397 #[test]
398 fn into_response_service_unavailable() {
399 let (status, body, _) = response_status_and_body(
400 AppError::ServiceUnavailable("try again in 5 minutes".into()),
401 );
402 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
403 assert_eq!(body, "try again in 5 minutes");
404 }
405
406 // ── Internal detail leakage ─────────────────────────────────────────
407
408 #[test]
409 fn into_response_internal_never_leaks_details() {
410 let inner = anyhow::anyhow!("pg connection pool exhausted on host db-primary:5432");
411 let (status, body, _) = response_status_and_body(AppError::Internal(inner));
412 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
413 assert!(body.contains("Something went wrong"));
414 assert!(!body.contains("pg connection"), "internal detail must not leak");
415 assert!(!body.contains("5432"), "host/port must not leak");
416 }
417
418 #[test]
419 fn into_response_database_never_leaks_details() {
420 let err = AppError::Database(sqlx::Error::PoolTimedOut);
421 let (status, body, _) = response_status_and_body(err);
422 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
423 assert!(body.contains("Something went wrong"));
424 assert!(!body.contains("PoolTimedOut"), "sqlx variant must not leak");
425 }
426
427 #[test]
428 fn into_response_storage_never_leaks_details() {
429 let (status, body, _) = response_status_and_body(
430 AppError::Storage("S3 PutObject failed: AccessDenied on bucket mnw-prod".into()),
431 );
432 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
433 assert!(body.contains("Something went wrong"));
434 assert!(!body.contains("S3"), "S3 detail must not leak");
435 assert!(!body.contains("mnw-prod"), "bucket name must not leak");
436 }
437
438 // ── ResultExt ───────────────────────────────────────────────────────
439
440 #[test]
441 fn result_ext_context_wraps_error() {
442 let original: std::result::Result<(), std::io::Error> =
443 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"));
444 let wrapped = original.context("loading config");
445 assert!(wrapped.is_err());
446 let app_err = wrapped.unwrap_err();
447 // Should produce an Internal variant
448 assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
449 // The context string should appear in the Debug representation
450 let debug = format!("{:?}", app_err);
451 assert!(debug.contains("loading config"), "context string should be in error chain");
452 }
453
454 #[test]
455 fn result_ext_with_context_wraps_error() {
456 let original: std::result::Result<(), std::io::Error> =
457 Err(std::io::Error::other("boom"));
458 let wrapped = original.with_context(|| format!("processing item {}", 42));
459 assert!(wrapped.is_err());
460 let app_err = wrapped.unwrap_err();
461 assert_eq!(app_err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
462 let debug = format!("{:?}", app_err);
463 assert!(debug.contains("processing item 42"));
464 }
465
466 #[test]
467 fn result_ext_ok_passes_through() {
468 let original: std::result::Result<i32, std::io::Error> = Ok(99);
469 let result = original.context("should not matter");
470 assert_eq!(result.unwrap(), 99);
471 }
472
473 // ── Tag coverage ────────────────────────────────────────────────────
474
475 #[test]
476 fn tag_matches_variant() {
477 assert_eq!(AppError::NotFound.tag(), "not_found");
478 assert_eq!(AppError::Unauthorized.tag(), "unauthorized");
479 assert_eq!(AppError::Forbidden.tag(), "forbidden");
480 assert_eq!(AppError::BadRequest("x".into()).tag(), "bad_request");
481 assert_eq!(AppError::validation("x").tag(), "validation");
482 assert_eq!(AppError::Storage("x".into()).tag(), "storage");
483 assert_eq!(AppError::InvalidFileType("x".into()).tag(), "invalid_file_type");
484 assert_eq!(AppError::FileTooLarge("x".into()).tag(), "file_too_large");
485 assert_eq!(AppError::MalwareDetected("x".into()).tag(), "malware_detected");
486 assert_eq!(AppError::ServiceUnavailable("x".into()).tag(), "service_unavailable");
487 assert_eq!(AppError::Internal(anyhow::anyhow!("x")).tag(), "internal");
488 }
489
490 // ── User message edge cases ─────────────────────────────────────────
491
492 #[test]
493 fn user_message_bad_request_preserves_content() {
494 let msg = AppError::BadRequest("".into()).user_message();
495 assert_eq!(msg, ""); // empty input -> empty output (no crash)
496 }
497
498 #[test]
499 fn user_message_malware_detected_hides_detail() {
500 let msg = AppError::MalwareDetected("Win.Trojan.Agent-123456".into()).user_message();
501 assert!(!msg.contains("Win.Trojan"));
502 assert!(msg.contains("security scanner"));
503 }
504
505 #[test]
506 fn validation_error_from_string() {
507 let v: ValidationError = "boom".to_string().into();
508 assert_eq!(v.summary, "boom");
509 }
510
511 #[test]
512 fn validation_error_from_str() {
513 let v: ValidationError = "boom".into();
514 assert_eq!(v.summary, "boom");
515 }
516
517 #[test]
518 fn validation_constructor_accepts_plain_string() {
519 let e = AppError::validation("nope");
520 assert_eq!(e.tag(), "validation");
521 assert_eq!(e.user_message(), "nope");
522 }
523
524 #[test]
525 fn validation_constructor_accepts_owned_string() {
526 let e = AppError::validation("nope".to_string());
527 assert_eq!(e.user_message(), "nope");
528 }
529 }
530