mod.rs 133 lignes · 4524 octets
//! Template utilities and macros for Rustwarden Core
//!
//! Provides the `impl_template_response!` macro and `LayoutContext` for Askama templates.

pub mod email_templates;

use serde::{Deserialize, Serialize};

/// Macro to implement Axum's IntoResponse for Askama templates
#[macro_export]
macro_rules! impl_template_response {
    ($($template:ty),+ $(,)?) => {
        $(
            impl axum::response::IntoResponse for $template {
                fn into_response(self) -> axum::response::Response {
                    match self.render() {
                        Ok(html) => axum::response::Html(html).into_response(),
                        Err(e) => {
                            tracing::error!("Template rendering error for {}: {}", stringify!($template), e);
                            (
                                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
                                format!("Template rendering failed: {}", e),
                            )
                                .into_response()
                        }
                    }
                }
            }
        )+
    };
}

/// Base layout context shared by all pages
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutContext {
    /// HTML `<title>` and heading text for the page.
    pub page_title: String,
    /// CSRF token embedded in forms to prevent cross-site request forgery.
    pub csrf_token: String,
    /// Application display name (from `APP_NAME` env var).
    pub app_name: String,
    /// Application version string from `Cargo.toml`.
    pub app_version: String,
}

impl LayoutContext {
    /// Create a new `LayoutContext` with the given page title and CSRF token.
    pub fn new(page_title: &str, csrf_token: &str) -> Self {
        Self {
            page_title: page_title.to_string(),
            csrf_token: csrf_token.to_string(),
            app_name: crate::config::app::default_app_name(),
            app_version: env!("CARGO_PKG_VERSION").to_string(),
        }
    }

    /// Async constructor that populates `csrf_token` from the tower-sessions session.
    ///
    /// Replaces the manual pattern:
    /// ```rust,ignore
    /// let csrf_token = get_or_create_csrf_token(&session).await?;
    /// let ctx = LayoutContext::new("My Page", &csrf_token);
    /// ```
    ///
    /// Falls back to an empty token (with a warning log) if session access fails,
    /// so template rendering never panics on a transient session backend error.
    pub async fn from_session(session: &tower_sessions::Session, page_title: &str) -> Self {
        let token = match crate::middleware::csrf::get_or_create_csrf_token(session).await {
            Ok(t) => t,
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "LayoutContext::from_session failed to fetch CSRF token, falling back to empty"
                );
                String::new()
            }
        };
        Self::new(page_title, &token)
    }
}

impl Default for LayoutContext {
    fn default() -> Self {
        Self::new("", "")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;
    use tower_sessions::{MemoryStore, Session};

    fn make_test_session() -> Session {
        let store = Arc::new(MemoryStore::default());
        Session::new(None, store, None)
    }

    #[tokio::test]
    async fn from_session_generates_csrf_token_when_absent() {
        let session = make_test_session();
        let ctx = LayoutContext::from_session(&session, "Home").await;

        assert_eq!(ctx.page_title, "Home");
        assert_eq!(
            ctx.csrf_token.len(),
            64,
            "csrf_token should be a 32-byte hex string (64 chars)"
        );
    }

    #[tokio::test]
    async fn from_session_returns_existing_csrf_token() {
        let session = make_test_session();
        let first = LayoutContext::from_session(&session, "First").await;
        let second = LayoutContext::from_session(&session, "Second").await;

        assert_eq!(
            first.csrf_token, second.csrf_token,
            "from_session must reuse the session's existing token"
        );
        assert_eq!(second.page_title, "Second");
    }

    #[tokio::test]
    async fn from_session_fills_app_metadata() {
        let session = make_test_session();
        let ctx = LayoutContext::from_session(&session, "Any").await;

        assert!(!ctx.app_name.is_empty());
        assert!(!ctx.app_version.is_empty());
    }
}