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/// Middleware that intercepts plain-text 500 responses and re-renders them
448/// through the configured `server_error_template` (or the embedded default
449/// when enabled). Already-HTML 500 responses pass through untouched — those
450/// were rendered by `CatchPanicLayer` (panics) or by the handler itself.
451///
452/// This closes the gap where a handler returning
453/// `Err((StatusCode::INTERNAL_SERVER_ERROR, msg))` previously produced a
454/// raw plain-text response instead of the configured 500 page. The
455/// `on_server_error` hook also fires for these paths, with the response
456/// body bytes as the error message and the request URI as the path.
457pub async fn render_500_middleware(
458    axum::extract::State(state): axum::extract::State<Render500State>,
459    req: axum::extract::Request,
460    next: axum::middleware::Next,
461) -> Response<Body> {
462    let path = req.uri().path().to_string();
463    let resp = next.run(req).await;
464
465    if resp.status() != StatusCode::INTERNAL_SERVER_ERROR {
466        return resp;
467    }
468
469    // Already-rendered HTML 500s (from CatchPanicLayer or a custom handler)
470    // pass through. Only the raw text/plain or no-content-type 500s get
471    // re-rendered.
472    let ct = resp
473        .headers()
474        .get(header::CONTENT_TYPE)
475        .and_then(|v| v.to_str().ok())
476        .unwrap_or("");
477    if ct.starts_with("text/html") {
478        return resp;
479    }
480
481    // Capture the body to extract the error message for the hook + dev
482    // context. 64KB cap: error messages don't need more, and we don't
483    // want a malicious upstream to OOM us.
484    let (_parts, body) = resp.into_parts();
485    let bytes = axum::body::to_bytes(body, 64 * 1024)
486        .await
487        .unwrap_or_default();
488    let error_msg = String::from_utf8_lossy(&bytes).to_string();
489
490    tracing::error!(
491        error = %error_msg,
492        path = %path,
493        "handler returned 500; rendering server-error template",
494    );
495
496    fire_server_error_hook(&state.hook, &error_msg, &path);
497
498    let dev = is_dev_mode();
499    let chain = vec![error_msg.clone()];
500    let ctx = build_500_context(&error_msg, &chain, &path, dev);
501    let (body_str, content_type) = render_500(state.template.as_deref(), &ctx);
502
503    (
504        StatusCode::INTERNAL_SERVER_ERROR,
505        [(header::CONTENT_TYPE, content_type)],
506        body_str,
507    )
508        .into_response()
509}
510
511// ─── General error pages (any status code) ──────────────────────────────────
512
513/// State for the general error-page middleware: a status → template-name map.
514/// Cloned per request (an `Arc`, cheap).
515#[derive(Clone)]
516pub struct RenderErrorState {
517    pub templates: std::sync::Arc<std::collections::HashMap<StatusCode, String>>,
518}
519
520/// Middleware that styles error responses for ANY registered status code
521/// (e.g. 429, 403, 410) the way [`render_500_middleware`] does for 500. After
522/// the handler runs, if the response status has a registered template and the
523/// body isn't already HTML, the body text is captured as the `message` and the
524/// template is rendered in its place — preserving the original status code.
525///
526/// Registered via `App::builder().error_template(status, "name.html")`. 404
527/// and 500 keep their dedicated paths (`not_found_template` /
528/// `server_error_template`); this covers everything else a handler returns as
529/// `Err((status, message))`.
530pub async fn render_error_middleware(
531    axum::extract::State(state): axum::extract::State<RenderErrorState>,
532    req: axum::extract::Request,
533    next: axum::middleware::Next,
534) -> Response<Body> {
535    let path = req.uri().path().to_string();
536    // API / AJAX clients (Accept: application/json) keep the raw status +
537    // message body so they can read it programmatically; only browser
538    // navigations (Accept: text/html, the default) get the styled HTML page.
539    let wants_json = req
540        .headers()
541        .get(header::ACCEPT)
542        .and_then(|v| v.to_str().ok())
543        .map(|a| a.contains("application/json"))
544        .unwrap_or(false);
545    let resp = next.run(req).await;
546
547    let status = resp.status();
548    let Some(template) = state.templates.get(&status).cloned() else {
549        return resp;
550    };
551    if wants_json {
552        return resp;
553    }
554
555    // Already-HTML error responses (a handler that rendered its own page) pass
556    // through untouched — only bare text/plain (or no content-type) errors get
557    // the styled template.
558    let ct = resp
559        .headers()
560        .get(header::CONTENT_TYPE)
561        .and_then(|v| v.to_str().ok())
562        .unwrap_or("");
563    if ct.starts_with("text/html") {
564        return resp;
565    }
566
567    // Capture the body (the handler's message). 64KB cap, same as the 500 path.
568    let (_parts, body) = resp.into_parts();
569    let bytes = axum::body::to_bytes(body, 64 * 1024)
570        .await
571        .unwrap_or_default();
572    let message = String::from_utf8_lossy(&bytes).to_string();
573
574    let ctx = error_context(status, &message, &path, is_dev_mode());
575    let (body_str, content_type) = render_error_page(&template, status, &ctx);
576
577    (status, [(header::CONTENT_TYPE, content_type)], body_str).into_response()
578}
579
580/// Template context for a general error page: `{ status, status_text, message,
581/// request_path, dev_mode }`.
582fn error_context(status: StatusCode, message: &str, path: &str, dev: bool) -> minijinja::Value {
583    minijinja::context! {
584        status => status.as_u16(),
585        status_text => status.canonical_reason().unwrap_or(""),
586        message => message,
587        request_path => path,
588        dev_mode => dev,
589    }
590}
591
592/// Render `template` for an error page, falling back to the status' canonical
593/// reason phrase as plain text if the template can't render. Mirrors the
594/// loud-fail posture of [`render_500`].
595fn render_error_page(
596    template: &str,
597    status: StatusCode,
598    ctx: &minijinja::Value,
599) -> (String, &'static str) {
600    match crate::templates::render(template, ctx) {
601        Ok(html) => (html, "text/html; charset=utf-8"),
602        Err(secondary) => {
603            tracing::error!(
604                template = %template,
605                status = %status.as_u16(),
606                error = %secondary,
607                "render_error_page: the configured error template failed to render; \
608                 falling back to plain text",
609            );
610            let reason = status.canonical_reason().unwrap_or("Error");
611            (reason.to_string(), "text/plain; charset=utf-8")
612        }
613    }
614}
615
616// ─── Tests ──────────────────────────────────────────────────────────────────
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn error_context_carries_status_reason_message_and_path() {
624        let ctx = error_context(
625            StatusCode::TOO_MANY_REQUESTS,
626            "slow down",
627            "/p/notes",
628            false,
629        );
630        let mut env = minijinja::Environment::new();
631        env.add_template(
632            "t",
633            "{{ status }}|{{ status_text }}|{{ message }}|{{ request_path }}|{{ dev_mode }}",
634        )
635        .unwrap();
636        let out = env.get_template("t").unwrap().render(ctx).unwrap();
637        assert_eq!(out, "429|Too Many Requests|slow down|/p/notes|false");
638    }
639
640    #[test]
641    fn render_error_page_falls_back_to_plain_text_when_template_cant_render() {
642        // No ambient template engine in this unit test, so `render()` errors
643        // and we land on the canonical-reason plain-text fallback.
644        let ctx = error_context(StatusCode::TOO_MANY_REQUESTS, "msg", "/x", false);
645        let (body, ct) = render_error_page("nonexistent.html", StatusCode::TOO_MANY_REQUESTS, &ctx);
646        assert!(ct.starts_with("text/plain"), "content-type: {ct}");
647        assert_eq!(body, "Too Many Requests");
648    }
649
650    #[test]
651    fn render_not_found_returns_plain_text_when_no_template() {
652        let resp = render_not_found(None, "/missing");
653        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
654        let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
655        assert!(ct.to_str().unwrap().starts_with("text/plain"));
656    }
657
658    #[test]
659    fn render_not_found_falls_back_to_plain_text_when_template_missing() {
660        // No templates engine initialised in this test — render() errors
661        // out, so we should land on the plain-text fallback even though
662        // a template name was provided.
663        let resp = render_not_found(Some("nonexistent.html"), "/x");
664        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
665    }
666
667    #[test]
668    fn default_404_renders_route_panel_when_dev_mode_and_registry_populated() {
669        // Render the embedded default template through a fresh
670        // minijinja environment so the test doesn't depend on the
671        // (OnceLock-published) global engine state. We feed the same
672        // ctx shape `render_not_found` builds in dev mode and assert
673        // the route list lands in the output.
674        let mut env = minijinja::Environment::new();
675        env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
676        env.add_template("default_404.html", DEFAULT_404_HTML)
677            .unwrap();
678
679        let ctx = minijinja::context! {
680            path => "/typo",
681            dev_mode => true,
682            routes_by_plugin => serde_json::json!([
683                {
684                    "plugin": "app",
685                    "routes": [
686                        { "path": "/",         "methods": ["GET"],       "method_label": "GET" },
687                        { "path": "/articles", "methods": ["GET","POST"], "method_label": "GET·POST" },
688                    ],
689                },
690                {
691                    "plugin": "admin",
692                    "routes": [
693                        { "path": "/admin/",      "methods": ["GET"],      "method_label": "GET" },
694                        { "path": "/admin/login", "methods": ["GET","POST"], "method_label": "GET·POST" },
695                    ],
696                },
697            ]),
698        };
699        let out = env
700            .get_template("default_404.html")
701            .unwrap()
702            .render(&ctx)
703            .unwrap();
704
705        // minijinja's HTML autoescape encodes `/` as `&#x2f;` inside
706        // text nodes — the assertion checks the escaped form (which is
707        // what the browser will then unescape and display verbatim).
708        assert!(
709            out.contains("Dev only"),
710            "dev-mode panel header should be in the output"
711        );
712        assert!(
713            out.contains("&#x2f;admin&#x2f;login"),
714            "admin route should be listed: {out}"
715        );
716        assert!(
717            out.contains("&#x2f;articles"),
718            "app route should be listed: {out}"
719        );
720        // Method badges land in the markup.
721        assert!(
722            out.contains("GET·POST"),
723            "composite-method badge label should render: {out}"
724        );
725        // GET-coloured badge applied to the bare-GET row.
726        assert!(
727            out.contains("emerald"),
728            "GET badge should carry the emerald tint class"
729        );
730    }
731
732    #[test]
733    fn default_404_omits_route_panel_when_dev_mode_is_off() {
734        // Same template, but `dev_mode = false` — the panel block must
735        // collapse to nothing. The page should still render the path
736        // and the action buttons (those are outside the gated block).
737        let mut env = minijinja::Environment::new();
738        env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
739        env.add_template("default_404.html", DEFAULT_404_HTML)
740            .unwrap();
741
742        let ctx = minijinja::context! {
743            path => "/typo",
744            dev_mode => false,
745            routes_by_plugin => Vec::<minijinja::Value>::new(),
746        };
747        let out = env
748            .get_template("default_404.html")
749            .unwrap()
750            .render(&ctx)
751            .unwrap();
752
753        assert!(
754            !out.contains("Dev only"),
755            "production response must not surface the route registry"
756        );
757    }
758
759    #[test]
760    fn collect_error_chain_single_level() {
761        let chain = collect_error_chain("top error", None);
762        assert_eq!(chain, vec!["top error"]);
763    }
764
765    #[test]
766    fn build_500_context_prod_mode_has_empty_fields() {
767        let ctx = build_500_context("boom", &["boom".to_owned()], "/path", false);
768        // Serialize to JSON and inspect: prod mode has dev_mode=false and
769        // empty error_display.
770        let json = serde_json::to_value(&ctx).expect("context serialises");
771        assert_eq!(json["dev_mode"], serde_json::Value::Bool(false));
772        assert_eq!(
773            json["error_display"],
774            serde_json::Value::String("".to_string())
775        );
776    }
777
778    #[test]
779    fn build_500_context_dev_mode_has_error_info() {
780        let chain = vec!["cause one".to_owned(), "cause two".to_owned()];
781        let ctx = build_500_context("top error", &chain, "/api/items", true);
782        let json = serde_json::to_value(&ctx).expect("context serialises");
783        assert_eq!(json["dev_mode"], serde_json::Value::Bool(true));
784        assert_eq!(
785            json["error_display"],
786            serde_json::Value::String("top error".to_string())
787        );
788        // error_chain should be a two-element array
789        let arr = json["error_chain"]
790            .as_array()
791            .expect("error_chain is array");
792        assert_eq!(arr.len(), 2);
793    }
794}