tests: rework the actually useful tests to live in the integration crate
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m14s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m11s

This commit is contained in:
Nils Andreas Svee committed 2026-10-03 13:59:38 +07:00
1 parent 74e7532965
commit 48a50d7f3a
101 files changed
+3636 -15159

No files matched your search

+479
View File
@@ -0,0 +1,479 @@
#[cfg(feature = "client")]
#[path = "support/in_tree_server.rs"]
mod in_tree_server;
#[cfg(feature = "client")]
use irtt_server::ServerConfig;
use std::process::Command;
#[cfg(feature = "client")]
struct ClientProcess(std::process::Child);
#[cfg(feature = "client")]
impl Drop for ClientProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(feature = "client")]
impl ClientProcess {
fn wait(&mut self) -> std::process::ExitStatus {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(status) = self.0.try_wait().unwrap() {
return status;
}
assert!(
std::time::Instant::now() < deadline,
"client did not terminate within five seconds"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
// Dispatch/help/availability belong to the application and run in every build.
#[test]
fn dispatcher_reports_and_runs_the_enabled_applets() {
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
let help = Command::new(dispatcher).arg("--help").output().unwrap();
assert!(help.status.success());
let help = String::from_utf8(help.stdout).unwrap();
for (applet, enabled) in [
("client", cfg!(feature = "client")),
("server", cfg!(feature = "server")),
("tui", cfg!(feature = "tui")),
] {
let row = help
.lines()
.find(|line| line.trim_start().starts_with(applet))
.unwrap();
assert_eq!(row.contains("not available"), !enabled);
let output = Command::new(dispatcher)
.args([applet, "--help"])
.output()
.unwrap();
assert_eq!(output.status.success(), enabled, "{applet}");
if !enabled {
assert!(String::from_utf8(output.stderr)
.unwrap()
.contains("not available"));
}
}
let binaries = [
dispatcher,
#[cfg(feature = "client")]
env!("CARGO_BIN_EXE_irtt-client"),
#[cfg(feature = "server")]
env!("CARGO_BIN_EXE_irtt-server"),
#[cfg(feature = "tui")]
env!("CARGO_BIN_EXE_irtt-tui"),
];
for binary in binaries {
assert!(Command::new(binary)
.arg("--help")
.output()
.unwrap()
.status
.success());
assert!(!Command::new(binary)
.arg("--invalid-option")
.output()
.unwrap()
.status
.success());
}
}
// The default presentation must reach a meaningful packet summary for a finite run.
#[cfg(feature = "client")]
#[test]
fn default_client_output_emits_a_packet_summary() {
use std::{io::Read, process::Stdio, thread};
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
let mut client = ClientProcess(
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.env_clear()
.args([
"--duration",
"100ms",
"--interval",
"20ms",
&server.addr.to_string(),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let mut stdout = client.0.stdout.take().unwrap();
let stdout = thread::spawn(move || {
let mut text = String::new();
stdout.read_to_string(&mut text).unwrap();
text
});
let mut stderr = client.0.stderr.take().unwrap();
let stderr = thread::spawn(move || {
let mut text = String::new();
stderr.read_to_string(&mut text).unwrap();
text
});
let status = client.wait();
let stdout = stdout.join().unwrap();
let stderr = stderr.join().unwrap();
assert!(status.success(), "{stderr}");
assert!(stdout.contains("irtt-rs summary"), "{stdout}");
let packets = stdout
.lines()
.find(|line| line.starts_with("packets:"))
.expect("default output must include the packet summary");
for field in ["sent=", "received="] {
let count = packets
.split_whitespace()
.find_map(|word| word.strip_prefix(field))
.unwrap()
.parse::<u64>()
.unwrap();
assert!(
count > 0,
"summary must report {field} a positive count: {packets}"
);
}
}
#[cfg(feature = "client")]
#[test]
fn target_authentication_inherits_overrides_or_disables_the_global_key() {
use clap::Parser;
use irtt_app::cmd::client::args::ClientArgs;
let args = ClientArgs::try_parse_from([
"irtt-client",
"--hmac",
"global-test-key",
"inherit=127.0.0.1:2112",
"override=127.0.0.1:2113@hmac=target-test-key",
"disable=127.0.0.1:2114@hmac=",
])
.unwrap();
let setup = args.prepare().unwrap();
assert_eq!(
setup.managed_config().client.hmac_key.as_deref(),
Some(b"global-test-key".as_slice())
);
let targets = setup.managed_targets();
let authentication: Vec<_> = targets
.iter()
.map(|target| target.auth.as_ref().map(|auth| auth.hmac_key.as_deref()))
.collect();
assert_eq!(
authentication,
[None, Some(Some(b"target-test-key".as_slice())), Some(None)]
);
}
// A real authenticated server detects broken client argument-to-config mapping.
#[cfg(feature = "client")]
#[test]
fn client_arguments_export_a_real_client_run() {
use std::{io::Read, process::Stdio, thread};
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
for option in ["--dscp", "--ttl"] {
assert!(!Command::new(dispatcher)
.args(["client", option, "256", "127.0.0.1:9"])
.output()
.unwrap()
.status
.success());
}
let server =
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
let mut client = ClientProcess(
Command::new(dispatcher)
.args([
"client",
"--duration",
"100ms",
"--interval",
"20ms",
"--hmac",
"cli-test",
"--length",
"128",
"--sfill",
"pattern:ab",
"--clock",
"wall",
"--tstamp",
"both",
"--stats",
"both",
"--ttl",
"64",
"--format",
"jsonl",
"--columns",
"target,event,bytes",
&format!("edge={}", server.addr),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
// Drain both pipes while waiting so output cannot block client termination.
let mut stdout = client.0.stdout.take().unwrap();
let stdout = thread::spawn(move || {
let mut bytes = Vec::new();
stdout.read_to_end(&mut bytes).unwrap();
bytes
});
let mut stderr = client.0.stderr.take().unwrap();
let stderr = thread::spawn(move || {
let mut bytes = Vec::new();
stderr.read_to_end(&mut bytes).unwrap();
bytes
});
let status = client.wait();
let stdout = stdout.join().unwrap();
let stderr = stderr.join().unwrap();
assert!(status.success(), "{}", String::from_utf8_lossy(&stderr));
let stdout = String::from_utf8(stdout).unwrap();
let rows: Vec<_> = stdout.lines().collect();
assert!(!rows.is_empty());
assert!(rows.iter().all(|row| row.starts_with('{')
&& row.ends_with('}')
&& row.contains("\"target\":\"edge\"")));
assert!(rows
.iter()
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
assert!(rows
.iter()
.any(|row| row.contains("\"event\":\"session_closed\"")));
}
#[cfg(any(feature = "client", feature = "tui"))]
#[test]
fn malformed_target_diagnostics_do_not_expose_authentication_keys() {
let secret = "malformed-target-secret-92817";
for applet in [
#[cfg(feature = "client")]
"client",
#[cfg(feature = "tui")]
"tui",
] {
let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
.args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")])
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.trim().is_empty(),
"malformed target must report a diagnostic"
);
assert!(!stderr.contains(secret), "target authentication key leaked");
assert!(!String::from_utf8_lossy(&output.stdout).contains(secret));
}
}
#[cfg(feature = "server")]
#[test]
fn server_arguments_resolve_binds_and_map_policy() {
use clap::{CommandFactory, FromArgMatches, Parser};
use irtt_app::cmd::server::ServerArgs;
use irtt_server::TimestampAllowance;
use std::time::Duration;
// Isolate zero-argument defaults from the test runner's environment without
// mutating process-global environment in this parallel test process.
let defaults = ServerArgs::from_arg_matches(
&ServerArgs::command()
.mut_args(|arg| arg.env(None::<&str>))
.try_get_matches_from(["irtt-server"])
.unwrap(),
)
.unwrap();
assert_eq!(
defaults.resolve_binds(),
vec![
"[::]:2112".parse().unwrap(),
"0.0.0.0:2112".parse().unwrap()
]
);
assert_eq!(
defaults.server_config(),
irtt_server::ServerConfig::default()
);
let args = ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:2113",
"--bind",
"[::1]:2114",
"--hmac",
"server-cli-test",
"--max-sessions",
"3",
"--max-packet-length",
"96",
"--min-interval",
"25ms",
"--burst",
"2",
"--idle-timeout",
"8s",
"--max-duration",
"1m",
"--timestamp-allowance",
"single",
"--no-dscp",
])
.unwrap();
assert_eq!(
args.resolve_binds(),
vec![
"127.0.0.1:2113".parse().unwrap(),
"[::1]:2114".parse().unwrap()
]
);
let config = args.server_config();
assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice()));
assert_eq!(config.max_sessions(), 3);
assert_eq!(config.max_packet_length(), 96);
assert_eq!(config.min_send_interval(), Duration::from_millis(25));
assert_eq!(config.burst_allowance(), 2);
assert_eq!(config.idle_timeout(), Duration::from_secs(8));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60)));
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
assert!(!config.dscp_allowed());
}
#[cfg(feature = "server")]
#[test]
fn server_command_line_binds_replace_the_environment_bind() {
use std::net::UdpSocket;
// Occupied ports give bounded, observable startup failures, without starting
// a daemon or needing a process/signal harness merely to inspect arguments.
let environment = UdpSocket::bind("127.0.0.1:0").unwrap();
let explicit = UdpSocket::bind("127.0.0.1:0").unwrap();
let environment_addr = environment.local_addr().unwrap().to_string();
let explicit_addr = explicit.local_addr().unwrap().to_string();
for use_explicit in [false, true] {
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server"));
command
.env_clear()
.env("IRTT_SERVER_BIND", &environment_addr)
.env("IRTT_SERVER_MAX_SESSIONS", "invalid")
.args(["--max-sessions", "3"]);
if use_explicit {
command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]);
}
let output = command.output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
let expected = if use_explicit {
&explicit_addr
} else {
&environment_addr
};
assert!(
stderr.contains(expected),
"startup must attempt the selected bind: {stderr}"
);
assert!(
!stderr.contains("listening on"),
"partial startup must not announce listeners"
);
}
}
// Exercise stdin replacement and EOF through the actual application process.
#[cfg(feature = "client")]
#[test]
fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
use std::{
io::{BufRead, BufReader, Read, Write},
process::Stdio,
sync::mpsc,
thread,
time::{Duration, Instant},
};
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
let mut client = ClientProcess(
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.env_clear()
.args([
"--duration",
"0",
"--targets-stdin",
"--interval",
"20ms",
"--format",
"jsonl",
"--columns",
"target,event",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
);
let stdout = client.0.stdout.take().unwrap();
let (tx, rx) = mpsc::channel();
let reader = thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if tx.send(line.unwrap()).is_err() {
break;
}
}
});
let mut stdin = client.0.stdin.take().unwrap();
let mut rows = Vec::new();
for target in ["first", "second"] {
writeln!(stdin, "{target}={}", server.addr).unwrap();
stdin.flush().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let row = rx
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.expect("stdin target set did not produce a reply");
let replied = row.contains(&format!("\"target\":\"{target}\""))
&& row.contains("\"event\":\"echo_reply\"");
rows.push(row);
if replied {
break;
}
}
}
// Retirement can finish after the newer target's first reply.
let deadline = Instant::now() + Duration::from_secs(5);
while !rows.iter().any(|row| {
row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"")
}) {
rows.push(
rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.expect("superseded stdin target did not retire"),
);
}
drop(stdin);
let status = client.wait();
reader.join().unwrap();
rows.extend(rx.try_iter());
let mut stderr = String::new();
client
.0
.stderr
.take()
.unwrap()
.read_to_string(&mut stderr)
.unwrap();
assert!(status.success(), "{stderr}");
assert!(rows
.iter()
.any(|row| row.contains("\"target\":\"second\"")
&& row.contains("\"event\":\"session_closed\"")));
}
@@ -0,0 +1,74 @@
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::mpsc,
thread::{self, JoinHandle},
time::Duration,
};
use irtt_server::{Server, ServerConfig};
use tokio::sync::oneshot;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
pub struct InTreeServer {
pub addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
}
impl InTreeServer {
pub fn start(config: ServerConfig) -> Self {
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let done = thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build in-tree test server runtime");
runtime.block_on(async move {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let mut server = Server::bind(bind_addr, config).await?;
startup_tx
.send(server.local_addr()?)
.expect("test stopped waiting for in-tree server startup");
server
.run(async {
let _ = shutdown_rx.await;
})
.await
})
});
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(addr) => addr,
Err(error) => {
let _ = shutdown_tx.send(());
let result = done.join().expect("in-tree test server thread panicked");
panic!("in-tree test server failed before startup ({error}): {result:?}");
}
};
Self {
addr,
shutdown: Some(shutdown_tx),
done: Some(done),
}
}
fn stop_inner(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(done) = self.done.take() {
done.join()
.expect("in-tree test server thread panicked")
.expect("in-tree test server stopped with an error");
}
}
}
impl Drop for InTreeServer {
fn drop(&mut self) {
self.stop_inner();
}
}