use anyhow::Result;
use std::io::Write;
use tempfile::NamedTempFile;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::time::{Duration, Instant, timeout, timeout_at};
use nntp_proxy::config::{ClientAuth, HealthCheck, Proxy, Server};
use nntp_proxy::{Config, NntpProxy, RoutingMode, load_config};
mod test_helpers;
use test_helpers::*;
#[tokio::test]
async fn test_proxy_with_mock_servers() -> Result<()> {
let (mock_port1, _mock1) = MockNntpServer::new()
.with_name("Mock NNTP Server")
.on_command("DATE", "111 20260505120000\r\n")
.spawn_on_random_port()
.await?;
let (mock_port2, _mock2) = MockNntpServer::new()
.with_name("Mock NNTP Server")
.on_command("DATE", "111 20260505120000\r\n")
.spawn_on_random_port()
.await?;
let config = Config {
servers: vec![
create_test_server_config_with_max_connections(
"127.0.0.1",
mock_port1,
"Mock Server 1",
10,
),
create_test_server_config_with_max_connections(
"127.0.0.1",
mock_port2,
"Mock Server 2",
10,
),
],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, false).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let welcome = String::from_utf8_lossy(&buffer[..n]);
assert!(welcome.contains("201 NNTP Proxy Ready"));
client.write_all(b"DATE\r\n").await?;
let n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(response.contains("111"));
client.write_all(b"QUIT\r\n").await?;
let n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let goodbye = String::from_utf8_lossy(&buffer[..n]);
assert!(goodbye.contains("205 Goodbye"));
Ok(())
}
#[tokio::test]
async fn test_round_robin_distribution() -> Result<()> {
let (mock_port1, _mock1) = MockNntpServer::new()
.with_name("Mock Server 1")
.on_command("HELP", "100 HELP command received\r\n")
.spawn_on_random_port()
.await?;
let (mock_port2, _mock2) = MockNntpServer::new()
.with_name("Mock Server 2")
.on_command("HELP", "100 HELP command received\r\n")
.spawn_on_random_port()
.await?;
let config = Config {
servers: vec![
create_test_server_config_with_max_connections(
"127.0.0.1",
mock_port1,
"Mock Server 1",
10,
),
create_test_server_config_with_max_connections(
"127.0.0.1",
mock_port2,
"Mock Server 2",
10,
),
],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::Stateful).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, false).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
for _ in 0..6 {
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(response.contains("201 NNTP Proxy Ready"));
let _ = client.write_all(b"QUIT\r\n").await;
}
Ok(())
}
#[tokio::test]
async fn test_config_file_loading() -> Result<()> {
let config_content = r#"
[[servers]]
host = "test1.example.com"
port = 119
name = "Test Server 1"
[[servers]]
host = "test2.example.com"
port = 563
name = "Test Server 2"
"#;
let mut temp_file = NamedTempFile::new()?;
write!(temp_file, "{config_content}")?;
let config = load_config(temp_file.path().to_str().unwrap())?;
assert_eq!(config.servers.len(), 2);
assert_eq!(config.servers[0].host.as_str(), "test1.example.com");
assert_eq!(config.servers[0].port.get(), 119);
assert_eq!(config.servers[1].host.as_str(), "test2.example.com");
assert_eq!(config.servers[1].port.get(), 563);
Ok(())
}
#[tokio::test]
async fn test_proxy_handles_connection_failure() -> Result<()> {
let config = Config {
servers: vec![create_test_server_config_with_max_connections(
"127.0.0.1",
1,
"Nonexistent Server",
10,
)],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::Stateful).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, false).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(
greeting.contains("201"),
"Expected greeting, got: {greeting:?}"
);
let n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let error_response = String::from_utf8_lossy(&buffer[..n]);
assert!(
error_response.contains("400 Backend server unavailable"),
"Expected backend unavailable error, got: {error_response:?}"
);
Ok(())
}
#[tokio::test]
async fn test_response_flushing_with_rapid_commands() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("Mock NNTP Server")
.on_command("BODY", "220 0 <test@example.com>\r\nArticle body line 1\r\nArticle body line 2\r\nArticle body line 3\r\n.\r\n")
.on_command("ARTICLE", "220 0 <test@example.com>\r\nArticle body line 1\r\nArticle body line 2\r\nArticle body line 3\r\n.\r\n")
.spawn_on_random_port()
.await?;
let config = create_test_config(vec![(mock_port, "TestServer")]);
let proxy = NntpProxy::new(config, RoutingMode::Stateful).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, false).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = timeout(Duration::from_millis(500), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout reading greeting - not flushed!"))?
.map_err(|e| anyhow::anyhow!("Failed to read greeting: {e}"))?;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(
greeting.contains("201"),
"Expected greeting, got: {greeting}"
);
for i in 1..=10 {
let cmd = format!("BODY <msg{i}@test.com>\r\n");
client.write_all(cmd.as_bytes()).await?;
client.flush().await?;
let deadline = Instant::now() + Duration::from_millis(200);
let status_line = timeout_at(deadline, read_line_from_stream(&mut client, "BODY status"))
.await
.map_err(|_| {
anyhow::anyhow!(
"Timeout reading response #{i} - proxy likely not flushing responses!"
)
})??;
let mut body_lines = Vec::new();
loop {
let line = timeout_at(deadline, read_line_from_stream(&mut client, "BODY line"))
.await
.map_err(|_| {
anyhow::anyhow!(
"Timeout reading response #{i} body - proxy likely not flushing responses!"
)
})??;
if line == ".\r\n" {
break;
}
body_lines.push(line);
}
assert!(
status_line.contains("220") && !body_lines.is_empty(),
"Response #{i} incomplete or malformed: {status_line}{body_lines:?}"
);
}
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_auth_and_reject_response_flushing() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("TestServer")
.spawn_on_random_port()
.await?;
let config = create_test_config(vec![(mock_port, "TestServer")]);
let proxy = NntpProxy::new(config, RoutingMode::Stateful).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = timeout(Duration::from_millis(500), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout reading greeting - not flushed!"))?
.map_err(|e| anyhow::anyhow!("Failed to read greeting: {e}"))?;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(greeting.contains("201"));
client.write_all(b"AUTHINFO USER testuser\r\n").await?;
client.flush().await?;
let n = timeout(Duration::from_millis(200), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout reading auth response - not flushed!"))?
.map_err(|e| anyhow::anyhow!("Failed to read auth response: {e}"))?;
let auth_response = String::from_utf8_lossy(&buffer[..n]);
assert!(
auth_response.contains("381"),
"Expected password request, got: {auth_response}"
);
client.write_all(b"CAPABILITIES\r\n").await?;
client.flush().await?;
let n = timeout(Duration::from_millis(500), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout reading CAPABILITIES response!"))?
.map_err(|e| anyhow::anyhow!("Failed to read CAPABILITIES response: {e}"))?;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(!response.is_empty(), "Expected some response");
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_sequential_requests_no_delay() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("Ready")
.on_command("STAT", "200 Command OK\r\n")
.spawn_on_random_port()
.await?;
let config = create_test_config(vec![(mock_port, "TestServer")]);
let proxy = NntpProxy::new(config, RoutingMode::Stateful).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = timeout(Duration::from_millis(500), client.read(&mut buffer)).await??;
assert!(n > 0, "Expected greeting");
println!(
"Greeting received: {}",
String::from_utf8_lossy(&buffer[..n])
);
for i in 1..=5 {
let cmd = format!("STAT <msg{i}@test.com>\r\n");
println!("Sending command {}: {}", i, cmd.trim());
client.write_all(cmd.as_bytes()).await?;
client.flush().await?;
buffer = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buffer))
.await
.map_err(|_| {
anyhow::anyhow!(
"Timeout waiting for response to command {i}. Possible server or network issue."
)
})?
.map_err(|e| anyhow::anyhow!("Read error on command {i}: {e}"))?;
assert!(n > 0, "Empty response on command {i}");
let response = String::from_utf8_lossy(&buffer[..n]);
println!("Response {}: {}", i, response.trim());
assert!(
response.contains("200"),
"Expected '200' in response #{i}, got: {response}"
);
}
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_hybrid_mode_stateless_commands() -> Result<()> {
let (mock_port1, _mock1) = create_smart_mock_builder("Server1")
.spawn_on_random_port()
.await?;
let (mock_port2, _mock2) = create_smart_mock_builder("Server2")
.spawn_on_random_port()
.await?;
let config = Config {
servers: vec![
create_test_server_config("127.0.0.1", mock_port1, "Mock Server 1"),
create_test_server_config("127.0.0.1", mock_port2, "Mock Server 2"),
],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = client.read(&mut buffer).await?;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(greeting.contains("201"));
let stateless_commands = vec![
"LIST\r\n",
"DATE\r\n",
"CAPABILITIES\r\n",
"HELP\r\n",
"ARTICLE <msg1@example.com>\r\n",
"HEAD <msg2@example.com>\r\n",
"BODY <msg3@example.com>\r\n",
"STAT <msg4@example.com>\r\n",
];
for cmd in stateless_commands {
client.write_all(cmd.as_bytes()).await?;
client.flush().await?;
buffer = [0; 1024];
let n = timeout(Duration::from_secs(2), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout waiting for response to: {}", cmd.trim()))?
.map_err(|e| anyhow::anyhow!("Read error for command {}: {}", cmd.trim(), e))?;
let response = String::from_utf8_lossy(&buffer[..n]);
println!("Response to {}: {}", cmd.trim(), response.trim());
assert!(n > 0, "Empty response to command: {}", cmd.trim());
}
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_hybrid_mode_stateful_switching() -> Result<()> {
let (mock_port, _mock) = create_smart_mock_builder("StatefulServer")
.spawn_on_random_port()
.await?;
let config = Config {
servers: vec![create_test_server_config(
"127.0.0.1",
mock_port,
"Mock Server",
)],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = client.read(&mut buffer).await?;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(greeting.contains("201"));
client.write_all(b"LIST\r\n").await?;
client.flush().await?;
buffer = [0; 1024];
let n = client.read(&mut buffer).await?;
assert!(n > 0);
client.write_all(b"GROUP alt.test\r\n").await?;
client.flush().await?;
buffer = [0; 1024];
let n = timeout(Duration::from_secs(2), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout waiting for GROUP response"))?
.map_err(|e| anyhow::anyhow!("Read error for GROUP command: {e}"))?;
let response = String::from_utf8_lossy(&buffer[..n]);
println!("GROUP Response: {}", response.trim());
assert!(n > 0, "Empty response to GROUP command");
let stateful_commands = vec!["ARTICLE 1\r\n", "HEAD 2\r\n", "XOVER 1-10\r\n", "NEXT\r\n"];
for cmd in stateful_commands {
client.write_all(cmd.as_bytes()).await?;
client.flush().await?;
buffer = [0; 1024];
let n = timeout(Duration::from_secs(2), client.read(&mut buffer))
.await
.map_err(|_| anyhow::anyhow!("Timeout waiting for response to: {}", cmd.trim()))?
.map_err(|e| anyhow::anyhow!("Read error for command {}: {}", cmd.trim(), e))?;
let response = String::from_utf8_lossy(&buffer[..n]);
println!("Response to {}: {}", cmd.trim(), response.trim());
assert!(n > 0, "Empty response to command: {}", cmd.trim());
}
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_hybrid_mode_multiple_clients() -> Result<()> {
let mock_listener = TcpListener::bind("127.0.0.1:0").await?;
let mock_port = mock_listener.local_addr()?.port();
let proxy_listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy_port = proxy_listener.local_addr()?.port();
let _mock = create_smart_mock_builder("MultiServer").spawn_on_listener(mock_listener);
wait_for_server(&format!("127.0.0.1:{mock_port}"), 20).await?;
let config = Config {
servers: vec![create_test_server_config_with_max_connections(
"127.0.0.1",
mock_port,
"Mock Server",
10,
)],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
tokio::spawn(async move {
loop {
if let Ok((stream, addr)) = proxy_listener.accept().await {
let proxy_clone = proxy.clone();
tokio::spawn(async move {
let _ = proxy_clone.handle_client(stream, addr.into()).await;
});
}
}
});
wait_for_server(&proxy_addr, 20).await?;
let results = tokio::try_join!(
async {
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buf = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buf)).await??;
assert!(n > 0);
client.write_all(b"DATE\r\n").await?;
let n = timeout(Duration::from_secs(1), client.read(&mut buf)).await??;
assert!(n > 0);
client.write_all(b"QUIT\r\n").await?;
anyhow::Ok(())
},
async {
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buf = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buf)).await??;
assert!(n > 0);
client.write_all(b"LIST\r\n").await?;
let n = timeout(Duration::from_secs(1), client.read(&mut buf)).await??;
assert!(n > 0);
client.write_all(b"QUIT\r\n").await?;
anyhow::Ok(())
},
async {
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buf = [0; 1024];
let n = timeout(Duration::from_secs(1), client.read(&mut buf)).await??;
assert!(n > 0);
client.write_all(b"ARTICLE <msg@example.com>\r\n").await?;
let n = timeout(Duration::from_secs(1), client.read(&mut buf)).await??;
assert!(n > 0);
client.write_all(b"QUIT\r\n").await?;
anyhow::Ok(())
}
);
results?;
Ok(())
}
fn create_smart_mock_builder(server_name: &str) -> MockNntpServer {
MockNntpServer::new()
.with_name(format!("{server_name} Mock NNTP Server"))
.on_command("LIST", "215 List of newsgroups\r\nalt.test 100 1 y\r\n.\r\n")
.on_command("DATE", "111 20231013120000\r\n")
.on_command("CAPABILITIES", "101 Capability list\r\nVERSION 2\r\nREADER\r\n.\r\n")
.on_command("HELP", "100 Help text\r\nCommands available\r\n.\r\n")
.on_command("GROUP", "211 100 1 100 alt.test\r\n")
.on_command("ARTICLE", "220 1 <msg@example.com>\r\nSubject: Test\r\n\r\nTest body\r\n.\r\n")
.on_command("HEAD", "221 1 <msg@example.com>\r\nSubject: Test\r\n.\r\n")
.on_command("BODY", "222 1 <msg@example.com>\r\nTest body\r\n.\r\n")
.on_command("STAT", "223 1 <current@example.com>\r\n")
.on_command("XOVER", "224 Overview\r\n1\tTest Subject\tauthor@example.com\t13 Oct 2023\t<msg1@example.com>\t\t100\t5\r\n.\r\n")
.on_command("NEXT", "223 2 <next@example.com>\r\n")
.on_command("LAST", "223 1 <prev@example.com>\r\n")
}
#[tokio::test]
async fn test_backend_223_response_for_message_id() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("Backend That Returns 223")
.on_command("ARTICLE <missing@", "223 0 <missing@example.com>\r\n")
.on_command(
"ARTICLE <exists@",
"220 1 <exists@example.com>\r\nSubject: Test\r\n\r\nBody\r\n.\r\n",
)
.spawn_on_random_port()
.await?;
let config = Config {
servers: vec![create_test_server_config_with_max_connections(
"127.0.0.1",
mock_port,
"Backend That Returns 223",
5,
)],
..Default::default()
};
let proxy = NntpProxy::new(config, RoutingMode::PerCommand).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 1024];
let n = client.read(&mut buffer).await?;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(
greeting.starts_with("201"),
"Expected greeting, got: {greeting}"
);
client
.write_all(b"ARTICLE <missing@example.com>\r\n")
.await?;
client.flush().await?;
buffer = [0; 1024];
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(
response.starts_with("223"),
"Expected 223 response, got: {response}"
);
assert!(
response.contains("<missing@example.com>"),
"Response should contain message-ID"
);
client
.write_all(b"ARTICLE <exists@example.com>\r\n")
.await?;
client.flush().await?;
let mut large_buffer = [0u8; 4096];
let n = timeout(Duration::from_secs(2), client.read(&mut large_buffer)).await??;
let response = String::from_utf8_lossy(&large_buffer[..n]);
assert!(
response.starts_with("220"),
"Expected 220 response, got: {response}"
);
assert!(
response.contains("Subject: Test"),
"Should have article headers"
);
assert!(
response.ends_with(".\r\n"),
"Should have multiline terminator"
);
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
fn build_tiered_server(port: u16, name: &str, tier: u8) -> Result<Server> {
Server::builder("127.0.0.1", nntp_proxy::types::Port::try_new(port)?)
.name(name)
.tier(tier)
.max_connections(nntp_proxy::types::MaxConnections::try_new(5)?)
.build()
}
async fn start_tiered_proxy(servers: Vec<Server>) -> Result<u16> {
let config = Config {
servers,
proxy: Proxy::default(),
routing: Default::default(),
memory: Default::default(),
health_check: HealthCheck::default(),
cache: None,
client_auth: ClientAuth::default(),
};
let proxy_port = spawn_proxy_with_config(config, RoutingMode::PerCommand).await?;
Ok(proxy_port)
}
async fn connect_tiered_client(proxy_port: u16) -> Result<TcpStream> {
let mut client = TcpStream::connect(format!("127.0.0.1:{proxy_port}")).await?;
let mut buffer = [0u8; 4096];
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
let greeting = String::from_utf8_lossy(&buffer[..n]);
assert!(
greeting.starts_with("201"),
"Expected greeting, got: {greeting}"
);
Ok(client)
}
async fn assert_article_response(
client: &mut TcpStream,
command: &str,
expected_body: &str,
context: &str,
) -> Result<()> {
client.write_all(command.as_bytes()).await?;
client.flush().await?;
let mut buffer = [0u8; 4096];
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(
response.starts_with("220"),
"{context}: expected 220, got: {response}"
);
assert!(
response.contains(expected_body),
"{context}: expected body {expected_body:?}, got: {response}"
);
Ok(())
}
#[tokio::test]
async fn test_tier_0_exhaustion_before_escalation() -> Result<()> {
let (backend_0_port, _backend_0) = MockNntpServer::new()
.with_name("Backend-0-Tier-0")
.on_command(
"ARTICLE",
"430 No such article\r\n", )
.spawn_on_random_port()
.await?;
let (backend_1_port, _backend_1) = MockNntpServer::new()
.with_name("Backend-1-Tier-0")
.on_command(
"ARTICLE",
"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody from backend 1\r\n.\r\n", )
.spawn_on_random_port()
.await?;
let (backend_2_port, _backend_2) = MockNntpServer::new()
.with_name("Backend-2-Tier-1")
.on_command(
"ARTICLE",
"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody from backend 2\r\n.\r\n",
)
.spawn_on_random_port()
.await?;
let proxy_port = start_tiered_proxy(vec![
build_tiered_server(backend_0_port, "Backend-0-Tier-0", 0)?,
build_tiered_server(backend_1_port, "Backend-1-Tier-0", 0)?,
build_tiered_server(backend_2_port, "Backend-2-Tier-1", 1)?,
])
.await?;
let mut client = connect_tiered_client(proxy_port).await?;
assert_article_response(
&mut client,
"ARTICLE <test@example.com>\r\n",
"Body from backend 1",
"initial request",
)
.await?;
for i in 1..=5 {
assert_article_response(
&mut client,
&format!("ARTICLE <test{i}@example.com>\r\n"),
"Body from backend 1",
&format!("request {i}"),
)
.await?;
}
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_tier_exhaustion_multi_tier() -> Result<()> {
let (backend_0_port, _backend_0) = MockNntpServer::new()
.with_name("Backend-0-Tier-0")
.on_command(
"ARTICLE",
"430 No such article\r\n", )
.spawn_on_random_port()
.await?;
let (backend_1_port, _backend_1) = MockNntpServer::new()
.with_name("Backend-1-Tier-0")
.on_command(
"ARTICLE",
"430 No such article\r\n", )
.spawn_on_random_port()
.await?;
let (backend_2_port, _backend_2) = MockNntpServer::new()
.with_name("Backend-2-Tier-1")
.on_command(
"ARTICLE",
"220 0 <test@example.com>\r\nSubject: Test\r\n\r\nBody from tier 1\r\n.\r\n", )
.spawn_on_random_port()
.await?;
let proxy_port = start_tiered_proxy(vec![
build_tiered_server(backend_0_port, "Backend-0-Tier-0", 0)?,
build_tiered_server(backend_1_port, "Backend-1-Tier-0", 0)?,
build_tiered_server(backend_2_port, "Backend-2-Tier-1", 1)?,
])
.await?;
let mut client = connect_tiered_client(proxy_port).await?;
assert_article_response(
&mut client,
"ARTICLE <test@example.com>\r\n",
"Body from tier 1",
"initial request",
)
.await?;
for i in 1..=5 {
assert_article_response(
&mut client,
&format!("ARTICLE <test{i}@example.com>\r\n"),
"Body from tier 1",
&format!("request {i}"),
)
.await?;
}
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_oversized_pipelined_command_rejected_with_500() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("TestServer")
.on_command("STAT", "223 0 <test@example.com> status\r\n")
.spawn_on_random_port()
.await?;
let config = create_test_config(vec![(mock_port, "TestServer")]);
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 4096];
let _n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let oversized_msg_id = format!("<{}@example.com>", "x".repeat(510));
let oversized_cmd = format!("STAT {oversized_msg_id}\r\n");
assert!(
oversized_cmd.len() > 512,
"Test setup: command must exceed 512 bytes, got {}",
oversized_cmd.len()
);
let mut pipelined = b"STAT <valid@example.com>\r\n".to_vec();
pipelined.extend_from_slice(oversized_cmd.as_bytes());
client.write_all(&pipelined).await?;
client.flush().await?;
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(
response.contains("223"),
"Expected 223 for valid STAT, got: {response}"
);
let observed_response = if response.contains("501") {
response.to_string()
} else {
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
String::from_utf8_lossy(&buffer[..n]).to_string()
};
assert!(
observed_response.contains("501"),
"Expected 501 error for oversized command, got: {observed_response}"
);
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_empty_pipelined_command_rejected_with_501() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("TestServer")
.on_command("STAT", "223 0 <test@example.com> status\r\n")
.spawn_on_random_port()
.await?;
let config = create_test_config(vec![(mock_port, "TestServer")]);
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 4096];
let _n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
client
.write_all(b"STAT <valid@example.com>\r\n\r\n")
.await?;
client.flush().await?;
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(
response.contains("223"),
"Expected 223 for valid STAT, got: {response}"
);
let observed_response = if response.contains("501") {
response.to_string()
} else {
let n = timeout(Duration::from_secs(2), client.read(&mut buffer)).await??;
String::from_utf8_lossy(&buffer[..n]).to_string()
};
assert!(
observed_response.contains("501 Syntax error in command"),
"Expected 501 syntax error for empty command, got: {observed_response}"
);
client.write_all(b"QUIT\r\n").await?;
Ok(())
}
#[tokio::test]
async fn test_partial_buffered_command_does_not_block() -> Result<()> {
let (mock_port, _mock) = MockNntpServer::new()
.with_name("TestServer")
.on_command("STAT", "223 0 <test@example.com> status\r\n")
.spawn_on_random_port()
.await?;
let config = create_test_config(vec![(mock_port, "TestServer")]);
let proxy = NntpProxy::new(config, RoutingMode::Hybrid).await?;
let proxy_port = spawn_test_proxy_on_random_port(proxy, true).await?;
let proxy_addr = format!("127.0.0.1:{proxy_port}");
wait_for_server(&proxy_addr, 20).await?;
let mut client = TcpStream::connect(&proxy_addr).await?;
let mut buffer = [0; 4096];
let _n = timeout(Duration::from_secs(1), client.read(&mut buffer)).await??;
let mut pipelined = b"STAT <valid@example.com>\r\n".to_vec();
pipelined.extend_from_slice(b"STAT <partial-no-newline@examp"); client.write_all(&pipelined).await?;
client.flush().await?;
let n = timeout(Duration::from_millis(500), client.read(&mut buffer))
.await
.map_err(|_| {
anyhow::anyhow!(
"Timeout! Proxy blocked on partial command instead of responding to first STAT"
)
})??;
let response = String::from_utf8_lossy(&buffer[..n]);
assert!(
response.contains("223"),
"Expected 223 for valid STAT, got: {response}"
);
client.write_all(b"le.com>\r\nQUIT\r\n").await?;
client.flush().await?;
Ok(())
}