Skip to main content

laterite_admin/
error.rs

1//! The admin surface's typed HTTP error.
2//!
3//! Handlers return `Result<_, AdminError>` and use `?`. `IntoResponse` renders a
4//! styled page per variant; an `Internal` cause is logged via `tracing` and
5//! masked (shown on the page only in debug mode). `From` impls keep a
6//! `CoreError`/`AuthError`'s status rather than collapsing to 500. Each response
7//! carries an [`ErrorMeta`] extension: the seam for a later rendering layer.
8
9use askama::Template;
10use axum::http::StatusCode;
11use axum::response::{Html, IntoResponse, Response};
12use laterite_auth::AuthError;
13use laterite_core::{t, CoreError, Text, Translator};
14
15/// A typed error an admin handler can return.
16#[derive(Debug)]
17pub enum AdminError {
18    /// The requested record does not exist (404).
19    NotFound,
20    /// The signed-in operator lacks the required permission (403).
21    Forbidden,
22    /// An unexpected failure (500): the cause is logged and masked.
23    Internal(anyhow::Error),
24}
25
26/// The kind of error, stamped onto every error response's extensions so a later
27/// rendering layer can upgrade the standalone page to a shell-wrapped or HTMX
28/// fragment without the handler changing.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ErrorMeta {
31    pub kind: ErrorKind,
32}
33
34/// The response classes an [`AdminError`] renders.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ErrorKind {
37    NotFound,
38    Forbidden,
39    Internal,
40}
41
42impl IntoResponse for AdminError {
43    fn into_response(self) -> Response {
44        // Rendered without a request translator (this is the no-context sink), so the
45        // page shows the English source. The auth guard re-renders it localized via
46        // `localized_error` when a request translator is in hand (the `ErrorMeta` seam).
47        let (kind, detail) = match self {
48            AdminError::NotFound => (ErrorKind::NotFound, None),
49            AdminError::Forbidden => (ErrorKind::Forbidden, None),
50            AdminError::Internal(err) => {
51                // Report point: log the cause, then mask it on the page below.
52                tracing::error!(error = format!("{err:#}"), "admin request failed");
53                let detail = laterite_core::config::debug().then(|| format!("{err:#}"));
54                (ErrorKind::Internal, detail)
55            }
56        };
57        let (title, message) = kind_texts(kind);
58        page(
59            kind,
60            kind_status(kind),
61            title.source(),
62            message.source(),
63            detail,
64        )
65    }
66}
67
68/// The HTTP status each error kind renders with.
69fn kind_status(kind: ErrorKind) -> StatusCode {
70    match kind {
71        ErrorKind::NotFound => StatusCode::NOT_FOUND,
72        ErrorKind::Forbidden => StatusCode::FORBIDDEN,
73        ErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
74    }
75}
76
77/// The title and message for an error kind, as localizable [`Text`]. One source, used
78/// for both the English fallback (`.source()`) and the localized re-render.
79fn kind_texts(kind: ErrorKind) -> (Text, Text) {
80    match kind {
81        ErrorKind::NotFound => (
82            t!("Not found"),
83            t!("The page or record you asked for does not exist."),
84        ),
85        ErrorKind::Forbidden => (
86            t!("Forbidden"),
87            t!("You do not have permission to view this."),
88        ),
89        ErrorKind::Internal => (
90            t!("Something went wrong"),
91            t!("The server hit an unexpected error. It has been logged."),
92        ),
93    }
94}
95
96/// Re-renders the error page for `kind` localized through the request `tr`, for the
97/// auth guard to replace the English fallback a handler produced (the `ErrorMeta`
98/// seam). The debug cause is not carried across the re-render; it stays logged.
99pub(crate) fn localized_error(kind: ErrorKind, tr: &Translator) -> Response {
100    let (title, message) = kind_texts(kind);
101    page(
102        kind,
103        kind_status(kind),
104        &tr.t(&title),
105        &tr.t(&message),
106        None,
107    )
108}
109
110/// Renders the standalone error page and stamps the [`ErrorMeta`] seam onto the
111/// response so the auth guard can re-render it localized.
112fn page(
113    kind: ErrorKind,
114    status: StatusCode,
115    title: &str,
116    message: &str,
117    detail: Option<String>,
118) -> Response {
119    let body = ErrorPage {
120        title: title.to_string(),
121        message: message.to_string(),
122        detail,
123    }
124    .render()
125    .unwrap_or_else(|_| title.to_string());
126    let mut response = (status, Html(body)).into_response();
127    response.extensions_mut().insert(ErrorMeta { kind });
128    response
129}
130
131/// A 403 for a rejected mutation: a bad request origin or a missing/mismatched
132/// CSRF token. A distinct message from the generic forbidden page so the
133/// operator knows to reload and resubmit rather than that they lack permission.
134/// Returned before the request translator exists, so it renders in English.
135pub(crate) fn csrf_rejected() -> Response {
136    page(
137        ErrorKind::Forbidden,
138        StatusCode::FORBIDDEN,
139        t!("Request blocked").source(),
140        t!("This form expired or its origin was not recognised. Go back, reload the page, and try again.")
141            .source(),
142        None,
143    )
144}
145
146/// A masked 500 page with no known cause, for the legacy `render_error` helper
147/// whose call sites discarded the error. Prefer returning `AdminError` so the
148/// cause is logged.
149pub(crate) fn masked_500() -> Response {
150    page(
151        ErrorKind::Internal,
152        StatusCode::INTERNAL_SERVER_ERROR,
153        t!("Something went wrong").source(),
154        t!("The server hit an unexpected error.").source(),
155        None,
156    )
157}
158
159/// The standalone, self-contained error page. It carries its own styles (no
160/// `Shell`, no external stylesheet), so it renders from `IntoResponse` where no
161/// request context is available.
162#[derive(Template)]
163#[template(path = "error.html")]
164struct ErrorPage {
165    title: String,
166    message: String,
167    /// The internal cause, shown only in debug mode (`None` otherwise).
168    detail: Option<String>,
169}
170
171impl From<CoreError> for AdminError {
172    fn from(err: CoreError) -> Self {
173        match err {
174            CoreError::NotFound(_) => AdminError::NotFound,
175            CoreError::Unauthorized | CoreError::Forbidden(_) => AdminError::Forbidden,
176            other => AdminError::Internal(other.into()),
177        }
178    }
179}
180
181impl From<AuthError> for AdminError {
182    // Auth failures already map to CoreError (see `laterite_auth`), so route
183    // through it to inherit the variant-aware classification above.
184    fn from(err: AuthError) -> Self {
185        AdminError::from(CoreError::from(err))
186    }
187}
188
189// Failures with no domain meaning at the request boundary collapse to a logged,
190// masked internal error.
191macro_rules! into_internal {
192    ($($ty:ty),* $(,)?) => {$(
193        impl From<$ty> for AdminError {
194            fn from(err: $ty) -> Self {
195                AdminError::Internal(err.into())
196            }
197        }
198    )*};
199}
200into_internal!(anyhow::Error, sqlx::Error, askama::Error);
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn client_errors_map_to_their_status() {
208        assert_eq!(
209            AdminError::NotFound.into_response().status(),
210            StatusCode::NOT_FOUND
211        );
212        assert_eq!(
213            AdminError::Forbidden.into_response().status(),
214            StatusCode::FORBIDDEN
215        );
216    }
217
218    #[test]
219    fn core_errors_keep_their_meaning() {
220        assert!(matches!(
221            AdminError::from(CoreError::NotFound("x".into())),
222            AdminError::NotFound
223        ));
224        assert!(matches!(
225            AdminError::from(CoreError::Forbidden("x".into())),
226            AdminError::Forbidden
227        ));
228        assert!(matches!(
229            AdminError::from(CoreError::Unauthorized),
230            AdminError::Forbidden
231        ));
232        // An unexpected variant collapses to Internal.
233        assert!(matches!(
234            AdminError::from(CoreError::Conflict("x".into())),
235            AdminError::Internal(_)
236        ));
237    }
238
239    #[tokio::test]
240    async fn internal_masks_the_cause_and_stamps_meta() {
241        let resp =
242            AdminError::Internal(anyhow::anyhow!("secret connection string")).into_response();
243        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
244        assert_eq!(
245            resp.extensions().get::<ErrorMeta>().map(|m| m.kind),
246            Some(ErrorKind::Internal)
247        );
248        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
249            .await
250            .unwrap();
251        let body = String::from_utf8(bytes.to_vec()).unwrap();
252        // Debug mode is off in tests, so the cause is not on the page.
253        assert!(!body.contains("secret connection string"));
254    }
255}