use std::sync::Arc;
mod common;
use bugwarden::config::Cli;
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, 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};
#[path = "common/deadline.rs"]
mod deadline;
#[path = "common/pinned_cli.rs"]
mod pinned_cli;
#[path = "common/refused.rs"]
mod refused;
use deadline::bounded;
use pinned_cli::pinned;
#[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 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<Cli> = Arc::new(pinned(&[
"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, USER_AGENT).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;
}
});
bounded("the MCP handshake", ().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");
};
bounded(
&format!("the {tool} call"),
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_custom_field_reaches_the_post_body() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.and(body_partial_json(json!({ "cf_fixed_in": "1.2.3" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 4243 })))
.expect(1)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let mut args = create_args("openSUSE");
args["custom_fields"] = json!({ "cf_fixed_in": "1.2.3" });
let result = call(&client, "create_bug", args).await;
assert!(!is_error(&result), "a cf_* key must reach the POST body");
}
#[tokio::test]
async fn create_bug_rejects_non_cf_custom_keys_with_no_upstream_request() {
let mock = MockServer::start().await;
let client = client_for("", &mock).await;
let mut args = create_args("openSUSE");
args["custom_fields"] = json!({ "assigned_to": "someone@example.org" });
let result = call(&client, "create_bug", args).await;
assert!(is_error(&result));
assert_eq!(
text_of(&result),
"Invalid custom field 'assigned_to': 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 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"))
.and(query_param("offset", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [bug] })))
.expect(1)
.mount(&mock)
.await;
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": [] })))
.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)
);
let search_json: Value = serde_json::from_str(&text_of(&search)).expect("search returns JSON");
assert!(
search_json
.get("bugs")
.and_then(Value::as_array)
.is_some_and(|bugs| bugs.iter().any(|b| b.get("id") == Some(&json!(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_download(mock: &MockServer, content_type: &str, data_b64: &str) {
mount_classify(mock, world_readable_bug(7)).await;
Mock::given(method("GET"))
.and(path("/rest/bug/attachment/55"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attachments": { "55": {
"id": 55,
"bug_id": 7,
"is_private": false,
"size": 128,
"file_name": "log.txt",
"content_type": content_type,
"data": data_b64,
} }
})))
.expect(2)
.mount(mock)
.await;
}
fn summary_of(result: &CallToolResult) -> Value {
let block = result.content[0]
.as_text()
.expect("the summary block is text");
serde_json::from_str(&block.text).expect("the summary block is JSON")
}
#[tokio::test]
async fn download_attachment_windows_text_head_and_tail() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let text: String = (1..=10).map(|i| format!("line{i}\n")).collect();
mount_download(
&mock,
"text/plain",
&general_purpose::STANDARD.encode(&text),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"download_attachment",
json!({ "attachment_id": 55, "head_lines": 2, "tail_lines": 2 }),
)
.await;
assert!(
!is_error(&result),
"windowed download must succeed: {}",
text_of(&result)
);
assert_eq!(result.content.len(), 2, "summary block + windowed text");
let window = result.content[1]
.as_text()
.expect("the windowed payload is a text block");
assert_eq!(window.text, "line1\nline2\nline9\nline10");
let summary = summary_of(&result);
assert_eq!(
summary["truncation"],
json!({ "total_lines": 10, "shown_lines": 4, "truncated_chars": false })
);
assert!(summary.get("windowing_ignored").is_none());
}
#[tokio::test]
async fn download_attachment_max_chars_alone_caps_the_full_text() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let text = "hello world\nsecond line\n";
mount_download(&mock, "text/plain", &general_purpose::STANDARD.encode(text)).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"download_attachment",
json!({ "attachment_id": 55, "max_chars": 5 }),
)
.await;
assert!(!is_error(&result), "capped download: {}", text_of(&result));
let window = result.content[1]
.as_text()
.expect("the capped payload is a text block");
assert_eq!(window.text, "hello");
assert_eq!(
summary_of(&result)["truncation"],
json!({ "total_lines": 2, "shown_lines": 1, "truncated_chars": true })
);
}
#[tokio::test]
async fn download_attachment_ignores_windowing_params_on_an_image() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let png = general_purpose::STANDARD.encode(b"\x89PNG\r\n\x1a\n");
mount_download(&mock, "image/png", &png).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"download_attachment",
json!({ "attachment_id": 55, "head_lines": 1, "max_chars": 4 }),
)
.await;
assert!(
!is_error(&result),
"the image is served: {}",
text_of(&result)
);
let image = result.content[1]
.as_image()
.expect("an image attachment is served as image content");
assert_eq!(image.data, png);
let summary = summary_of(&result);
assert_eq!(
summary["windowing_ignored"],
json!("not a text content type")
);
assert!(summary.get("truncation").is_none());
}
#[tokio::test]
async fn download_attachment_ignores_windowing_params_on_a_binary() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let blob = general_purpose::STANDARD.encode(b"\x00\x01\x02\x03");
mount_download(&mock, "application/octet-stream", &blob).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"download_attachment",
json!({ "attachment_id": 55, "tail_lines": 3 }),
)
.await;
assert!(
!is_error(&result),
"the blob is served: {}",
text_of(&result)
);
let Some(embedded) = result.content[1].as_resource() else {
panic!("a binary attachment is served as a blob resource")
};
let rmcp::model::ResourceContents::BlobResourceContents { uri, blob: b, .. } =
&embedded.resource
else {
panic!("a binary attachment is served as a BLOB resource")
};
assert_eq!(uri, "bugzilla://attachment/55");
assert_eq!(b, &blob, "the payload is served unwindowed");
let summary = summary_of(&result);
assert_eq!(
summary["windowing_ignored"],
json!("not a text content type")
);
assert!(summary.get("truncation").is_none());
}
#[tokio::test]
async fn download_attachment_denial_is_byte_identical_with_windowing_params() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/attachment/999"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "attachments": {} })))
.expect(2)
.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": [] })))
.expect(2)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let plain = call(
&client,
"download_attachment",
json!({ "attachment_id": 999 }),
)
.await;
let windowed = call(
&client,
"download_attachment",
json!({ "attachment_id": 999, "head_lines": 5, "tail_lines": 5, "max_chars": 10 }),
)
.await;
assert!(is_error(&plain) && is_error(&windowed));
assert_eq!(
text_of(&plain),
"Attachment 999 is not accessible through this server"
);
assert_eq!(
serde_json::to_value(&plain).unwrap(),
serde_json::to_value(&windowed).unwrap(),
"a denial must not change by one byte when windowing params ride along (I2)"
);
}
#[tokio::test]
async fn download_attachment_windowing_cannot_serve_an_over_cap_attachment() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let text: String = (1..=20).map(|i| format!("secret line {i}\n")).collect();
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_readable_bug(7)] })),
)
.expect(2)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/attachment/55"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"attachments": { "55": {
"id": 55,
"bug_id": 7,
"is_private": false,
"size": 8,
"file_name": "log.txt",
"content_type": "text/plain",
"data": general_purpose::STANDARD.encode(&text),
} }
})))
.expect(4)
.mount(&mock)
.await;
let client = client_for("[global]\nmax_attachment_bytes = 64\n", &mock).await;
let windowed = call(
&client,
"download_attachment",
json!({ "attachment_id": 55, "head_lines": 1 }),
)
.await;
let plain = call(
&client,
"download_attachment",
json!({ "attachment_id": 55 }),
)
.await;
assert!(is_error(&windowed), "an over-cap attachment stays refused");
assert_eq!(
text_of(&windowed),
"Attachment 55 exceeds the size limit of this server"
);
assert_eq!(
windowed.content.len(),
1,
"the refusal carries no content block, windowed or not"
);
assert_eq!(
serde_json::to_value(&windowed).unwrap(),
serde_json::to_value(&plain).unwrap(),
"windowing params must not move the over-cap refusal by one byte"
);
}
#[tokio::test]
async fn download_attachment_without_windowing_params_keeps_the_blob_shape() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let text = "alpha\nbeta\ngamma\n";
let data = general_purpose::STANDARD.encode(text);
mount_download(&mock, "text/plain", &data).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"download_attachment",
json!({ "attachment_id": 55 }),
)
.await;
assert!(
!is_error(&result),
"unwindowed download: {}",
text_of(&result)
);
assert_eq!(result.content.len(), 2, "summary block + blob resource");
let summary = result.content[0].as_text().expect("summary text block");
assert_eq!(
summary.text,
serde_json::to_string(&json!({
"id": 55,
"bug_id": 7,
"file_name": "log.txt",
"content_type": "text/plain",
"size": 128,
}))
.unwrap()
);
let Some(embedded) = result.content[1].as_resource() else {
panic!("a text attachment without params keeps the blob resource")
};
let rmcp::model::ResourceContents::BlobResourceContents { uri, blob, .. } = &embedded.resource
else {
panic!("blob resource")
};
assert_eq!(uri, "bugzilla://attachment/55");
assert_eq!(blob, &data, "the full payload, unwindowed");
}
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(move |req: &wiremock::Request| {
let q: std::collections::HashMap<_, _> = req.url.query_pairs().collect();
if q.get("offset").is_some_and(|v| v != "0") {
return ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] }));
}
ResponseTemplate::new(200).set_body_json(json!({ "bugs": rows.clone() }))
})
.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"));
}
const HIDE_SECRET_POLICY: &str = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
fn ungroup(envelope: &Value) -> Vec<Value> {
envelope["groups"]
.as_array()
.expect("a grouped envelope has `groups`")
.iter()
.flat_map(|g| {
let hoisted: Vec<(String, Value)> = g
.as_object()
.expect("group object")
.iter()
.filter(|(k, _)| *k != "bugs")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
g["bugs"]
.as_array()
.expect("each group has `bugs`")
.iter()
.map(move |b| {
let mut bug = b.as_object().expect("bug object").clone();
for (k, v) in &hoisted {
bug.insert(k.clone(), v.clone());
}
Value::Object(bug)
})
.collect::<Vec<_>>()
})
.collect()
}
#[tokio::test]
async fn quicksearch_groups_follow_served_row_order_not_sort_order() {
let mock = MockServer::start().await;
let mut first = world_readable_bug(101);
first["product"] = json!("Zebra");
let mut second = world_readable_bug(102);
second["product"] = json!("Alpha");
let mut third = world_readable_bug(103);
third["product"] = json!("Zebra");
mount_search(&mock, vec![first, second, third]).await;
let client = client_for("", &mock).await;
let grouped =
quicksearch_json_args(&client, json!({ "query": "kernel", "group_by": "product" })).await;
let groups = grouped["groups"].as_array().expect("groups");
assert_eq!(groups.len(), 2);
assert_eq!(
groups[0]["product"],
json!("Zebra"),
"first appearance wins"
);
assert_eq!(groups[1]["product"], json!("Alpha"));
let ids: Vec<&Value> = groups[0]["bugs"]
.as_array()
.expect("bugs")
.iter()
.map(|b| &b["id"])
.collect();
assert_eq!(
ids,
vec![&json!(101), &json!(103)],
"and within a group too"
);
}
#[tokio::test]
async fn quicksearch_empty_group_by_is_the_flat_response() {
let mock = MockServer::start().await;
mount_search(&mock, vec![world_readable_bug(101)]).await;
let client = client_for("", &mock).await;
let flat = quicksearch_json_args(&client, json!({ "query": "kernel" })).await;
let empty = quicksearch_json_args(&client, json!({ "query": "kernel", "group_by": "" })).await;
assert_eq!(empty, flat, "an empty group_by changes nothing");
}
#[tokio::test]
async fn quicksearch_grouping_only_reshapes_the_same_bugs() {
let mock = MockServer::start().await;
let mut other = world_readable_bug(102);
other["component"] = json!("YaST");
mount_search(&mock, vec![world_readable_bug(101), other]).await;
let client = client_for("", &mock).await;
let flat = quicksearch_json_args(&client, json!({ "query": "kernel" })).await;
let grouped = quicksearch_json_args(
&client,
json!({ "query": "kernel", "group_by": "product,severity" }),
)
.await;
assert_eq!(
grouped["groups"].as_array().expect("groups").len(),
1,
"both bugs share product+severity"
);
assert_eq!(grouped["groups"][0]["product"], json!("openSUSE"));
assert_eq!(grouped["groups"][0]["severity"], json!("normal"));
assert!(
grouped["groups"][0]["bugs"][0].get("product").is_none(),
"a grouped field is reported once per group, not per bug"
);
let mut round_tripped = ungroup(&grouped);
for bug in &mut round_tripped {
bug.as_object_mut().expect("bug object").remove("severity");
}
assert_eq!(
round_tripped,
*flat["bugs"].as_array().expect("flat bugs"),
"grouping must not add, drop or reorder a bug"
);
}
#[tokio::test]
async fn quicksearch_grouping_hides_the_same_bugs_as_a_flat_search() {
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(HIDE_SECRET_POLICY, &hiding).await;
let filtered =
quicksearch_json_args(&client, json!({ "query": "kernel", "group_by": "product" })).await;
let clean = MockServer::start().await;
mount_search(&clean, vec![world_readable_bug(102)]).await;
let client = client_for(HIDE_SECRET_POLICY, &clean).await;
let reference =
quicksearch_json_args(&client, json!({ "query": "kernel", "group_by": "product" })).await;
assert_eq!(
filtered, reference,
"a dropped bug must not show up as a group, a header or a count"
);
assert!(
!serde_json::to_string(&filtered)
.expect("serializable")
.contains("SecretSauce"),
"the hidden bug's product must not be hoisted into a header"
);
}
#[tokio::test]
async fn quicksearch_grouping_leaves_the_advisory_note_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 flat = quicksearch_json(&client, "#101, 102").await;
let grouped = quicksearch_json_args(
&client,
json!({ "query": "#101, 102", "group_by": "product" }),
)
.await;
assert_eq!(
grouped["note"], flat["note"],
"the advisory must survive grouping unchanged"
);
assert!(
grouped.get("bugs").is_none() && grouped.get("groups").is_some(),
"and it must not have replaced the grouped envelope"
);
}
#[tokio::test]
async fn quicksearch_grouping_keeps_the_redacted_marker() {
let policy = concat!(
"[[rule]]\nname = \"summary-only\"\naction = \"restrict\"\n",
"capabilities = [\"summary\"]\n",
"[rule.match]\nproducts = [\"openSUSE\"]\n",
);
let mock = MockServer::start().await;
mount_search(&mock, vec![world_readable_bug(101)]).await;
let client = client_for(policy, &mock).await;
let grouped = quicksearch_json_args(
&client,
json!({ "query": "kernel", "group_by": "product,status" }),
)
.await;
assert_eq!(grouped["groups"][0]["product"], json!("openSUSE"));
assert_eq!(grouped["groups"][0]["status"], json!("NEW"));
assert_eq!(grouped["groups"][0]["bugs"][0]["_redacted"], json!(true));
}
#[tokio::test]
async fn quicksearch_rejects_an_unknown_group_by_without_calling_upstream() {
let mock = MockServer::start().await;
mount_search(&mock, vec![world_readable_bug(101)]).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"bugs_quicksearch",
json!({ "query": "kernel", "group_by": "assigned_to" }),
)
.await;
assert!(is_error(&result));
let text = text_of(&result);
assert!(text.contains("assigned_to"), "text: {text}");
assert!(text.contains("product"), "the vocabulary is listed: {text}");
assert!(
mock.received_requests()
.await
.unwrap_or_default()
.is_empty(),
"a malformed projection must not cost a Bugzilla round trip"
);
}
#[tokio::test]
async fn quicksearch_group_by_does_not_soften_a_failing_search() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"bugs_quicksearch",
json!({ "query": "kernel", "group_by": "product" }),
)
.await;
assert!(is_error(&result));
assert_eq!(text_of(&result), "Search failed");
}
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 sole_put_body(mock: &MockServer) -> 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")
}
#[tokio::test]
async fn update_bug_status_without_resolution_omits_it_from_the_wire() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(&mock, json!({ "status": "RESOLVED" })).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_status",
json!({ "bug_id": 7, "status": "RESOLVED" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(sole_put_body(&mock).await, json!({ "status": "RESOLVED" }));
}
#[tokio::test]
async fn update_bug_status_with_resolution_sends_both() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(
&mock,
json!({ "status": "RESOLVED", "resolution": "FIXED" }),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_status",
json!({ "bug_id": 7, "status": "RESOLVED", "resolution": "FIXED" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(
sole_put_body(&mock).await,
json!({ "status": "RESOLVED", "resolution": "FIXED" })
);
}
#[tokio::test]
async fn update_bug_status_closed_without_resolution_reaches_upstream() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(&mock, json!({ "status": "CLOSED" })).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_status",
json!({ "bug_id": 7, "status": "CLOSED" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(sole_put_body(&mock).await, json!({ "status": "CLOSED" }));
}
#[tokio::test]
async fn mark_as_duplicate_sends_only_dupe_of_and_comment() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_classify(&mock, world_readable_bug(8)).await;
mount_update_put(
&mock,
json!({
"dupe_of": 8,
"comment": { "body": "Marking as duplicate of bug 8" },
}),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"mark_as_duplicate",
json!({ "bug_id": 7, "duplicate_of": 8 }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(
sole_put_body(&mock).await,
json!({
"dupe_of": 8,
"comment": { "body": "Marking as duplicate of bug 8" },
})
);
}
async fn listed_tools(client: &RunningService<RoleClient, ()>) -> Vec<String> {
bounded("tools/list", 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)"
);
}
const DECLARED_IDENTITY_POLICY: &str = concat!(
"[global]\n",
"identity_source = \"declared\"\n",
"identity_login = \"reporter@example.com\"\n",
"[[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",
);
#[tokio::test]
async fn declared_identity_carves_own_reports_out_with_zero_whoami_hits() {
let mock = MockServer::start().await;
mount_whoami(&mock, "reporter@example.com", 0).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(DECLARED_IDENTITY_POLICY, &mock).await;
let own = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
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 under a declared login"
);
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)"
);
}
async fn mount_history_window_fixture(mock: &MockServer, entries: Value) {
mount_bug_and_padding(mock, world_readable_bug(7)).await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": [{ "id": 7, "history": entries }]
})))
.mount(mock)
.await;
}
fn five_history_entries() -> Value {
(1..=5)
.map(|n| {
json!({
"when": format!("2020-01-0{n}T00:00:00Z"),
"who": "dev@example.org",
"changes": [
{ "field_name": "status", "removed": "NEW", "added": "CONFIRMED" },
],
})
})
.collect()
}
fn history_json(result: &CallToolResult) -> Value {
serde_json::from_str(&text_of(result)).expect("bug_history returns JSON")
}
#[tokio::test]
async fn bug_history_without_window_params_serves_the_bare_array() {
let mock = MockServer::start().await;
mount_history_window_fixture(&mock, five_history_entries()).await;
let client = client_for("", &mock).await;
let served = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(
!is_error(&served),
"the history is served: {}",
text_of(&served)
);
let parsed = history_json(&served);
let entries = parsed
.as_array()
.expect("no window params means the bare array, not an envelope");
assert_eq!(entries.len(), 5);
}
#[tokio::test]
async fn bug_history_head_keeps_the_first_entries() {
let mock = MockServer::start().await;
mount_history_window_fixture(&mock, five_history_entries()).await;
let client = client_for("", &mock).await;
let served = call(&client, "bug_history", json!({ "id": 7, "head": 2 })).await;
let parsed = history_json(&served);
let shown = parsed["history"].as_array().expect("windowed envelope");
assert_eq!(shown.len(), 2);
assert_eq!(shown[0]["when"], json!("2020-01-01T00:00:00Z"));
assert_eq!(shown[1]["when"], json!("2020-01-02T00:00:00Z"));
assert_eq!(
parsed["truncation"],
json!({ "omitted_entries": 3, "shown_entries": 2 }),
"the envelope reports what the window omitted: {parsed}"
);
}
#[tokio::test]
async fn bug_history_tail_keeps_the_last_entries() {
let mock = MockServer::start().await;
mount_history_window_fixture(&mock, five_history_entries()).await;
let client = client_for("", &mock).await;
let served = call(&client, "bug_history", json!({ "id": 7, "tail": 2 })).await;
let parsed = history_json(&served);
let shown = parsed["history"].as_array().expect("windowed envelope");
assert_eq!(shown.len(), 2);
assert_eq!(shown[0]["when"], json!("2020-01-04T00:00:00Z"));
assert_eq!(shown[1]["when"], json!("2020-01-05T00:00:00Z"));
assert_eq!(
parsed["truncation"],
json!({ "omitted_entries": 3, "shown_entries": 2 })
);
}
#[tokio::test]
async fn bug_history_head_and_tail_keep_both_ends() {
let mock = MockServer::start().await;
mount_history_window_fixture(&mock, five_history_entries()).await;
let client = client_for("", &mock).await;
let served = call(
&client,
"bug_history",
json!({ "id": 7, "head": 1, "tail": 1 }),
)
.await;
let parsed = history_json(&served);
let shown = parsed["history"].as_array().expect("windowed envelope");
assert_eq!(shown.len(), 2);
assert_eq!(shown[0]["when"], json!("2020-01-01T00:00:00Z"));
assert_eq!(shown[1]["when"], json!("2020-01-05T00:00:00Z"));
assert_eq!(
parsed["truncation"],
json!({ "omitted_entries": 3, "shown_entries": 2 })
);
}
#[tokio::test]
async fn bug_history_overlapping_windows_omit_nothing() {
let mock = MockServer::start().await;
mount_history_window_fixture(&mock, five_history_entries()).await;
let client = client_for("", &mock).await;
let served = call(
&client,
"bug_history",
json!({ "id": 7, "head": 3, "tail": 3 }),
)
.await;
let parsed = history_json(&served);
assert_eq!(parsed["history"].as_array().unwrap().len(), 5);
assert_eq!(
parsed["truncation"],
json!({ "omitted_entries": 0, "shown_entries": 5 })
);
}
#[tokio::test]
async fn bug_history_windows_after_the_i14_scrub() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).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-01-01T00:00:00Z",
"who": "dev@example.org",
"changes": [
{ "field_name": "status", "removed": "NEW", "added": "CONFIRMED" },
],
},
{
"when": "2020-01-02T00:00:00Z",
"who": "dev@example.org",
"changes": [
{ "field_name": "depends_on", "removed": "", "added": "666" },
],
},
{
"when": "2020-01-03T00:00:00Z",
"who": "dev@example.org",
"changes": [
{ "field_name": "status", "removed": "CONFIRMED", "added": "RESOLVED" },
],
},
] }]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "666"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
),
&mock,
)
.await;
let served = call(&client, "bug_history", json!({ "id": 7, "head": 2 })).await;
assert!(
!is_error(&served),
"the history is served: {}",
text_of(&served)
);
let text = text_of(&served);
assert!(
!text.contains("666"),
"the hidden id must never reach the client (I14): {text}"
);
let parsed: Value = serde_json::from_str(&text).expect("bug_history returns JSON");
let shown = parsed["history"].as_array().expect("windowed envelope");
assert_eq!(
shown.len(),
2,
"the scrubbed-out entry consumed no window slot: {parsed}"
);
assert_eq!(shown[0]["when"], json!("2020-01-01T00:00:00Z"));
assert_eq!(shown[1]["when"], json!("2020-01-03T00:00:00Z"));
assert_eq!(
parsed["truncation"],
json!({ "omitted_entries": 0, "shown_entries": 2 }),
"the window omitted nothing — the scrub, not the window, dropped \
the entry: {parsed}"
);
}
#[tokio::test]
async fn bug_history_window_params_leave_the_denial_untouched_i2() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": [{ "id": 7, "history": five_history_entries() }]
})))
.expect(0)
.mount(&mock)
.await;
let client = client_for(
concat!(
"[[rule]]\nname = \"hide-kernel\"\naction = \"deny\"\n",
"[rule.match]\ncomponents = [\"Kernel\"]\n",
),
&mock,
)
.await;
let plain = call(&client, "bug_history", json!({ "id": 7 })).await;
assert!(is_error(&plain));
assert_eq!(
text_of(&plain),
"Bug 7 is not accessible through this server"
);
for args in [
json!({ "id": 7, "head": 2 }),
json!({ "id": 7, "tail": 2 }),
json!({ "id": 7, "head": 1, "tail": 1 }),
] {
let windowed = call(&client, "bug_history", args.clone()).await;
assert!(is_error(&windowed), "{args}");
assert_eq!(
text_of(&windowed),
text_of(&plain),
"a windowed denial must be byte-identical to the plain one: {args}"
);
}
}
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 base = refused::refused_base_url();
let cfg: Arc<Cli> = Arc::new(pinned(&[
"bugwarden",
"--bugzilla-server",
&base,
"--transport",
"stdio",
"--api-key",
"SUPERSECRETKEY123",
]));
let guard = Arc::new(Guard {
policy: Policy::from_toml_str(IDENTITY_POLICY).expect("test policy must parse"),
});
let bz = Arc::new(BugzillaClient::new(&base, false, USER_AGENT).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, ()> = bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed");
let result = tokio::time::timeout(
common::REFUSED_CONNECT_BUDGET,
call(&client, "bug_info", json!({ "bug_ids": [7] })),
)
.await
.expect("connect to the refused privileged port must not hang");
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")
);
}
const DISCOVERY_POLICY: &str = "[global]\nallow_discovery = true\n";
#[tokio::test]
async fn bugzilla_products_catalog_is_id_name_pairs_only() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product_enterable"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [1, 2] })))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/product"))
.and(query_param("ids", "1"))
.and(query_param("ids", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"products": [
{ "id": 1, "name": "TestProduct", "description": "hidden from the catalog" },
{ "id": 2, "name": "OtherProduct" },
]
})))
.mount(&mock)
.await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(&client, "bugzilla_products", json!({})).await;
assert!(!is_error(&result), "{}", text_of(&result));
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
assert_eq!(
envelope["products"],
json!([
{ "id": 1, "name": "TestProduct" },
{ "id": 2, "name": "OtherProduct" },
])
);
}
#[tokio::test]
async fn bugzilla_products_detail_strips_account_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/product"))
.and(query_param("names", "TestProduct"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"products": [{
"id": 1,
"name": "TestProduct",
"description": "A test product.",
"is_active": true,
"default_milestone": "---",
"has_unconfirmed": true,
"components": [{
"name": "core",
"description": "Core component",
"is_active": true,
"default_assigned_to": "admin@bugzilla.org",
"default_qa_contact": "qa@bugzilla.org",
}],
"versions": [{ "name": "1.0", "is_active": true }],
"milestones": [{ "name": "---", "is_active": true }],
}]
})))
.mount(&mock)
.await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(
&client,
"bugzilla_products",
json!({ "products": ["TestProduct"] }),
)
.await;
assert!(!is_error(&result), "{}", text_of(&result));
let text = text_of(&result);
assert!(
!text.contains("default_assigned_to") && !text.contains("default_qa_contact"),
"account emails must never appear in the response: {text}"
);
let envelope: Value = serde_json::from_str(&text).expect("JSON");
assert_eq!(
envelope["products"][0],
json!({
"name": "TestProduct",
"description": "A test product.",
"is_active": true,
"default_milestone": "---",
"has_unconfirmed": true,
"components": [{ "name": "core", "description": "Core component", "is_active": true }],
"versions": [{ "name": "1.0", "is_active": true }],
"milestones": [{ "name": "---", "is_active": true }],
})
);
}
#[tokio::test]
async fn bugzilla_products_over_cap_makes_no_upstream_request() {
let mock = MockServer::start().await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(
&client,
"bugzilla_products",
json!({ "products": ["a", "b", "c", "d", "e", "f"] }),
)
.await;
assert!(is_error(&result));
assert_eq!(text_of(&result), "At most 5 products per call");
assert!(
mock.received_requests().await.unwrap().is_empty(),
"the cap refusal must make zero upstream requests"
);
}
#[tokio::test]
async fn bug_fields_catalog_carries_no_values() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [{
"id": 13,
"name": "priority",
"display_name": "Priority",
"type": 2,
"is_custom": false,
"is_mandatory": false,
"is_on_bug_entry": false,
"visibility_field": null,
"visibility_values": [],
"values": [{ "name": "P1" }, { "name": "P2" }],
}]
})))
.mount(&mock)
.await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(&client, "bug_fields", json!({})).await;
assert!(!is_error(&result), "{}", text_of(&result));
let text = text_of(&result);
assert!(
!text.contains("\"values\""),
"catalog must carry no values: {text}"
);
let envelope: Value = serde_json::from_str(&text).expect("JSON");
assert_eq!(
envelope["fields"][0],
json!({
"name": "priority",
"display_name": "Priority",
"type": 2,
"is_custom": false,
"is_mandatory": false,
"is_on_bug_entry": false,
"visibility_field": null,
"visibility_values": [],
"has_values": true,
})
);
}
#[tokio::test]
async fn bug_fields_catalog_can_be_filtered_to_bug_entry_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [
{ "name": "priority", "is_on_bug_entry": false },
{ "name": "cf_severity_extra", "is_on_bug_entry": true },
]
})))
.mount(&mock)
.await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(&client, "bug_fields", json!({ "on_bug_entry_only": true })).await;
assert!(!is_error(&result), "{}", text_of(&result));
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
let names: Vec<&str> = envelope["fields"]
.as_array()
.unwrap()
.iter()
.map(|f| f["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["cf_severity_extra"]);
}
#[tokio::test]
async fn bug_fields_detail_reports_workflow_data_when_upstream_carries_it() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug/bug_status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [{
"name": "bug_status",
"display_name": "Status",
"is_custom": false,
"is_mandatory": false,
"is_on_bug_entry": false,
"values": [
{
"name": "NEW",
"is_open": true,
"can_change_to": [
{ "name": "ASSIGNED", "comment_required": false },
{ "name": "RESOLVED", "comment_required": true },
],
},
{ "name": "RESOLVED", "is_open": false, "can_change_to": [] },
],
}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/field/bug/priority"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [{
"name": "priority",
"display_name": "Priority",
"is_custom": false,
"is_mandatory": false,
"is_on_bug_entry": false,
"values": [{ "name": "P1" }, { "name": "P2" }],
}]
})))
.mount(&mock)
.await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(
&client,
"bug_fields",
json!({ "field_names": ["bug_status"] }),
)
.await;
assert!(!is_error(&result), "{}", text_of(&result));
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
assert_eq!(
envelope["fields"][0]["values"],
json!([
{
"name": "NEW",
"is_open": true,
"can_change_to": [
{ "name": "ASSIGNED", "comment_required": false },
{ "name": "RESOLVED", "comment_required": true },
],
},
{ "name": "RESOLVED", "is_open": false, "can_change_to": [] },
])
);
let result = call(
&client,
"bug_fields",
json!({ "field_names": ["priority"] }),
)
.await;
assert!(!is_error(&result), "{}", text_of(&result));
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
assert_eq!(
envelope["fields"][0]["values"],
json!([{ "name": "P1" }, { "name": "P2" }])
);
}
#[tokio::test]
async fn bug_fields_over_cap_makes_no_upstream_request() {
let mock = MockServer::start().await;
let client = client_for(DISCOVERY_POLICY, &mock).await;
let result = call(
&client,
"bug_fields",
json!({ "field_names": ["a", "b", "c", "d", "e", "f"] }),
)
.await;
assert!(is_error(&result));
assert_eq!(text_of(&result), "At most 5 field names per call");
assert!(
mock.received_requests().await.unwrap().is_empty(),
"the cap refusal must make zero upstream requests"
);
}
#[tokio::test]
async fn discovery_tools_absent_from_the_listing_by_default() {
let mock = MockServer::start().await;
let client = client_for("", &mock).await;
let tools = bounded("tools/list", client.list_all_tools())
.await
.expect("list_tools must succeed");
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(!names.contains(&"bugzilla_products"));
assert!(!names.contains(&"bug_fields"));
let client = client_for(DISCOVERY_POLICY, &mock).await;
let tools = bounded("tools/list", client.list_all_tools())
.await
.expect("list_tools must succeed");
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"bugzilla_products"));
assert!(names.contains(&"bug_fields"));
}
fn detailed_bug(id: u64) -> Value {
let mut bug = world_readable_bug(id);
bug["assigned_to"] = json!("dev@example.com");
bug["assigned_to_detail"] = json!({
"id": 5, "email": "dev@example.com", "name": "dev@example.com",
"real_name": "Dev",
});
bug["cc"] = json!(["watcher@example.com"]);
bug["cc_detail"] = json!([{
"id": 6, "email": "watcher@example.com", "name": "watcher@example.com",
"real_name": "Watcher",
}]);
bug
}
#[tokio::test]
async fn bug_info_detail_false_strips_only_detail_fields() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, detailed_bug(7)).await;
let client = client_for("", &mock).await;
let full = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
let full: Value = serde_json::from_str(&text_of(&full)).expect("bug_info returns JSON");
assert!(
full["bugs"][0].get("assigned_to_detail").is_some(),
"the default view keeps the detail fields"
);
let lean = call(
&client,
"bug_info",
json!({ "bug_ids": [7], "detail": false }),
)
.await;
assert!(!is_error(&lean), "{}", text_of(&lean));
let lean: Value = serde_json::from_str(&text_of(&lean)).expect("bug_info returns JSON");
let bug = lean["bugs"][0].as_object().expect("bug object");
assert_eq!(bug.get("assigned_to"), Some(&json!("dev@example.com")));
let leaked: Vec<&String> = bug.keys().filter(|k| k.ends_with("_detail")).collect();
assert!(
leaked.is_empty(),
"detail=false must drop every *_detail field: {leaked:?}"
);
}
#[tokio::test]
async fn bug_info_include_fields_projects_and_always_keeps_id() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, detailed_bug(7)).await;
let client = client_for("", &mock).await;
let projected = call(
&client,
"bug_info",
json!({ "bug_ids": [7], "include_fields": "summary, product" }),
)
.await;
let projected: Value =
serde_json::from_str(&text_of(&projected)).expect("bug_info returns JSON");
let keys: Vec<&String> = projected["bugs"][0]
.as_object()
.expect("bug object")
.keys()
.collect();
assert_eq!(
keys,
vec!["id", "product", "summary"],
"exactly the requested fields plus the forced id: {keys:?}"
);
}
#[tokio::test]
async fn bug_info_include_fields_preserves_the_redacted_marker() {
let policy = concat!(
"[[rule]]\nname = \"summary-only\"\naction = \"restrict\"\n",
"capabilities = [\"summary\"]\n",
"[rule.match]\nproducts = [\"openSUSE\"]\n",
);
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [detailed_bug(7)] })),
)
.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;
let client = client_for(policy, &mock).await;
let result = call(
&client,
"bug_info",
json!({ "bug_ids": [7], "include_fields": "summary" }),
)
.await;
let result: Value = serde_json::from_str(&text_of(&result)).expect("bug_info returns JSON");
assert_eq!(result["bugs"][0]["_redacted"], json!(true));
assert_eq!(result["bugs"][0]["summary"], json!("a plain bug"));
assert!(
result["bugs"][0].get("product").is_none(),
"a field outside the projection stays out even in a summary view"
);
}
#[tokio::test]
async fn bug_info_include_fields_and_detail_are_mutually_exclusive() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(0)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"bug_info",
json!({ "bug_ids": [7], "include_fields": "id,summary", "detail": false }),
)
.await;
assert!(is_error(&result), "both projections set must be an error");
assert!(
text_of(&result).contains("mutually exclusive"),
"the refusal must say why: {}",
text_of(&result)
);
}
#[tokio::test]
async fn bug_info_projection_drops_link_fields_before_the_disclosure_fetch() {
let mut bug = detailed_bug(7);
bug["blocks"] = json!([9]);
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, bug).await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "9"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_readable_bug(9)] })),
)
.expect(0)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let projected = call(
&client,
"bug_info",
json!({ "bug_ids": [7], "include_fields": "id,summary" }),
)
.await;
let projected: Value =
serde_json::from_str(&text_of(&projected)).expect("bug_info returns JSON");
assert!(
projected["bugs"][0].get("blocks").is_none(),
"a link field outside the projection is simply absent"
);
}
#[tokio::test]
async fn bug_info_projected_link_fields_are_still_scrubbed() {
let mut bug = detailed_bug(7);
bug["blocks"] = json!([9]);
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, bug).await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "9"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(1)
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let projected = call(
&client,
"bug_info",
json!({ "bug_ids": [7], "include_fields": "id,blocks" }),
)
.await;
let projected: Value =
serde_json::from_str(&text_of(&projected)).expect("bug_info returns JSON");
assert_eq!(
projected["bugs"][0]["blocks"],
json!([]),
"a hidden linked bug is scrubbed out of the projected field (I14)"
);
}
#[tokio::test]
async fn bug_info_scalar_link_fields_are_scrubbed_like_arrays() {
for (slot, linked, expected) in [
(json!(9), vec![], Value::Null),
(json!(9), vec![world_readable_bug(9)], json!(9)),
(json!("9"), vec![world_readable_bug(9)], Value::Null),
] {
let mut bug = detailed_bug(7);
for field in Guard::LINKED_ID_FIELDS {
bug[*field] = slot.clone();
}
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, bug).await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "9"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": linked })))
.mount(&mock)
.await;
let client = client_for("", &mock).await;
let served = call(&client, "bug_info", json!({ "bug_ids": [7] })).await;
assert!(!is_error(&served), "bug_info failed: {}", text_of(&served));
let served: Value = serde_json::from_str(&text_of(&served)).expect("bug_info returns JSON");
for field in Guard::LINKED_ID_FIELDS {
assert_eq!(
served["bugs"][0][*field], expected,
"a bare {slot} in {field} must be served as {expected} (I14)"
);
}
}
}
#[tokio::test]
async fn bug_info_projection_never_changes_a_restricted_entry() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "8"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.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;
let client = client_for("", &mock).await;
let mut restricted: Vec<String> = Vec::new();
for args in [
json!({ "bug_ids": [8] }),
json!({ "bug_ids": [8], "detail": false }),
json!({ "bug_ids": [8], "include_fields": "id,summary" }),
] {
let result = call(&client, "bug_info", args).await;
let result: Value = serde_json::from_str(&text_of(&result)).expect("bug_info returns JSON");
assert!(result["bugs"].as_array().expect("bugs").is_empty());
restricted.push(result["restricted"].to_string());
}
assert!(
restricted.windows(2).all(|w| w[0] == w[1]),
"the restricted entry is byte-identical across projections: {restricted:?}"
);
}
fn six_comments() -> Vec<Value> {
(1..=6)
.map(|i| {
json!({ "id": i, "bug_id": 7, "is_private": false, "text": format!("comment {i}") })
})
.collect()
}
async fn mount_comments(mock: &MockServer, comments: Vec<Value>) {
Mock::given(method("GET"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": { "7": { "comments": comments } }
})))
.mount(mock)
.await;
}
fn windowed_ids(result: &Value) -> Vec<u64> {
result["comments"]
.as_array()
.expect("envelope carries a comments array")
.iter()
.filter_map(|c| c["id"].as_u64())
.collect()
}
#[tokio::test]
async fn bug_comments_without_windowing_params_stays_a_bare_array() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
mount_comments(&mock, six_comments()).await;
let client = client_for("", &mock).await;
let result = call(&client, "bug_comments", json!({ "id": 7 })).await;
let parsed: Value = serde_json::from_str(&text_of(&result)).expect("bug_comments returns JSON");
assert!(
parsed.is_array(),
"no windowing params: the response stays the bare array it always was"
);
assert_eq!(parsed.as_array().unwrap().len(), 6);
}
#[tokio::test]
async fn bug_comments_head_tail_windows_out_the_middle() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
mount_comments(&mock, six_comments()).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"bug_comments",
json!({ "id": 7, "head": 1, "tail": 2 }),
)
.await;
let parsed: Value = serde_json::from_str(&text_of(&result)).expect("bug_comments returns JSON");
assert_eq!(
windowed_ids(&parsed),
vec![1, 5, 6],
"first 1 + last 2, middle omitted"
);
assert_eq!(
parsed["truncation"],
json!({ "omitted_comments": 3, "shown_comments": 3 })
);
}
#[tokio::test]
async fn bug_comments_tail_only_keeps_the_end() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
mount_comments(&mock, six_comments()).await;
let client = client_for("", &mock).await;
let result = call(&client, "bug_comments", json!({ "id": 7, "tail": 2 })).await;
let parsed: Value = serde_json::from_str(&text_of(&result)).expect("bug_comments returns JSON");
assert_eq!(windowed_ids(&parsed), vec![5, 6]);
assert_eq!(
parsed["truncation"],
json!({ "omitted_comments": 4, "shown_comments": 2 })
);
}
#[tokio::test]
async fn bug_comments_window_overlap_omits_nothing() {
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
mount_comments(&mock, six_comments()).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"bug_comments",
json!({ "id": 7, "head": 4, "tail": 4 }),
)
.await;
let parsed: Value = serde_json::from_str(&text_of(&result)).expect("bug_comments returns JSON");
assert_eq!(windowed_ids(&parsed), vec![1, 2, 3, 4, 5, 6]);
assert_eq!(
parsed["truncation"],
json!({ "omitted_comments": 0, "shown_comments": 6 })
);
}
#[tokio::test]
async fn bug_comments_window_counts_only_post_filter_comments() {
let comments = vec![
json!({ "id": 1, "bug_id": 7, "is_private": false, "text": "first public" }),
json!({ "id": 2, "bug_id": 7, "is_private": true, "text": "canary-private-3f9d" }),
json!({ "id": 3, "bug_id": 7, "is_private": false, "text": "last public" }),
];
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
mount_comments(&mock, comments).await;
let client = client_for("", &mock).await;
let result = call(&client, "bug_comments", json!({ "id": 7, "tail": 1 })).await;
let text = text_of(&result);
assert!(
!text.contains("canary-private-3f9d"),
"private stays out (I5)"
);
let parsed: Value = serde_json::from_str(&text).expect("bug_comments returns JSON");
assert_eq!(windowed_ids(&parsed), vec![3]);
assert_eq!(
parsed["truncation"],
json!({ "omitted_comments": 1, "shown_comments": 1 }),
"counts are computed after the private filter, never from the raw list"
);
}
#[tokio::test]
async fn bug_comments_window_runs_after_duplicate_marker_scrubbing() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
let comments = vec![
json!({ "id": 1, "bug_id": 7, "is_private": false,
"text": "*** Bug 666 has been marked as a duplicate of this bug ***" }),
json!({ "id": 2, "bug_id": 7, "is_private": false, "text": "the real answer" }),
];
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "666"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.expect(1)
.mount(&mock)
.await;
mount_comments(&mock, comments).await;
let client = client_for(policy, &mock).await;
let result = call(&client, "bug_comments", json!({ "id": 7, "tail": 1 })).await;
let text = text_of(&result);
assert!(!text.contains("666"), "the hidden id never appears (I14)");
let parsed: Value = serde_json::from_str(&text).expect("bug_comments returns JSON");
assert_eq!(
windowed_ids(&parsed),
vec![2],
"the scrubbed marker did not consume the window slot"
);
assert_eq!(
parsed["truncation"],
json!({ "omitted_comments": 0, "shown_comments": 1 })
);
}
#[tokio::test]
async fn bug_comments_max_comment_chars_caps_and_marks() {
let long = "a".repeat(300);
let multibyte = "日本語".repeat(40); let comments = vec![
json!({ "id": 1, "bug_id": 7, "is_private": false, "text": long }),
json!({ "id": 2, "bug_id": 7, "is_private": false, "text": multibyte }),
json!({ "id": 3, "bug_id": 7, "is_private": false, "text": "short" }),
];
let mock = MockServer::start().await;
mount_bug_and_padding(&mock, world_readable_bug(7)).await;
mount_comments(&mock, comments).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"bug_comments",
json!({ "id": 7, "max_comment_chars": 100 }),
)
.await;
let parsed: Value = serde_json::from_str(&text_of(&result)).expect("bug_comments returns JSON");
let comments = parsed["comments"].as_array().expect("comments array");
let first = &comments[0];
assert_eq!(first["text"].as_str().unwrap().chars().count(), 100);
assert_eq!(
first["text_truncated"],
json!({ "shown_chars": 100, "total_chars": 300 })
);
let second = &comments[1];
assert_eq!(second["text"].as_str().unwrap().chars().count(), 100);
assert_eq!(
second["text_truncated"],
json!({ "shown_chars": 100, "total_chars": 120 })
);
let third = &comments[2];
assert_eq!(third["text"], json!("short"));
assert!(
third.get("text_truncated").is_none(),
"an uncapped comment carries no marker"
);
assert_eq!(
parsed["truncation"],
json!({ "omitted_comments": 0, "shown_comments": 3 })
);
}