mini-static 0.19.2

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use mini_static::{Server, SpaTransition};
use std::fs;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

async fn get(addr: &str, path: &str) -> String {
    let mut conn = TcpStream::connect(addr).await.unwrap();
    conn.write_all(
        format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").as_bytes(),
    )
    .await
    .unwrap();
    let mut response_data = Vec::new();
    conn.read_to_end(&mut response_data).await.unwrap();
    String::from_utf8_lossy(&response_data).into_owned()
}

const FIXTURE_HTML: &[u8] = b"<html><body><h1>hi</h1></body></html>";

#[tokio::test]
async fn spa_mode_disabled_by_default_serves_html_unmodified() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path()).unwrap();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(response.contains("HTTP/1.1 200"));
    assert!(
        !response.contains("<script>"),
        "no spa script should be injected when spa-mode was never enabled, got: {response}"
    );
    assert!(
        response.ends_with(std::str::from_utf8(FIXTURE_HTML).unwrap()),
        "body should be byte-identical to the fixture, got: {response}"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn with_spa_mode_injects_a_script_targeting_document_body() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path()).unwrap().with_spa_mode();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(response.contains("mini-static:navigate"), "got: {response}");
    assert!(response.contains("doc.body"), "got: {response}");
    assert!(response.contains("ROOT_SELECTOR=null;"), "got: {response}");

    handle.shutdown().await;
}

#[tokio::test]
async fn with_spa_root_injects_a_script_targeting_the_configured_selector() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path()).unwrap().with_spa_root("#app");
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(
        response.contains("ROOT_SELECTOR=\"#app\";"),
        "got: {response}"
    );
    assert!(
        !response.contains("ROOT_SELECTOR=null;"),
        "configuring a root selector should not leave the null-body fallback, got: {response}"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn spa_mode_and_root_builders_compose_regardless_of_call_order() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let root_then_mode = Server::new(root.path())
        .unwrap()
        .with_spa_root("#app")
        .with_spa_mode();
    let (port_a, handle_a) = root_then_mode.run_ephemeral().await.unwrap();
    let addr_a = format!("127.0.0.1:{port_a}");
    let response_a = get(&addr_a, "/index.html").await;
    assert!(
        response_a.contains("ROOT_SELECTOR=\"#app\";"),
        "with_spa_root().with_spa_mode() should keep the configured selector, got: {response_a}"
    );
    handle_a.shutdown().await;

    let mode_then_root = Server::new(root.path())
        .unwrap()
        .with_spa_mode()
        .with_spa_root("#app");
    let (port_b, handle_b) = mode_then_root.run_ephemeral().await.unwrap();
    let addr_b = format!("127.0.0.1:{port_b}");
    let response_b = get(&addr_b, "/index.html").await;
    assert!(
        response_b.contains("ROOT_SELECTOR=\"#app\";"),
        "with_spa_mode().with_spa_root() should set the selector, got: {response_b}"
    );
    handle_b.shutdown().await;
}

#[tokio::test]
async fn default_transition_injects_no_style_tag() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path()).unwrap().with_spa_mode();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(
        !response.contains("<style>"),
        "fade is the default and injects no CSS, got: {response}"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn slide_transition_injects_a_style_tag_before_the_script() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path())
        .unwrap()
        .with_spa_root("#app")
        .with_spa_transition(SpaTransition::Slide);
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(
        response.contains("mix-blend-mode:normal"),
        "got: {response}"
    );
    let style_pos = response.find("<style>").expect("style tag present");
    let script_pos = response.find("<script>").expect("script tag present");
    assert!(
        style_pos < script_pos,
        "style tag should precede the script tag, got: {response}"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn with_spa_transition_alone_enables_spa_mode() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path())
        .unwrap()
        .with_spa_transition(SpaTransition::Slide);
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(response.contains("mini-static:navigate"), "got: {response}");

    handle.shutdown().await;
}

#[tokio::test]
async fn spa_mode_composes_with_live_reload_both_scripts_present() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), FIXTURE_HTML).unwrap();

    let server = Server::new(root.path())
        .unwrap()
        .with_spa_mode()
        .with_live_reload();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let response = get(&addr, "/index.html").await;
    assert!(
        response.contains("mini-static:navigate"),
        "spa script missing, got: {response}"
    );
    assert!(
        response.contains("EventSource"),
        "reload script missing, got: {response}"
    );

    let body_start = response.find("<html>").expect("html body present");
    let body_end = response.rfind("</body>").expect("closing body tag present");
    let spa_pos = response.find("mini-static:navigate").unwrap();
    let reload_pos = response.find("EventSource").unwrap();
    assert!(
        (body_start..body_end).contains(&spa_pos),
        "spa script must land before </body>, got: {response}"
    );
    assert!(
        (body_start..body_end).contains(&reload_pos),
        "reload script must land before </body>, got: {response}"
    );

    handle.shutdown().await;
}