use std::sync::Arc;
use bugwarden::config::Cli;
use bugwarden::server::{BugWarden, WRITE_TOOLS};
use bugwarden_core::client::BugzillaClient;
use bugwarden_core::guard::Guard;
use bugwarden_core::policy::Policy;
use clap::Parser as _;
use rmcp::model::{CallToolRequestParams, CallToolResult};
use rmcp::service::{RoleClient, RunningService};
use rmcp::ServiceExt as _;
use serde_json::{json, Value};
use wiremock::matchers::{body_partial_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const CREATE_DENIAL: &str = "Filing this bug is not permitted through this server";
async fn client_for(policy: &str, mock: &MockServer) -> RunningService<RoleClient, ()> {
let cfg = Arc::new(Cli::parse_from([
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"stdio",
"--api-key",
"test-key",
]));
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(policy).expect("test policy must parse"),
});
let bz = Arc::new(BugzillaClient::new(&mock.uri(), false).expect("client must build"));
let server = BugWarden::new(cfg, guard, bz).expect("server must build");
let (client_io, server_io) = tokio::io::duplex(1 << 16);
tokio::spawn(async move {
if let Ok(running) = server.serve(server_io).await {
let _ = running.waiting().await;
}
});
().serve(client_io)
.await
.expect("MCP handshake must succeed")
}
async fn call(client: &RunningService<RoleClient, ()>, tool: &str, args: Value) -> CallToolResult {
let Value::Object(args) = args else {
panic!("tool arguments must be a JSON object");
};
client
.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args))
.await
.expect("tool call must not be a protocol error")
}
fn text_of(result: &CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| c.as_text())
.map(|t| t.text.as_str())
.collect()
}
fn is_error(result: &CallToolResult) -> bool {
result.is_error == Some(true)
}
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_classify(mock: &MockServer, bug: Value) {
let id = bug["id"].as_u64().expect("bug fixture has an id");
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": [bug] })))
.expect(1)
.mount(mock)
.await;
}
fn create_args(product: &str) -> Value {
json!({
"product": product,
"component": "core",
"summary": "crash on start",
"version": "1.0",
})
}
fn attachment_args(bug_id: u64, data: &str) -> Value {
json!({
"bug_id": bug_id,
"data": data,
"file_name": "log.txt",
"summary": "boot log",
"content_type": "text/plain",
})
}
#[tokio::test]
async fn create_bug_policy_and_upstream_refusals_are_indistinguishable() {
let denied = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(1)
.mount(&denied)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 1 })))
.expect(0)
.mount(&denied)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
),
&denied,
)
.await;
let refused = call(&client, "create_bug", create_args("SecretSauce")).await;
assert!(is_error(&refused), "policy-denied filing must be refused");
let policy_text = text_of(&refused);
assert_eq!(policy_text, CREATE_DENIAL);
assert_eq!(
denied.received_requests().await.unwrap().len(),
1,
"a policy refusal must cost exactly one upstream request"
);
let upstream = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": true,
"message": "There is no version named '1.0' in the 'openSUSE' product."
})))
.expect(1)
.mount(&upstream)
.await;
let client = client_for("", &upstream).await;
let failed = call(&client, "create_bug", create_args("openSUSE")).await;
assert!(is_error(&failed), "an upstream refusal is still a refusal");
assert_eq!(
text_of(&failed),
policy_text,
"policy and upstream refusals must be byte-identical (I2)"
);
assert_eq!(
upstream.received_requests().await.unwrap().len(),
1,
"an upstream refusal costs the same one request"
);
}
#[tokio::test]
async fn create_bug_success_reaches_bugzilla_untouched() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.and(body_partial_json(json!({
"product": "openSUSE",
"component": "core",
"summary": "crash on start",
"version": "1.0",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 4242 })))
.expect(1)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let result = call(&client, "create_bug", create_args("openSUSE")).await;
assert!(!is_error(&result), "an allowed create must go through");
assert!(text_of(&result).contains("4242"));
}
#[tokio::test]
async fn create_bug_claimed_groups_never_defeat_a_group_rule() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(2)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 1 })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"embargo\"\naction = \"deny\"\n",
"[rule.match]\ngroups = [\"embargo*\"]\n",
),
&mock,
)
.await;
let mut args = create_args("openSUSE");
args["groups"] = json!(["totally-harmless"]);
let claimed = call(&client, "create_bug", args).await;
assert!(
is_error(&claimed),
"a client-claimed group list must not decide a group rule"
);
assert_eq!(text_of(&claimed), CREATE_DENIAL);
let omitted = call(&client, "create_bug", create_args("openSUSE")).await;
assert!(is_error(&omitted));
assert_eq!(text_of(&omitted), CREATE_DENIAL);
}
#[tokio::test]
async fn create_bug_group_restricted_policy_refuses_all_creation() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 1 })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"group-restricted\"\naction = \"deny\"\n",
"[rule.match]\ngroup_restricted = true\n",
),
&mock,
)
.await;
let result = call(&client, "create_bug", create_args("openSUSE")).await;
assert!(is_error(&result));
assert_eq!(text_of(&result), CREATE_DENIAL);
}
#[tokio::test]
async fn add_attachment_requires_attach_not_the_read_side_attachments() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/attachment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [1] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(
concat!(
"default_action = \"deny\"\n",
"[[rule]]\nname = \"read-side\"\naction = \"restrict\"\n",
"capabilities = [\"read\", \"attachments\"]\n",
),
&mock,
)
.await;
let result = call(&client, "add_attachment", attachment_args(7, "QUFB")).await;
assert!(
is_error(&result),
"attachments (read) must not permit upload"
);
assert_eq!(
text_of(&result),
"Bug 7 is not accessible through this server"
);
}
#[tokio::test]
async fn add_attachment_attach_grant_uploads() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/attachment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [31] })))
.expect(1)
.mount(&mock)
.await;
let client = client_for(
concat!(
"default_action = \"deny\"\n",
"[[rule]]\nname = \"uploader\"\naction = \"restrict\"\n",
"capabilities = [\"attach\"]\n",
),
&mock,
)
.await;
let result = call(&client, "add_attachment", attachment_args(7, "QUFB")).await;
assert!(!is_error(&result), "attach grant must permit the upload");
assert!(text_of(&result).contains("31"));
}
#[tokio::test]
async fn add_attachment_size_cap_blocks_before_any_upload() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/attachment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [1] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for("[global]\nmax_attachment_bytes = 8\n", &mock).await;
let oversized = "QUFBQUFBQUFBQUFB"; let result = call(&client, "add_attachment", attachment_args(7, oversized)).await;
assert!(is_error(&result), "an oversized upload must be refused");
assert_eq!(
text_of(&result),
"Attachment exceeds the size limit of this server"
);
}
async fn mount_search(mock: &MockServer, rows: Vec<Value>) {
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;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": rows })))
.mount(mock)
.await;
}
async fn quicksearch_json(client: &RunningService<RoleClient, ()>, query: &str) -> Value {
quicksearch_json_args(client, json!({ "query": query })).await
}
async fn quicksearch_json_args(client: &RunningService<RoleClient, ()>, args: Value) -> Value {
let result = call(client, "bugs_quicksearch", args).await;
assert!(!is_error(&result), "search failed: {}", text_of(&result));
serde_json::from_str(&text_of(&result)).expect("quicksearch returns JSON")
}
#[tokio::test]
async fn quicksearch_id_list_advisory_tracks_the_query_alone() {
let mock = MockServer::start().await;
mount_search(
&mock,
vec![world_readable_bug(101), world_readable_bug(102)],
)
.await;
let client = client_for("", &mock).await;
let mut with_note = quicksearch_json(&client, "#101, 102").await;
let note = with_note["note"]
.as_str()
.expect("an id-list query must carry the advisory")
.to_string();
assert!(note.contains("bug_info"), "the note must steer to bug_info");
let without = quicksearch_json(&client, "kernel crash 101").await;
assert!(
without.get("note").is_none(),
"a content query must not carry the advisory"
);
with_note.as_object_mut().unwrap().remove("note");
assert_eq!(with_note, without);
}
#[tokio::test]
async fn quicksearch_advisory_ignores_hidden_bugs() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
let plain = MockServer::start().await;
mount_search(
&plain,
vec![world_readable_bug(101), world_readable_bug(102)],
)
.await;
let client = client_for(policy, &plain).await;
let served_both = quicksearch_json(&client, "101, 102").await;
assert_eq!(served_both["bugs"].as_array().unwrap().len(), 2);
let note_both = served_both["note"]
.as_str()
.expect("note present")
.to_string();
let hiding = MockServer::start().await;
let mut hidden = world_readable_bug(101);
hidden["product"] = json!("SecretSauce");
mount_search(&hiding, vec![hidden, world_readable_bug(102)]).await;
let client = client_for(policy, &hiding).await;
let served_one = quicksearch_json(&client, "101, 102").await;
let bugs = served_one["bugs"].as_array().unwrap();
assert_eq!(bugs.len(), 1, "the hidden bug is silently dropped");
assert_eq!(bugs[0]["id"], json!(102));
assert_eq!(
served_one["note"].as_str().expect("note still present"),
note_both,
"a hidden bug must not change the advisory"
);
}
#[tokio::test]
async fn quicksearch_advisory_survives_an_all_hidden_result() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
let plain = MockServer::start().await;
mount_search(
&plain,
vec![world_readable_bug(101), world_readable_bug(102)],
)
.await;
let client = client_for(policy, &plain).await;
let visible = quicksearch_json(&client, "101, 102").await;
assert_eq!(visible["bugs"].as_array().unwrap().len(), 2);
let reference_note = visible["note"].as_str().expect("note").to_string();
let hiding = MockServer::start().await;
let mut h1 = world_readable_bug(101);
h1["product"] = json!("SecretSauce");
let mut h2 = world_readable_bug(102);
h2["product"] = json!("SecretSauce");
mount_search(&hiding, vec![h1, h2]).await;
let client = client_for(policy, &hiding).await;
let empty = quicksearch_json(&client, "101, 102").await;
assert_eq!(
empty["bugs"].as_array().unwrap().len(),
0,
"every match is policy-hidden"
);
assert_eq!(
empty["note"]
.as_str()
.expect("the note must survive an empty result"),
reference_note,
"an all-hidden result must not move the advisory"
);
}
#[tokio::test]
async fn quicksearch_advisory_unmoved_by_link_scrubbing() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
let args = json!({
"query": "101, 102",
"include_fields": "id,summary,depends_on",
});
let mut linked = world_readable_bug(101);
linked["depends_on"] = json!([666]);
let plain = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "666"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_readable_bug(666)] })),
)
.mount(&plain)
.await;
mount_search(&plain, vec![linked.clone()]).await;
let client = client_for(policy, &plain).await;
let unscrubbed = quicksearch_json_args(&client, args.clone()).await;
assert_eq!(unscrubbed["bugs"][0]["depends_on"], json!([666]));
let reference_note = unscrubbed["note"].as_str().expect("note").to_string();
let hiding = MockServer::start().await;
let mut secret = world_readable_bug(666);
secret["product"] = json!("SecretSauce");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "666"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [secret] })))
.mount(&hiding)
.await;
mount_search(&hiding, vec![linked]).await;
let client = client_for(policy, &hiding).await;
let scrubbed = quicksearch_json_args(&client, args).await;
assert_eq!(
scrubbed["bugs"][0]["depends_on"],
json!([]),
"the hidden link must actually be scrubbed (I14)"
);
assert_eq!(
scrubbed["note"]
.as_str()
.expect("the note must survive link scrubbing"),
reference_note,
"link scrubbing must not move the advisory"
);
}
#[tokio::test]
async fn quicksearch_advisory_wording_tracks_status_and_id_count() {
let mock = MockServer::start().await;
mount_search(&mock, vec![]).await;
let client = client_for("", &mock).await;
let dflt = quicksearch_json(&client, "101, 102").await;
let dflt_note = dflt["note"].as_str().expect("id-list note");
assert!(dflt_note.contains("matches bug text"), "{dflt_note}");
assert!(!dflt_note.contains("id lookup"), "{dflt_note}");
let bare = quicksearch_json_args(&client, json!({ "query": "101, 102", "status": "" })).await;
let bare_note = bare["note"].as_str().expect("note on the bare path");
assert!(bare_note.contains("exact id lookup"), "{bare_note}");
assert!(!bare_note.contains("matches bug text"), "{bare_note}");
assert!(bare_note.contains("bug_info"), "{bare_note}");
let long_query = (1..=26)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(" ");
let long = quicksearch_json(&client, &long_query).await;
let long_note = long["note"].as_str().expect("note on a long id list");
assert!(long_note.contains("at most 25 ids"), "{long_note}");
assert!(long_note.contains("batch"), "{long_note}");
let cap_query = (1..=25)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(" ");
let cap = quicksearch_json(&client, &cap_query).await;
let cap_note = cap["note"].as_str().expect("note at the cap");
assert!(!cap_note.contains("batch"), "{cap_note}");
}
#[tokio::test]
async fn add_attachment_comment_travels_as_a_plain_string() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/attachment"))
.and(body_partial_json(json!({
"ids": [7],
"data": "QUFB",
"file_name": "log.txt",
"comment": "see the boot log",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [55] })))
.expect(1)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let mut args = attachment_args(7, "QUFB");
args["comment"] = json!("see the boot log");
let result = call(&client, "add_attachment", args).await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert!(text_of(&result).contains("55"));
}
async fn listed_tools(client: &RunningService<RoleClient, ()>) -> Vec<String> {
client
.list_all_tools()
.await
.expect("list_tools must succeed")
.into_iter()
.map(|t| t.name.to_string())
.collect()
}
#[tokio::test]
async fn list_tools_serves_the_pruned_instance_router_i13() {
let mock = MockServer::start().await;
let client = client_for("[global]\nread_only = true\n", &mock).await;
let names = listed_tools(&client).await;
for tool in WRITE_TOOLS {
assert!(
!names.iter().any(|n| n == tool),
"read-only mode must delist write tool {tool} (I13): {names:?}"
);
}
assert!(
names.iter().any(|n| n == "bug_info"),
"a read tool stays listed: {names:?}"
);
let client = client_for("[global]\ndisabled_tools = [\"bug_history\"]\n", &mock).await;
let names = listed_tools(&client).await;
assert!(
!names.iter().any(|n| n == "bug_history"),
"a policy-disabled tool must be delisted (I13): {names:?}"
);
assert!(
names.iter().any(|n| n == "bug_info"),
"a read tool stays listed: {names:?}"
);
}