Skip to main content

umbral_core/
errors.rs

1//! Custom 404 / 500 page helpers.
2//!
3//! Two pieces of [`AppBuilder`](crate::app::AppBuilder) state plug
4//! together with the existing templates engine to deliver the
5//! "drop a `404.html` in your templates dir" experience:
6//!
7//! - `not_found_template(name)` — installs a fallback that renders
8//!   the named template with `{ path }` in scope and returns 404.
9//! - `server_error_template(name)` — wraps the router with a
10//!   panic-catching tower-http layer that renders the named template
11//!   on any handler panic and returns 500.
12//!
13//! Gap 35 extensions:
14//!
15//! - `on_server_error(hook)` — an opt-in hook that fires before the 500
16//!   template is rendered. The closure receives the error message and the
17//!   request path. Runs synchronously on the error path (panic or `Err`
18//!   propagated as a 500). Cannot change the response; used for logging,
19//!   Sentry dispatch, etc.
20//! - Default Tailwind 404/500 templates — shipped as embedded strings so
21//!   they work without any `templates/` directory on disk. Used when the
22//!   user hasn't set their own template name via the builder. Opt-out via
23//!   `App::builder().disable_default_error_pages()`.
24//! - Dev-mode error detail — when `settings.environment == Dev`, the 500
25//!   template receives an `error_chain` context variable listing the full
26//!   `std::error::Error` source chain. In prod the variable is empty.
27//!
28//! Both the 404 and 500 fallbacks are opt-in. When unset (and default
29//! pages are disabled), the fallback returns plain-text "Not Found" and
30//! panics propagate axum-style (log + empty 500 body).
31//!
32//! The 404 path composes with [`SlashRedirect`](crate::slash::SlashRedirect)
33//! — if the redirect probe finds an alternate, it 308s; otherwise the
34//! configured not-found template renders. Users get one consistent
35//! 404 page across normal misses and slash-redirect dead-ends.
36
37use std::any::Any;
38
39use axum::body::Body;
40use axum::http::{Request, Response, StatusCode, header};
41use axum::response::IntoResponse;
42use minijinja::context;
43
44// ─── Embedded default templates ─────────────────────────────────────────────
45
46/// Default 404 page: centered, inline Tailwind utility classes. Degrades
47/// gracefully without Tailwind loaded — the page is functional even as
48/// unstyled HTML.
49pub const DEFAULT_404_HTML: &str = include_str!("templates/defaults/default_404.html");
50
51/// Default 500 page: same shape as the 404. In dev mode the template
52/// receives `error_display`, `error_chain` (vec of source strings), and
53/// `request_path` context variables that render into an expandable detail
54/// block. In prod those variables are empty strings / empty vecs.
55pub const DEFAULT_500_HTML: &str = include_str!("templates/defaults/default_500.html");
56
57// Template names used when registering the defaults into minijinja.
58// `pub` so integration tests can verify the constants without duplicating
59// the string literals.
60pub const DEFAULT_404_TEMPLATE_NAME: &str = "__umbral__/default_404.html";
61pub const DEFAULT_500_TEMPLATE_NAME: &str = "__umbral__/default_500.html";
62
63// ─── On-server-error hook type ───────────────────────────────────────────────
64
65/// Shared callback type for the `on_server_error` hook.
66///
67/// The hook fires on every 500 — both panics and handler errors that are
68/// turned into a 500 response — before the template is rendered.
69///
70/// Arguments:
71/// - `error_display`: the `Display` form of the error, or the stringified
72///   panic payload.
73/// - `request_path`: the URI path of the failing request.
74pub type ServerErrorHook = std::sync::Arc<dyn Fn(&str, &str) + Send + Sync + 'static>;
75
76// ─── Ambient default-pages flag ─────────────────────────────────────────────
77
78use std::sync::OnceLock;
79
80/// Whether the default error pages are enabled. Set during `App::build()`.
81/// `true` (default) — use the embedded templates when the user hasn't
82/// supplied their own. `false` — user called `.disable_default_error_pages()`.
83static DEFAULT_PAGES_ENABLED: OnceLock<bool> = OnceLock::new();
84
85/// Publish the default-pages flag. Called by `AppBuilder::build()` only.
86pub(crate) fn init_default_pages(enabled: bool) {
87    // Ignore the error if already set (e.g. two App builds in the same
88    // process in tests). The first caller wins, matching the OnceLock
89    // contract everywhere else in the framework.
90    let _ = DEFAULT_PAGES_ENABLED.set(enabled);
91}
92
93/// Return whether default pages are enabled.
94pub(crate) fn default_pages_enabled() -> bool {
95    // When called outside App::build() (unit tests that exercise render_*
96    // directly), default to true so the helpers behave like a real app.
97    *DEFAULT_PAGES_ENABLED.get().unwrap_or(&true)
98}
99
100// ─── 404 helpers ────────────────────────────────────────────────────────────
101
102/// Render the configured 404 template with `{ path }` in scope, or
103/// fall back to the plain-text response when no template is set or
104/// rendering fails.
105///
106/// When `template` is `None` and the default pages are enabled, the
107/// framework's own `default_404.html` is rendered instead. When
108/// default pages are disabled and no template name is set, returns
109/// plain "Not Found".
110///
111/// Used by:
112///
113/// - [`crate::slash::slash_redirect_fallback`] for the no-alternate
114///   branch.
115/// - The standalone not-found fallback installed when only
116///   `not_found_template` is set (no slash redirect).
117///
118/// The template gets the request path as `path` so it can render
119/// `The page {{ path }} doesn't exist.` without the user wiring
120/// extractors. Other request state isn't exposed yet — the v1 shape
121/// is intentionally narrow.
122pub fn render_not_found(template: Option<&str>, path: &str) -> Response<Body> {
123    // Resolve the effective template name:
124    //   1. User-supplied name takes highest priority.
125    //   2. Embedded default (registered as __umbral__/default_404.html) when
126    //      default pages are enabled.
127    //   3. Plain-text fallback.
128    let effective_template = template.or_else(|| {
129        if default_pages_enabled() {
130            Some(DEFAULT_404_TEMPLATE_NAME)
131        } else {
132            None
133        }
134    });
135
136    // Derive Content-Type from whether render actually produced HTML.
137    // When the engine isn't initialised or the template fails to render,
138    // the fallback "Not Found" body is plaintext; it would be wrong to
139    // ship it as text/html.
140    //
141    // In dev mode, surface the registered-route registry so a
142    // developer who hits a typoed URL can see what's actually
143    // available. Production responses stay minimal — `dev_mode` is
144    // false there, so the template's `{% if dev_mode %}` block
145    // collapses to nothing.
146    let dev_mode = crate::settings::get_opt()
147        .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
148        .unwrap_or(false);
149    let routes_ctx: Vec<minijinja::Value> = if dev_mode {
150        crate::routes::get()
151            .map(|reg| {
152                reg.by_plugin
153                    .iter()
154                    .filter(|(_, specs)| !specs.is_empty())
155                    .map(|(plugin, specs)| {
156                        // Pre-shape each route entry for the
157                        // template's loop: a path string and a
158                        // pre-joined method label. Pre-joining here
159                        // lets the template render the badge with a
160                        // single `{{ route.method_label }}` access
161                        // instead of nesting another for-loop.
162                        let routes: Vec<minijinja::Value> = specs
163                            .iter()
164                            .map(|s| {
165                                let method_label = if s.methods.is_empty() {
166                                    "ANY".to_string()
167                                } else {
168                                    s.methods.join("·")
169                                };
170                                minijinja::context! {
171                                    path => s.path.as_str(),
172                                    methods => s.methods.clone(),
173                                    method_label => method_label,
174                                }
175                            })
176                            .collect();
177                        minijinja::context! {
178                            plugin => plugin.as_str(),
179                            routes => routes,
180                        }
181                    })
182                    .collect()
183            })
184            .unwrap_or_default()
185    } else {
186        Vec::new()
187    };
188    let ctx = context! {
189        path => path,
190        dev_mode => dev_mode,
191        routes_by_plugin => routes_ctx,
192    };
193    let (body, content_type) = effective_template
194        .and_then(|name| match crate::templates::render(name, &ctx) {
195            Ok(html) => Some(html),
196            // Falling back to plain text is intentional (no double-fault on
197            // the error path), but a broken error template should leave a
198            // trace rather than silently degrade.
199            Err(e) => {
200                tracing::warn!(
201                    "error-page template `{name}` failed to render ({e}); \
202                     falling back to plain text"
203                );
204                None
205            }
206        })
207        .map(|html| (html, "text/html; charset=utf-8"))
208        .unwrap_or_else(|| ("Not Found".to_string(), "text/plain; charset=utf-8"));
209
210    let mut response = Response::new(Body::from(body));
211    *response.status_mut() = StatusCode::NOT_FOUND;
212    response.headers_mut().insert(
213        header::CONTENT_TYPE,
214        content_type.parse().expect("valid content-type"),
215    );
216    response
217}
218
219/// Build an axum fallback handler that renders the configured 404
220/// template. Used when `not_found_template` is set but
221/// `slash_redirect` is `Off` — `App::build` skips the slash redirect
222/// path and installs this directly.
223pub fn not_found_fallback(
224    template: Option<String>,
225) -> impl Fn(
226    Request<Body>,
227) -> std::pin::Pin<Box<dyn std::future::Future<Output = Response<Body>> + Send>>
228+ Clone
229+ Send
230+ Sync
231+ 'static {
232    move |req: Request<Body>| {
233        let template = template.clone();
234        Box::pin(async move {
235            let path = req.uri().path().to_owned();
236            render_not_found(template.as_deref(), &path)
237        })
238    }
239}
240
241// ─── 500 helpers ────────────────────────────────────────────────────────────
242
243/// Walk the `std::error::Error::source()` chain and collect every
244/// `Display` message into a `Vec<String>`. The first entry is the top-level
245/// error itself; subsequent entries are its causes.
246///
247/// Used by the handler-error path (where `Err` variants produce 500s) to
248/// surface the full cause chain in dev-mode 500 pages. The panic path uses
249/// a synthetic single-element chain instead (panics aren't `dyn Error`).
250pub fn collect_error_chain(top: &str, mut source: Option<&dyn std::error::Error>) -> Vec<String> {
251    let mut chain = vec![top.to_owned()];
252    while let Some(cause) = source {
253        chain.push(cause.to_string());
254        source = cause.source();
255    }
256    chain
257}
258
259/// Determine whether the current settings are dev mode.
260///
261/// Returns `false` when the settings OnceLock isn't initialised (i.e. tests
262/// that exercise the 500 helpers directly without calling `App::build`).
263fn is_dev_mode() -> bool {
264    crate::settings::SETTINGS
265        .get()
266        .map(|s| matches!(s.environment, crate::settings::Environment::Dev))
267        .unwrap_or(false)
268}
269
270/// Build the template context for a 500 response.
271///
272/// In dev mode, `error_display`, `error_chain` (Vec<String>), and
273/// `request_path` are populated. In prod they are empty string / empty
274/// vec / empty string so the template's conditional block collapses
275/// to nothing.
276fn build_500_context(
277    error_display: &str,
278    error_chain: &[String],
279    request_path: &str,
280    dev: bool,
281) -> minijinja::Value {
282    if dev {
283        context! {
284            dev_mode => true,
285            error_display => error_display,
286            error_chain => error_chain,
287            request_path => request_path,
288        }
289    } else {
290        context! {
291            dev_mode => false,
292            error_display => "",
293            error_chain => Vec::<String>::new(),
294            request_path => "",
295        }
296    }
297}
298
299/// Render the 500 template with the given context.
300///
301/// Resolves the effective template name the same way `render_not_found`
302/// resolves the 404: user-supplied name → embedded default → plain text.
303/// If the chosen template itself errors during render (the
304/// recovery-path-failed case: usually a `{% extends "wrapper.html" %}`
305/// that breaks because wrapper.html shares the bug that fired the
306/// original 500), the secondary error gets `tracing::error!`'d AND
307/// — when dev mode is on — embedded in the plain-text fallback body
308/// so the developer sees the recovery failure inline instead of
309/// staring at a generic "Internal Server Error" while the real
310/// chain hides in the logs.
311fn render_500(template: Option<&str>, ctx: &minijinja::Value) -> (String, &'static str) {
312    let effective = template.or_else(|| {
313        if default_pages_enabled() {
314            Some(DEFAULT_500_TEMPLATE_NAME)
315        } else {
316            None
317        }
318    });
319
320    let Some(name) = effective else {
321        return (
322            "Internal Server Error".to_string(),
323            "text/plain; charset=utf-8",
324        );
325    };
326
327    match crate::templates::render(name, ctx) {
328        Ok(html) => (html, "text/html; charset=utf-8"),
329        Err(secondary) => {
330            // The secondary failure WAS being silently swallowed by
331            // `.ok()`. Loud-fail it instead — the operator needs to
332            // see both errors (the original handler 500 already
333            // logged in `render_500_middleware`, plus this one).
334            tracing::error!(
335                template = %name,
336                error = %secondary,
337                "render_500: secondary template render failed; the configured \
338                 server-error template can't render itself. Likely a broken \
339                 `{{% extends \"wrapper.html\" %}}` chain. Falling back to \
340                 plain text.",
341            );
342            if is_dev_mode() {
343                // In dev, include both errors in the body so the
344                // user doesn't have to grep server logs to see why
345                // their 500 page didn't render.
346                let body = format!(
347                    "Internal Server Error\n\n\
348                     (dev) The configured 500 template `{name}` itself failed \
349                     to render: {secondary}\n\n\
350                     Check the original handler error in the server logs \
351                     (line above this one) for the trigger."
352                );
353                (body, "text/plain; charset=utf-8")
354            } else {
355                (
356                    "Internal Server Error".to_string(),
357                    "text/plain; charset=utf-8",
358                )
359            }
360        }
361    }
362}
363
364/// Build the panic-handler closure for
365/// `tower_http::catch_panic::CatchPanicLayer::custom`.
366///
367/// Renders the configured `server_error_template` (or the built-in default
368/// when enabled) with optional dev-mode error context. Before rendering,
369/// calls the `on_server_error` hook if one was registered.
370///
371/// In dev mode the template receives:
372/// - `dev_mode: true`
373/// - `error_display`: the stringified panic payload
374/// - `error_chain`: `[error_display]` (panics have no error chain)
375/// - `request_path`: empty string (not available in a panic handler)
376///
377/// In prod, all three are empty.
378pub fn server_error_panic_handler(
379    template: Option<String>,
380    hook: Option<ServerErrorHook>,
381) -> impl Fn(Box<dyn Any + Send + 'static>) -> Response<Body> + Clone + Send + Sync + 'static {
382    move |err: Box<dyn Any + Send + 'static>| {
383        // Extract a human-readable panic message for the log line.
384        let panic_message = if let Some(s) = err.downcast_ref::<&'static str>() {
385            (*s).to_string()
386        } else if let Some(s) = err.downcast_ref::<String>() {
387            s.clone()
388        } else {
389            "<non-string panic payload>".to_string()
390        };
391        tracing::error!(
392            panic_message = %panic_message,
393            "handler panicked; serving 500 page",
394        );
395
396        // Fire the on_server_error hook before rendering.
397        if let Some(ref h) = hook {
398            h(&panic_message, "");
399        }
400
401        let dev = is_dev_mode();
402        let chain = vec![panic_message.clone()];
403        let ctx = build_500_context(&panic_message, &chain, "", dev);
404        let (body, content_type) = render_500(template.as_deref(), &ctx);
405
406        (
407            StatusCode::INTERNAL_SERVER_ERROR,
408            [(header::CONTENT_TYPE, content_type)],
409            body,
410        )
411            .into_response()
412    }
413}
414
415/// Build an axum fallback or middleware that converts a handler `Err`
416/// response into a 500 with optional dev-mode detail and hook notification.
417///
418/// Used internally when a handler returns a type that produces a 500
419/// status code (e.g. `(StatusCode::INTERNAL_SERVER_ERROR, body)`). The
420/// wrapper intercepts 500 responses, fires the hook if set, and optionally
421/// re-renders them through the 500 template.
422///
423/// Because axum handlers choose their own `IntoResponse` impl, this path
424/// is specifically for handlers that return
425/// `(StatusCode::INTERNAL_SERVER_ERROR, ...)` tuples. Panics are caught
426/// by the `CatchPanicLayer` above.
427///
428/// Note: this function is primarily used by the test suite to verify that
429/// `on_server_error` fires for handler errors. In production the hook is
430/// most naturally wired through a middleware.
431pub fn fire_server_error_hook(hook: &Option<ServerErrorHook>, error_msg: &str, path: &str) {
432    if let Some(h) = hook {
433        h(error_msg, path);
434    }
435}
436
437// ─── Response-rendering middleware (handler-Err path) ───────────────────────
438
439/// State for the response-rendering middleware. Cloned per-request; both
440/// fields are cheap to clone (`Option<String>` + `Option<Arc<...>>`).
441#[derive(Clone)]
442pub struct Render500State {
443    pub template: Option<String>,
444    pub hook: Option<ServerErrorHook>,
445}
446
447/// Turn an error response body into a human sentence for an error page.
448///
449/// gaps3 #57. `ApiError` — the error type every handler is supposed to return — renders
450/// as JSON, because its first audience is an API client. The error-page middlewares then
451/// captured that JSON *verbatim* and printed it as the page's message, so a browser hit
452/// a styled 500 that said `{"code":"database_error","error":"internal server error"}`.
453///
454/// That papercut is not cosmetic: it is a reason not to use `ApiError` in an HTML
455/// handler, which leaves the handler hand-rolling `(StatusCode, String)` — and the
456/// hand-rolled version passes `err.to_string()` straight to the browser, leaking table
457/// names and SQL fragments. The ugly page pushed people toward the leaky pattern.
458///
459/// So: if the body is a JSON object carrying a message, use that message. Anything else
460/// (plain text, an empty body, non-JSON) passes through untouched.
461fn humanize_error_body(raw: &str) -> String {
462    let trimmed = raw.trim();
463    if !trimmed.starts_with('{') {
464        return raw.to_string();
465    }
466    let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) else {
467        return raw.to_string();
468    };
469    // `error` is ApiError's message key; `detail` is the throttling / DRF-style one.
470    for key in ["error", "detail", "message"] {
471        if let Some(msg) = v.get(key).and_then(|m| m.as_str()) {
472            if !msg.is_empty() {
473                return msg.to_string();
474            }
475        }
476    }
477    // A validation failure has no single message — flatten the field errors into
478    // something a human can act on rather than showing them raw JSON.
479    if let Some(fields) = v.get("field_errors").and_then(|f| f.as_object()) {
480        let mut parts: Vec<String> = fields
481            .iter()
482            .map(|(field, msgs)| {
483                let joined = msgs
484                    .as_array()
485                    .map(|a| {
486                        a.iter()
487                            .filter_map(|m| m.as_str())
488                            .collect::<Vec<_>>()
489                            .join(" ")
490                    })
491                    .unwrap_or_default();
492                format!("{field}: {joined}")
493            })
494            .collect();
495        parts.sort();
496        if !parts.is_empty() {
497            return parts.join("; ");
498        }
499    }
500    raw.to_string()
501}
502
503/// Middleware that intercepts plain-text 500 responses and re-renders them
504/// through the configured `server_error_template` (or the embedded default
505/// when enabled). Already-HTML 500 responses pass through untouched — those
506/// were rendered by `CatchPanicLayer` (panics) or by the handler itself.
507///
508/// This closes the gap where a handler returning
509/// `Err((StatusCode::INTERNAL_SERVER_ERROR, msg))` previously produced a
510/// raw plain-text response instead of the configured 500 page. The
511/// `on_server_error` hook also fires for these paths, with the response
512/// body bytes as the error message and the request URI as the path.
513pub async fn render_500_middleware(
514    axum::extract::State(state): axum::extract::State<Render500State>,
515    req: axum::extract::Request,
516    next: axum::middleware::Next,
517) -> Response<Body> {
518    let path = req.uri().path().to_string();
519    let resp = next.run(req).await;
520
521    if resp.status() != StatusCode::INTERNAL_SERVER_ERROR {
522        return resp;
523    }
524
525    // Already-rendered HTML 500s (from CatchPanicLayer or a custom handler)
526    // pass through. Only the raw text/plain or no-content-type 500s get
527    // re-rendered.
528    let ct = resp
529        .headers()
530        .get(header::CONTENT_TYPE)
531        .and_then(|v| v.to_str().ok())
532        .unwrap_or("");
533    if ct.starts_with("text/html") {
534        return resp;
535    }
536
537    // Capture the body to extract the error message for the hook + dev
538    // context. 64KB cap: error messages don't need more, and we don't
539    // want a malicious upstream to OOM us.
540    let (_parts, body) = resp.into_parts();
541    let bytes = axum::body::to_bytes(body, 64 * 1024)
542        .await
543        .unwrap_or_default();
544    let error_msg = humanize_error_body(&String::from_utf8_lossy(&bytes));
545
546    tracing::error!(
547        error = %error_msg,
548        path = %path,
549        "handler returned 500; rendering server-error template",
550    );
551
552    fire_server_error_hook(&state.hook, &error_msg, &path);
553
554    let dev = is_dev_mode();
555    let chain = vec![error_msg.clone()];
556    let ctx = build_500_context(&error_msg, &chain, &path, dev);
557    let (body_str, content_type) = render_500(state.template.as_deref(), &ctx);
558
559    (
560        StatusCode::INTERNAL_SERVER_ERROR,
561        [(header::CONTENT_TYPE, content_type)],
562        body_str,
563    )
564        .into_response()
565}
566
567// ─── General error pages (any status code) ──────────────────────────────────
568
569/// State for the general error-page middleware: a status → template-name map.
570/// Cloned per request (an `Arc`, cheap).
571#[derive(Clone)]
572pub struct RenderErrorState {
573    pub templates: std::sync::Arc<std::collections::HashMap<StatusCode, String>>,
574}
575
576/// Middleware that styles error responses for ANY registered status code
577/// (e.g. 429, 403, 410) the way [`render_500_middleware`] does for 500. After
578/// the handler runs, if the response status has a registered template and the
579/// body isn't already HTML, the body text is captured as the `message` and the
580/// template is rendered in its place — preserving the original status code.
581///
582/// Registered via `App::builder().error_template(status, "name.html")`. 404
583/// and 500 keep their dedicated paths (`not_found_template` /
584/// `server_error_template`); this covers everything else a handler returns as
585/// `Err((status, message))`.
586pub async fn render_error_middleware(
587    axum::extract::State(state): axum::extract::State<RenderErrorState>,
588    req: axum::extract::Request,
589    next: axum::middleware::Next,
590) -> Response<Body> {
591    let path = req.uri().path().to_string();
592    // API / AJAX clients (Accept: application/json) keep the raw status +
593    // message body so they can read it programmatically; only browser
594    // navigations (Accept: text/html, the default) get the styled HTML page.
595    let wants_json = req
596        .headers()
597        .get(header::ACCEPT)
598        .and_then(|v| v.to_str().ok())
599        .map(|a| a.contains("application/json"))
600        .unwrap_or(false);
601    let resp = next.run(req).await;
602
603    let status = resp.status();
604    let Some(template) = state.templates.get(&status).cloned() else {
605        return resp;
606    };
607    if wants_json {
608        return resp;
609    }
610
611    // Already-HTML error responses (a handler that rendered its own page) pass
612    // through untouched — only bare text/plain (or no content-type) errors get
613    // the styled template.
614    let ct = resp
615        .headers()
616        .get(header::CONTENT_TYPE)
617        .and_then(|v| v.to_str().ok())
618        .unwrap_or("");
619    if ct.starts_with("text/html") {
620        return resp;
621    }
622
623    // Capture the body (the handler's message). 64KB cap, same as the 500 path.
624    let (_parts, body) = resp.into_parts();
625    let bytes = axum::body::to_bytes(body, 64 * 1024)
626        .await
627        .unwrap_or_default();
628    let message = humanize_error_body(&String::from_utf8_lossy(&bytes));
629
630    let ctx = error_context(status, &message, &path, is_dev_mode());
631    let (body_str, content_type) = render_error_page(&template, status, &ctx);
632
633    (status, [(header::CONTENT_TYPE, content_type)], body_str).into_response()
634}
635
636/// Template context for a general error page: `{ status, status_text, message,
637/// request_path, dev_mode }`.
638fn error_context(status: StatusCode, message: &str, path: &str, dev: bool) -> minijinja::Value {
639    minijinja::context! {
640        status => status.as_u16(),
641        status_text => status.canonical_reason().unwrap_or(""),
642        message => message,
643        request_path => path,
644        dev_mode => dev,
645    }
646}
647
648/// Render `template` for an error page, falling back to the status' canonical
649/// reason phrase as plain text if the template can't render. Mirrors the
650/// loud-fail posture of [`render_500`].
651fn render_error_page(
652    template: &str,
653    status: StatusCode,
654    ctx: &minijinja::Value,
655) -> (String, &'static str) {
656    match crate::templates::render(template, ctx) {
657        Ok(html) => (html, "text/html; charset=utf-8"),
658        Err(secondary) => {
659            tracing::error!(
660                template = %template,
661                status = %status.as_u16(),
662                error = %secondary,
663                "render_error_page: the configured error template failed to render; \
664                 falling back to plain text",
665            );
666            let reason = status.canonical_reason().unwrap_or("Error");
667            (reason.to_string(), "text/plain; charset=utf-8")
668        }
669    }
670}
671
672// ─── Tests ──────────────────────────────────────────────────────────────────
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn error_context_carries_status_reason_message_and_path() {
680        let ctx = error_context(
681            StatusCode::TOO_MANY_REQUESTS,
682            "slow down",
683            "/p/notes",
684            false,
685        );
686        let mut env = minijinja::Environment::new();
687        env.add_template(
688            "t",
689            "{{ status }}|{{ status_text }}|{{ message }}|{{ request_path }}|{{ dev_mode }}",
690        )
691        .unwrap();
692        let out = env.get_template("t").unwrap().render(ctx).unwrap();
693        assert_eq!(out, "429|Too Many Requests|slow down|/p/notes|false");
694    }
695
696    #[test]
697    fn render_error_page_falls_back_to_plain_text_when_template_cant_render() {
698        // No ambient template engine in this unit test, so `render()` errors
699        // and we land on the canonical-reason plain-text fallback.
700        let ctx = error_context(StatusCode::TOO_MANY_REQUESTS, "msg", "/x", false);
701        let (body, ct) = render_error_page("nonexistent.html", StatusCode::TOO_MANY_REQUESTS, &ctx);
702        assert!(ct.starts_with("text/plain"), "content-type: {ct}");
703        assert_eq!(body, "Too Many Requests");
704    }
705
706    #[test]
707    fn render_not_found_returns_plain_text_when_no_template() {
708        let resp = render_not_found(None, "/missing");
709        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
710        let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
711        assert!(ct.to_str().unwrap().starts_with("text/plain"));
712    }
713
714    #[test]
715    fn render_not_found_falls_back_to_plain_text_when_template_missing() {
716        // No templates engine initialised in this test — render() errors
717        // out, so we should land on the plain-text fallback even though
718        // a template name was provided.
719        let resp = render_not_found(Some("nonexistent.html"), "/x");
720        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
721    }
722
723    #[test]
724    fn default_404_renders_route_panel_when_dev_mode_and_registry_populated() {
725        // Render the embedded default template through a fresh
726        // minijinja environment so the test doesn't depend on the
727        // (OnceLock-published) global engine state. We feed the same
728        // ctx shape `render_not_found` builds in dev mode and assert
729        // the route list lands in the output.
730        let mut env = minijinja::Environment::new();
731        env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
732        env.add_template("default_404.html", DEFAULT_404_HTML)
733            .unwrap();
734
735        let ctx = minijinja::context! {
736            path => "/typo",
737            dev_mode => true,
738            routes_by_plugin => serde_json::json!([
739                {
740                    "plugin": "app",
741                    "routes": [
742                        { "path": "/",         "methods": ["GET"],       "method_label": "GET" },
743                        { "path": "/articles", "methods": ["GET","POST"], "method_label": "GET·POST" },
744                    ],
745                },
746                {
747                    "plugin": "admin",
748                    "routes": [
749                        { "path": "/admin/",      "methods": ["GET"],      "method_label": "GET" },
750                        { "path": "/admin/login", "methods": ["GET","POST"], "method_label": "GET·POST" },
751                    ],
752                },
753            ]),
754        };
755        let out = env
756            .get_template("default_404.html")
757            .unwrap()
758            .render(&ctx)
759            .unwrap();
760
761        // minijinja's HTML autoescape encodes `/` as `&#x2f;` inside
762        // text nodes — the assertion checks the escaped form (which is
763        // what the browser will then unescape and display verbatim).
764        assert!(
765            out.contains("Dev only"),
766            "dev-mode panel header should be in the output"
767        );
768        assert!(
769            out.contains("&#x2f;admin&#x2f;login"),
770            "admin route should be listed: {out}"
771        );
772        assert!(
773            out.contains("&#x2f;articles"),
774            "app route should be listed: {out}"
775        );
776        // Method badges land in the markup.
777        assert!(
778            out.contains("GET·POST"),
779            "composite-method badge label should render: {out}"
780        );
781        // GET-coloured badge applied to the bare-GET row.
782        assert!(
783            out.contains("emerald"),
784            "GET badge should carry the emerald tint class"
785        );
786    }
787
788    #[test]
789    fn default_404_copy_button_does_not_interpolate_path_into_js() {
790        // Regression for the reflected-XSS finding: the request path is
791        // fully attacker-controlled and must never cross into a
792        // JavaScript-string context. HTML autoescape only guards the
793        // HTML-text/attribute context; the browser HTML-decodes an
794        // `onclick` attribute before the JS parser sees it, so an escaped
795        // quote still closes a JS string literal. The copy button must
796        // therefore read the path from the inert DOM text node rather
797        // than have it interpolated into `writeText('...')`.
798        let mut env = minijinja::Environment::new();
799        env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
800        env.add_template("default_404.html", DEFAULT_404_HTML)
801            .unwrap();
802
803        // A path crafted to break out of `writeText('…')` and execute JS.
804        let ctx = minijinja::context! {
805            path => "/x');alert(document.domain);('",
806            dev_mode => false,
807            routes_by_plugin => Vec::<minijinja::Value>::new(),
808        };
809        let out = env
810            .get_template("default_404.html")
811            .unwrap()
812            .render(&ctx)
813            .unwrap();
814
815        // No path data may be interpolated into a JS string literal.
816        // The literal `writeText('` prefix only exists in the vulnerable
817        // form where `{{ path }}` was inlined between the quotes; the
818        // fixed handler reads `writeText(document…textContent)`.
819        assert!(
820            !out.contains("writeText('"),
821            "copy button must not interpolate the path into a JS string literal: {out}"
822        );
823        // The raw (un-encoded) payload must not appear anywhere in the
824        // output — the safe contexts (title/text node) HTML-encode it.
825        assert!(
826            !out.contains("');alert(document.domain);('"),
827            "un-encoded JS-breakout payload leaked into the output: {out}"
828        );
829    }
830
831    #[test]
832    fn default_500_copy_button_does_not_interpolate_path_into_js() {
833        // Same JS-string-context guard as the 404 test. `request_path` is
834        // blanked in production, but dev/staging servers are frequently
835        // exposed, so the copy button must never inline the path into JS.
836        let mut env = minijinja::Environment::new();
837        env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
838        env.add_template("default_500.html", DEFAULT_500_HTML)
839            .unwrap();
840
841        let ctx = minijinja::context! {
842            dev_mode => true,
843            error_display => "boom",
844            error_chain => vec!["boom".to_owned()],
845            request_path => "/x');alert(document.domain);('",
846        };
847        let out = env
848            .get_template("default_500.html")
849            .unwrap()
850            .render(&ctx)
851            .unwrap();
852
853        assert!(
854            !out.contains("writeText('"),
855            "copy button must not interpolate request_path into a JS string literal: {out}"
856        );
857        assert!(
858            !out.contains("');alert(document.domain);('"),
859            "un-encoded JS-breakout payload leaked into the output: {out}"
860        );
861    }
862
863    #[test]
864    fn default_404_omits_route_panel_when_dev_mode_is_off() {
865        // Same template, but `dev_mode = false` — the panel block must
866        // collapse to nothing. The page should still render the path
867        // and the action buttons (those are outside the gated block).
868        let mut env = minijinja::Environment::new();
869        env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
870        env.add_template("default_404.html", DEFAULT_404_HTML)
871            .unwrap();
872
873        let ctx = minijinja::context! {
874            path => "/typo",
875            dev_mode => false,
876            routes_by_plugin => Vec::<minijinja::Value>::new(),
877        };
878        let out = env
879            .get_template("default_404.html")
880            .unwrap()
881            .render(&ctx)
882            .unwrap();
883
884        assert!(
885            !out.contains("Dev only"),
886            "production response must not surface the route registry"
887        );
888    }
889
890    #[test]
891    fn collect_error_chain_single_level() {
892        let chain = collect_error_chain("top error", None);
893        assert_eq!(chain, vec!["top error"]);
894    }
895
896    #[test]
897    fn build_500_context_prod_mode_has_empty_fields() {
898        let ctx = build_500_context("boom", &["boom".to_owned()], "/path", false);
899        // Serialize to JSON and inspect: prod mode has dev_mode=false and
900        // empty error_display.
901        let json = serde_json::to_value(&ctx).expect("context serialises");
902        assert_eq!(json["dev_mode"], serde_json::Value::Bool(false));
903        assert_eq!(
904            json["error_display"],
905            serde_json::Value::String("".to_string())
906        );
907    }
908
909    #[test]
910    fn build_500_context_dev_mode_has_error_info() {
911        let chain = vec!["cause one".to_owned(), "cause two".to_owned()];
912        let ctx = build_500_context("top error", &chain, "/api/items", true);
913        let json = serde_json::to_value(&ctx).expect("context serialises");
914        assert_eq!(json["dev_mode"], serde_json::Value::Bool(true));
915        assert_eq!(
916            json["error_display"],
917            serde_json::Value::String("top error".to_string())
918        );
919        // error_chain should be a two-element array
920        let arr = json["error_chain"]
921            .as_array()
922            .expect("error_chain is array");
923        assert_eq!(arr.len(), 2);
924    }
925}