a3s-boot 0.1.1

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

use a3s_boot::{
    BootApplication, BootRequest, BootResponse, CorsOptions, CsrfOptions, HttpMethod,
    RateLimitOptions, RouteDefinition, SecurityHeadersOptions,
};
use serde_json::json;
use std::time::Duration;

#[tokio::test]
async fn cors_preflight_routes_are_generated_for_registered_routes() {
    let app = BootApplication::builder()
        .use_global_cors(
            CorsOptions::new()
                .allow_origin("https://app.example")
                .allow_methods([HttpMethod::Get, HttpMethod::Post])
                .allow_headers(["content-type", "x-csrf-token"])
                .with_max_age(600),
        )
        .route(RouteDefinition::get("/cats", |_| async { Ok(BootResponse::text("ok")) }).unwrap())
        .build()
        .unwrap();

    let response = app
        .call(
            BootRequest::new(HttpMethod::Options, "/cats")
                .with_header("origin", "https://app.example")
                .with_header("access-control-request-method", "GET")
                .with_header("access-control-request-headers", "Content-Type"),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), 204);
    assert_eq!(
        response.header("access-control-allow-origin"),
        Some("https://app.example")
    );
    assert_eq!(
        response.header("access-control-allow-methods"),
        Some("GET,POST")
    );
    assert_eq!(
        response.header("access-control-allow-headers"),
        Some("content-type,x-csrf-token")
    );
    assert_eq!(response.header("access-control-max-age"), Some("600"));
    assert_eq!(
        response.header("vary"),
        Some("origin, access-control-request-method, access-control-request-headers")
    );
}

#[tokio::test]
async fn cors_actual_responses_include_allowed_origin_headers() {
    let app = BootApplication::builder()
        .use_global_cors(
            CorsOptions::new()
                .allow_origin("https://app.example")
                .allow_credentials()
                .expose_header("x-request-id"),
        )
        .route(
            RouteDefinition::get("/cats", |_| async {
                BootResponse::json(&json!({ "name": "Milo" }))
            })
            .unwrap(),
        )
        .build()
        .unwrap();

    let response = app
        .call(
            BootRequest::new(HttpMethod::Get, "/cats").with_header("origin", "https://app.example"),
        )
        .await
        .unwrap();

    assert_eq!(
        response.header("access-control-allow-origin"),
        Some("https://app.example")
    );
    assert_eq!(
        response.header("access-control-allow-credentials"),
        Some("true")
    );
    assert_eq!(
        response.header("access-control-expose-headers"),
        Some("x-request-id")
    );
    assert_eq!(response.header("vary"), Some("origin"));
}

#[tokio::test]
async fn security_headers_interceptor_adds_defaults_without_overwriting_handlers() {
    let app = BootApplication::builder()
        .use_global_security_headers(
            SecurityHeadersOptions::new()
                .with_content_security_policy("default-src 'self'")
                .with_strict_transport_security("max-age=31536000"),
        )
        .route(
            RouteDefinition::get("/secure", |_| async {
                Ok(BootResponse::text("ok").with_header("x-frame-options", "SAMEORIGIN"))
            })
            .unwrap(),
        )
        .build()
        .unwrap();

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

    assert_eq!(response.header("x-content-type-options"), Some("nosniff"));
    assert_eq!(response.header("x-frame-options"), Some("SAMEORIGIN"));
    assert_eq!(response.header("referrer-policy"), Some("no-referrer"));
    assert_eq!(
        response.header("cross-origin-opener-policy"),
        Some("same-origin")
    );
    assert_eq!(
        response.header("cross-origin-resource-policy"),
        Some("same-origin")
    );
    assert_eq!(
        response.header("content-security-policy"),
        Some("default-src 'self'")
    );
    assert_eq!(
        response.header("strict-transport-security"),
        Some("max-age=31536000")
    );
}

#[tokio::test]
async fn csrf_guard_allows_matching_tokens_and_rejects_missing_tokens() {
    let app = BootApplication::builder()
        .use_global_csrf(CsrfOptions::new())
        .route(
            RouteDefinition::post("/cats", |_| async { Ok(BootResponse::text("created")) })
                .unwrap(),
        )
        .build()
        .unwrap();

    let allowed = app
        .call(
            BootRequest::new(HttpMethod::Post, "/cats")
                .with_header("x-csrf-token", "token-1")
                .with_header("cookie", "csrf-token=token-1"),
        )
        .await
        .unwrap();
    let rejected = app
        .handle(BootRequest::new(HttpMethod::Post, "/cats"))
        .await;

    assert_eq!(allowed.status(), 200);
    assert_eq!(allowed.body_text().unwrap(), "created");
    assert_eq!(rejected.status(), 403);
    assert_eq!(rejected.body_text().unwrap(), "invalid CSRF token");
}

#[tokio::test]
async fn rate_limit_guard_rejects_requests_after_the_window_limit() {
    let app = BootApplication::builder()
        .use_global_rate_limit(
            RateLimitOptions::new()
                .with_max_requests(2)
                .with_window(Duration::from_secs(60))
                .with_key_header("x-user-id")
                .without_bearer_token(),
        )
        .route(
            RouteDefinition::get("/limited", |_| async { Ok(BootResponse::text("ok")) }).unwrap(),
        )
        .build()
        .unwrap();

    for _ in 0..2 {
        let response = app
            .call(BootRequest::new(HttpMethod::Get, "/limited").with_header("x-user-id", "u1"))
            .await
            .unwrap();
        assert_eq!(response.status(), 200);
    }

    let rejected = app
        .handle(BootRequest::new(HttpMethod::Get, "/limited").with_header("x-user-id", "u1"))
        .await;
    let separate_key = app
        .call(BootRequest::new(HttpMethod::Get, "/limited").with_header("x-user-id", "u2"))
        .await
        .unwrap();

    assert_eq!(rejected.status(), 429);
    assert_eq!(rejected.body_text().unwrap(), "rate limit exceeded");
    assert_eq!(separate_key.status(), 200);
}