Menu
AkurAI-Build
publicLatest 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)
}