#![cfg(feature = "static")]
use fandhe_backend_core::interceptor::Interceptor;
use fandhe_backend_core::{Handler, Server, handle_connection};
use fandhe_backend_http::request::RequestHead;
use fandhe_backend_http::response::Response;
use fandhe_backend_plugin_static::StaticFilesConfig;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
struct NotCalledHandler;
impl Handler for NotCalledHandler {
fn handle(&self, _head: &RequestHead, _body: &[u8]) -> fandhe_backend_routes::HandlerFuture {
panic!("plugin::try_intercept が Some を返したのに既定 Handler が呼ばれた");
}
}
struct FixedOkHandler;
impl Handler for FixedOkHandler {
fn handle(&self, _head: &RequestHead, _body: &[u8]) -> fandhe_backend_routes::HandlerFuture {
Box::pin(std::future::ready(Response::new(200, b"ok".to_vec())))
}
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let mut path = std::env::temp_dir();
path.push(format!(
"fandhe-core-plugin-static-boundary-{label}-{}",
std::process::id()
));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn write(&self, rel: &str, contents: &[u8]) {
std::fs::write(self.0.join(rel), contents).unwrap();
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn roundtrip(server: &Server, request: &[u8]) -> Vec<u8> {
let (mut client, server_stream) = tokio::io::duplex(1 << 20);
client.write_all(request).await.unwrap();
client.shutdown().await.unwrap();
handle_connection(server, server_stream).await;
let mut out = Vec::new();
client.read_to_end(&mut out).await.unwrap();
out
}
#[tokio::test]
async fn registered_static_serves_file_and_bypasses_default_handler() {
let dir = TempDir::new("serves");
dir.write("app.js", b"console.log('hi')");
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new().handler(NotCalledHandler).static_files(config);
let request = b"GET /static/app.js HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.contains("Content-Type: text/javascript; charset=utf-8\r\n"));
assert!(response.contains("X-Content-Type-Options: nosniff\r\n"));
assert!(response.contains("Content-Length: 17\r\n"));
assert!(response.ends_with("console.log('hi')"));
}
#[tokio::test]
async fn registered_static_serves_directory_index_with_trailing_slash() {
let dir = TempDir::new("trailing-slash");
std::fs::create_dir_all(dir.path().join("docs")).unwrap();
std::fs::write(dir.path().join("docs/index.html"), b"<h1>docs</h1>").unwrap();
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new().handler(NotCalledHandler).static_files(config);
let request = b"GET /static/docs/ HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.ends_with("<h1>docs</h1>"));
}
#[tokio::test]
async fn registered_static_traversal_attempt_returns_404() {
let dir = TempDir::new("traversal");
dir.write("index.html", b"hi");
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new().handler(NotCalledHandler).static_files(config);
let request = b"GET /static/../Cargo.toml HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 404 Not Found\r\n"));
}
#[tokio::test]
async fn unregistered_static_falls_through_to_default_handler() {
let server = Server::new();
let request = b"GET /static/app.js HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 404 Not Found\r\n"));
}
#[tokio::test]
async fn wrong_method_falls_through_to_default_handler() {
let dir = TempDir::new("wrong-method");
dir.write("app.js", b"x");
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new().handler(FixedOkHandler).static_files(config);
let request = b"POST /static/app.js HTTP/1.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.ends_with("ok"));
}
#[tokio::test]
async fn unrelated_path_falls_through_to_default_handler() {
let dir = TempDir::new("unrelated");
dir.write("app.js", b"x");
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new().handler(FixedOkHandler).static_files(config);
let response = roundtrip(
&server,
b"GET /api/todos HTTP/1.1\r\nConnection: close\r\n\r\n",
)
.await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.ends_with("ok"));
}
#[tokio::test]
async fn config_built_via_core_reexport_serves_file() {
let dir = TempDir::new("reexport");
dir.write("app.js", b"console.log('re-export')");
let config =
fandhe_backend_core::plugin_static::StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new().handler(NotCalledHandler).static_files(config);
let request = b"GET /static/app.js HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.ends_with("console.log('re-export')"));
}
struct TrailingSlashRedirect;
impl Interceptor for TrailingSlashRedirect {
fn name(&self) -> &'static str {
"trailing-slash-redirect"
}
fn intercept(&self, head: &RequestHead, _body: &[u8]) -> Option<Response> {
let path = head.path();
if path.len() > 1 && path.ends_with('/') {
Response::redirect(301, path.trim_end_matches('/').to_string()).ok()
} else {
None
}
}
}
#[tokio::test]
async fn interceptor_intercept_takes_priority_over_static_mount() {
let dir = TempDir::new("intercept-priority");
dir.write("index.html", b"hi");
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new()
.interceptor(TrailingSlashRedirect)
.handler(NotCalledHandler)
.static_files(config);
let request = b"GET /static/ HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 301"), "response: {response}");
assert!(
response.contains("Location: /static\r\n"),
"response: {response}"
);
}
struct Custom404Page;
impl Interceptor for Custom404Page {
fn name(&self) -> &'static str {
"custom-404-page"
}
fn map_response(&self, _head: &RequestHead, response: Response) -> Response {
if response.status == 404 {
Response::new(404, b"<html>custom static 404</html>".to_vec())
} else {
response
}
}
}
#[tokio::test]
async fn interceptor_map_response_rewrites_static_404_body() {
let dir = TempDir::new("map-response-404");
dir.write("index.html", b"hi");
let config = StaticFilesConfig::builder("/static", dir.path())
.build()
.unwrap();
let server = Server::new()
.interceptor(Custom404Page)
.handler(NotCalledHandler)
.static_files(config);
let request = b"GET /static/missing.js HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 404"), "response: {response}");
assert!(
response.ends_with("<html>custom static 404</html>"),
"response: {response}"
);
}
#[tokio::test]
async fn root_mount_without_fallthrough_shadows_default_handler_with_404() {
let dir = TempDir::new("root-mount-no-fallthrough");
dir.write("index.html", b"<h1>root</h1>");
let config = StaticFilesConfig::builder("/", dir.path()).build().unwrap();
let server = Server::new().handler(FixedOkHandler).static_files(config);
let request = b"GET /healthz HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 404 Not Found\r\n"));
}
#[tokio::test]
async fn root_mount_with_fallthrough_serves_static_file() {
let dir = TempDir::new("root-mount-fallthrough-hit");
dir.write("index.html", b"<h1>root</h1>");
let config = StaticFilesConfig::builder("/", dir.path())
.fallthrough_on_miss(true)
.build()
.unwrap();
let server = Server::new().handler(FixedOkHandler).static_files(config);
let request = b"GET /index.html HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.ends_with("<h1>root</h1>"));
}
#[tokio::test]
async fn root_mount_with_fallthrough_reaches_default_handler_on_miss() {
let dir = TempDir::new("root-mount-fallthrough-miss");
dir.write("index.html", b"<h1>root</h1>");
let config = StaticFilesConfig::builder("/", dir.path())
.fallthrough_on_miss(true)
.build()
.unwrap();
let server = Server::new().handler(FixedOkHandler).static_files(config);
let request = b"GET /healthz HTTP/1.1\r\nConnection: close\r\n\r\n";
let response = roundtrip(&server, request).await;
let response = String::from_utf8_lossy(&response);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.ends_with("ok"));
}