use mini_static::Server;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[test]
fn server_new_canonicalizes_root() {
let root = TempDir::new().unwrap();
let result = Server::new(root.path());
assert!(
result.is_ok(),
"Server::new should succeed with a valid root"
);
}
#[test]
fn server_new_fails_with_invalid_root() {
let result = Server::new(std::path::Path::new(
"/nonexistent/path/that/does/not/exist",
));
assert!(result.is_err(), "Server::new should fail with invalid root");
}
#[test]
fn server_multiple_resolves_without_root_recanonical() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("file1.txt"), b"content1").unwrap();
fs::write(root.path().join("file2.txt"), b"content2").unwrap();
let server = Server::new(root.path()).unwrap();
let res1 = server.resolve("/file1.txt");
let res2 = server.resolve("/file2.txt");
let res3 = server.resolve("/file1.txt");
assert!(res1.is_ok());
assert!(res2.is_ok());
assert!(res3.is_ok());
}
#[test]
fn server_rejects_traversal_on_multiple_requests() {
let root = TempDir::new().unwrap();
let server = Server::new(root.path()).unwrap();
let res1 = server.resolve("/../etc/passwd");
let res2 = server.resolve("/../../etc/passwd");
let res3 = server.resolve("/../etc/passwd");
assert!(res1.is_err());
assert!(res2.is_err());
assert!(res3.is_err());
}
#[test]
fn server_resolve_with_canonical_root_direct() {
let root = TempDir::new().unwrap();
let test_file = root.path().join("test.txt");
fs::write(&test_file, b"content").unwrap();
let root_canon = root.path().canonicalize().unwrap();
let result = mini_static::resolve_with_canonical_root(&root_canon, "/test.txt");
assert!(result.is_ok());
}
#[test]
fn server_multiple_requests_same_effectiveness() {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("subdir")).unwrap();
fs::write(root.path().join("subdir/index.html"), b"<html></html>").unwrap();
fs::write(root.path().join("file.txt"), b"content").unwrap();
let server = Server::new(root.path()).unwrap();
let dir_with_index = server.resolve("/subdir");
let file = server.resolve("/file.txt");
let missing = server.resolve("/missing.txt");
let traversal = server.resolve("/../etc/passwd");
assert!(
dir_with_index.is_ok(),
"directory with index should resolve"
);
assert!(file.is_ok(), "regular file should resolve");
assert!(missing.is_err(), "missing file should error");
assert!(traversal.is_err(), "traversal should error");
}
#[tokio::test]
async fn server_header_read_timeout_closes_idle_connection() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"hello world").unwrap();
let server = Server::new(root.path()).unwrap();
let header_timeout = Duration::from_millis(100);
let (port, _handle) = server.run(header_timeout).await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut stream = TcpStream::connect(&addr).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
let mut buf = vec![0u8; 1024];
match stream.read(&mut buf).await {
Ok(n) => assert_eq!(n, 0, "read from timed-out connection should return EOF"),
Err(e) => panic!("read from timed-out connection failed: {}", e),
}
let mut new_stream = TcpStream::connect(&addr).await.unwrap();
new_stream
.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = vec![0u8; 1024];
let n = new_stream.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(
response.contains("HTTP/1.1 200"),
"new connection should work and return 200, got: {}",
response
);
}
#[tokio::test]
async fn server_run_ephemeral_binds_to_loopback() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("index.html"), b"<html>hello</html>").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream
.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"run_ephemeral should be accessible on loopback, got: {}",
response
);
assert!(
response.contains("hello"),
"response should contain file content, got: {}",
response
);
}
#[tokio::test]
async fn server_streams_large_files_efficiently() {
let root = TempDir::new().unwrap();
let large_content = vec![42u8; 1024 * 1024];
fs::write(root.path().join("large.bin"), &large_content).unwrap();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream
.write_all(b"GET /large.bin HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response_str = String::from_utf8_lossy(&response_data);
let header_part = response_str.split("\r\n\r\n").next().unwrap_or("");
let headers_lowercase = header_part.to_lowercase();
assert!(
response_str.contains("HTTP/1.1 200"),
"should return 200 OK"
);
assert!(
headers_lowercase.contains("content-length: 1048576"),
"Content-Length should be exactly 1MB, got headers: {}",
header_part
);
if let Some(body_start) = response_data.windows(4).position(|w| w == b"\r\n\r\n") {
let body = &response_data[body_start + 4..];
assert_eq!(body.len(), 1048576, "body should be exactly 1MB");
assert!(
body.iter().all(|&b| b == 42),
"file content should be preserved through streaming"
);
} else {
panic!("could not find body separator in response");
}
}
#[tokio::test]
async fn server_serves_non_ascii_filenames() {
let root = TempDir::new().unwrap();
let filename = "café.txt";
let content = "hello from café";
fs::write(root.path().join(filename), content).unwrap();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let encoded_path = "/caf%C3%A9.txt".to_string();
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
encoded_path
);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"non-ASCII filename should resolve correctly, got: {}",
response
);
assert!(
response.contains("hello from café"),
"response should contain file content with non-ASCII chars"
);
}
#[tokio::test]
async fn server_non_ascii_filenames_still_block_traversal() {
let root = TempDir::new().unwrap();
let subdir = root.path().join("subdir");
fs::create_dir(&subdir).unwrap();
fs::write(root.path().join("secret.txt"), b"secret").unwrap();
fs::write(subdir.join("public.txt"), b"public").unwrap();
let server = Server::new(&subdir).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let traversal_attempts = vec![
"/../secret.txt", "/%2E%2E/secret.txt", ];
for attempt in traversal_attempts {
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
attempt
);
let mut stream = TcpStream::connect(&addr).await.unwrap();
stream.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 404"),
"traversal attempt {} should return 404, got: {}",
attempt,
response
);
assert!(
!response.contains("secret"),
"traversal attempt {} should not leak file content",
attempt
);
}
}
#[tokio::test]
async fn server_with_max_connections_bounds_concurrent_connections() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"content").unwrap();
let header_timeout = Duration::from_millis(150);
let server = Server::new(root.path()).unwrap().with_max_connections(1);
let (port, _handle) = server.run(header_timeout).await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let _blocking_conn = TcpStream::connect(&addr).await.unwrap();
let mut conn_b = TcpStream::connect(&addr).await.unwrap();
conn_b
.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = vec![0u8; 1024];
let early_read = tokio::time::timeout(Duration::from_millis(60), conn_b.read(&mut buf)).await;
assert!(
early_read.is_err(),
"connection B should still be waiting for a permit while A holds the only slot"
);
let n = tokio::time::timeout(Duration::from_millis(500), conn_b.read(&mut buf))
.await
.expect("connection B should be served once A's slot frees up")
.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(
response.contains("HTTP/1.1 200"),
"connection B should succeed after A's slot is released, got: {}",
response
);
}
#[tokio::test]
async fn server_shutdown_drains_in_flight_and_stops_accepting() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200") && response.contains("content"),
"request before shutdown should succeed normally, got: {}",
response
);
tokio::time::timeout(Duration::from_secs(2), handle.shutdown())
.await
.expect("shutdown() should complete promptly, not hang");
let reconnect = TcpStream::connect(&addr).await;
assert!(
reconnect.is_err(),
"server should stop accepting new connections after shutdown"
);
}
#[tokio::test]
async fn server_honors_if_none_match_returns_304() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"file content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"first request should return 200, got: {}",
response
);
assert!(
response.to_lowercase().contains("etag:"),
"should have ETag header, got: {}",
response
);
let etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header");
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 304"),
"matching If-None-Match should return 304, got: {}",
response
);
assert!(
!response.contains("file content"),
"304 should have no body"
);
handle.shutdown().await;
}
#[tokio::test]
async fn server_returns_200_on_if_none_match_mismatch() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"file content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: \"wrong-etag\"\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"non-matching If-None-Match should return 200, got: {}",
response
);
assert!(response.contains("file content"), "200 should have body");
handle.shutdown().await;
}
#[tokio::test]
async fn no_cache_header_present_on_200_and_304_responses() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"file content").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.to_lowercase().contains("cache-control: no-cache"),
"200 response should carry Cache-Control: no-cache, got: {}",
response
);
let etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.to_string();
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /test.txt HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 304"),
"expected 304, got: {}",
response
);
assert!(
response.to_lowercase().contains("cache-control: no-cache"),
"304 response should carry Cache-Control: no-cache, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn precompressed_sidecar_served_when_accept_encoding_matches() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.js"), b"console.log('plain');").unwrap();
fs::write(root.path().join("app.js.gz"), b"gzip-sidecar-bytes").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.to_lowercase().contains("content-encoding: gzip"),
"should carry Content-Encoding: gzip, got: {}",
response
);
assert!(
response.contains("gzip-sidecar-bytes"),
"should serve the sidecar's bytes, got: {}",
response
);
assert!(
!response.contains("console.log('plain');"),
"should not serve the plain file's bytes when a sidecar is chosen, got: {}",
response
);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
!response.to_lowercase().contains("content-encoding:"),
"plain request should carry no Content-Encoding, got: {}",
response
);
assert!(
response.contains("console.log('plain');"),
"should serve the plain file's bytes unchanged, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn etag_reflects_the_served_sidecar_variant() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.js"), b"console.log('plain');").unwrap();
fs::write(root.path().join("app.js.gz"), b"gzip-sidecar-bytes").unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
let gzip_etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.to_string();
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
gzip_etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 304"),
"matching gzip-variant ETag should 304, got: {}",
response
);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
let plain_etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.to_string();
assert_ne!(
gzip_etag.trim(),
plain_etag.trim(),
"gzip and plain variants should have different ETags"
);
handle.shutdown().await;
}
async fn get_body(addr: &str, path: &str) -> String {
let mut conn = TcpStream::connect(addr).await.unwrap();
let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
conn.write_all(request.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()
}
#[tokio::test]
async fn with_minify_serves_minified_css() {
let root = TempDir::new().unwrap();
let source = "body {\n /* comment */\n color: red;\n}\n";
fs::write(root.path().join("app.css"), source).unwrap();
let server = Server::new(root.path()).unwrap().with_minify();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let response = get_body(&addr, "/app.css").await;
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
!response.contains("comment"),
"comment should be minified out, got: {}",
response
);
assert!(
!response.contains(" color"),
"redundant whitespace should be minified out, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn head_content_length_matches_minified_get_body_length() {
let root = TempDir::new().unwrap();
let source = "body {\n /* comment */\n color: red;\n}\n";
fs::write(root.path().join("app.css"), source).unwrap();
let server = Server::new(root.path()).unwrap().with_minify();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.css HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut get_response = Vec::new();
conn.read_to_end(&mut get_response).await.unwrap();
let get_response = String::from_utf8_lossy(&get_response);
let get_content_length: usize = get_response
.lines()
.find(|line| line.to_lowercase().starts_with("content-length:"))
.and_then(|line| line.split(':').nth(1))
.and_then(|v| v.trim().parse().ok())
.expect("GET response must have a Content-Length header");
assert!(
get_content_length < source.len(),
"sanity check: minified content-length ({get_content_length}) should be smaller than the source ({} bytes)",
source.len()
);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"HEAD /app.css HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut head_response = Vec::new();
conn.read_to_end(&mut head_response).await.unwrap();
let head_response = String::from_utf8_lossy(&head_response);
let head_content_length: usize = head_response
.lines()
.find(|line| line.to_lowercase().starts_with("content-length:"))
.and_then(|line| line.split(':').nth(1))
.and_then(|v| v.trim().parse().ok())
.expect("HEAD response must have a Content-Length header");
assert_eq!(
head_content_length, get_content_length,
"HEAD's Content-Length must match what GET actually sends, not the on-disk size"
);
handle.shutdown().await;
}
#[tokio::test]
async fn minify_disabled_by_default_serves_unminified() {
let root = TempDir::new().unwrap();
let source = "body {\n /* comment */\n color: red;\n}\n";
fs::write(root.path().join("app.css"), source).unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let response = get_body(&addr, "/app.css").await;
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.contains(source),
"bytes must be byte-identical to disk when minify is off"
);
handle.shutdown().await;
}
#[tokio::test]
async fn with_minify_bypasses_already_minified_files() {
let root = TempDir::new().unwrap();
let already_minified = "body{color:red}/* not really minified, just named .min.css */";
fs::write(root.path().join("app.min.css"), already_minified).unwrap();
let server = Server::new(root.path()).unwrap().with_minify();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let response = get_body(&addr, "/app.min.css").await;
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.contains(already_minified),
"*.min.css must be served byte-identical, not re-minified, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn precompressed_sidecar_takes_priority_over_minification() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("app.js"), "function f( ) { return 1; }").unwrap();
fs::write(root.path().join("app.js.gz"), "gzip-sidecar-bytes").unwrap();
let server = Server::new(root.path()).unwrap().with_minify();
let (port, handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /app.js HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"expected 200, got: {}",
response
);
assert!(
response.contains("gzip-sidecar-bytes"),
"sidecar bytes should win over minification, got: {}",
response
);
handle.shutdown().await;
}
#[tokio::test]
async fn enabling_minify_invalidates_a_previously_cached_etag() {
let root = TempDir::new().unwrap();
let source = "body {\n /* comment */\n color: red;\n}\n";
fs::write(root.path().join("app.css"), source).unwrap();
let unminified_server = Server::new(root.path()).unwrap();
let (port, handle) = unminified_server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let response = get_body(&addr, "/app.css").await;
let stale_etag = response
.lines()
.find(|line| line.to_lowercase().starts_with("etag:"))
.and_then(|line| line.split(": ").nth(1))
.expect("should have ETag header")
.trim()
.to_string();
handle.shutdown().await;
let minified_server = Server::new(root.path()).unwrap().with_minify();
let (port, handle) = minified_server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let addr = format!("127.0.0.1:{}", port);
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET /app.css HTTP/1.1\r\nHost: localhost\r\nIf-None-Match: {}\r\nConnection: close\r\n\r\n",
stale_etag
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 200"),
"the pre-minification ETag must not match post-minification — a 304 here would \
mean the client keeps serving its stale unminified cached copy forever, got: {}",
response
);
assert!(
!response.contains("comment"),
"the fresh 200 must actually be minified, got: {}",
response
);
handle.shutdown().await;
}