diff --git a/Cargo.lock b/Cargo.lock index 6a2acd0..563502f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1217,6 +1217,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2231,6 +2237,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -2496,6 +2508,9 @@ dependencies = [ "axum-server", "data-encoding", "sha2 0.10.9", + "tempfile", + "tokio", + "tower", "tower-http 0.7.0", "tower-sessions", "tracing", @@ -3095,7 +3110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "syn 2.0.118", @@ -3646,6 +3661,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.35" @@ -4324,6 +4352,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 04a70a1..fe70104 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -15,4 +15,9 @@ tracing.workspace = true anyhow.workspace = true axum.workspace = true sha2.workspace = true -url.workspace = true \ No newline at end of file +url.workspace = true + +[dev-dependencies] +tokio.workspace = true +tower = { version = "0.5", features = ["util"] } +tempfile = "3" \ No newline at end of file diff --git a/crates/server/src/ui.rs b/crates/server/src/ui.rs index 24ba31b..126a81e 100644 --- a/crates/server/src/ui.rs +++ b/crates/server/src/ui.rs @@ -9,7 +9,6 @@ use sha2::Digest as _; use tower_http::{ services::{ServeDir, ServeFile}, set_header::SetResponseHeaderLayer, - set_status::SetStatus, }; use tracing::warn; @@ -23,7 +22,7 @@ use tracing::warn; pub fn serve_static_ui( ui_path: &str, force_no_cache: bool, -) -> ServeDir> { +) -> ServeDir { let directory = PathBuf::from(ui_path); let index = directory.join("index.html"); @@ -32,7 +31,7 @@ pub fn serve_static_ui( if force_no_cache { return ServeDir::new(directory) - .not_found_service(add_no_cache_layer(index_router)); + .fallback(add_no_cache_layer(index_router)); } let index = match hash_encode_contents(&index) { @@ -53,7 +52,7 @@ pub fn serve_static_ui( } }; - ServeDir::new(directory).not_found_service(index) + ServeDir::new(directory).fallback(index) } fn hash_encode_contents(path: &Path) -> anyhow::Result { @@ -74,3 +73,111 @@ fn add_no_cache_layer(router: Router) -> Router { HeaderValue::from_static("no-cache"), )) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt as _; + + fn make_ui_dir( + index: &'static str, + ) -> (tempfile::TempDir, &'static str) { + let dir = tempfile::tempdir().expect("Failed to create temp dir"); + std::fs::write(dir.path().join("index.html"), index) + .expect("Failed to write index.html"); + std::fs::write(dir.path().join("asset.txt"), "asset-body") + .expect("Failed to write asset.txt"); + (dir, index) + } + + /// Wraps `serve_static_ui` in an axum `Router` fallback, exactly the way + /// downstream consumers (e.g. komodo) attach it. + fn make_app(dir: &std::path::Path, force_no_cache: bool) -> Router { + Router::new().fallback_service(serve_static_ui( + dir.to_str().expect("Valid path"), + force_no_cache, + )) + } + + async fn get(app: &Router, path: &str) -> axum::response::Response { + app + .clone() + .oneshot( + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("Valid request"), + ) + .await + .expect("Service should not fail") + } + + async fn body_bytes(res: axum::response::Response) -> Vec { + axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .expect("Failed to read response body") + .to_vec() + } + + #[tokio::test] + async fn root_returns_index_html_with_etag() { + let (dir, index) = make_ui_dir("index"); + let service = make_app(dir.path(), false); + + let res = get(&service, "/").await; + assert_eq!(res.status(), StatusCode::OK); + assert!( + res.headers().contains_key(header::ETAG), + "Expected ETag header on index.html" + ); + assert_eq!(body_bytes(res).await, index.as_bytes()); + } + + #[tokio::test] + async fn spa_route_returns_index_html_with_200() { + let (dir, index) = make_ui_dir("index"); + let service = make_app(dir.path(), false); + + let res = get(&service, "/login").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(body_bytes(res).await, index.as_bytes()); + } + + #[tokio::test] + async fn deep_spa_route_returns_index_html_with_200() { + let (dir, index) = make_ui_dir("index"); + let service = make_app(dir.path(), false); + + let res = get(&service, "/stacks/abc").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(body_bytes(res).await, index.as_bytes()); + } + + #[tokio::test] + async fn existing_static_file_is_served() { + let (dir, _index) = + make_ui_dir("index"); + let service = make_app(dir.path(), false); + + let res = get(&service, "/asset.txt").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(body_bytes(res).await, b"asset-body"); + } + + #[tokio::test] + async fn force_no_cache_spa_route_returns_200_with_no_cache() { + let (dir, index) = make_ui_dir("index"); + let service = make_app(dir.path(), true); + + let res = get(&service, "/login").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CACHE_CONTROL), + Some(&HeaderValue::from_static("no-cache")), + "Expected Cache-Control: no-cache header" + ); + assert_eq!(body_bytes(res).await, index.as_bytes()); + } +}