Skip to main content

max / makenotwork

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