use std::net::SocketAddr;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use bugwarden::config::Cli;
use bugwarden::http_auth::{self, HttpAuth, HttpEnv};
use bugwarden::server::{BugWarden, USER_AGENT, WRITE_TOOLS};
use bugwarden_core::client::BugzillaClient;
use bugwarden_core::guard::Guard;
use bugwarden_core::policy::Policy;
use rmcp::model::CallToolRequestParams;
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpService,
};
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::ServiceExt as _;
use serde_json::{json, Value};
use tokio::process::Child;
use wiremock::matchers::{any, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[path = "common/raw_post.rs"]
mod raw_post;
#[path = "common/deadline.rs"]
mod deadline;
#[path = "common/raw_client.rs"]
mod raw_client;
#[path = "common/pinned_cli.rs"]
mod pinned_cli;
#[path = "common/scrub_env.rs"]
mod scrub_env;
#[path = "common/startup_line.rs"]
mod startup_line;
use deadline::bounded;
use pinned_cli::pinned;
use raw_client::raw_client;
#[test]
fn the_environment_pin_holds() {
pinned_cli::assert_the_pin_drops_every_fallback::<Cli>();
pinned_cli::assert_the_pin_neutralises_a_flag_added_later::<Cli>();
}
const WRITE_TOKEN: &str = "0123456789abcdef0123456789abcdef";
const READ_TOKEN: &str = "fedcba9876543210fedcba9876543210";
const EXIT_TIMEOUT: Duration = Duration::from_secs(20);
#[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,
);
}
async fn serve_guarded(mock: &MockServer, env: &HttpEnv, insecure: bool) -> SocketAddr {
let cli: Cli = pinned(&[
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"http",
]);
let guard = Arc::new(Guard {
policy: Policy::default(),
});
let bz =
Arc::new(BugzillaClient::new(&mock.uri(), false, USER_AGENT).expect("client must build"));
let auth = Arc::new(HttpAuth::resolve(env, insecure).expect("the test gate must resolve"));
let server = BugWarden::new(Arc::new(cli), guard, bz)
.expect("server must build")
.with_scope_enforcement(!auth.is_insecure());
let config = server
.http_server_config()
.expect("the test Host list must be matchable");
let service = StreamableHttpService::new(
move || Ok(server.clone()),
LocalSessionManager::default().into(),
config,
);
let router = http_auth::guard_router(
axum::Router::new().nest_service("/mcp", service),
Arc::clone(&auth),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind an ephemeral port");
let addr = listener.local_addr().expect("bound address");
tokio::spawn(async move {
let _ = axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await;
});
addr
}
fn both_tokens() -> HttpEnv {
HttpEnv {
write: Some(WRITE_TOKEN.to_owned()),
read: Some(READ_TOKEN.to_owned()),
}
}
async fn connect(addr: SocketAddr, token: Option<&str>) -> RunningService<RoleClient, ()> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("ApiKey", "test-key".parse().expect("header value"));
if let Some(token) = token {
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {token}").parse().expect("header value"),
);
}
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.expect("reqwest client");
let transport = StreamableHttpClientTransport::with_client(
client,
StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")),
);
bounded("the MCP handshake", ().serve(transport))
.await
.expect("MCP handshake must succeed")
}
async fn listed_tools(client: &RunningService<RoleClient, ()>) -> Vec<String> {
bounded("tools/list", client.list_all_tools())
.await
.expect("tools/list must succeed")
.into_iter()
.map(|tool| tool.name.to_string())
.collect()
}
fn world_readable_bug(id: u64) -> Value {
json!({
"id": id,
"summary": "a plain bug",
"product": "openSUSE",
"component": "Kernel",
"status": "NEW",
"severity": "normal",
"priority": "P3",
"keywords": [],
"groups": [],
"whiteboard": "",
"creation_time": "2020-01-01T00:00:00Z",
})
}
async fn mount_bug(mock: &MockServer, id: u64) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", id.to_string()))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_readable_bug(id)] })),
)
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.mount(mock)
.await;
}
#[derive(Debug, PartialEq, Eq)]
struct Observed {
status: u16,
www_authenticate: Option<String>,
body: Vec<u8>,
}
async fn probe(client: &reqwest::Client, url: &str, authorization: &[&str]) -> Observed {
let mut request = client.post(url).json(&json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}
}));
for value in authorization {
request = request.header(reqwest::header::AUTHORIZATION, *value);
}
let response = request.send().await.expect("the server must answer");
Observed {
status: response.status().as_u16(),
www_authenticate: response
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.map(|v| v.to_str().expect("ascii header").to_owned()),
body: response.bytes().await.expect("body").to_vec(),
}
}
#[tokio::test]
async fn every_unauthenticated_request_gets_one_byte_identical_refusal() {
let mock = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(500))
.expect(0)
.named("a refused request must make no upstream request")
.mount(&mock)
.await;
let addr = serve_guarded(&mock, &both_tokens(), false).await;
let http = raw_client();
let mcp = format!("http://{addr}/mcp");
let baseline = probe(&http, &mcp, &[]).await;
assert_eq!(baseline.status, 401);
assert_eq!(baseline.www_authenticate.as_deref(), Some("Bearer"));
assert!(baseline.body.is_empty(), "{baseline:?}");
let good = format!("Bearer {WRITE_TOKEN}");
let good_read = format!("Bearer {READ_TOKEN}");
for authorization in [
vec!["Bearer 11111111111111111111111111111111"],
vec!["Bearer 0123456789abcdef0123456789abcde"],
vec![&*format!("Basic {WRITE_TOKEN}")],
vec!["Bearer"],
vec![WRITE_TOKEN],
vec![""],
vec![&*good, &*good_read],
vec![&*good_read, &*good],
vec![&*good, &*good],
vec![&*good, "Bearer 11111111111111111111111111111111"],
vec!["Bearer 11111111111111111111111111111111", &*good],
] {
assert_eq!(
probe(&http, &mcp, &authorization).await,
baseline,
"{authorization:?} must be refused exactly like a missing header"
);
}
assert_ne!(probe(&http, &mcp, &[&good]).await.status, 401);
for url in [
format!("http://{addr}/"),
format!("http://{addr}/mcp/nope"),
format!("http://{addr}/.well-known/oauth-protected-resource"),
] {
assert_eq!(
probe(&http, &url, &[]).await,
baseline,
"{url} must be refused exactly like /mcp"
);
assert_ne!(
probe(&http, &url, &[&good]).await.status,
401,
"an authenticated caller gets the router's own answer, not the gate's"
);
}
}
#[tokio::test]
async fn an_oversized_body_from_a_stranger_is_refused_by_the_gate_not_the_cap() {
let mock = MockServer::start().await;
let addr = serve_guarded(&mock, &both_tokens(), false).await;
let oversized = "x".repeat(5 * 1024 * 1024).into_bytes();
let anonymous = raw_post::post_status_line(addr, None, &oversized).await;
assert!(
anonymous.starts_with("HTTP/1.1 401"),
"the gate answers first: {anonymous:?}"
);
let authenticated =
raw_post::post_status_line(addr, Some(&format!("Bearer {WRITE_TOKEN}")), &oversized).await;
assert!(
authenticated.starts_with("HTTP/1.1 413"),
"a credentialed caller reaches the cap, and only then: {authenticated:?}"
);
let read_scope =
raw_post::post_status_line(addr, Some(&format!("Bearer {READ_TOKEN}")), &oversized).await;
assert!(
read_scope.starts_with("HTTP/1.1 413"),
"the read scope reaches the cap too: {read_scope:?}"
);
}
#[tokio::test]
async fn the_write_token_reaches_the_whole_tool_surface() {
let mock = MockServer::start().await;
mount_bug(&mock, 7).await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 99 })))
.expect(1)
.mount(&mock)
.await;
let addr = serve_guarded(&mock, &both_tokens(), false).await;
let client = connect(addr, Some(WRITE_TOKEN)).await;
let listed = listed_tools(&client).await;
for name in WRITE_TOOLS {
assert!(
listed.iter().any(|t| t == name),
"the write scope must be offered {name}: {listed:?}"
);
}
assert!(listed.iter().any(|t| t == "bug_info"), "{listed:?}");
let result = bounded(
"the add_comment call",
client.call_tool(
CallToolRequestParams::new("add_comment".to_owned()).with_arguments(
json!({ "bug_id": 7, "comment": "hello" })
.as_object()
.expect("object")
.clone(),
),
),
)
.await
.expect("the write scope must reach a write tool");
assert_ne!(result.is_error, Some(true), "{result:?}");
}
#[tokio::test]
async fn the_read_token_is_offered_only_the_read_tools() {
let mock = MockServer::start().await;
let addr = serve_guarded(&mock, &both_tokens(), false).await;
let read = listed_tools(&connect(addr, Some(READ_TOKEN)).await).await;
let write = listed_tools(&connect(addr, Some(WRITE_TOKEN)).await).await;
for name in WRITE_TOOLS {
assert!(
!read.iter().any(|t| t == name),
"the read scope must not be offered {name}: {read:?}"
);
}
let expected: Vec<&String> = write
.iter()
.filter(|t| !WRITE_TOOLS.contains(&t.as_str()))
.collect();
assert_eq!(
read.iter().collect::<Vec<_>>(),
expected,
"the read listing must be the write listing minus the write tools"
);
assert!(!expected.is_empty(), "the fixture must offer read tools");
}
#[tokio::test]
async fn a_read_scope_write_call_is_refused_as_unrouted_and_reaches_no_bugzilla() {
let mock = MockServer::start().await;
mount_bug(&mock, 7).await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.named("a scope-refused write call must POST nothing")
.mount(&mock)
.await;
let addr = serve_guarded(&mock, &both_tokens(), false).await;
let client = connect(addr, Some(READ_TOKEN)).await;
let served = bounded(
"the bug_info call",
client.call_tool(
CallToolRequestParams::new("bug_info".to_owned()).with_arguments(
json!({ "bug_ids": [7] })
.as_object()
.expect("object")
.clone(),
),
),
)
.await
.expect("the read scope must reach a read tool");
assert_ne!(served.is_error, Some(true), "{served:?}");
let refused = bounded(
"the scope-refused add_comment call",
client.call_tool(
CallToolRequestParams::new("add_comment".to_owned()).with_arguments(
json!({ "bug_id": 7, "comment": "hello" })
.as_object()
.expect("object")
.clone(),
),
),
)
.await
.expect_err("the read scope must not reach a write tool");
let unknown = bounded(
"the no_such_tool_at_all call",
client.call_tool(CallToolRequestParams::new("no_such_tool_at_all".to_owned())),
)
.await
.expect_err("an unknown tool is an error");
assert_eq!(
refused.to_string(),
unknown.to_string(),
"a write tool must look exactly like a tool that does not exist"
);
}
const PER_REQUEST_REVISION: &str = "2026-07-28";
async fn per_request_post(
addr: SocketAddr,
token: Option<&str>,
method: &str,
mut params: Value,
) -> reqwest::Response {
params["_meta"] = json!({
"io.modelcontextprotocol/protocolVersion": PER_REQUEST_REVISION,
"io.modelcontextprotocol/clientCapabilities": {},
});
let mut builder = raw_client()
.post(format!("http://{addr}/mcp"))
.header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json")
.header("ApiKey", "test-key")
.header("MCP-Protocol-Version", PER_REQUEST_REVISION)
.header("Mcp-Method", method.to_owned());
if let Some(name) = params.get("name").and_then(Value::as_str) {
builder = builder.header("Mcp-Name", name.to_owned());
}
if let Some(token) = token {
builder = builder.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
}
builder
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }))
.send()
.await
.expect("the server must answer")
}
#[tokio::test]
async fn the_gate_and_its_scopes_cover_the_handshake_free_path() {
let mock = MockServer::start().await;
mount_bug(&mock, 7).await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.named("a scope-refused write call must POST nothing")
.mount(&mock)
.await;
let addr = serve_guarded(&mock, &both_tokens(), false).await;
let stranger = per_request_post(
addr,
None,
"tools/call",
json!({ "name": "bug_info", "arguments": { "bug_ids": [7] } }),
)
.await;
assert_eq!(
stranger.status(),
reqwest::StatusCode::UNAUTHORIZED,
"the gate precedes the lifecycle routing, so a handshake-free \
request from a stranger is refused like any other"
);
let served = per_request_post(
addr,
Some(READ_TOKEN),
"tools/call",
json!({ "name": "bug_info", "arguments": { "bug_ids": [7] } }),
)
.await
.text()
.await
.expect("a body");
assert!(
served.contains("a plain bug"),
"the read scope must reach a read tool here too: {served}"
);
let refused = per_request_post(
addr,
Some(READ_TOKEN),
"tools/call",
json!({ "name": "add_comment", "arguments": { "bug_id": 7, "comment": "hi" } }),
)
.await;
let refused = (refused.status(), refused.text().await.expect("a body"));
let unknown = per_request_post(
addr,
Some(READ_TOKEN),
"tools/call",
json!({ "name": "no_such_tool_at_all", "arguments": {} }),
)
.await;
let unknown = (unknown.status(), unknown.text().await.expect("a body"));
assert_eq!(
refused, unknown,
"a write tool must look exactly like a tool that does not exist"
);
let listing = per_request_post(addr, Some(READ_TOKEN), "tools/list", json!({}))
.await
.text()
.await
.expect("a body");
assert!(
listing.contains("\"bug_info\""),
"the read scope is offered the read tools: {listing}"
);
for write_tool in ["add_comment", "create_bug", "update_bug_status"] {
assert!(
!listing.contains(write_tool),
"{write_tool} must not appear in a read-scope listing: {listing}"
);
}
}
#[tokio::test]
async fn insecure_no_auth_serves_every_caller_the_full_surface() {
let mock = MockServer::start().await;
let addr = serve_guarded(&mock, &HttpEnv::default(), true).await;
let listed = listed_tools(&connect(addr, None).await).await;
for name in WRITE_TOOLS {
assert!(
listed.iter().any(|t| t == name),
"--insecure-no-auth grants the full write scope: {listed:?}"
);
}
}
async fn run_binary(args: &[&str], env: &[(&str, &str)]) -> (Option<i32>, String) {
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_bugwarden"));
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
for var in scrub_env::AMBIENT_VARS {
cmd.env_remove(var);
}
for (key, value) in env {
cmd.env(key, value);
}
let child = cmd.spawn().expect("the built binary must start");
let output = tokio::time::timeout(EXIT_TIMEOUT, child.wait_with_output())
.await
.expect("the binary must exit rather than serve")
.expect("the binary must be waitable");
(
output.status.code(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[tokio::test]
async fn every_startup_misconfiguration_refuses_before_the_port_is_bound() {
let occupied = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = occupied.local_addr().expect("addr").port().to_string();
let base: Vec<&str> = vec![
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--port",
&port,
];
let secret = "SUPERSECRETTOKENSUPERSECRETTOKEN0";
struct Case<'a> {
flags: Vec<&'a str>,
env: Vec<(&'a str, &'a str)>,
expected: &'a str,
}
let case = |flags: Vec<&'static str>, env, expected| Case {
flags,
env,
expected,
};
let cases = vec![
case(vec![], vec![], "requires a bearer token"),
case(
vec!["--insecure-no-auth"],
vec![("BUGWARDEN_HTTP_TOKEN", secret)],
"conflicts with a configured bearer token",
),
case(
vec![],
vec![("BUGWARDEN_HTTP_TOKEN", "tooshort")],
"holds fewer than 32 characters",
),
case(
vec![],
vec![(
"BUGWARDEN_HTTP_READ_TOKEN",
"0123456789abcdef 0123456789abcdef",
)],
"use printable ASCII, and no spaces",
),
case(
vec![],
vec![
("BUGWARDEN_HTTP_TOKEN", secret),
("BUGWARDEN_HTTP_READ_TOKEN", secret),
],
"identical",
),
];
for Case {
flags,
env,
expected,
} in cases
{
let args: Vec<&str> = base.iter().copied().chain(flags.iter().copied()).collect();
let (code, stderr) = run_binary(&args, &env).await;
assert_eq!(code, Some(1), "{expected}: {stderr}");
assert!(
stderr.contains(expected),
"expected {expected:?} in: {stderr}"
);
assert!(
!stderr.contains("failed to bind"),
"the refusal must come before the bind: {stderr}"
);
for (var, value) in &env {
assert!(
stderr.contains(var) || expected == "requires a bearer token",
"{stderr}"
);
assert!(
!stderr.contains(value),
"the error must not echo the token: {stderr}"
);
}
}
drop(occupied);
}
async fn bound_addr(child: &mut Child) -> SocketAddr {
let mut stderr = startup_line::stderr_lines(child);
let mut log = String::new();
let line = startup_line::wait_for_line(
&mut stderr,
&mut log,
startup_line::HTTP_READY,
EXIT_TIMEOUT,
)
.await;
tokio::spawn(async move { while matches!(stderr.next_line().await, Ok(Some(_))) {} });
startup_line::parse_bound_addr(&line)
}
#[tokio::test]
async fn the_shipped_binary_wires_the_read_token_to_the_read_surface() {
let mock = MockServer::start().await;
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_bugwarden"));
cmd.args(["--bugzilla-server", &mock.uri()])
.args(["--host", "127.0.0.1", "--port", "0"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
for var in scrub_env::AMBIENT_VARS {
cmd.env_remove(var);
}
cmd.env("BUGWARDEN_HTTP_TOKEN", WRITE_TOKEN)
.env("BUGWARDEN_HTTP_READ_TOKEN", READ_TOKEN)
.env("RUST_LOG", "info");
cmd.kill_on_drop(true);
let mut child = cmd.spawn().expect("the built binary must start");
let addr = bound_addr(&mut child).await;
let read = listed_tools(&connect(addr, Some(READ_TOKEN)).await).await;
let write = listed_tools(&connect(addr, Some(WRITE_TOKEN)).await).await;
for name in WRITE_TOOLS {
assert!(
write.iter().any(|t| t == name),
"the deployed write token must reach {name}: {write:?}"
);
assert!(
!read.iter().any(|t| t == name),
"the deployed read token must NOT reach {name}: {read:?}"
);
}
assert!(!read.is_empty(), "the read token must still see read tools");
let _ = child.kill().await;
}
#[tokio::test]
async fn a_token_refusal_precedes_the_audit_sink() {
let dir = tempfile::tempdir().expect("tempdir");
let audit_path = dir.path().join("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 (code, stderr) = run_binary(
&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--audit-config",
config_path.to_str().expect("utf-8 path"),
],
&[],
)
.await;
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("requires a bearer token"), "{stderr}");
assert!(
!audit_path.exists(),
"a refused start must not have opened the audit sink"
);
}
#[tokio::test]
async fn a_stdio_start_ignores_the_bearer_tokens() {
for env in [
vec![("BUGWARDEN_HTTP_TOKEN", "tooshort")],
vec![
("BUGWARDEN_HTTP_TOKEN", "0123456789abcdef0123456789abcdef"),
(
"BUGWARDEN_HTTP_READ_TOKEN",
"0123456789abcdef0123456789abcdef",
),
],
] {
let (code, stderr) = run_binary(
&[
"--bugzilla-server",
"https://bugzilla.example.invalid",
"--transport",
"stdio",
],
&env,
)
.await;
assert_eq!(code, Some(1), "{stderr}");
assert!(
stderr.contains("--transport stdio requires"),
"a stdio start must fail on its key, not on the tokens: {stderr}"
);
}
}