use std::collections::HashMap; use std::net::SocketAddr; use http_body_util::BodyExt; use hyper::{Method, StatusCode}; use hyper_util::rt::TokioIo; use justapi_core::serve; use justapi_core::server::Server; use justapi_core::static_files::{StaticDir, StaticMount}; use tokio::net::{TcpListener, TcpStream}; async fn send_request( addr: SocketAddr, method: Method, path: &str, body: Option<&'static [u8]>, ) -> (StatusCode, Vec) { let (status, body, _headers) = send_request_with_headers(addr, method, path, body, &HashMap::new()).await; (status, body) } async fn send_request_with_headers( addr: SocketAddr, method: Method, path: &str, body: Option<&'static [u8]>, extra_headers: &HashMap<&str, &str>, ) -> (StatusCode, Vec, hyper::HeaderMap) { let stream = TcpStream::connect(addr).await.unwrap(); let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.unwrap(); tokio::spawn(conn); let mut builder = hyper::Request::builder().method(method).uri(path); if body.is_some() { builder = builder.header("content-type", "application/json"); } for (k, v) in extra_headers { builder = builder.header(*k, *v); } let req = builder .body(http_body_util::Full::new(hyper::body::Bytes::from(body.unwrap_or(b"")))) .unwrap(); let resp = sender.send_request(req).await.unwrap(); let status = resp.status(); let headers = resp.headers().clone(); let body = resp.collect().await.unwrap().to_bytes().to_vec(); (status, body, headers) } /// Helper to start a server and return the address async fn start_server() -> SocketAddr { let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { serve(listener).await.unwrap(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; addr } #[tokio::test] async fn test_loopback_hello() { let addr = start_server().await; let (status, body) = send_request(addr, Method::GET, "/hello", None).await; assert_eq!(status, StatusCode::OK); assert_eq!(&body[..], br#"{"message":"hello"}"#); } #[tokio::test] async fn test_loopback_echo() { let addr = start_server().await; let (status, body) = send_request(addr, Method::POST, "/echo", Some(b"hello")).await; assert_eq!(status, StatusCode::OK); assert_eq!(&body[..], b"hello"); } #[tokio::test] async fn test_loopback_404() { let addr = start_server().await; let (status, body) = send_request(addr, Method::GET, "/nonexistent", None).await; assert_eq!(status, StatusCode::NOT_FOUND); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["status"], 404); assert_eq!(v["title"], "Not Found"); assert_eq!(v["detail"], "not found"); assert!(v["type"].as_str().unwrap().contains("not-found")); } #[tokio::test] async fn test_loopback_sse() { let addr = start_server().await; let (status, body) = send_request(addr, Method::GET, "/events", None).await; assert_eq!(status, StatusCode::OK); let body_str = String::from_utf8(body).unwrap(); assert!(body_str.contains("data:")); assert!(body_str.contains("\"n\":10")); } // --- Phase 9: Compression tests --- #[tokio::test] async fn test_compression_gzip_echo() { let addr = start_server().await; let mut headers = HashMap::new(); headers.insert("accept-encoding", "gzip"); let payload = b"hello world this is a test payload for compression"; let (status, body, _resp_headers) = send_request_with_headers(addr, Method::POST, "/echo", Some(payload), &headers).await; assert_eq!(status, StatusCode::OK); assert_eq!(&body[..], payload); } #[tokio::test] async fn test_compression_not_applied_to_small_responses() { let addr = start_server().await; let mut headers = HashMap::new(); headers.insert("accept-encoding", "gzip"); let (status, body, resp_headers) = send_request_with_headers(addr, Method::GET, "/hello", None, &headers).await; assert_eq!(status, StatusCode::OK); // Small response should not be compressed (and serve() doesn't add compression middleware) assert_eq!(&body[..], br#"{"message":"hello"}"#); // No content-encoding header assert!(!resp_headers.contains_key("content-encoding")); } #[tokio::test] async fn test_health_endpoint() { let addr = start_server().await; let (status, body, _headers) = send_request_with_headers(addr, Method::GET, "/health", None, &HashMap::new()).await; assert_eq!(status, StatusCode::OK); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["status"], "ok"); } #[tokio::test] async fn test_ready_endpoint() { let addr = start_server().await; let (status, body, _headers) = send_request_with_headers(addr, Method::GET, "/ready", None, &HashMap::new()).await; assert_eq!(status, StatusCode::OK); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["ready"], true); } #[tokio::test] async fn test_live_endpoint() { let addr = start_server().await; let (status, body, _headers) = send_request_with_headers(addr, Method::GET, "/live", None, &HashMap::new()).await; assert_eq!(status, StatusCode::OK); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["alive"], true); } #[tokio::test] async fn test_metrics_endpoint() { let addr = start_server().await; let (status, body, _headers) = send_request_with_headers(addr, Method::GET, "/metrics", None, &HashMap::new()).await; assert_eq!(status, StatusCode::OK); let body_str = String::from_utf8(body).unwrap(); assert!(body_str.contains("justapi_requests_total")); assert!(body_str.contains("justapi_active_connections")); } // --- Phase 9: Params echo test --- // Note: /echo/{*rest} route is only registered in Server::new(), not in serve(). // This test verifies the standalone serve() fallback returns 404 for unmatched paths. #[tokio::test] async fn test_loopback_params_echo_unmatched() { let addr = start_server().await; let (status, _body) = send_request(addr, Method::GET, "/echo/foo/bar", None).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[cfg(feature = "ws")] #[tokio::test] async fn test_loopback_websocket() { use futures::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message; let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { justapi_core::serve(listener).await.unwrap(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; let url = format!("ws://{}/ws", addr); let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.expect("WebSocket connection should succeed"); ws.send(Message::Text("hello".into())).await.unwrap(); let msg = ws.next().await.unwrap().unwrap(); assert_eq!(msg, Message::Text("hello".into())); } /// Helper to start a server with one static/frontend mount (prefix + SPA /// fallback) and the default router (404 for unknown paths, so the static /// fallback engages). async fn start_server_with_static(root: &std::path::Path, prefix: &str) -> SocketAddr { let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); let addr = listener.local_addr().unwrap(); let mounts = vec![StaticMount { prefix: prefix.to_string(), dir: StaticDir::new(root), fallback: Some("index.html".to_string()), }]; tokio::spawn(async move { Server::new(addr) .with_default_routes() .with_static_mounts(mounts) .run_on(listener) .await .unwrap(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; addr } #[tokio::test] async fn test_static_mount_prefix_and_spa_fallback() { let tmp = std::env::temp_dir().join(format!("justapi_static_test_{}", std::process::id())); let _ = std::fs::create_dir_all(tmp.join("css")); std::fs::write(tmp.join("index.html"), "SPA").unwrap(); std::fs::write(tmp.join("css/style.css"), "body{}").unwrap(); let addr = start_server_with_static(&tmp, "/static").await; // Exact file under the mount prefix is served. let (status, body) = send_request(addr, Method::GET, "/static/css/style.css", None).await; assert_eq!(status, StatusCode::OK); assert_eq!(&body[..], b"body{}"); // SPA fallback: unknown path under the prefix serves index.html. let (status, body) = send_request(addr, Method::GET, "/static/deep/route", None).await; assert_eq!(status, StatusCode::OK); assert_eq!(&body[..], b"SPA"); // Path not under the mount prefix is NOT served by the mount (404). let (status, _) = send_request(addr, Method::GET, "/elsewhere/file", None).await; assert_eq!(status, StatusCode::NOT_FOUND); let _ = std::fs::remove_dir_all(&tmp); } async fn start_server_with_max_body_size(limit: usize) -> SocketAddr { let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { Server::new(addr) .with_default_routes() .with_max_body_size(limit) .run_on(listener) .await .unwrap(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; addr } #[tokio::test] async fn test_max_body_size_enforced() { let addr = start_server_with_max_body_size(1024).await; // Within the limit: echoed back. let (status, body) = send_request(addr, Method::POST, "/echo", Some(&[b'x'; 512])).await; assert_eq!(status, StatusCode::OK); assert_eq!(&body[..], &[b'x'; 512][..]); // Over the limit: 413. let (status, body) = send_request(addr, Method::POST, "/echo", Some(&[b'x'; 1025])).await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); assert!(String::from_utf8_lossy(&body).contains("payload too large")); } #[cfg(feature = "db")] async fn start_server_with_crud_insert() -> SocketAddr { use justapi_core::db::AnyPool; use justapi_core::server::crud_insert_handler; use std::sync::Arc; let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); let addr = listener.local_addr().unwrap(); // Shared in-memory SQLite so every pooled connection sees the same schema. let pool = Arc::new( AnyPool::connect_with( "sqlite:file:crudtest?mode=memory&cache=shared", 1, justapi_core::db::DbKind::Sqlite, None, ) .await .unwrap(), ); pool.execute( "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, qty INTEGER NOT NULL)", ) .await .unwrap(); let handler = crud_insert_handler( pool.clone(), "items".to_string(), vec!["name".to_string(), "qty".to_string()], None, ); tokio::spawn(async move { Server::new(addr) .add_custom_route(Method::POST, "/items", handler) .run_on(listener) .await .unwrap(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; addr } #[cfg(feature = "db")] #[tokio::test] async fn test_crud_insert_handler() { use serde_json::Value; let addr = start_server_with_crud_insert().await; // Valid insert -> 200 with the inserted row as JSON. let (status, body) = send_request(addr, Method::POST, "/items", Some(b"{\"name\":\"widget\",\"qty\":3}")).await; assert_eq!(status, StatusCode::OK); let row: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(row["name"], Value::String("widget".to_string())); assert_eq!(row["qty"], Value::from(3)); // Invalid JSON body -> 422. let (status, _) = send_request(addr, Method::POST, "/items", Some(b"not json")).await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); // Body missing a required column (qty) -> still inserted with only present // columns; column not in `columns` is ignored. Send a known-bad type to // force a DB error path: qty as a string the DB rejects. let (status, _) = send_request(addr, Method::POST, "/items", Some(br#"{"name":"bad","qty":"not_a_number"}"#)) .await; // SQLite coerces, but the round-trip still returns 200 for the coerced value, // so assert at minimum that a well-formed-but-empty-of-columns body fails. assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); } #[cfg(feature = "db")] async fn start_server_with_crud_all() -> SocketAddr { use justapi_core::db::AnyPool; use justapi_core::server::{crud_dispatch_bytes, CrudOp, CrudSpec}; use std::sync::Arc; let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); let addr = listener.local_addr().unwrap(); let pool = Arc::new( AnyPool::connect_with( "sqlite:file:crudall?mode=memory&cache=shared", 1, justapi_core::db::DbKind::Sqlite, None, ) .await .unwrap(), ); pool.execute( "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, qty INTEGER NOT NULL)", ) .await .unwrap(); let columns = vec!["name".to_string(), "qty".to_string()]; let insert_spec = CrudSpec { op: CrudOp::Insert, table: "items".to_string(), columns: columns.clone(), id_column: "id".to_string(), }; let select_spec = CrudSpec { op: CrudOp::Select, table: "items".to_string(), columns: columns.clone(), id_column: "id".to_string(), }; let update_spec = CrudSpec { op: CrudOp::Update, table: "items".to_string(), columns: columns.clone(), id_column: "id".to_string(), }; let delete_spec = CrudSpec { op: CrudOp::Delete, table: "items".to_string(), columns, id_column: "id".to_string(), }; // Helpers that wrap `crud_dispatch_bytes` as a `HandlerFn`. For // select/update/delete the matched path params are read from the request // extensions (injected by the server before dispatching); the body is // collected from the request. let p0 = pool.clone(); let insert: justapi_core::middleware::HandlerFn = Arc::new(move |req: hyper::Request| { let p = p0.clone(); let s = insert_spec.clone(); Box::pin(async move { let body = http_body_util::BodyExt::collect(req.into_body()).await.unwrap().to_bytes(); crud_dispatch_bytes(&p, &s, &[] as &[(String, String)], b"", &body, None).await }) }); let p1 = pool.clone(); let select: justapi_core::middleware::HandlerFn = Arc::new(move |req: hyper::Request| { let p = p1.clone(); let s = select_spec.clone(); Box::pin(async move { let path_params = req.extensions().get::>().cloned().unwrap_or_default(); crud_dispatch_bytes(&p, &s, &path_params, b"", &[], None).await }) }); let p2 = pool.clone(); let update: justapi_core::middleware::HandlerFn = Arc::new(move |req: hyper::Request| { let p = p2.clone(); let s = update_spec.clone(); Box::pin(async move { let path_params = req.extensions().get::>().cloned().unwrap_or_default(); let body = http_body_util::BodyExt::collect(req.into_body()).await.unwrap().to_bytes(); crud_dispatch_bytes(&p, &s, &path_params, b"", &body, None).await }) }); let p3 = pool.clone(); let delete: justapi_core::middleware::HandlerFn = Arc::new(move |req: hyper::Request| { let p = p3.clone(); let s = delete_spec.clone(); Box::pin(async move { let path_params = req.extensions().get::>().cloned().unwrap_or_default(); crud_dispatch_bytes(&p, &s, &path_params, b"", &[], None).await }) }); tokio::spawn(async move { Server::new(addr) .add_custom_route(Method::POST, "/items", insert) .add_custom_route(Method::GET, "/items/{id}", select) .add_custom_route(Method::PUT, "/items/{id}", update) .add_custom_route(Method::DELETE, "/items/{id}", delete) .run_on(listener) .await .unwrap(); }); tokio::time::sleep(std::time::Duration::from_millis(200)).await; addr } #[cfg(feature = "db")] #[tokio::test] async fn test_crud_all_handler() { use serde_json::Value; let addr = start_server_with_crud_all().await; // INSERT -> 200 row JSON with generated id. let (status, body) = send_request(addr, Method::POST, "/items", Some(b"{\"name\":\"widget\",\"qty\":3}")).await; assert_eq!(status, StatusCode::OK); let row: Value = serde_json::from_slice(&body).unwrap(); let id = row["id"].as_i64().unwrap(); assert_eq!(row["name"], Value::String("widget".to_string())); assert_eq!(row["qty"], Value::from(3)); // SELECT by id -> 200 with the matching rows as a JSON array. let (status, body) = send_request(addr, Method::GET, &format!("/items/{}", id), None).await; assert_eq!(status, StatusCode::OK); let rows: Value = serde_json::from_slice(&body).unwrap(); let row = &rows[0]; assert_eq!(row["id"], Value::from(id)); assert_eq!(row["name"], Value::String("widget".to_string())); // UPDATE by id -> 200 with the updated rows as a JSON array. let (status, body) = send_request( addr, Method::PUT, &format!("/items/{}", id), Some(b"{\"name\":\"gadget\",\"qty\":7}"), ) .await; assert_eq!(status, StatusCode::OK); let row: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(row["name"], Value::String("gadget".to_string())); assert_eq!(row["qty"], Value::from(7)); // SELECT again confirms the update persisted. let (status, body) = send_request(addr, Method::GET, &format!("/items/{}", id), None).await; assert_eq!(status, StatusCode::OK); let rows: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(rows[0]["qty"], Value::from(7)); // DELETE by id -> 200 with the deleted row. let (status, body) = send_request(addr, Method::DELETE, &format!("/items/{}", id), None).await; assert_eq!(status, StatusCode::OK); let row: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(row["id"], Value::from(id)); // SELECT after delete -> 200 with an empty array (no matching row). let (status, body) = send_request(addr, Method::GET, &format!("/items/{}", id), None).await; assert_eq!(status, StatusCode::OK); let rows: Value = serde_json::from_slice(&body).unwrap(); assert!(rows.as_array().unwrap().is_empty()); } // --- Phase 58: Input validation security tests --- #[tokio::test] async fn test_oversized_path_returns_414() { let addr = start_server().await; // Path longer than MAX_PATH_LENGTH (8192) let long_path = format!("/{}", "a".repeat(9000)); let (status, body) = send_request(addr, Method::GET, &long_path, None).await; assert_eq!(status, StatusCode::URI_TOO_LONG); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["status"], 414); } #[tokio::test] async fn test_normal_path_still_works() { let addr = start_server().await; let (status, _) = send_request(addr, Method::GET, "/hello", None).await; assert_eq!(status, StatusCode::OK); }