//! HTTP behavior parity tests: verify asupersync h1 matches reqwest semantics //! that `lore` depends on. //! //! These tests confirm six critical behaviors: //! 1. Auto redirect: 301 -> follows Location header //! 2. Proxy: HTTP_PROXY not supported (documented) //! 3. Connection keep-alive: sequential requests reuse connections //! 4. System DNS: hostname resolution works //! 5. Content-Length on POST: header is auto-added //! 6. TLS cert validation: invalid certs are rejected use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use lore::http::Client; /// Run an async block on the asupersync runtime. fn run, T>(f: F) -> T { asupersync::runtime::RuntimeBuilder::new() .build() .unwrap() .block_on(f) } /// Read one HTTP request from a stream (drain until double-CRLF), returning /// the raw request bytes (headers only). fn drain_request(stream: &mut std::net::TcpStream) -> Vec { let mut buf = Vec::new(); let mut tmp = [0u8; 1]; loop { let n = stream.read(&mut tmp).unwrap(); if n == 0 { break; } buf.extend_from_slice(&tmp[..n]); if buf.len() >= 4 && buf[buf.len() - 4..] == *b"\r\n\r\n" { break; } } buf } // ========================================================================= // Test 1: Auto redirect — 301 is followed transparently // ========================================================================= #[test] fn redirect_301_is_followed() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { // First request: return 301 with Location pointing to /final let (mut stream, _) = listener.accept().unwrap(); drain_request(&mut stream); let redirect = format!( "HTTP/1.1 301 Moved Permanently\r\n\ Location: http://127.0.0.1:{port}/final\r\n\ Content-Length: 0\r\n\ \r\n" ); stream.write_all(redirect.as_bytes()).unwrap(); stream.flush().unwrap(); drop(stream); // Second request (after redirect): return 200 with body let (mut stream2, _) = listener.accept().unwrap(); drain_request(&mut stream2); let body = r#"{"redirected":true}"#; let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ \r\n\ {body}", body.len() ); stream2.write_all(response.as_bytes()).unwrap(); stream2.flush().unwrap(); }); let base = format!("http://127.0.0.1:{port}"); run(async { let client = Client::with_timeout(Duration::from_secs(5)); let resp = client.get(&format!("{base}/original"), &[]).await.unwrap(); assert!( resp.is_success(), "expected 200 after redirect, got {}", resp.status ); assert_eq!(resp.status, 200); let parsed: serde_json::Value = resp.json().unwrap(); assert_eq!(parsed["redirected"], true); }); } // ========================================================================= // Test 2: Proxy — HTTP_PROXY is NOT auto-detected (documented difference) // ========================================================================= #[test] fn proxy_env_not_auto_detected() { // Set HTTP_PROXY to a bogus address. If the client respected it, the // request would fail connecting to the proxy. Since asupersync ignores // proxy env vars, the request should go directly to the target. let body = r#"{"direct":true}"#; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); drain_request(&mut stream); let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ \r\n\ {body}", body.len() ); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); }); // Set a bogus proxy — the client should ignore it. // SAFETY: test-only; no other thread reads HTTP_PROXY concurrently. unsafe { std::env::set_var("HTTP_PROXY", "http://192.0.2.1:9999") }; let base = format!("http://127.0.0.1:{port}"); run(async { let client = Client::with_timeout(Duration::from_secs(5)); let resp = client.get(&format!("{base}/api/test"), &[]).await.unwrap(); assert!(resp.is_success()); let parsed: serde_json::Value = resp.json().unwrap(); assert_eq!(parsed["direct"], true); }); // Clean up env var. // SAFETY: test-only; no other thread reads HTTP_PROXY concurrently. unsafe { std::env::remove_var("HTTP_PROXY") }; } // ========================================================================= // Test 3: Connection keep-alive — sequential requests to same host // ========================================================================= #[test] fn sequential_requests_connect_separately() { // Track how many TCP connections are accepted. Each request should // establish its own connection (current behavior — pool not yet wired). let connection_count = Arc::new(AtomicUsize::new(0)); let count_clone = Arc::clone(&connection_count); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { for _ in 0..3 { let (mut stream, _) = listener.accept().unwrap(); count_clone.fetch_add(1, Ordering::SeqCst); drain_request(&mut stream); let body = r#"{"ok":true}"#; let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ Connection: keep-alive\r\n\ \r\n\ {body}", body.len() ); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); } }); let base = format!("http://127.0.0.1:{port}"); run(async { let client = Client::with_timeout(Duration::from_secs(5)); for _ in 0..3 { let resp = client.get(&format!("{base}/api/data"), &[]).await.unwrap(); assert!(resp.is_success()); } }); let total = connection_count.load(Ordering::SeqCst); // Document current behavior: each request opens a new connection. // If/when connection pooling is wired, this assertion should change // to assert!(total <= 2) to verify keep-alive. assert!( (1..=3).contains(&total), "expected 1-3 connections (got {total}); \ 3 = no pooling, 1 = full keep-alive" ); } // ========================================================================= // Test 4: System DNS — localhost resolves and connects // ========================================================================= #[test] fn system_dns_resolves_localhost() { let body = r#"{"dns":"ok"}"#; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); drain_request(&mut stream); let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ \r\n\ {body}", body.len() ); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); }); run(async { let client = Client::with_timeout(Duration::from_secs(5)); // Use "localhost" instead of "127.0.0.1" to exercise DNS resolution. let resp = client .get( &format!("http://localhost:{port}/api/dns-test"), &[("Accept", "application/json")], ) .await .unwrap(); assert!(resp.is_success()); let parsed: serde_json::Value = resp.json().unwrap(); assert_eq!(parsed["dns"], "ok"); }); } // ========================================================================= // Test 5: Content-Length on POST — header is auto-added by codec // ========================================================================= #[test] fn post_includes_content_length_header() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let (tx, rx) = std::sync::mpsc::channel::(); std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let request_bytes = drain_request(&mut stream); let request_text = String::from_utf8_lossy(&request_bytes).to_string(); tx.send(request_text).unwrap(); let body = r#"{"received":true}"#; let response = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ \r\n\ {body}", body.len() ); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); }); let base = format!("http://127.0.0.1:{port}"); run(async { let client = Client::with_timeout(Duration::from_secs(5)); #[derive(serde::Serialize)] struct Payload { model: String, input: Vec, } let payload = Payload { model: "test-model".into(), input: vec!["hello".into()], }; let resp = client .post_json(&format!("{base}/api/embed"), &[], &payload) .await .unwrap(); assert!(resp.is_success()); }); let captured = rx.recv_timeout(Duration::from_secs(5)).unwrap(); // Verify Content-Length header was present in the request. let has_content_length = captured .lines() .any(|line| line.to_lowercase().starts_with("content-length:")); assert!( has_content_length, "POST request should include Content-Length header.\n\ Captured request:\n{captured}" ); // Verify Content-Length value matches actual body length. let cl_value: usize = captured .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("Content-Length should be a valid number"); assert!( cl_value > 0, "Content-Length should be > 0 for non-empty POST body" ); } // ========================================================================= // Test 6: TLS cert validation — self-signed/invalid cert is rejected // ========================================================================= #[test] fn tls_rejects_plain_tcp_as_https() { // Start a plain TCP server and try to connect via https://. // The TLS handshake should fail because the server doesn't speak TLS. let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { // Accept and hold the connection so the client can attempt TLS. if let Ok((mut stream, _)) = listener.accept() { // Send garbage — the TLS handshake will fail on the client side. let _ = stream.write_all(b"NOT TLS\r\n"); std::thread::sleep(Duration::from_secs(2)); } }); run(async { let client = Client::with_timeout(Duration::from_secs(5)); let result = client .get(&format!("https://127.0.0.1:{port}/api/test"), &[]) .await; assert!( result.is_err(), "expected TLS error when connecting to plain TCP" ); let err_str = format!("{:?}", result.unwrap_err()); // The error should be TLS-related (not a generic connection error). assert!( err_str.contains("Tls") || err_str.to_lowercase().contains("tls") || err_str.to_lowercase().contains("ssl") || err_str.to_lowercase().contains("handshake") || err_str.to_lowercase().contains("certificate"), "error should mention TLS/SSL, got: {err_str}" ); }); } // ========================================================================= // Test 6b: TLS cert validation — connection to unreachable host fails // ========================================================================= #[test] fn tls_connection_to_nonexistent_host_fails() { run(async { let client = Client::with_timeout(Duration::from_secs(3)); // 192.0.2.1 is TEST-NET-1 (RFC 5737) — guaranteed unroutable. let result = client.get("https://192.0.2.1:443/api/test", &[]).await; assert!( result.is_err(), "expected error connecting to unroutable host" ); }); }