use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
struct Router {
child: Child,
port: u16,
data_dir: tempfile::TempDir,
}
impl Drop for Router {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Router {
fn start() -> Self {
let port = TcpListener::bind("127.0.0.1:0")
.expect("bind an ephemeral port")
.local_addr()
.expect("ephemeral address")
.port();
let data_dir = tempfile::tempdir().expect("temporary data directory");
let child = Command::new(env!("CARGO_BIN_EXE_link-assistant-router"))
.arg("serve")
.env("TOKEN_SECRET", "request-logging-test-secret")
.env("ROUTER_HOST", "127.0.0.1")
.env("ROUTER_PORT", port.to_string())
.env("DATA_DIR", data_dir.path())
.env("STORAGE_POLICY", "text")
.env("CLAUDE_CODE_HOME", data_dir.path().join("claude"))
.env("DISABLE_LOGIN_API", "true")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start router");
let router = Self {
child,
port,
data_dir,
};
router.wait_until_ready();
router
}
fn request(&self, extra_headers: &str) -> Option<String> {
let mut stream = TcpStream::connect(("127.0.0.1", self.port)).ok()?;
write!(
stream,
"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n{extra_headers}\r\n"
)
.ok()?;
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
Some(response)
}
fn post(&self, path: &str, headers: &str, body: &str) -> Option<String> {
let mut stream = TcpStream::connect(("127.0.0.1", self.port)).ok()?;
write!(
stream,
"POST {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\
Content-Type: application/json\r\nContent-Length: {}\r\n{headers}\r\n{body}",
body.len()
)
.ok()?;
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
Some(response)
}
fn await_client_request(&self, needle: &str) -> String {
let log_path = self
.data_dir
.path()
.join("requests/unauthenticated/requests.jsonl");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Ok(log) = std::fs::read_to_string(&log_path) {
for line in log.lines() {
if line.contains("\"client_request\"") && line.contains(needle) {
return line.to_string();
}
}
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("no client_request record containing {needle}");
}
fn wait_until_ready(&self) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if self
.request("")
.is_some_and(|r| r.starts_with("HTTP/1.1 200"))
{
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("router did not become ready");
}
}
#[test]
fn successful_request_is_logged_by_default() {
let router = Router::start();
let response = router
.request("x-test-marker: issue-100-request\r\n")
.expect("successful request");
assert!(response.starts_with("HTTP/1.1 200"));
let log_path = router
.data_dir
.path()
.join("requests/unauthenticated/requests.jsonl");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Ok(log) = std::fs::read_to_string(&log_path)
&& log.contains("issue-100-request")
{
assert!(log.contains("correlation_id"));
assert!(log.contains("client_request"));
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!(
"successful request was not written to {}",
log_path.display()
);
}
#[test]
fn a_rejected_request_does_not_claim_an_empty_body() {
let router = Router::start();
let response = router
.post(
"/v1/chat/completions",
"authorization: Bearer la_sk_invalid\r\nx-test-marker: issue-210-rejected\r\n",
r#"{"model":"m","messages":[{"role":"user","content":"MARKER-210"}]}"#,
)
.expect("rejected request");
assert!(
response.starts_with("HTTP/1.1 401"),
"expected a 401, got: {}",
response.lines().next().unwrap_or_default()
);
let record = router.await_client_request("issue-210-rejected");
let record: serde_json::Value = serde_json::from_str(&record).expect("record is JSON");
let body = record["body"].as_str().expect("body field is a string");
assert_ne!(
body, "",
"a request that declared a body must not be logged as empty: {record}"
);
assert!(
body.contains("MARKER-210") || body.contains("NOT READ"),
"body must be the content or an explicit marker, got {body:?}"
);
}
#[test]
fn a_bodiless_request_is_still_logged_as_empty() {
let router = Router::start();
router
.request("x-test-marker: issue-210-bodiless\r\n")
.expect("bodiless request");
let record = router.await_client_request("issue-210-bodiless");
let record: serde_json::Value = serde_json::from_str(&record).expect("record is JSON");
assert_eq!(
record["body"], "",
"a request that declared no body is genuinely empty: {record}"
);
}
#[test]
fn a_declared_content_length_implies_a_non_empty_logged_body() {
let router = Router::start();
router
.post(
"/v1/chat/completions",
"authorization: Bearer la_sk_invalid\r\nx-test-marker: issue-210-consistent\r\n",
r#"{"model":"m","messages":[{"role":"user","content":"CONSISTENCY"}]}"#,
)
.expect("rejected request");
let record = router.await_client_request("issue-210-consistent");
let record: serde_json::Value = serde_json::from_str(&record).expect("record is JSON");
let declared: u64 = record["headers"]["content-length"]
.as_str()
.expect("content-length is logged")
.parse()
.expect("content-length is a number");
assert!(declared > 0, "{record}");
assert_ne!(
record["body"], "",
"content-length {declared} contradicts an empty body: {record}"
);
}
#[test]
fn a_zero_length_body_is_not_reported_as_unread() {
let router = Router::start();
router
.post(
"/v1/chat/completions",
"authorization: Bearer la_sk_invalid\r\nx-test-marker: issue-210-zero-length\r\n",
"",
)
.expect("zero-length request");
let record = router.await_client_request("issue-210-zero-length");
let record: serde_json::Value = serde_json::from_str(&record).expect("record is JSON");
let body = record["body"].as_str().unwrap_or_default();
assert!(
!body.contains("NOT READ"),
"a declared-empty body must not be marked unread: {record}"
);
}