- Add 7 cancellation integration tests (ShutdownSignal, transaction rollback) - Add 7 HTTP behavior parity tests (redirect, proxy, keep-alive, DNS, TLS) - Add 9 E2E runtime acceptance tests (lifecycle, cancel+resume, tracing, HTTP pipeline) - Total: 1190 tests, all passing Phases 4-5 of asupersync migration.
393 lines
13 KiB
Rust
393 lines
13 KiB
Rust
//! 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<F: std::future::Future<Output = T>, 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<u8> {
|
|
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::<String>();
|
|
|
|
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<String>,
|
|
}
|
|
|
|
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"
|
|
);
|
|
});
|
|
}
|