a3s-boot 0.1.2

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
#![cfg(feature = "session")]

use a3s_boot::{
    BootApplication, BootRequest, BootResponse, HttpMethod, RouteDefinition, SessionManager,
    SessionModule, SessionOptions,
};
use std::time::Duration;

#[tokio::test]
async fn session_module_persists_session_data_with_a_cookie() {
    let manager = SessionManager::in_memory(
        SessionOptions::new()
            .with_cookie_name("sid")
            .with_ttl(Duration::from_secs(60)),
    );
    let login_manager = manager.clone();
    let profile_manager = manager.clone();
    let app = BootApplication::builder()
        .use_global_session_module(SessionModule::from_manager("sessions", manager))
        .route(
            RouteDefinition::get("/login", move |request: BootRequest| {
                let manager = login_manager.clone();
                async move {
                    let session_id = manager.require_session_id(&request)?;
                    manager.set(&session_id, "user_id", &"u1")?;
                    Ok(BootResponse::text("logged in"))
                }
            })
            .unwrap(),
        )
        .route(
            RouteDefinition::get("/profile", move |request: BootRequest| {
                let manager = profile_manager.clone();
                async move {
                    let session_id = manager.require_session_id(&request)?;
                    let user_id = manager
                        .get::<String>(&session_id, "user_id")?
                        .unwrap_or_else(|| "anonymous".to_string());
                    Ok(BootResponse::text(user_id))
                }
            })
            .unwrap(),
        )
        .build()
        .unwrap();

    let login = app
        .call(BootRequest::new(HttpMethod::Get, "/login"))
        .await
        .unwrap();
    let cookie = cookie_pair(login.header_values("set-cookie")[0]);
    let profile = app
        .call(BootRequest::new(HttpMethod::Get, "/profile").with_header("cookie", cookie))
        .await
        .unwrap();

    assert_eq!(login.body_text().unwrap(), "logged in");
    assert!(login.header_values("set-cookie")[0].starts_with("sid="));
    assert!(login.header_values("set-cookie")[0].contains("HttpOnly"));
    assert_eq!(profile.body_text().unwrap(), "u1");
}

#[tokio::test]
async fn session_cookie_is_not_written_until_session_has_data() {
    let app = BootApplication::builder()
        .use_global_session_module(SessionModule::in_memory("sessions"))
        .route(RouteDefinition::get("/ping", |_| async { Ok(BootResponse::text("pong")) }).unwrap())
        .build()
        .unwrap();

    let response = app
        .call(BootRequest::new(HttpMethod::Get, "/ping"))
        .await
        .unwrap();

    assert_eq!(response.body_text().unwrap(), "pong");
    assert!(response.header_values("set-cookie").is_empty());
}

#[tokio::test]
async fn session_destroy_clears_existing_cookie() {
    let manager = SessionManager::in_memory(SessionOptions::new().with_cookie_name("sid"));
    let create_manager = manager.clone();
    let destroy_manager = manager.clone();
    let app = BootApplication::builder()
        .use_global_session_module(SessionModule::from_manager("sessions", manager))
        .route(
            RouteDefinition::get("/create", move |request: BootRequest| {
                let manager = create_manager.clone();
                async move {
                    let session_id = manager.require_session_id(&request)?;
                    manager.set(&session_id, "name", &"Milo")?;
                    Ok(BootResponse::text("created"))
                }
            })
            .unwrap(),
        )
        .route(
            RouteDefinition::get("/logout", move |request: BootRequest| {
                let manager = destroy_manager.clone();
                async move {
                    let session_id = manager.require_session_id(&request)?;
                    manager.destroy(&session_id)?;
                    Ok(BootResponse::text("logged out"))
                }
            })
            .unwrap(),
        )
        .build()
        .unwrap();

    let created = app
        .call(BootRequest::new(HttpMethod::Get, "/create"))
        .await
        .unwrap();
    let cookie = cookie_pair(created.header_values("set-cookie")[0]);
    let logout = app
        .call(BootRequest::new(HttpMethod::Get, "/logout").with_header("cookie", cookie))
        .await
        .unwrap();

    assert_eq!(logout.body_text().unwrap(), "logged out");
    assert!(logout.header_values("set-cookie")[0].starts_with("sid=;"));
    assert!(logout.header_values("set-cookie")[0].contains("Max-Age=0"));
}

#[test]
fn session_module_supports_named_and_global_exports() {
    let app = BootApplication::builder()
        .use_global_session_module(
            SessionModule::in_memory("sessions")
                .named("web-session")
                .global(),
        )
        .build()
        .unwrap();

    let manager = app.get_named::<SessionManager>("web-session").unwrap();

    assert_eq!(manager.options().cookie_name(), "a3s.sid");
}

fn cookie_pair(set_cookie: &str) -> String {
    set_cookie
        .split(';')
        .next()
        .expect("set-cookie header should contain a cookie pair")
        .to_string()
}