use std::collections::BTreeSet;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use bugwarden::server::WRITE_TOOLS;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
use tokio::process::{Child, ChildStdin, Command};
#[path = "common/scrub_env.rs"]
mod scrub_env;
#[path = "common/startup_line.rs"]
mod startup_line;
const REPLY_BUDGET: Duration = Duration::from_secs(20);
const STARTUP_READY: &str = "Starting Bugzilla MCP server on ";
const NO_AUDIT_WARNING: &str = "auditing is OFF";
#[test]
fn the_scrub_list_covers_every_environment_fallback() {
scrub_env::assert_the_scrub_list_covers_every_environment_fallback(
scrub_env::AMBIENT_VARS,
scrub_env::HTTP_TOKEN_VARS,
);
}
fn command(args: &[&str]) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bugwarden"));
cmd.args(args).kill_on_drop(true);
for var in scrub_env::AMBIENT_VARS {
cmd.env_remove(var);
}
cmd
}
struct Server {
child: Child,
stdin: ChildStdin,
stdout: tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
}
impl Server {
fn spawn(args: &[&str]) -> Self {
let mut cmd = command(&[
"--transport",
"stdio",
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--api-key",
"test-key",
]);
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = cmd.spawn().expect("the built binary must start");
let stdin = child.stdin.take().expect("stdin is piped");
let stdout = BufReader::new(child.stdout.take().expect("stdout is piped")).lines();
Server {
child,
stdin,
stdout,
}
}
async fn send(&mut self, message: Value) {
self.stdin
.write_all(format!("{message}\n").as_bytes())
.await
.expect("the child must accept input");
}
async fn call(&mut self, message: Value) -> Value {
self.send(message).await;
let line = tokio::time::timeout(REPLY_BUDGET, self.stdout.next_line())
.await
.expect("the child must answer within the budget")
.expect("the child's stdout must be readable")
.expect("the child must not close stdout before answering");
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{line:?}: {e}"))
}
async fn handshake(&mut self) {
let init = self
.call(json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "binary-startup-policy-test", "version": "0" }
}
}))
.await;
assert_eq!(init["result"]["protocolVersion"], "2025-11-25", "{init}");
self.send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }))
.await;
}
async fn close(mut self) {
drop(self.stdin);
let status = tokio::time::timeout(REPLY_BUDGET, self.child.wait())
.await
.expect("the child must exit when stdin closes")
.expect("the child must be waitable");
assert_eq!(
status.code(),
Some(0),
"a peer hangup must end the process cleanly: {status}"
);
}
}
fn policy_file(dir: &Path, read_only: bool) -> PathBuf {
let path = dir.join(format!("policy-read-only-{read_only}.toml"));
std::fs::write(&path, format!("[global]\nread_only = {read_only}\n"))
.expect("the policy file must be writable");
path
}
async fn listed_tools(policy: &Path, read_only_flag: bool) -> BTreeSet<String> {
let policy = policy.to_str().expect("utf-8 path");
let mut args = vec!["--policy", policy];
if read_only_flag {
args.push("--read-only");
}
let mut server = Server::spawn(&args);
server.handshake().await;
let listed = server
.call(json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }))
.await;
let tools: BTreeSet<String> = listed["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("a served listing carries an array of tools: {listed}"))
.iter()
.map(|tool| {
tool["name"]
.as_str()
.unwrap_or_else(|| panic!("every listed tool is named: {listed}"))
.to_owned()
})
.collect();
server.close().await;
tools
}
fn assert_delisted(restricted: &BTreeSet<String>, loosened: &BTreeSet<String>, what: &str) {
for name in WRITE_TOOLS {
assert!(
loosened.contains(*name),
"{what}: {name} must be listed by a deployment that restricts \
nothing, or its absence under one proves nothing: {loosened:?}"
);
assert!(
!restricted.contains(*name),
"{what}: {name} must be removed from the listing (I13): {restricted:?}"
);
}
let expected: BTreeSet<String> = loosened
.iter()
.filter(|name| !WRITE_TOOLS.contains(&name.as_str()))
.cloned()
.collect();
assert!(
!expected.is_empty(),
"{what}: the read tools must survive the restriction, \
or the assertion below passes on an empty listing"
);
assert_eq!(
*restricted, expected,
"{what}: read-only removes the write tools and nothing else"
);
}
#[tokio::test]
async fn the_flag_over_a_read_only_policy_keeps_the_write_tools_delisted() {
let dir = tempfile::tempdir().expect("tempdir");
let restrictive = policy_file(dir.path(), true);
let policy_alone = listed_tools(&restrictive, false).await;
let with_the_flag = listed_tools(&restrictive, true).await;
let loosened = listed_tools(&policy_file(dir.path(), false), false).await;
assert_delisted(&policy_alone, &loosened, "policy true, no flag");
assert_delisted(&with_the_flag, &loosened, "policy true + --read-only");
}
#[tokio::test]
async fn the_flag_tightens_a_policy_that_says_read_only_false() {
let dir = tempfile::tempdir().expect("tempdir");
let policy = policy_file(dir.path(), false);
let restricted = listed_tools(&policy, true).await;
let loosened = listed_tools(&policy, false).await;
assert_delisted(&restricted, &loosened, "policy false + --read-only");
}
#[tokio::test]
async fn a_file_bearing_selection_writes_the_operator_s_audit_file() {
let dir = tempfile::tempdir().expect("tempdir");
let audit_path = dir.path().join("startup-policy-audit.jsonl");
let config_path = dir.path().join("audit.toml");
std::fs::write(
&config_path,
format!("path = {:?}\n", audit_path.to_str().expect("utf-8 path")),
)
.expect("write the audit config");
let mut server = Server::spawn(&["--audit-config", config_path.to_str().expect("utf-8 path")]);
server.handshake().await;
let info = server
.call(json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "mcp_server_info", "arguments": {} }
}))
.await;
assert_eq!(info["result"]["isError"], json!(false), "{info}");
server.close().await;
let written = std::fs::read_to_string(&audit_path).unwrap_or_else(|e| {
panic!(
"the operator's audit file must exist at {}: {e}",
audit_path.display()
)
});
let records: Vec<Value> = written
.lines()
.map(|line| serde_json::from_str(line).unwrap_or_else(|e| panic!("{line:?}: {e}")))
.collect();
assert!(
records.iter().any(|r| r["event"] == "initialize"),
"the session's handshake must be recorded: {written}"
);
let served = records
.iter()
.filter(|r| r["event"] == "tool_call" && r["request"]["tool"] == "mcp_server_info")
.count();
assert_eq!(
served, 1,
"the served call must reach the operator's file exactly once (I15): {written}"
);
}
async fn startup_log(args: &[&str]) -> String {
let mut cmd = command(args);
cmd.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("the built binary must start");
let _stdin = child.stdin.take().expect("stdin is piped");
let mut lines = startup_line::stderr_lines(&mut child);
let mut log = String::new();
startup_line::wait_for_line(&mut lines, &mut log, STARTUP_READY, REPLY_BUDGET).await;
let _ = child.kill().await;
log
}
fn audit_config_in(dir: &Path) -> PathBuf {
let path = dir.join("audit.toml");
std::fs::write(
&path,
format!(
"path = {:?}\n",
dir.join("audit.jsonl").to_str().expect("utf-8 path")
),
)
.expect("write the audit config");
path
}
#[tokio::test]
async fn http_without_a_sink_warns_that_auditing_is_off() {
let log = startup_log(&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--host",
"127.0.0.1",
"--port",
"0",
"--insecure-no-auth",
])
.await;
assert!(
log.contains(NO_AUDIT_WARNING),
"an unaudited http start must say so: {log}"
);
}
#[tokio::test]
async fn http_with_an_audit_file_does_not_warn() {
let dir = tempfile::tempdir().expect("tempdir");
let config = audit_config_in(dir.path());
let log = startup_log(&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--host",
"127.0.0.1",
"--port",
"0",
"--insecure-no-auth",
"--audit-config",
config.to_str().expect("utf-8 path"),
])
.await;
assert!(
!log.contains(NO_AUDIT_WARNING),
"an audited http start must not warn: {log}"
);
}
#[tokio::test]
async fn stdio_without_a_sink_does_not_warn() {
let log = startup_log(&[
"--transport",
"stdio",
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--api-key",
"test-key",
])
.await;
assert!(
!log.contains(NO_AUDIT_WARNING),
"a local stdio session is not the deployment this warns about: {log}"
);
}