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 mut cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"stdio",
"--api-key",
"test-key",
]);
cli.api_key_file = None;
let cfg = Arc::new(cli);
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 create_scoped_rule_files_bugs_without_hiding_reads_issue_26() {
let mock = MockServer::start().await;
let mut bug = world_readable_bug(7);
bug["product"] = json!("SUSE Linux Enterprise Server 15");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("quicksearch", "ALL product:Enterprise"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [bug] })))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 4242 })))
.expect(1)
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"file-new-bugs\"\naction = \"restrict\"\n",
"capabilities = [\"create\"]\noperations = [\"create\"]\n",
"[rule.match]\nproducts = [\"SUSE Linux Enterprise*\"]\n",
"[[rule]]\nname = \"group-restricted\"\naction = \"deny\"\n",
"[rule.match]\ngroup_restricted = true\n",
),
&mock,
)
.await;
let search = call(
&client,
"bugs_quicksearch",
json!({ "query": "product:Enterprise" }),
)
.await;
assert!(
!is_error(&search),
"search must succeed: {}",
text_of(&search)
);
assert!(
text_of(&search).contains("\"id\": 7"),
"the existing bug must not vanish from search: {}",
text_of(&search)
);
let created = call(
&client,
"create_bug",
create_args("SUSE Linux Enterprise Server 15"),
)
.await;
assert!(
!is_error(&created),
"create must be permitted: {}",
text_of(&created)
);
assert!(text_of(&created).contains("4242"));
}
#[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 mount_update_put(mock: &MockServer, body: Value) {
Mock::given(method("PUT"))
.and(path("/rest/bug/7"))
.and(body_partial_json(body))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "id": 7 }] })))
.expect(1)
.mount(mock)
.await;
}
#[tokio::test]
async fn update_fields_sends_see_also_add_and_remove() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(
&mock,
json!({
"see_also": {
"add": ["https://bugzilla.example.org/show_bug.cgi?id=101"],
"remove": ["https://bugzilla.example.org/show_bug.cgi?id=102"],
}
}),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_fields",
json!({
"bug_id": 7,
"see_also_add": ["https://bugzilla.example.org/show_bug.cgi?id=101"],
"see_also_remove": ["https://bugzilla.example.org/show_bug.cgi?id=102"],
}),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
}
#[tokio::test]
async fn update_fields_sends_keywords_as_add_remove_never_set() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(&mock, json!({ "keywords": { "add": ["regression"] } })).await;
Mock::given(method("PUT"))
.and(path("/rest/bug/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "id": 7 }] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_fields",
json!({ "bug_id": 7, "keywords_add": ["regression"] }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
}
#[tokio::test]
async fn update_fields_sets_scalar_fields() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(
&mock,
json!({
"summary": "clearer title",
"url": "https://example.org/crash-report",
"whiteboard": "triaged",
"version": "15.6",
"target_milestone": "Beta1",
"comment": { "body": "retitled after triage" },
}),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_fields",
json!({
"bug_id": 7,
"summary": "clearer title",
"url": "https://example.org/crash-report",
"whiteboard": "triaged",
"version": "15.6",
"target_milestone": "Beta1",
"comment": "retitled after triage",
}),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
}
#[tokio::test]
async fn update_fields_ignores_empty_strings_and_empty_lists() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
Mock::given(method("PUT"))
.and(path("/rest/bug/7"))
.and(body_partial_json(json!({ "summary": "" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "id": 7 }] })))
.expect(0)
.mount(&mock)
.await;
mount_update_put(&mock, json!({ "priority": "P2" })).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_fields",
json!({ "bug_id": 7, "summary": "", "keywords_add": [], "priority": "P2" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
let put_body: Value = mock
.received_requests()
.await
.unwrap()
.iter()
.find(|r| r.method == wiremock::http::Method::PUT)
.map(|r| serde_json::from_slice(&r.body).expect("PUT body is JSON"))
.expect("one PUT reached the mock");
assert_eq!(
put_body,
json!({ "priority": "P2" }),
"empty strings and empty lists must not reach the wire"
);
}
#[tokio::test]
async fn update_fields_new_fields_respect_the_guard() {
let mock = MockServer::start().await;
let mut secret = world_readable_bug(7);
secret["product"] = json!("SecretSauce");
mount_classify(&mock, secret).await;
Mock::given(method("PUT"))
.and(path("/rest/bug/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "id": 7 }] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
),
&mock,
)
.await;
let result = call(
&client,
"update_bug_fields",
json!({ "bug_id": 7, "summary": "probe", "keywords_add": ["regression"] }),
)
.await;
assert!(is_error(&result));
assert_eq!(
text_of(&result),
"Bug 7 is not accessible through this server",
"a denied bug takes the uniform denial for new fields too (I2)"
);
}
#[tokio::test]
async fn update_fields_see_also_targets_respect_the_guard() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
let mut secret = world_readable_bug(999);
secret["product"] = json!("SecretSauce");
mount_classify(&mock, secret).await;
Mock::given(method("PUT"))
.and(path("/rest/bug/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "id": 7 }] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
),
&mock,
)
.await;
let result = call(
&client,
"update_bug_fields",
json!({
"bug_id": 7,
"see_also_add": [format!("{}/show_bug.cgi?id=999", mock.uri())],
}),
)
.await;
assert!(is_error(&result));
assert_eq!(
text_of(&result),
"Bug 999 is not accessible through this server",
"a policy-denied see_also target takes the uniform denial (I2), no PUT"
);
}
#[tokio::test]
async fn update_fields_still_rejects_non_cf_custom_keys() {
let mock = MockServer::start().await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_fields",
json!({
"bug_id": 7,
"custom_fields": {
"see_also": ["https://bugzilla.example.org/show_bug.cgi?id=101"],
},
}),
)
.await;
assert!(is_error(&result));
assert_eq!(
text_of(&result),
"Invalid custom field 'see_also': custom field names must start with 'cf_'"
);
assert!(
mock.received_requests().await.unwrap().is_empty(),
"the cf_ gate must refuse before any upstream request (I7)"
);
}
#[tokio::test]
async fn update_fields_all_empty_call_errors_without_calling_bugzilla() {
let mock = MockServer::start().await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_fields",
json!({
"bug_id": 7,
"summary": "",
"url": "",
"whiteboard": "",
"keywords_add": [],
"see_also_remove": [],
}),
)
.await;
assert!(is_error(&result));
assert_eq!(text_of(&result), "At least one field must be specified");
assert!(
mock.received_requests().await.unwrap().is_empty(),
"an all-empty call must not contact Bugzilla"
);
}
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:?}"
);
}
const IDENTITY_POLICY: &str = concat!(
"[[rule]]\nname = \"my-own-reports\"\naction = \"restrict\"\n",
"capabilities = [\"read\", \"comments\", \"history\", \"attachments\"]\n",
"operations = [\"access\"]\n",
"[rule.match]\ncreated_by_me = true\n",
"[[rule]]\nname = \"group-restricted\"\naction = \"deny\"\n",
"[rule.match]\ngroup_restricted = true\n",
);
fn restricted_bug(id: u64, creator: &str) -> Value {
let mut bug = world_readable_bug(id);
bug["groups"] = json!(["secteam"]);
bug["creator"] = json!(creator);
bug
}
async fn mount_whoami(mock: &MockServer, login: &str, hits: u64) {
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": 1, "name": login, "real_name": "Reporter",
})))
.expect(hits)
.mount(mock)
.await;
}
async fn mount_bug_and_padding(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] })))
.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;
}
#[tokio::test]
async fn created_by_me_carves_own_reports_out_of_a_group_restricted_deny() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 2).await;
mount_bug_and_padding(&mock, restricted_bug(7, "reporter@example.com")).await;
mount_bug_and_padding(&mock, restricted_bug(8, "other.person@example.com")).await;
let client = client_for(IDENTITY_POLICY, &mock).await;
let own = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
assert!(
!is_error(&own),
"own report must be served: {}",
text_of(&own)
);
let own: Value = serde_json::from_str(&text_of(&own)).expect("bug_info returns JSON");
assert_eq!(
own["bugs"][0]["id"],
json!(7),
"the caller's own group-restricted bug is readable"
);
assert!(own["restricted"].as_array().unwrap().is_empty());
let foreign = call(&client, "bug_info", json!({ "bug_ids": [8] })).await;
let foreign: Value = serde_json::from_str(&text_of(&foreign)).expect("bug_info returns JSON");
assert!(foreign["bugs"].as_array().unwrap().is_empty());
assert_eq!(
foreign["restricted"][0]["note"],
json!("Bug 8 is not accessible through this server"),
"someone else's restricted bug takes the uniform denial (I2)"
);
}
#[tokio::test]
async fn created_by_me_whoami_failure_yields_the_same_uniform_denial() {
let broken = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({
"error": true, "message": "internal server error"
})))
.mount(&broken)
.await;
mount_bug_and_padding(&broken, restricted_bug(7, "reporter@example.com")).await;
let client = client_for(IDENTITY_POLICY, &broken).await;
let under_outage = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
let healthy = MockServer::start().await;
mount_whoami(&healthy, "reporter@example.com", 1).await;
mount_bug_and_padding(&healthy, restricted_bug(7, "other.person@example.com")).await;
let client = client_for(IDENTITY_POLICY, &healthy).await;
let foreign = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
assert_eq!(
serde_json::to_string(&under_outage).unwrap(),
serde_json::to_string(&foreign).unwrap(),
"a whoami outage must be indistinguishable from a foreign bug (I2/I4)"
);
let envelope: Value = serde_json::from_str(&text_of(&under_outage)).expect("JSON");
assert_eq!(
envelope["restricted"][0]["note"],
json!("Bug 7 is not accessible through this server")
);
}
#[tokio::test]
async fn whoami_is_called_once_per_tool_call_under_an_identity_policy() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 1).await;
mount_bug_and_padding(&mock, restricted_bug(7, "reporter@example.com")).await;
let client = client_for(IDENTITY_POLICY, &mock).await;
let result = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
assert!(!is_error(&result));
}
#[tokio::test]
async fn whoami_is_never_called_under_a_policy_without_identity_criteria() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 0).await;
mount_bug_and_padding(&mock, world_readable_bug(7)).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, "bug_info", json!({ "bug_ids": [7] })).await;
assert!(!is_error(&result));
}
#[tokio::test]
async fn created_by_me_keeps_own_reports_in_quicksearch_results() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 1).await;
mount_search(
&mock,
vec![
restricted_bug(101, "reporter@example.com"),
restricted_bug(102, "other.person@example.com"),
],
)
.await;
let client = client_for(IDENTITY_POLICY, &mock).await;
let served = quicksearch_json(&client, "kernel crash").await;
let ids: Vec<u64> = served["bugs"]
.as_array()
.expect("quicksearch returns a bugs array")
.iter()
.filter_map(|b| b["id"].as_u64())
.collect();
assert_eq!(
ids,
vec![101],
"search must keep the caller's own restricted bug and drop the foreign one: {served}"
);
}
#[tokio::test]
async fn created_by_me_carves_own_reports_out_for_bug_comments_too() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 2).await;
mount_bug_and_padding(&mock, restricted_bug(7, "reporter@example.com")).await;
mount_bug_and_padding(&mock, restricted_bug(8, "other.person@example.com")).await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": { "7": { "comments": [
{ "id": 1, "bug_id": 7, "text": "first comment", "is_private": false },
] } }
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/8/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": { "8": { "comments": [] } }
})))
.expect(0)
.mount(&mock)
.await;
let client = client_for(IDENTITY_POLICY, &mock).await;
let own = call(&client, "bug_comments", json!({ "id": 7 })).await;
assert!(
!is_error(&own),
"the caller's own report must serve its comments: {}",
text_of(&own)
);
assert!(text_of(&own).contains("first comment"));
let foreign = call(&client, "bug_comments", json!({ "id": 8 })).await;
assert!(is_error(&foreign));
assert_eq!(
text_of(&foreign),
"Bug 8 is not accessible through this server",
"someone else's restricted bug takes the uniform denial (I2)"
);
}
#[tokio::test]
async fn created_by_me_carves_own_reports_out_for_bug_history_too() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 2).await;
mount_bug_and_padding(&mock, restricted_bug(7, "reporter@example.com")).await;
mount_bug_and_padding(&mock, restricted_bug(8, "other.person@example.com")).await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": [{ "id": 7, "history": [{
"when": "2020-02-01T00:00:00Z",
"who": "someone@example.com",
"changes": [
{ "field_name": "status", "removed": "NEW", "added": "CONFIRMED" },
],
}] }]
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/8/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(IDENTITY_POLICY, &mock).await;
let own = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(
!is_error(&own),
"the caller's own report must serve its history: {}",
text_of(&own)
);
assert!(text_of(&own).contains("CONFIRMED"));
let foreign = call(&client, "bug_history", json!({ "id": 8 })).await;
assert!(is_error(&foreign));
assert_eq!(
text_of(&foreign),
"Bug 8 is not accessible through this server",
"someone else's restricted bug takes the uniform denial (I2)"
);
}
const IDENTITY_WRITE_POLICY: &str = concat!(
"[[rule]]\nname = \"my-own-reports\"\naction = \"restrict\"\n",
"capabilities = [\"read\", \"comment\"]\n",
"operations = [\"access\"]\n",
"[rule.match]\ncreated_by_me = true\n",
"[[rule]]\nname = \"group-restricted\"\naction = \"deny\"\n",
"[rule.match]\ngroup_restricted = true\n",
);
#[tokio::test]
async fn created_by_me_reaches_the_write_gate_add_comment_too() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 2).await;
mount_bug_and_padding(&mock, restricted_bug(7, "reporter@example.com")).await;
mount_bug_and_padding(&mock, restricted_bug(8, "other.person@example.com")).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;
Mock::given(method("POST"))
.and(path("/rest/bug/8/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 100 })))
.expect(0)
.mount(&mock)
.await;
let client = client_for(IDENTITY_WRITE_POLICY, &mock).await;
let own = call(
&client,
"add_comment",
json!({ "bug_id": 7, "comment": "adding context" }),
)
.await;
assert!(
!is_error(&own),
"the caller may comment on their own report: {}",
text_of(&own)
);
let foreign = call(
&client,
"add_comment",
json!({ "bug_id": 8, "comment": "adding context" }),
)
.await;
assert!(is_error(&foreign));
assert_eq!(
text_of(&foreign),
"Bug 8 is not accessible through this server",
"someone else's restricted bug takes the uniform denial (I2)"
);
}
#[tokio::test]
async fn whoami_transport_error_does_not_leak_the_api_key_i12() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
let mut cli = Cli::parse_from([
"bugwarden",
"--bugzilla-server",
&format!("http://{addr}"),
"--transport",
"stdio",
"--api-key",
"SUPERSECRETKEY123",
]);
cli.api_key_file = None; let cfg = Arc::new(cli);
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(IDENTITY_POLICY).expect("test policy must parse"),
});
let bz =
Arc::new(BugzillaClient::new(&format!("http://{addr}"), 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;
}
});
let client: RunningService<RoleClient, ()> =
().serve(client_io)
.await
.expect("MCP handshake must succeed");
let result = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
let text = serde_json::to_string(&result).unwrap();
assert!(
!text.contains("SUPERSECRETKEY123"),
"API key leaked into a client-visible result: {text}"
);
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
assert_eq!(
envelope["restricted"][0]["note"],
json!("Bug 7 is not accessible through this server")
);
}