a3s-boot 0.1.3

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

use a3s_boot::{BootApplication, BootError, BootRequest, HttpMethod, StaticModule};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

#[tokio::test]
async fn static_module_serves_files_under_serve_root() {
    let temp = TempDir::new("static-module-serves-files");
    temp.write("public/app.css", "body { color: #222; }");
    temp.write("public/index.html", "<main>A3S</main>");

    let app = BootApplication::builder()
        .import(StaticModule::new("static", temp.path().join("public")).with_serve_root("/assets"))
        .build()
        .unwrap();

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

    assert_eq!(response.status(), 200);
    assert_eq!(response.content_type(), Some("text/css; charset=utf-8"));
    assert_eq!(response.body_text().unwrap(), "body { color: #222; }");
    assert_eq!(index.body_text().unwrap(), "<main>A3S</main>");
}

#[tokio::test]
async fn static_module_supports_head_and_cache_control() {
    let temp = TempDir::new("static-module-head");
    temp.write("public/app.js", "console.log('boot');");

    let app = BootApplication::builder()
        .import(
            StaticModule::new("static", temp.path().join("public"))
                .with_serve_root("/assets")
                .with_cache_control("public, max-age=60"),
        )
        .build()
        .unwrap();

    let response = app
        .call(BootRequest::new(HttpMethod::Head, "/assets/app.js"))
        .await
        .unwrap();

    assert_eq!(response.status(), 200);
    assert_eq!(response.body(), b"");
    assert_eq!(response.content_length().unwrap(), Some(20));
    assert_eq!(
        response.content_type(),
        Some("text/javascript; charset=utf-8")
    );
    assert_eq!(response.header("cache-control"), Some("public, max-age=60"));
}

#[tokio::test]
async fn static_module_can_fallback_to_spa_index() {
    let temp = TempDir::new("static-module-fallback");
    temp.write("dist/index.html", "<div id=\"app\"></div>");

    let app = BootApplication::builder()
        .import(
            StaticModule::new("static", temp.path().join("dist"))
                .with_serve_root("/")
                .with_fallback_file("index.html"),
        )
        .build()
        .unwrap();

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

    assert_eq!(response.status(), 200);
    assert_eq!(response.content_type(), Some("text/html; charset=utf-8"));
    assert_eq!(response.body_text().unwrap(), "<div id=\"app\"></div>");
}

#[tokio::test]
async fn static_module_rejects_traversal_and_dotfiles() {
    let temp = TempDir::new("static-module-security");
    temp.write("public/index.html", "ok");
    temp.write("public/.env", "secret");

    let app = BootApplication::builder()
        .import(StaticModule::new("static", temp.path().join("public")))
        .build()
        .unwrap();

    let traversal = app
        .call(BootRequest::new(HttpMethod::Get, "/../Cargo.toml"))
        .await
        .unwrap_err();
    let dotfile = app
        .call(BootRequest::new(HttpMethod::Get, "/.env"))
        .await
        .unwrap_err();

    assert!(matches!(traversal, BootError::Forbidden(_)));
    assert!(matches!(dotfile, BootError::Forbidden(_)));
}

struct TempDir {
    path: PathBuf,
}

impl TempDir {
    fn new(name: &str) -> Self {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("a3s-boot-{name}-{timestamp}"));
        fs::create_dir_all(&path).unwrap();
        Self { path }
    }

    fn path(&self) -> &Path {
        &self.path
    }

    fn write(&self, relative_path: &str, content: &str) {
        let path = self.path.join(relative_path);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, content).unwrap();
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}