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