#![cfg(unix)]
use std::net::SocketAddr;
use std::process::Stdio;
use std::time::Duration;
use rmcp::service::RoleClient;
use rmcp::service::RunningService;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::ServiceExt as _;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::Command;
const EXIT_TIMEOUT: Duration = Duration::from_secs(5);
const AMBIENT_VARS: &[&str] = &[
"BUGZILLA_SERVER",
"BUGZILLA_API_KEY",
"BUGZILLA_API_KEY_FILE",
"BUGWARDEN_POLICY",
"BUGWARDEN_AUDIT_CONFIG",
"BUGWARDEN_HTTP_TOKEN",
"BUGWARDEN_HTTP_READ_TOKEN",
"BUGZILLA_USE_AUTH_HEADER",
"MCP_TRANSPORT",
"MCP_HOST",
"MCP_PORT",
"MCP_ALLOWED_HOSTS",
"MCP_READ_ONLY",
"MCP_API_KEY_HEADER",
"RUST_LOG",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
];
#[test]
fn the_scrub_list_covers_every_environment_fallback() {
let mut cmd = bugwarden::config::command();
cmd.build();
let unscrubbed: Vec<String> = cmd
.get_arguments()
.filter_map(clap::Arg::get_env)
.map(|env| env.to_string_lossy().into_owned())
.filter(|env| !AMBIENT_VARS.contains(&env.as_str()))
.collect();
assert!(
unscrubbed.is_empty(),
"these environment fallbacks reach the spawned binary: {unscrubbed:?}"
);
for var in [
bugwarden::http_auth::WRITE_TOKEN_VAR,
bugwarden::http_auth::READ_TOKEN_VAR,
] {
assert!(AMBIENT_VARS.contains(&var), "{var} must be scrubbed");
}
let unscrubbed_otel: Vec<&str> = bugwarden::otel::ENV_VARS
.iter()
.copied()
.filter(|var| !AMBIENT_VARS.contains(var))
.collect();
assert!(
unscrubbed_otel.is_empty(),
"these OTLP variables reach the spawned binary: {unscrubbed_otel:?}"
);
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind")
.local_addr()
.expect("addr")
.port()
}
fn send_signal(pid: u32, signal: &str) {
let status = std::process::Command::new("kill")
.args(["-s", signal, &pid.to_string()])
.status()
.expect("kill must be executable");
assert!(status.success(), "kill -s {signal} {pid} failed: {status}");
}
fn spawn_binary(args: &[&str]) -> Child {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bugwarden"));
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for var in AMBIENT_VARS {
cmd.env_remove(var);
}
cmd.env("RUST_LOG", "info");
cmd.kill_on_drop(true);
cmd.spawn().expect("the built binary must start")
}
async fn wait_for_tcp(addr: SocketAddr) {
let ready = tokio::time::timeout(EXIT_TIMEOUT, async {
loop {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await;
assert!(ready.is_ok(), "the binary must start serving on {addr}");
}
async fn wait_for_stderr(child: &mut Child, needle: &str) {
let stderr = child.stderr.as_mut().expect("stderr is piped");
let mut lines = BufReader::new(stderr).lines();
let found = tokio::time::timeout(EXIT_TIMEOUT, async {
loop {
let line = lines
.next_line()
.await
.expect("stderr must be readable")
.expect("the server must log the startup line before EOF");
if line.contains(needle) {
return;
}
}
})
.await;
assert!(
found.is_ok(),
"timed out waiting for {needle:?} on the child's stderr"
);
}
async fn assert_graceful_exit(child: Child, what: &str) {
let output = tokio::time::timeout(EXIT_TIMEOUT, child.wait_with_output())
.await
.unwrap_or_else(|_| panic!("{what}: the process must exit within {EXIT_TIMEOUT:?}"))
.expect("the child must be waitable");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(0),
"{what}: SIGTERM/SIGINT must be a clean exit 0, not a signal-kill \
(code=None) or an error: status={status:?} stderr={stderr}",
status = output.status
);
assert!(
stderr.contains("received shutdown signal"),
"{what}: the shutdown path must log that it ran: {stderr}"
);
}
async fn connect_insecure(addr: SocketAddr) -> RunningService<RoleClient, ()> {
let transport = StreamableHttpClientTransport::with_client(
reqwest::Client::new(),
StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")),
);
().serve(transport)
.await
.expect("MCP handshake must succeed under --insecure-no-auth")
}
#[tokio::test]
async fn http_sigterm_exits_zero_on_an_idle_listener() {
let port = free_port();
let child = spawn_binary(&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
"--insecure-no-auth",
]);
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr");
wait_for_tcp(addr).await;
let pid = child.id().expect("the child has a pid");
send_signal(pid, "TERM");
assert_graceful_exit(child, "http idle SIGTERM").await;
}
#[tokio::test]
async fn http_sigint_still_exits_zero() {
let port = free_port();
let child = spawn_binary(&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
"--insecure-no-auth",
]);
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr");
wait_for_tcp(addr).await;
let pid = child.id().expect("the child has a pid");
send_signal(pid, "INT");
assert_graceful_exit(child, "http idle SIGINT").await;
}
#[tokio::test]
async fn http_sigterm_cancels_a_live_session() {
let port = free_port();
let child = spawn_binary(&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
"--insecure-no-auth",
]);
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr");
wait_for_tcp(addr).await;
let _session = connect_insecure(addr).await;
let pid = child.id().expect("the child has a pid");
send_signal(pid, "TERM");
assert_graceful_exit(child, "http live-session SIGTERM").await;
}
#[tokio::test]
async fn stdio_sigterm_during_the_handshake_wait_exits_zero() {
let mut child = spawn_binary(&[
"--transport",
"stdio",
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--api-key",
"test-key",
]);
wait_for_stderr(&mut child, "Starting Bugzilla MCP server on stdio").await;
let _stdin = child.stdin.take();
let pid = child.id().expect("the child has a pid");
send_signal(pid, "TERM");
let status = tokio::time::timeout(EXIT_TIMEOUT, child.wait())
.await
.expect("stdio pre-handshake SIGTERM: the process must exit")
.expect("the child must be waitable");
assert_eq!(
status.code(),
Some(0),
"stdio pre-handshake SIGTERM: clean exit 0, not a signal-kill: {status:?}"
);
}
#[tokio::test]
async fn stdio_sigterm_after_initialize_exits_zero() {
let mut child = spawn_binary(&[
"--transport",
"stdio",
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--api-key",
"test-key",
]);
wait_for_stderr(&mut child, "Starting Bugzilla MCP server on stdio").await;
let mut stdin = child.stdin.take().expect("stdin is piped");
let stdout = child.stdout.take().expect("stdout is piped");
let mut stdout = BufReader::new(stdout).lines();
stdin
.write_all(
br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"binary-shutdown-test","version":"0"}}}
"#,
)
.await
.expect("the child must accept the handshake");
let reply = tokio::time::timeout(EXIT_TIMEOUT, stdout.next_line())
.await
.expect("the handshake must not hang")
.expect("stdout must be readable")
.expect("the server must answer initialize");
assert!(
reply.contains("bugwarden"),
"initialize must complete before the signal: {reply}"
);
let _drain =
tokio::spawn(async move { while stdout.next_line().await.ok().flatten().is_some() {} });
let pid = child.id().expect("the child has a pid");
send_signal(pid, "TERM");
let status = tokio::time::timeout(EXIT_TIMEOUT, child.wait())
.await
.expect("stdio post-handshake SIGTERM: the process must exit")
.expect("the child must be waitable");
assert_eq!(
status.code(),
Some(0),
"stdio post-handshake SIGTERM: clean exit 0, not a signal-kill: {status:?}"
);
}