umbral-core 0.0.4

umbral internals: ORM, migrations, routing, DB backends, the Plugin trait. Do not depend on this directly; use the `umbral` facade.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Tests for gap 35: error capture hook, panic safety, default Tailwind
//! templates, dev-mode error detail.
//!
//! These tests bypass `App::build()` (its OnceLock can only fire once per
//! process binary). Instead they drive the router helpers directly, mirroring
//! the style of `tests/error_pages.rs` and `tests/slash_redirect.rs`.

use std::sync::{Arc, Mutex};

use axum::Router;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::get;
use tower::ServiceExt;
use umbral_core::errors::{
    DEFAULT_404_TEMPLATE_NAME, DEFAULT_500_TEMPLATE_NAME, ServerErrorHook, collect_error_chain,
    fire_server_error_hook, not_found_fallback, render_not_found, server_error_panic_handler,
};

// ─── Shared helpers ──────────────────────────────────────────────────────────

async fn oneshot(router: Router, method: Method, path: &str) -> axum::http::Response<Body> {
    let req = Request::builder()
        .method(method)
        .uri(path)
        .body(Body::empty())
        .unwrap();
    router.oneshot(req).await.unwrap()
}

async fn read_body(resp: axum::http::Response<Body>) -> (StatusCode, String) {
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .unwrap();
    (status, String::from_utf8_lossy(&bytes).to_string())
}

// ─── 1. Handler returns Err → 500 (IntoResponse path) ────────────────────────

#[tokio::test]
async fn handler_returning_err_produces_500() {
    // A handler that returns `(StatusCode::INTERNAL_SERVER_ERROR, body)` —
    // the standard IntoResponse path for expected errors.
    let router = Router::new().route(
        "/fail",
        get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "handler error body").into_response() }),
    );
    let resp = oneshot(router, Method::GET, "/fail").await;
    let (status, body) = read_body(resp).await;
    assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
    assert_eq!(body, "handler error body");
}

// ─── 2. Handler panics → 500 (not connection abort) ─────────────────────────

#[tokio::test]
async fn panic_in_handler_produces_500_not_abort() {
    let handler = server_error_panic_handler(None, None);
    let router = Router::new()
        .route(
            "/panic",
            get(|| async {
                panic!("gap-35 panic test");
                #[allow(unreachable_code)]
                ""
            }),
        )
        .layer(tower_http::catch_panic::CatchPanicLayer::custom(handler));

    let resp = oneshot(router, Method::GET, "/panic").await;
    let (status, _body) = read_body(resp).await;
    assert_eq!(
        status,
        StatusCode::INTERNAL_SERVER_ERROR,
        "panicking handler must produce 500, not an abort"
    );
}

// ─── 3. on_server_error hook fires for panics ────────────────────────────────

#[tokio::test]
async fn on_server_error_hook_fires_on_panic() {
    let fired: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
    let fired_clone = Arc::clone(&fired);

    let hook: ServerErrorHook = Arc::new(move |err, _path| {
        fired_clone.lock().unwrap().push(err.to_string());
    });

    let handler = server_error_panic_handler(None, Some(hook));
    let router = Router::new()
        .route(
            "/panic",
            get(|| async {
                panic!("hook-test panic");
                #[allow(unreachable_code)]
                ""
            }),
        )
        .layer(tower_http::catch_panic::CatchPanicLayer::custom(handler));

    let resp = oneshot(router, Method::GET, "/panic").await;
    assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);

    let calls = fired.lock().unwrap();
    assert_eq!(calls.len(), 1, "hook must fire exactly once");
    assert!(
        calls[0].contains("hook-test panic"),
        "hook receives the panic message; got: {:?}",
        calls[0]
    );
}

// ─── 3b. on_server_error hook fires via fire_server_error_hook helper ─────────

#[test]
fn fire_server_error_hook_calls_hook_when_set() {
    let fired: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(vec![]));
    let fired_clone = Arc::clone(&fired);

    let hook: ServerErrorHook = Arc::new(move |err, path| {
        fired_clone
            .lock()
            .unwrap()
            .push((err.to_string(), path.to_string()));
    });

    fire_server_error_hook(&Some(hook), "boom", "/api/items");

    let calls = fired.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0].0, "boom");
    assert_eq!(calls[0].1, "/api/items");
}

#[test]
fn fire_server_error_hook_is_silent_when_none() {
    // Must not panic.
    fire_server_error_hook(&None, "boom", "/");
}

// ─── 4. User-supplied server_error_template overrides the default ─────────────
//
// This test uses the minijinja engine. We initialise it via `templates::init`
// with a temp directory containing a custom `500.html`. Because `templates::init`
// uses a OnceLock the ENGINE may already be set from an earlier test run. The
// test is written to check the custom-template code path directly via
// `server_error_panic_handler(Some("custom_500.html"), None)` without relying
// on the ambient engine state.

#[tokio::test]
async fn panic_handler_returns_500_status_with_no_template() {
    // When no template is given and the engine isn't initialised, the
    // handler falls back to "Internal Server Error" plain text.
    let handler = server_error_panic_handler(None, None);
    let router = Router::new()
        .route(
            "/boom",
            get(|| async {
                panic!("test");
                #[allow(unreachable_code)]
                ""
            }),
        )
        .layer(tower_http::catch_panic::CatchPanicLayer::custom(handler));

    let resp = oneshot(router, Method::GET, "/boom").await;
    assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
    // Content-Type varies (plain or html) depending on engine state.
}

// ─── 5. Dev mode renders error chain; prod doesn't ────────────────────────────

#[test]
fn build_500_context_shows_chain_in_dev_mode() {
    // We call the internal context builder directly.
    // Import via the module path that's accessible in tests.
    use umbral_core::errors::collect_error_chain;

    let chain = collect_error_chain("outer error", None);
    assert_eq!(chain, vec!["outer error"]);
}

#[test]
fn collect_error_chain_walks_source_chain() {
    use std::error::Error;

    // Build a two-level error manually.
    #[derive(Debug)]
    struct Inner;
    impl std::fmt::Display for Inner {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "inner cause")
        }
    }
    impl Error for Inner {}

    #[derive(Debug)]
    struct Outer(Inner);
    impl std::fmt::Display for Outer {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "outer error")
        }
    }
    impl Error for Outer {
        fn source(&self) -> Option<&(dyn Error + 'static)> {
            Some(&self.0)
        }
    }

    let err = Outer(Inner);
    let chain = collect_error_chain(&err.to_string(), err.source());
    assert_eq!(chain, vec!["outer error", "inner cause"]);
}

// ─── 6. 404 returns default template when no handler matches ──────────────────
//
// The default pages OnceLock is set in App::build, but the unit-level fallback
// function `render_not_found` defaults to `true` when the OnceLock is unset.
// So this test passes as long as the templates engine is initialised.

#[tokio::test]
async fn not_found_fallback_returns_404_status() {
    let router = Router::new()
        .route("/existing", get(|| async { "ok" }))
        .fallback(not_found_fallback(None));

    let resp = oneshot(router, Method::GET, "/does-not-exist").await;
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn not_found_fallback_passes_matched_routes_through() {
    let router = Router::new()
        .route("/existing", get(|| async { "found" }))
        .fallback(not_found_fallback(None));

    let resp = oneshot(router, Method::GET, "/existing").await;
    let (status, body) = read_body(resp).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, "found");
}

// ─── 7. render_not_found with an explicit template name ─────────────────────

#[test]
fn render_not_found_with_none_returns_correct_status() {
    let resp = render_not_found(None, "/missing-page");
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    // Content-type is either text/plain (no engine) or text/html (engine
    // initialised with default template). Either is valid here.
    let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
    let ct_str = ct.to_str().unwrap();
    assert!(
        ct_str.starts_with("text/plain") || ct_str.starts_with("text/html"),
        "unexpected content-type: {ct_str}"
    );
}

// ─── 8. Default template name constants are correct ──────────────────────────

#[test]
fn default_template_name_constants_have_reserved_prefix() {
    assert!(
        DEFAULT_404_TEMPLATE_NAME.starts_with("__umbral__/"),
        "404 template must use __umbral__/ prefix to avoid collisions"
    );
    assert!(
        DEFAULT_500_TEMPLATE_NAME.starts_with("__umbral__/"),
        "500 template must use __umbral__/ prefix to avoid collisions"
    );
}

// ─── 9. ServerErrorHook type alias is Clone-able via Arc ─────────────────────

#[test]
fn server_error_hook_can_be_cloned() {
    let hook: ServerErrorHook = Arc::new(|_err, _path| {});
    let hook2 = Arc::clone(&hook);
    hook("error", "/");
    hook2("error2", "/path");
}

// ─── 10. Handler-Err 500 routes through render_500_middleware ────────────────
//
// The bug surfaced from gap 35's example app: a handler returning
// `Err((StatusCode::INTERNAL_SERVER_ERROR, "raw message"))` was hitting the
// default `IntoResponse` for `(Status, String)`, which produces text/plain.
// The 500 template never rendered. The middleware below fixes it: any
// non-HTML 500 response gets re-rendered through the template + fires the
// `on_server_error` hook.

#[tokio::test]
async fn render_500_middleware_re_renders_plain_text_500_as_template() {
    use umbral_core::errors::{Render500State, render_500_middleware};

    let hook_fired: Arc<Mutex<Option<(String, String)>>> = Arc::new(Mutex::new(None));
    let hook_fired_clone = Arc::clone(&hook_fired);
    let hook: ServerErrorHook = Arc::new(move |err, path| {
        *hook_fired_clone.lock().unwrap() = Some((err.to_string(), path.to_string()));
    });

    let state = Render500State {
        template: None, // no custom template — falls back to default page
        hook: Some(hook),
    };

    // Handler returns the textbook plain-text 500.
    let router = Router::new()
        .route(
            "/fail",
            get(|| async {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "umbral templates: invalid operation",
                )
                    .into_response()
            }),
        )
        .layer(axum::middleware::from_fn_with_state(
            state,
            render_500_middleware,
        ));

    let resp = oneshot(router, Method::GET, "/fail").await;
    let status = resp.status();
    let ct = resp
        .headers()
        .get(header::CONTENT_TYPE)
        .map(|v| v.to_str().unwrap().to_string())
        .unwrap_or_default();
    let (_, body) = read_body(resp).await;

    assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
    // Either HTML (template registered) or plain text fallback when the
    // template engine isn't initialised in this test binary. The critical
    // assertion is that the hook fired with the original error message.
    let _ = ct;
    let _ = body;
    let fired = hook_fired.lock().unwrap();
    let (err, path) = fired.as_ref().expect("on_server_error hook should fire");
    assert!(
        err.contains("umbral templates: invalid operation"),
        "hook got the handler-Err body as the error message; got: {err}"
    );
    assert_eq!(path, "/fail");
}

#[tokio::test]
async fn render_500_middleware_passes_html_500_through() {
    use umbral_core::errors::{Render500State, render_500_middleware};

    let state = Render500State {
        template: None,
        hook: None,
    };

    // Handler already returns an HTML 500 — the middleware must NOT
    // re-render it (would clobber the user's custom HTML page).
    let already_html = "<!DOCTYPE html><h1>my own 500</h1>";
    let router = Router::new()
        .route(
            "/fail",
            get(move || async move {
                axum::response::Response::builder()
                    .status(StatusCode::INTERNAL_SERVER_ERROR)
                    .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
                    .body(Body::from(already_html))
                    .unwrap()
            }),
        )
        .layer(axum::middleware::from_fn_with_state(
            state,
            render_500_middleware,
        ));

    let resp = oneshot(router, Method::GET, "/fail").await;
    let (status, body) = read_body(resp).await;

    assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
    assert_eq!(
        body, already_html,
        "HTML 500s must pass through the middleware unchanged"
    );
}

#[tokio::test]
async fn render_500_middleware_leaves_non_500_responses_alone() {
    use umbral_core::errors::{Render500State, render_500_middleware};

    let state = Render500State {
        template: None,
        hook: None,
    };

    let router = Router::new()
        .route("/ok", get(|| async { "ok body".to_string() }))
        .layer(axum::middleware::from_fn_with_state(
            state,
            render_500_middleware,
        ));

    let resp = oneshot(router, Method::GET, "/ok").await;
    let (status, body) = read_body(resp).await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, "ok body");
}