AkurAI Build
Menu

AkurAI-Build

public

Latest change 41f5eba4919e59f92e84857cf4b4314e159da9fb - tests: exercise native listener end to end by Olafur Bui

#![cfg(unix)]

mod support;

use std::{
    env,
    io::{Read, Write},
    net::TcpStream,
    path::{Path, PathBuf},
    process::Command,
    time::{Duration, Instant},
};

use support::{HttpResponse, ProcessServer, ScratchDir, free_port};

#[test]
fn static_artifact_http_matrix() {
    mpa_contract();
    spa_contract();
    base_path_contract();
}

fn mpa_contract() {
    let mut server = StaticServer::start("mpa");
    server.assert_no_runtime_state();

    let home = server.request("GET", "/");
    home.assert_status(200);
    home.assert_body_contains("hand-authored MPA home");
    home.assert_header_contains("content-type", "text/html");
    home.assert_header_contains("cache-control", "no-cache");
    home.assert_security_headers();

    let head = server.request("HEAD", "/");
    assert_head_matches(&home, &head);

    let etag = home.headers.get("etag").expect("GET omitted ETag").clone();
    assert!(
        etag.starts_with('"') && etag.ends_with('"') && !etag.starts_with("W/"),
        "static artifact ETag must be strong: {etag}"
    );
    let not_modified = server.request_with_headers("GET", "/", &[("If-None-Match", &etag)]);
    not_modified.assert_status(304);
    assert!(not_modified.body.is_empty(), "304 returned a body");
    not_modified.assert_header_contains("etag", &etag);
    not_modified.assert_header_contains("cache-control", "no-cache");
    not_modified.assert_header_contains("vary", "Accept-Encoding");
    not_modified.assert_security_headers();

    let nested = server.request("GET", "/guide/");
    nested.assert_status(200);
    nested.assert_body_contains("hand-authored nested MPA page");

    let missing = server.request("GET", "/missing");
    missing.assert_status(404);
    missing.assert_body_contains("hand-authored MPA 404");
    missing.assert_header_contains("cache-control", "no-cache");

    let asset = server.request("GET", "/assets/app-a1b2c3d4.css");
    asset.assert_status(200);
    asset.assert_body_contains("rgb(12 34 56)");
    asset.assert_header_contains("content-type", "text/css");
    asset.assert_header_contains("cache-control", "max-age=0");
    asset.assert_header_contains("cache-control", "must-revalidate");
    asset.assert_security_headers();

    let missing_asset = server.request("GET", "/assets/missing-a1b2c3d4.css");
    missing_asset.assert_status(404);

    assert_method_not_allowed(&server, "/");
    assert_traversal_rejected(&server);
    server.assert_no_runtime_state();
    server.shutdown_with_sigterm();
}

fn spa_contract() {
    let mut server = StaticServer::start("spa");
    server.assert_no_runtime_state();

    let index = server.request("GET", "/");
    index.assert_status(200);
    index.assert_body_contains("hand-authored SPA fallback shell");
    index.assert_body_excludes("hand-authored SPA 200 fallback");

    let deep_link = server.request("GET", "/dashboard/settings?tab=one");
    deep_link.assert_status(200);
    deep_link.assert_body_contains("hand-authored SPA 200 fallback");
    deep_link.assert_header_contains("cache-control", "no-cache");
    deep_link.assert_security_headers();

    let asset = server.request("GET", "/assets/app-deadbeef.js");
    asset.assert_status(200);
    asset.assert_body_contains("static-spa");
    asset.assert_header_contains("content-type", "javascript");
    asset.assert_header_contains("cache-control", "must-revalidate");

    for excluded in [
        "/assets/missing.js",
        "/assets/missing",
        "/static/missing",
        "/_next/missing",
        "/_nuxt/missing",
        "/missing.js",
        "/api/private",
    ] {
        let response = server.request("GET", excluded);
        response.assert_status(404);
        response.assert_body_excludes("hand-authored SPA 200 fallback");
    }

    assert_method_not_allowed(&server, "/dashboard");
    assert_traversal_rejected(&server);
    server.shutdown_with_sigterm();
}

fn base_path_contract() {
    let mut server = StaticServer::start("base_path");

    for outside in ["/", "/guide/", "/assets/app-c0ffee12.css"] {
        server.request("GET", outside).assert_status(404);
    }

    let home = server.request("GET", "/docs/");
    home.assert_status(200);
    home.assert_body_contains("hand-authored /docs base-path home");

    let redirect = server.request("GET", "/docs?x=1");
    redirect.assert_status(308);
    redirect.assert_header_contains("location", "/docs/?x=1");

    let nested = server.request("GET", "/docs/guide/");
    nested.assert_status(200);
    nested.assert_body_contains("hand-authored /docs/guide page");

    let asset = server.request("GET", "/docs/assets/app-c0ffee12.css");
    asset.assert_status(200);
    asset.assert_header_contains("cache-control", "must-revalidate");

    server
        .request("GET", "/docs/assets/missing-c0ffee12.css")
        .assert_status(404);
    assert_method_not_allowed(&server, "/docs/");
    server.shutdown_with_sigterm();
}

// A slow-header (slowloris) client that never terminates its request head must
// be disconnected by the per-connection header-read timeout, not pinned for the
// full 30 s request timeout. The server runs with a short 500 ms header-read
// timeout via BUNFORK_REQUEST_TIMEOUT_MS so the bound is exercised quickly.
#[test]
fn slow_header_client_is_disconnected_by_the_header_read_timeout() {
    let mut server = StaticServer::start_with_env("mpa", &[("BUNFORK_REQUEST_TIMEOUT_MS", "500")]);

    // A normal request still completes: the bound does not break healthy traffic.
    server.request("GET", "/").assert_status(200);

    let mut stream = TcpStream::connect(server.address()).expect("connect slow client");
    stream
        .set_read_timeout(Some(Duration::from_secs(5)))
        .expect("set slow-client read timeout");
    // A partial request head with no terminating CRLF: the server must never see
    // a complete request, so only the header-read timeout can end this socket.
    stream
        .write_all(b"GET / HTTP/1.1\r\nHost: slowloris\r\n")
        .expect("write partial head");

    let start = Instant::now();
    let mut buffer = [0u8; 64];
    let result = stream.read(&mut buffer);
    let elapsed = start.elapsed();
    assert!(
        elapsed < Duration::from_secs(4),
        "server did not close the slow-header socket promptly ({elapsed:?}); \
         it likely waited the full request timeout or never closed"
    );
    match result {
        // Clean EOF (server closed the connection) is the expected outcome; a
        // short error/response before close is also acceptable. The read must
        // simply return well before the 5 s socket read timeout.
        Ok(_) => {}
        Err(error) => panic!("expected the server to close the socket, got {error}"),
    }

    server.shutdown_with_sigterm();
}

fn assert_method_not_allowed(server: &StaticServer, path: &str) {
    let response = server.request("POST", path);
    response.assert_status(405);
    response.assert_header_contains("allow", "GET");
    response.assert_header_contains("allow", "HEAD");
    response.assert_security_headers();
}

fn assert_traversal_rejected(server: &StaticServer) {
    for path in ["/%2e%2e/secret.txt", "/assets/%2e%2e/index.html"] {
        let response = server.request("GET", path);
        assert!(
            matches!(response.status, 400 | 404),
            "traversal {path} returned {} instead of 400/404; response={response:?}",
            response.status
        );
        response.assert_body_excludes("hand-authored SPA 200 fallback");
        response.assert_body_excludes("hand-authored MPA home");
    }
}

fn assert_head_matches(get: &HttpResponse, head: &HttpResponse) {
    assert_eq!(head.status, get.status, "HEAD status differs from GET");
    assert!(head.body.is_empty(), "HEAD returned a response body");
    for name in ["content-type", "cache-control", "etag"] {
        assert_eq!(
            head.headers.get(name),
            get.headers.get(name),
            "HEAD {name} differs from GET"
        );
    }
    if let Some(length) = get.headers.get("content-length") {
        assert_eq!(
            head.headers.get("content-length"),
            Some(length),
            "HEAD content-length differs from GET"
        );
    }
}

struct StaticServer {
    process: ProcessServer,
    scratch: ScratchDir,
}

impl StaticServer {
    fn start(name: &str) -> Self {
        Self::start_with_env(name, &[])
    }

    fn start_with_env(name: &str, extra_env: &[(&str, &str)]) -> Self {
        let fixture = fixture(name);
        let site = fixture.join("site");
        let manifest = fixture.join("manifest.json");
        assert!(site.is_dir(), "missing fixture site {}", site.display());
        assert!(
            manifest.is_file(),
            "missing fixture manifest {}",
            manifest.display()
        );

        let scratch = ScratchDir::new(name);
        let address = format!("127.0.0.1:{}", free_port());
        let mut command = Command::new(env!("CARGO_BIN_EXE_bunfork"));
        command
            .args(["serve", "--static"])
            .arg(&site)
            .arg("--manifest")
            .arg(&manifest)
            .arg("--address")
            .arg(&address)
            .current_dir(scratch.path());
        for variable in [
            "BUNFORK_DB",
            "BUNFORK_DB_KEY",
            "BUNFORK_KEY_FILE",
            "BUNFORK_API_TOKEN",
            "BUNFORK_TOKEN_FILE",
            "BUNFORK_TENANT",
            "BUNFORK_MODEL",
        ] {
            command.env_remove(variable);
        }
        for (name, value) in extra_env {
            command.env(name, value);
        }

        Self {
            process: ProcessServer::start(command, address),
            scratch,
        }
    }

    fn address(&self) -> &str {
        self.process.address()
    }

    fn request(&self, method: &str, target: &str) -> HttpResponse {
        self.process.request(method, target)
    }

    fn request_with_headers(
        &self,
        method: &str,
        target: &str,
        headers: &[(&str, &str)],
    ) -> HttpResponse {
        self.process.request_with_headers(method, target, headers)
    }

    fn assert_no_runtime_state(&self) {
        for relative in [".bunfork.key", ".bunfork.token", "data", "backups"] {
            assert!(
                !self.scratch.path().join(relative).exists(),
                "static mode created runtime state: {relative}"
            );
        }
    }

    fn shutdown_with_sigterm(&mut self) {
        self.process.shutdown_with_sigterm();
    }
}

fn fixture(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/static")
        .join(name)
}