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 `/` 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("/admin/login"),
714 "admin route should be listed: {out}"
715 );
716 assert!(
717 out.contains("/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}