//! End-to-end integration tests. //! //! Each test spins up the server in a background OS thread on an //! ephemeral port, opens a regular `std::net::TcpStream` against it, and //! exercises the wire. Single thread for the smarm runtime per test so //! teardown happens cleanly when the test thread is dropped (the process //! exits at the end of the test binary). use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; use urus::{serve_with, Conn, Config, Next, Pipeline, Router}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Reserve a free localhost port by binding briefly and reading back the /// assigned port number. The port is released the instant we drop the /// listener; there's a tiny race window before the urus server claims it /// but it's well below the threshold for flakiness in practice. fn free_port() -> u16 { let l = TcpListener::bind("127.0.0.1:0").unwrap(); l.local_addr().unwrap().port() } fn spawn_server(pipeline: Pipeline) -> u16 { let port = free_port(); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); std::thread::spawn(move || { let cfg = Config { listener_pool: 2, scheduler_threads: Some(2), ..Config::new(addr) }; serve_with(cfg, pipeline).unwrap(); }); // Wait for the server to actually be listening. for _ in 0..50 { if TcpStream::connect(addr).is_ok() { return port; } std::thread::sleep(Duration::from_millis(50)); } panic!("server didn't come up on {addr}"); } fn send_request(port: u16, req: &[u8]) -> Vec { let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); s.write_all(req).unwrap(); s.shutdown(std::net::Shutdown::Write).ok(); let mut buf = Vec::new(); s.read_to_end(&mut buf).unwrap(); buf } fn http_status(resp: &[u8]) -> u16 { let s = std::str::from_utf8(&resp[..resp.len().min(64)]).unwrap(); let parts: Vec<&str> = s.splitn(3, ' ').collect(); parts[1].parse().unwrap() } fn http_body(resp: &[u8]) -> &[u8] { // Split on the first \r\n\r\n. for i in 0..resp.len().saturating_sub(3) { if &resp[i..i + 4] == b"\r\n\r\n" { return &resp[i + 4..]; } } &[] } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] fn hello_world() { let pipe = Pipeline::new().plug( Router::new().get("/", |c: Conn, _n: Next| { c.put_status(200).put_body("hello urus") }) ); let port = spawn_server(pipe); let resp = send_request(port, b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 200); assert_eq!(http_body(&resp), b"hello urus"); } #[test] fn echo_body() { let pipe = Pipeline::new().plug( Router::new().post("/echo", |c: Conn, _n: Next| { let body = c.body.as_bytes().to_vec(); c.put_status(200).put_body(body) }) ); let port = spawn_server(pipe); let resp = send_request( port, b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", ); assert_eq!(http_status(&resp), 200); assert_eq!(http_body(&resp), b"hello"); } #[test] fn path_params() { let pipe = Pipeline::new().plug( Router::new().get("/users/:id", |c: Conn, _n: Next| { let id = c.params.get("id").unwrap_or("?").to_string(); c.put_status(200).put_body(format!("user={id}")) }) ); let port = spawn_server(pipe); let resp = send_request(port, b"GET /users/42 HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 200); assert_eq!(http_body(&resp), b"user=42"); } #[test] fn method_not_allowed() { let pipe = Pipeline::new().plug( Router::new().get("/only-get", |c: Conn, _n: Next| c.put_status(200)) ); let port = spawn_server(pipe); let resp = send_request(port, b"POST /only-get HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 405); } #[test] fn unknown_route_falls_to_404() { // Router falls through; connection actor's default emits 404. let pipe = Pipeline::new().plug( Router::new().get("/known", |c: Conn, _n: Next| c.put_status(200)) ); let port = spawn_server(pipe); let resp = send_request(port, b"GET /missing HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 404); } #[test] fn keep_alive_two_requests_on_one_connection() { let pipe = Pipeline::new().plug( Router::new().get("/", |c: Conn, _n: Next| c.put_status(200).put_body("ok")) ); let port = spawn_server(pipe); let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); // First request, no Connection header (HTTP/1.1 default = keep-alive). s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); // Read the first response (Content-Length: 2 -> body "ok"). let mut buf = vec![0u8; 1024]; let mut total = 0; let mut first_end = None; while first_end.is_none() { let n = s.read(&mut buf[total..]).unwrap(); assert!(n > 0, "server hung up early"); total += n; // First response ends at header-terminator + 2 bytes of body. for i in 0..total.saturating_sub(3) { if &buf[i..i + 4] == b"\r\n\r\n" { let body_start = i + 4; if total >= body_start + 2 { first_end = Some(body_start + 2); } break; } } } let first = &buf[..first_end.unwrap()]; assert_eq!(http_status(first), 200); assert_eq!(http_body(first), b"ok"); // Second request on the same socket — proves keep-alive works. s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n").unwrap(); let mut rest = Vec::new(); s.read_to_end(&mut rest).unwrap(); assert_eq!(http_status(&rest), 200); assert_eq!(http_body(&rest), b"ok"); } #[test] fn panicking_handler_yields_500() { let pipe = Pipeline::new().plug( Router::new().get("/boom", |_c: Conn, _n: Next| -> Conn { panic!("handler explosion") }) ); let port = spawn_server(pipe); let resp = send_request(port, b"GET /boom HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 500); } #[test] fn middleware_can_short_circuit() { // Middleware that requires `X-Auth: secret`; otherwise returns 401 // without calling `next.run(conn)`. let auth = |c: Conn, n: Next| { if c.headers.get("x-auth").map(|s| s == "secret").unwrap_or(false) { n.run(c) } else { c.put_status(401).halt() } }; let pipe = Pipeline::new() .plug(auth) .plug(Router::new().get("/secret", |c: Conn, _n: Next| c.put_status(200).put_body("ok"))); let port = spawn_server(pipe); // Without auth → 401. let resp = send_request(port, b"GET /secret HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 401); // With auth → 200 + body. let resp = send_request( port, b"GET /secret HTTP/1.1\r\nHost: x\r\nX-Auth: secret\r\nConnection: close\r\n\r\n", ); assert_eq!(http_status(&resp), 200); assert_eq!(http_body(&resp), b"ok"); } // --------------------------------------------------------------------------- // Supervision (v0.2 chunk 1) // --------------------------------------------------------------------------- /// A panicking listener must be restarted by the pool supervisor, and the /// connection that was pending when it died must still be served. /// /// Pool size is 1 on purpose: with a sibling listener the test would pass /// even if restarts were broken (the sibling would pick up the accept). /// With one listener, the request after the injected panic can only /// succeed if a fresh listener came up on the same fd. #[test] fn panicking_listener_restarts() { let pipe = Pipeline::new().plug( Router::new().get("/ping", |c: Conn, _n: Next| c.put_status(200).put_body("pong")) ); let port = free_port(); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); std::thread::spawn(move || { let cfg = Config { listener_pool: 1, scheduler_threads: Some(2), ..Config::new(addr) }; serve_with(cfg, pipe).unwrap(); }); for _ in 0..50 { if TcpStream::connect(addr).is_ok() { break; } std::thread::sleep(Duration::from_millis(50)); } // Sanity: server answers before the fault. let resp = send_request(port, b"GET /ping HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 200); // Arm the fault: the listener's next accept-loop iteration panics // *before* accepting, so our connection waits in the kernel backlog // until the restarted listener picks it up. urus::serve::INJECT_LISTENER_PANICS.store(1, std::sync::atomic::Ordering::Relaxed); let resp = send_request(port, b"GET /ping HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 200, "request pending across the panic was not served"); assert_eq!(http_body(&resp), b"pong"); assert_eq!( urus::serve::INJECT_LISTENER_PANICS.load(std::sync::atomic::Ordering::Relaxed), 0, "fault was never consumed — listener didn't wake for the connection" ); // The restarted listener keeps serving. for _ in 0..5 { let resp = send_request(port, b"GET /ping HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); assert_eq!(http_status(&resp), 200); } }