use std::path::{Path, PathBuf};
use std::sync::Arc;
use bugwarden::audit::{
AuditConfig, AuditEvent, AuditEventKind, AuditSink, AuditState, FailMode, OutcomeClass,
TransportKind, Verdict,
};
use bugwarden::config::Cli;
use bugwarden::server::{BugWarden, USER_AGENT};
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::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[path = "common/deadline.rs"]
mod deadline;
#[path = "common/pinned_cli.rs"]
mod pinned_cli;
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>();
}
struct Audited {
client: RunningService<RoleClient, ()>,
audit_path: PathBuf,
_dir: tempfile::TempDir,
}
async fn audited_client_for(policy: &str, mock: &MockServer, api_key: &str) -> Audited {
audited_client_with(policy, mock, api_key, true).await
}
async fn audited_client_with(
policy: &str,
mock: &MockServer,
api_key: &str,
suppressed_ids: bool,
) -> Audited {
let dir = tempfile::tempdir().expect("tempdir");
let audit_path = dir.path().join("audit.jsonl");
let sink = AuditSink::open(AuditConfig {
path: Some(audit_path.clone()),
fsync: false,
fail_mode: None,
rotate_max_bytes: 0,
rotate_keep: 8,
suppressed_ids,
})
.expect("audit sink must open");
let audit = Arc::new(AuditState::new(
sink,
FailMode::Open,
Some("sha256:policy-under-test".to_string()),
));
let cfg: Arc<Cli> = Arc::new(pinned(&[
"bugwarden",
"--bugzilla-server",
&mock.uri(),
"--transport",
"stdio",
"--api-key",
api_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")
.with_audit(audit);
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 = bounded("the MCP handshake", ().serve(client_io))
.await
.expect("MCP handshake must succeed");
Audited {
client,
audit_path,
_dir: dir,
}
}
async fn plain_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 read_events(path: &Path) -> Vec<AuditEvent> {
let s = std::fs::read_to_string(path).expect("audit file must be readable");
s.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("every audit line must parse as AuditEvent"))
.collect()
}
fn last_tool_call(events: &[AuditEvent]) -> &bugwarden::audit::ToolCallEvent {
match &events.last().expect("at least one event").kind {
AuditEventKind::ToolCall(tc) => tc,
other => panic!("expected a tool_call record, got {other:?}"),
}
}
fn world_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_fixture(mock: &MockServer) {
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(|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": [world_bug(7)] }))
})
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/history"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [{ "history": [] }] })),
)
.mount(mock)
.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, "is_private": false, "text": "public comment" },
] } }
})))
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/attachment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": { "7": [] } })))
.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": 3,
"file_name": "log.txt",
"content_type": "text/plain",
"data": "QUFB",
} }
})))
.mount(mock)
.await;
Mock::given(method("PUT"))
.and(path("/rest/bug/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] })))
.mount(mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 11 })))
.mount(mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/attachment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [31] })))
.mount(mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 4242 })))
.mount(mock)
.await;
for (endpoint, body) in [
("/rest/version", json!({ "version": "5.0" })),
("/rest/extensions", json!({ "extensions": {} })),
(
"/rest/time",
json!({ "web_time": "2026-01-01T00:00:00Z", "tz_name": "UTC" }),
),
("/rest/parameters", json!({ "parameters": {} })),
] {
Mock::given(method("GET"))
.and(path(endpoint))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(mock)
.await;
}
Mock::given(method("GET"))
.and(path("/page.cgi"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html>quicksearch</html>"))
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/product_enterable"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "ids": [] })))
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/field/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "fields": [] })))
.mount(mock)
.await;
}
fn minimal_args(tool: &str) -> Value {
match tool {
"bug_info" => json!({ "bug_ids": [7] }),
"bug_history" => json!({ "id": 7 }),
"bug_comments" => json!({ "id": 7 }),
"bugs_quicksearch" => json!({ "query": "kernel" }),
"create_bug" => json!({
"product": "openSUSE",
"component": "core",
"summary": "crash on start",
"version": "1.0",
}),
"add_attachment" => json!({
"bug_id": 7,
"data": "QUFB",
"file_name": "log.txt",
"summary": "boot log",
"content_type": "text/plain",
}),
"add_comment" => json!({ "bug_id": 7, "comment": "hello" }),
"update_bug_status" => json!({ "bug_id": 7, "status": "NEW" }),
"assign_bug" => json!({ "bug_id": 7, "assignee": "dev@example.org" }),
"update_bug_fields" => json!({ "bug_id": 7, "priority": "P1" }),
"update_bug_dependencies" => json!({ "bug_id": 7, "blocks_add": [7] }),
"add_cc_to_bug" => json!({ "bug_id": 7, "cc_email": "cc@example.org" }),
"mark_as_duplicate" => json!({ "bug_id": 7, "duplicate_of": 7 }),
"list_attachments" => json!({ "bug_id": 7 }),
"download_attachment" => json!({ "attachment_id": 55 }),
"bug_url" => json!({ "bug_id": 7 }),
"bugzilla_server_info" => json!({}),
"bugzilla_products" => json!({}),
"bug_fields" => json!({}),
"quicksearch_syntax" => json!({}),
"mcp_server_info" => json!({}),
"summarize_bug" => json!({ "id": 7 }),
other => panic!("no minimal args for tool {other} — extend the map"),
}
}
async fn upstream_hits(mock: &MockServer) -> usize {
mock.received_requests()
.await
.expect("wiremock records requests by default")
.len()
}
#[tokio::test]
async fn every_routed_tool_writes_exactly_one_record_per_call() {
let mock = MockServer::start().await;
mount_fixture(&mock).await;
let audited = audited_client_for("[global]\nallow_discovery = true\n", &mock, "test-key").await;
let tools = bounded("tools/list", audited.client.list_all_tools())
.await
.expect("list_tools must succeed");
assert!(!tools.is_empty(), "the router lists the tool surface");
let mut expected = 1;
assert_eq!(read_events(&audited.audit_path).len(), expected);
for tool in &tools {
let before = upstream_hits(&mock).await;
let _ = call(&audited.client, &tool.name, minimal_args(&tool.name)).await;
let hits = upstream_hits(&mock).await - before;
expected += 1;
let events = read_events(&audited.audit_path);
assert_eq!(
events.len(),
expected,
"calling {} must grow the file by exactly one record",
tool.name
);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, tool.name);
assert!(
tc.request.id.as_deref().is_some_and(|id| !id.is_empty()),
"calling {} must record the JSON-RPC id the transport exposed: {:?}",
tool.name,
tc.request
);
assert!(
tc.client.name.as_deref().is_some_and(|n| !n.is_empty()),
"and the client that introduced itself in the handshake: {:?}",
tc.client
);
assert!(
tc.client.version.as_deref().is_some_and(|v| !v.is_empty()),
"version too: {:?}",
tc.client
);
assert_eq!(
(
tc.client.principal.as_deref(),
tc.client.work_context.as_deref()
),
(None, None),
"and NEITHER server-verified slot on any routed tool: no verifier \
exists, and nothing self-declared may be promoted into one \
(#32, #34): {:?}",
tc.client
);
assert_eq!(
tc.upstream.map_or(0, |u| u.requests) as usize,
hits,
"calling {} must record every upstream request it made: {:?}",
tool.name,
tc.upstream
);
assert_eq!(
tc.upstream.is_some(),
hits > 0,
"and carry the block exactly when Bugzilla was contacted: {}",
tool.name
);
if let Some(upstream) = tc.upstream {
assert_eq!(
upstream.status,
Some(200),
"and the status the fixture returned: {}",
tool.name
);
}
}
}
#[tokio::test]
async fn refusal_paths_write_exactly_one_record_each() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
"[[rule]]\nname = \"group-restricted\"\naction = \"deny\"\n",
"[rule.match]\ngroup_restricted = true\n",
);
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": [] })))
.mount(&mock)
.await;
let mut secret = world_bug(99);
secret["product"] = json!("SecretSauce");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "99"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [secret] })))
.mount(&mock)
.await;
let audited = audited_client_for(policy, &mock, "test-key").await;
let mut expected = 1;
let mut check = |events: Vec<AuditEvent>, verdict: Verdict, rule: Option<&str>, reqs: u32| {
expected += 1;
assert_eq!(events.len(), expected, "exactly one new record");
let tc = last_tool_call(&events);
let guard = tc.guard.as_ref().expect("the guard was consulted");
assert_eq!(guard.verdict, verdict);
assert_eq!(guard.rule.as_deref(), rule);
assert_eq!(
tc.upstream.map_or(0, |u| u.requests),
reqs,
"upstream requests: {:?}",
tc.upstream
);
assert_eq!(tc.upstream.is_some(), reqs > 0);
};
let ids: Vec<u64> = (1..=26).collect();
let refused = call(&audited.client, "bug_info", json!({ "bug_ids": ids })).await;
assert_eq!(refused.is_error, Some(true));
check(read_events(&audited.audit_path), Verdict::Refused, None, 0);
let refused = call(&audited.client, "bug_info", json!({ "bug_ids": [] })).await;
assert_eq!(refused.is_error, Some(true));
check(read_events(&audited.audit_path), Verdict::Refused, None, 0);
let denied = call(&audited.client, "bug_history", json!({ "id": 99 })).await;
assert_eq!(denied.is_error, Some(true));
check(
read_events(&audited.audit_path),
Verdict::Denied,
Some("hide-secret"),
1,
);
let events = read_events(&audited.audit_path);
assert_eq!(last_tool_call(&events).outcome.class, OutcomeClass::Ok);
let refused = call(
&audited.client,
"create_bug",
json!({
"product": "openSUSE",
"component": "core",
"summary": "crash on start",
"version": "1.0",
}),
)
.await;
assert_eq!(refused.is_error, Some(true));
check(read_events(&audited.audit_path), Verdict::Refused, None, 1);
let denied = call(&audited.client, "bug_info", json!({ "bug_ids": [99] })).await;
assert_eq!(denied.is_error, Some(false));
check(
read_events(&audited.audit_path),
Verdict::Denied,
Some("hide-secret"),
2,
);
}
#[tokio::test]
async fn a_default_decided_call_records_the_literal_default_rule() {
let mock = MockServer::start().await;
mount_fixture(&mock).await;
let audited = audited_client_for("default_action = \"deny\"\n", &mock, "test-key").await;
let denied = call(&audited.client, "bug_history", json!({ "id": 7 })).await;
assert_eq!(denied.is_error, Some(true));
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events)
.guard
.as_ref()
.expect("the guard was consulted");
assert_eq!(guard.verdict, Verdict::Denied);
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"a default-decided denial is named, not rule-less"
);
let audited = audited_client_for("default_action = \"allow\"\n", &mock, "test-key").await;
let served = call(&audited.client, "bug_history", json!({ "id": 7 })).await;
assert_ne!(served.is_error, Some(true), "the history is served");
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events)
.guard
.as_ref()
.expect("the guard was consulted");
assert_eq!(guard.verdict, Verdict::Served);
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"a default-decided serve is named too"
);
}
const RECORDED_MAX_CHARS: usize = 1024;
#[tokio::test]
async fn unknown_and_stripped_tools_error_identically_and_still_record() {
let mock = MockServer::start().await;
let read_only = "[global]\nread_only = true\n";
let audited = audited_client_for(read_only, &mock, "test-key").await;
let plain = plain_client_for(read_only, &mock).await;
let with_audit = bounded(
"the audited no_such_tool call",
audited
.client
.call_tool(CallToolRequestParams::new("no_such_tool".to_string())),
)
.await
.expect_err("unknown tool is a protocol error");
let without = bounded(
"the unaudited no_such_tool call",
plain.call_tool(CallToolRequestParams::new("no_such_tool".to_string())),
)
.await
.expect_err("unknown tool is a protocol error");
assert_eq!(
format!("{with_audit:?}"),
format!("{without:?}"),
"auditing must not change the protocol error"
);
let unknown_refusal = format!("{with_audit:?}");
let events = read_events(&audited.audit_path);
assert_eq!(events.len(), 2, "initialize + the failed call");
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "no_such_tool");
assert_eq!(tc.outcome.class, OutcomeClass::Error);
assert!(tc.guard.is_none(), "no guard ran for an unknown tool");
let with_audit = bounded(
"the audited add_comment call",
audited.client.call_tool(
CallToolRequestParams::new("add_comment".to_string()).with_arguments(
serde_json::Map::from_iter([
("bug_id".to_string(), json!(7)),
("comment".to_string(), json!("hi")),
]),
),
),
)
.await
.expect_err("a stripped tool is a protocol error");
let without = bounded(
"the unaudited add_comment call",
plain.call_tool(
CallToolRequestParams::new("add_comment".to_string()).with_arguments(
serde_json::Map::from_iter([
("bug_id".to_string(), json!(7)),
("comment".to_string(), json!("hi")),
]),
),
),
)
.await
.expect_err("a stripped tool is a protocol error");
assert_eq!(format!("{with_audit:?}"), format!("{without:?}"));
let events = read_events(&audited.audit_path);
assert_eq!(events.len(), 3, "one more record for the stripped call");
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "add_comment");
assert_eq!(tc.outcome.class, OutcomeClass::Error);
assert_eq!(tc.request.params["comment"], json!({ "_len": 4 }));
let huge = "z".repeat(RECORDED_MAX_CHARS * 4);
let with_audit = bounded(
"the audited oversized-name call",
audited
.client
.call_tool(CallToolRequestParams::new(huge.clone())),
)
.await
.expect_err("an unknown tool is a protocol error whatever its length");
let without = bounded(
"the unaudited oversized-name call",
plain.call_tool(CallToolRequestParams::new(huge.clone())),
)
.await
.expect_err("an unknown tool is a protocol error whatever its length");
assert_eq!(
format!("{with_audit:?}"),
format!("{without:?}"),
"auditing must not change the protocol error"
);
assert_eq!(
format!("{with_audit:?}"),
unknown_refusal,
"and the same refusal a short unknown name gets: no echo of the name"
);
let events = read_events(&audited.audit_path);
assert_eq!(events.len(), 4, "one record for the oversized name too");
let tc = last_tool_call(&events);
assert_eq!(tc.outcome.class, OutcomeClass::Error);
assert_eq!(
tc.request.tool.chars().count(),
RECORDED_MAX_CHARS,
"the recorded name is cut to the cap, not merely shortened"
);
assert_eq!(
tc.request.tool,
huge.chars().take(RECORDED_MAX_CHARS).collect::<String>(),
"and it is that prefix verbatim — capped() appends no marker"
);
}
const HIDE_SECRET_POLICY: &str = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
async fn mount_hidden_link(mock: &MockServer) {
let mut linked = world_bug(7);
linked["depends_on"] = json!([666]);
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [linked] })))
.mount(mock)
.await;
let mut secret = world_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(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bugs": [{ "history": [{
"when": "2020-01-02T00:00:00Z",
"who": "dev@example.org",
"changes": [
{ "field_name": "depends_on", "added": "666", "removed": "" },
],
}] }]
})))
.mount(mock)
.await;
}
#[tokio::test]
async fn suppressed_ids_reach_the_log_never_the_envelope() {
let mock = MockServer::start().await;
mount_hidden_link(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bug_info", json!({ "bug_ids": [7] })).await;
assert_ne!(result.is_error, Some(true), "bug 7 itself is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
!envelope.contains("666"),
"the hidden id must be scrubbed from the client envelope (I14): {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert!(
guard.suppressed_ids.contains(&666),
"the audit record must carry the suppressed id: {:?}",
guard.suppressed_ids
);
assert!(guard.suppressed_ids_count >= 1);
assert_eq!(
guard.suppressed_other_count, 0,
"a scrubbed link is an ID suppression and lands in no other tally: {guard:?}"
);
assert_eq!(
guard.policy_hash.as_deref(),
Some("sha256:policy-under-test"),
"records tie to the policy in force"
);
let result = call(&audited.client, "bug_history", json!({ "id": 7 })).await;
assert_ne!(result.is_error, Some(true), "the history itself is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
!envelope.contains("666"),
"the hidden id must be scrubbed from the served history (I14): {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_history");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert!(
guard.suppressed_ids.contains(&666),
"the bug_history record must carry the scrubbed id: {:?}",
guard.suppressed_ids
);
}
#[tokio::test]
async fn suppressed_ids_off_records_the_count_never_the_ids() {
let mock = MockServer::start().await;
mount_hidden_link(&mock).await;
let audited = audited_client_with(HIDE_SECRET_POLICY, &mock, "test-key", false).await;
let result = call(&audited.client, "bug_info", json!({ "bug_ids": [7] })).await;
assert_ne!(result.is_error, Some(true), "bug 7 itself is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
!envelope.contains("666"),
"the hidden id must be scrubbed from the client envelope (I14): {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert!(
guard.suppressed_ids_count >= 1,
"the id count survives the elision: {guard:?}"
);
assert_eq!(guard.suppressed_other_count, 0);
assert!(
guard.suppressed_ids.is_empty(),
"ids are elided when the knob is off: {:?}",
guard.suppressed_ids
);
}
async fn mount_mixed_suppression(mock: &MockServer) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_bug(7)] })))
.mount(mock)
.await;
let secret = |id: u64| {
let mut bug = world_bug(id);
bug["product"] = json!("SecretSauce");
bug
};
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "666,667"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "bugs": [secret(666), secret(667)] })),
)
.expect(1)
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "666,667,668"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "bugs": [secret(666), secret(667), secret(668)] })),
)
.mount(mock)
.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, "is_private": false, "text": "public comment" },
{ "id": 2, "bug_id": 7, "is_private": true, "text": "canary-private-8a1c one" },
{ "id": 3, "bug_id": 7, "is_private": true, "text": "canary-private-8a1c two" },
{ "id": 4, "bug_id": 7, "is_private": true,
"text": "canary-private-8a1c *** Bug 668 has been marked as a duplicate of this bug ***" },
{ "id": 5, "bug_id": 7, "is_private": false,
"text": "*** Bug 666 has been marked as a duplicate of this bug ***" },
{ "id": 6, "bug_id": 7, "is_private": false,
"text": "*** Bug 667 has been marked as a duplicate of this bug ***" },
] } }
})))
.mount(mock)
.await;
}
#[tokio::test]
async fn split_counters_report_both_populations_in_one_call() {
let mock = MockServer::start().await;
mount_mixed_suppression(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bug_comments", json!({ "id": 7 })).await;
assert_ne!(result.is_error, Some(true), "bug 7's comments are served");
let envelope = serde_json::to_string(&result).unwrap();
for id in ["666", "667"] {
assert!(
!envelope.contains(id),
"the hidden id must be scrubbed from the served comments (I14): {envelope}"
);
}
assert!(
!envelope.contains("canary-private-8a1c"),
"the private comments must not reach the client (I5): {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_comments");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.suppressed_ids,
vec![666, 667],
"the surviving comments' marker ids are named, and 668's — noted \
only inside a DROPPED private comment — is not: naming it would \
count that one comment in both tallies"
);
assert_eq!(
guard.suppressed_ids_count, 2,
"the two withheld ids, counted as themselves: {guard:?}"
);
assert_eq!(
guard.suppressed_other_count, 3,
"the three id-less private comments, counted as themselves: {guard:?}"
);
assert_eq!(
guard.suppressed_ids.len() as u64,
guard.suppressed_ids_count,
"the id list accounts for its own tally exactly, and for the other \
one not at all: {guard:?}"
);
}
#[tokio::test]
async fn summarize_bug_records_the_same_split_as_bug_comments() {
let mock = MockServer::start().await;
mount_mixed_suppression(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "summarize_bug", json!({ "id": 7 })).await;
assert_ne!(result.is_error, Some(true), "the summary prompt is served");
let envelope = serde_json::to_string(&result).unwrap();
for id in ["666", "667", "668"] {
assert!(
!envelope.contains(id),
"no hidden id may reach the summarization prompt (I14): {envelope}"
);
}
assert!(
!envelope.contains("canary-private-8a1c"),
"no private comment may reach the summarization prompt (I5): {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "summarize_bug");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.suppressed_ids,
vec![666, 667],
"the same two ids bug_comments names, and 668 no more than it does"
);
assert_eq!(
guard.suppressed_ids_count, 2,
"the same two ids, in the same field: {guard:?}"
);
assert_eq!(
guard.suppressed_other_count, 3,
"and the same three id-less comments: {guard:?}"
);
}
#[tokio::test]
async fn split_counters_survive_the_ids_config() {
let mock = MockServer::start().await;
mount_mixed_suppression(&mock).await;
let audited = audited_client_with(HIDE_SECRET_POLICY, &mock, "test-key", false).await;
let result = call(&audited.client, "bug_comments", json!({ "id": 7 })).await;
assert_ne!(result.is_error, Some(true), "bug 7's comments are served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
let guard = tc.guard.as_ref().expect("guard info recorded");
assert!(
guard.suppressed_ids.is_empty(),
"ids are elided when the knob is off: {:?}",
guard.suppressed_ids
);
assert_eq!(
guard.suppressed_ids_count, 2,
"the elided ids still COUNT — this is the operator's only remaining \
statement that two bugs were withheld: {guard:?}"
);
assert_eq!(
guard.suppressed_other_count, 3,
"and the id-less tally is untouched by that switch: {guard:?}"
);
}
#[tokio::test]
async fn bug_comments_with_nothing_filtered_stays_served_and_counts_zero() {
let mock = MockServer::start().await;
mount_fixture(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bug_comments", json!({ "id": 7 })).await;
assert_ne!(result.is_error, Some(true), "bug 7's comments are served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_comments");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.verdict,
Verdict::Served,
"the fixture's only comment is public and names no bug, so nothing \
was withheld: {guard:?}"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
assert!(guard.suppressed_ids.is_empty());
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"and the rule that decided the call survives — a filtered merge \
outranks a clean serve and would clear it"
);
}
async fn mount_disclosable_link(mock: &MockServer) {
let mut linked = world_bug(7);
linked["depends_on"] = json!([808]);
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [linked] })))
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "808"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_bug(808)] })))
.mount(mock)
.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, "is_private": false,
"text": "*** Bug 808 has been marked as a duplicate of this bug ***" },
] } }
})))
.mount(mock)
.await;
}
#[tokio::test]
async fn summarize_bug_with_nothing_filtered_stays_served_and_counts_zero() {
let mock = MockServer::start().await;
mount_disclosable_link(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "summarize_bug", json!({ "id": 7 })).await;
assert_ne!(result.is_error, Some(true), "the summary prompt is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
envelope.contains("Bug 808 has been marked as a duplicate"),
"the marker names a bug the policy allows, so the scrub keeps it \
and the recorded zero means nothing was withheld: {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "summarize_bug");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.verdict,
Verdict::Served,
"every comment is public and the only bug they name is disclosable, \
so nothing was withheld: {guard:?}"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
assert!(guard.suppressed_ids.is_empty());
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"and the rule that decided the call survives — a filtered merge \
outranks a clean serve and would clear it"
);
}
#[tokio::test]
async fn bug_info_with_every_link_disclosable_stays_served_and_redacts_nothing() {
let mock = MockServer::start().await;
mount_disclosable_link(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bug_info", json!({ "bug_ids": [7] })).await;
assert_ne!(result.is_error, Some(true), "bug 7 is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
envelope.contains("808"),
"the link names a bug the policy allows, so I14 keeps it and the \
empty suppression means nothing was withheld: {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_info");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.verdict,
Verdict::Served,
"bug 7 is served whole and its only link was disclosable: {guard:?}"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
assert!(guard.suppressed_ids.is_empty());
assert!(
guard.redacted_fields.is_empty(),
"and the bug was served whole, not as a summary view: {:?}",
guard.redacted_fields
);
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"and the rule that decided the call survives — a filtered merge \
outranks a clean serve and would clear it"
);
}
async fn mount_windowed_history(mock: &MockServer) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_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;
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", "added": "CONFIRMED", "removed": "NEW" },
] },
{ "when": "2020-01-02T00:00:00Z", "who": "dev@example.org", "changes": [
{ "field_name": "status", "added": "IN_PROGRESS", "removed": "CONFIRMED" },
] },
{ "when": "2020-01-03T00:00:00Z", "who": "dev@example.org", "changes": [
{ "field_name": "status", "added": "RESOLVED", "removed": "IN_PROGRESS" },
] },
] }]
})))
.mount(mock)
.await;
}
#[tokio::test]
async fn bug_history_windowing_records_the_omission_it_applied() {
let mock = MockServer::start().await;
mount_windowed_history(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(
&audited.client,
"bug_history",
json!({ "id": 7, "head": 1 }),
)
.await;
assert_ne!(
result.is_error,
Some(true),
"the windowed history is served"
);
let text = result.content[0]
.as_text()
.expect("bug_history answers with text");
let envelope: Value = serde_json::from_str(&text.text).expect("bug_history returns JSON");
assert_eq!(envelope["history"].as_array().unwrap().len(), 1);
assert_eq!(
envelope["truncation"],
json!({ "omitted_entries": 2, "shown_entries": 1 }),
"the window omitted two of three: {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_history");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.redacted_fields,
vec!["history_window".to_string()],
"the record names the window as the redaction it applied: {guard:?}"
);
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"and the granting rule survives: the CLIENT asked for one entry, so \
no rule decided the omission: {guard:?}"
);
assert_eq!(
(guard.suppressed_ids_count, guard.suppressed_other_count),
(0, 0),
"nothing else was withheld, so the window is the ONLY thing the \
filtered verdict can have come from: {guard:?}"
);
assert!(guard.suppressed_ids.is_empty());
}
#[tokio::test]
async fn bug_history_window_that_omits_nothing_stays_silent() {
let mock = MockServer::start().await;
mount_windowed_history(&mock).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(
&audited.client,
"bug_history",
json!({ "id": 7, "head": 3, "tail": 3 }),
)
.await;
assert_ne!(
result.is_error,
Some(true),
"the windowed history is served"
);
let text = result.content[0]
.as_text()
.expect("bug_history answers with text");
let envelope: Value = serde_json::from_str(&text.text).expect("bug_history returns JSON");
assert_eq!(envelope["history"].as_array().unwrap().len(), 3);
assert_eq!(
envelope["truncation"],
json!({ "omitted_entries": 0, "shown_entries": 3 }),
"the overlapping windows omitted nothing: {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_history");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.verdict,
Verdict::Served,
"a window that withheld nothing is a clean serve: {guard:?}"
);
assert!(
guard.redacted_fields.is_empty(),
"and the window note stays silent when nothing was omitted: {:?}",
guard.redacted_fields
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
assert!(guard.suppressed_ids.is_empty());
}
const SUMMARY_ONLY_POLICY: &str = concat!(
"[[rule]]\nname = \"summaries-only\"\naction = \"restrict\"\n",
"capabilities = [\"summary\"]\n",
"[rule.match]\nproducts = [\"openSUSE\"]\n",
);
async fn mount_summary_only_bug(mock: &MockServer) {
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 mut bug = world_bug(7);
bug["whiteboard"] = json!("canary-whole-body-4d21");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [bug] })))
.mount(mock)
.await;
}
#[tokio::test]
async fn bug_info_over_a_summary_view_records_the_redaction_it_applied() {
let mock = MockServer::start().await;
mount_summary_only_bug(&mock).await;
let audited = audited_client_for(SUMMARY_ONLY_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bug_info", json!({ "bug_ids": [7] })).await;
assert_ne!(result.is_error, Some(true), "the summary view is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
envelope.contains("_redacted"),
"the policy serves bug 7 as a summary view, not whole: {envelope}"
);
assert!(
!envelope.contains("canary-whole-body-4d21"),
"and the projection keeps no field outside SUMMARY_FIELDS: {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_info");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.redacted_fields,
vec!["summary_view".to_string()],
"the record names the projection that produced the verdict: {guard:?}"
);
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.rule.as_deref(),
Some("summaries-only"),
"and the restrict rule that granted the summary decided the call"
);
assert_eq!(
(guard.suppressed_ids_count, guard.suppressed_other_count),
(0, 0),
"nothing else was withheld, so the summary view is the ONLY thing \
the filtered verdict can have come from: {guard:?}"
);
assert!(guard.suppressed_ids.is_empty());
}
fn attachment(id: u64, is_private: bool, file_name: &str) -> Value {
json!({
"id": id,
"bug_id": 7,
"is_private": is_private,
"file_name": file_name,
"summary": "attached",
"content_type": "text/plain",
"size": 3,
})
}
async fn mount_attachments(mock: &MockServer, rows: Vec<Value>) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_bug(7)] })))
.mount(mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug/7/attachment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": { "7": rows } })))
.mount(mock)
.await;
}
#[tokio::test]
async fn list_attachments_counts_the_private_metadata_it_dropped() {
let mock = MockServer::start().await;
mount_attachments(
&mock,
vec![
attachment(51, false, "log.txt"),
attachment(52, true, "canary-private-3f7b-one.txt"),
attachment(53, true, "canary-private-3f7b-two.txt"),
],
)
.await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "list_attachments", json!({ "bug_id": 7 })).await;
assert_ne!(result.is_error, Some(true), "the public metadata is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
!envelope.contains("canary-private-3f7b"),
"private attachment metadata must not reach the client (I5): {envelope}"
);
let raw = std::fs::read_to_string(&audited.audit_path).expect("audit file readable");
assert!(
!raw.contains("canary-private-3f7b"),
"the withheld metadata must not reach the audit file either"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "list_attachments");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.suppressed_other_count, 2,
"both dropped attachments are counted, id-lessly: {guard:?}"
);
assert_eq!(
guard.suppressed_ids_count, 0,
"attachment metadata names no bug, so the id tally stays empty: {guard:?}"
);
assert!(
guard.suppressed_ids.is_empty(),
"and neither is named — list_attachments names no ids at all: {:?}",
guard.suppressed_ids
);
}
#[tokio::test]
async fn list_attachments_with_nothing_private_stays_served_and_counts_zero() {
let mock = MockServer::start().await;
mount_attachments(
&mock,
vec![
attachment(51, false, "log.txt"),
attachment(52, false, "trace.txt"),
],
)
.await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "list_attachments", json!({ "bug_id": 7 })).await;
assert_ne!(result.is_error, Some(true), "both attachments are served");
let envelope = serde_json::to_string(&result).unwrap();
for file_name in ["log.txt", "trace.txt"] {
assert!(
envelope.contains(file_name),
"the gate kept {file_name}, so nothing was withheld: {envelope}"
);
}
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "list_attachments");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.verdict,
Verdict::Served,
"a listing that withheld nothing is a clean serve: {guard:?}"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
assert!(guard.suppressed_ids.is_empty());
assert!(
guard.redacted_fields.is_empty(),
"and nothing was redacted from it either: {:?}",
guard.redacted_fields
);
}
async fn mount_windowed_attachment(mock: &MockServer, data_b64: &str) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_bug(7)] })))
.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": 128,
"file_name": "log.txt",
"content_type": "text/plain",
"data": data_b64,
} }
})))
.mount(mock)
.await;
}
#[tokio::test]
async fn download_attachment_windowing_that_cuts_records_the_redaction() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
let text: String = (1..=20).map(|i| format!("log line {i}\n")).collect();
mount_windowed_attachment(&mock, &general_purpose::STANDARD.encode(&text)).await;
let audited = audited_client_for("", &mock, "test-key").await;
let result = call(
&audited.client,
"download_attachment",
json!({ "attachment_id": 55, "head_lines": 3 }),
)
.await;
assert_ne!(result.is_error, Some(true), "the window is served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "download_attachment");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.redacted_fields,
vec!["attachment_window".to_string()],
"the record names the window that cut the payload: {guard:?}"
);
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"and the granting rule survives: the CLIENT asked for three lines, \
so no rule decided the cut: {guard:?}"
);
}
#[tokio::test]
async fn download_attachment_windowing_that_cuts_nothing_records_no_redaction() {
use base64::{engine::general_purpose, Engine as _};
let mock = MockServer::start().await;
mount_windowed_attachment(&mock, &general_purpose::STANDARD.encode("alpha\nbeta\n")).await;
let audited = audited_client_for("", &mock, "test-key").await;
let result = call(
&audited.client,
"download_attachment",
json!({ "attachment_id": 55, "head_lines": 10, "max_chars": 1000 }),
)
.await;
assert_ne!(result.is_error, Some(true), "the whole text is served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "download_attachment");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.verdict,
Verdict::Served,
"nothing was cut, so the serve stays clean: {guard:?}"
);
assert!(
guard.redacted_fields.is_empty(),
"and no redaction is noted: {:?}",
guard.redacted_fields
);
}
async fn mount_search_corpus(mock: &MockServer, rows: Vec<Value>) {
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.contains_key("quicksearch") {
return ResponseTemplate::new(200).set_body_json(json!({ "bugs": [] }));
}
let get = |k: &str| q.get(k).and_then(|v| v.parse::<usize>().ok());
let offset = get("offset").unwrap_or(0);
let limit = get("limit").unwrap_or(rows.len());
let page: Vec<_> = rows.iter().skip(offset).take(limit).cloned().collect();
ResponseTemplate::new(200).set_body_json(json!({ "bugs": page }))
})
.mount(mock)
.await;
}
fn secret_bug(id: u64) -> Value {
let mut bug = world_bug(id);
bug["product"] = json!("SecretSauce");
bug
}
#[tokio::test]
async fn quicksearch_scan_accounting_reaches_the_record_never_the_envelope() {
let mock = MockServer::start().await;
mount_search_corpus(
&mock,
vec![
world_bug(1),
secret_bug(2),
world_bug(3),
secret_bug(4),
world_bug(5),
],
)
.await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bugs_quicksearch", json!({ "query": "q" })).await;
assert_ne!(result.is_error, Some(true), "the search is served");
let envelope = serde_json::to_string(&result).unwrap();
for leak in ["scanned", "dropped", "\"scan\""] {
assert!(
!envelope.contains(leak),
"scan accounting leaked into the envelope: {envelope}"
);
}
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
let guard = tc.guard.as_ref().expect("guard info recorded");
let scan = guard.scan.expect("a search records its scan");
assert_eq!(scan.scanned, 5, "every upstream row was examined");
assert_eq!(scan.dropped, 2, "the two hidden rows were dropped");
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.suppressed_ids,
vec![2, 4],
"the dropped ids ride the suppressed-ids machinery"
);
assert_eq!(guard.suppressed_ids_count, 2);
assert_eq!(
guard.suppressed_other_count, 0,
"a dropped search row carries its own id, so nothing lands id-less"
);
}
#[tokio::test]
async fn quicksearch_response_is_byte_identical_whether_or_not_rows_were_dropped() {
let visible: Vec<u64> = vec![1, 3, 5, 6, 8, 9, 10, 11, 12];
let mock_mixed = MockServer::start().await;
mount_search_corpus(
&mock_mixed,
(1..=12)
.map(|id| {
if [2, 4, 7].contains(&id) {
secret_bug(id)
} else {
world_bug(id)
}
})
.collect(),
)
.await;
let mock_clean = MockServer::start().await;
mount_search_corpus(
&mock_clean,
visible.iter().map(|&id| world_bug(id)).collect(),
)
.await;
let mixed = audited_client_for(HIDE_SECRET_POLICY, &mock_mixed, "test-key").await;
let clean = audited_client_for(HIDE_SECRET_POLICY, &mock_clean, "test-key").await;
let args = json!({ "query": "q", "limit": 4, "offset": 2 });
let from_mixed = call(&mixed.client, "bugs_quicksearch", args.clone()).await;
let from_clean = call(&clean.client, "bugs_quicksearch", args).await;
assert_eq!(
serde_json::to_string(&from_mixed).unwrap(),
serde_json::to_string(&from_clean).unwrap(),
"dropped rows must not change a single response byte (I3)"
);
let mixed_events = read_events(&mixed.audit_path);
let mixed_guard = last_tool_call(&mixed_events).guard.as_ref().unwrap();
assert!(mixed_guard.scan.expect("scan recorded").dropped > 0);
let clean_events = read_events(&clean.audit_path);
let clean_guard = last_tool_call(&clean_events).guard.as_ref().unwrap();
assert_eq!(clean_guard.scan.expect("scan recorded").dropped, 0);
}
#[tokio::test]
async fn quicksearch_group_by_reshapes_the_envelope_and_nothing_in_the_record() {
let mock = MockServer::start().await;
mount_search_corpus(&mock, vec![world_bug(1), secret_bug(2), world_bug(3)]).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let flat = call(&audited.client, "bugs_quicksearch", json!({ "query": "q" })).await;
assert_ne!(flat.is_error, Some(true), "the flat search is served");
let flat_guard = last_tool_call(&read_events(&audited.audit_path))
.guard
.clone()
.expect("guard info recorded");
let grouped = call(
&audited.client,
"bugs_quicksearch",
json!({ "query": "q", "group_by": "product,status" }),
)
.await;
assert_ne!(grouped.is_error, Some(true), "the grouped search is served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
let grouped_guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
*grouped_guard, flat_guard,
"grouping must not move a single field of the guard record"
);
assert!(
grouped_guard.redacted_fields.is_empty(),
"hoisting a field into a group header is not a redaction: {:?}",
grouped_guard.redacted_fields
);
assert_eq!(
tc.request.params["group_by"],
json!("product,status"),
"an allowlisted vocabulary param is recorded verbatim"
);
assert!(
serde_json::to_string(&grouped).unwrap().contains("groups"),
"the envelope really was grouped"
);
}
#[tokio::test]
async fn quicksearch_drop_count_survives_the_suppressed_ids_knob() {
let mock = MockServer::start().await;
mount_search_corpus(&mock, vec![world_bug(1), secret_bug(2), world_bug(3)]).await;
let audited = audited_client_with(HIDE_SECRET_POLICY, &mock, "test-key", false).await;
let result = call(&audited.client, "bugs_quicksearch", json!({ "query": "q" })).await;
assert_ne!(result.is_error, Some(true), "the search is served");
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events).guard.as_ref().unwrap();
let scan = guard.scan.expect("a search records its scan");
assert_eq!(scan.dropped, 1, "the count survives the elision");
assert_eq!(scan.scanned, 3);
assert!(guard.suppressed_ids_count >= 1, "so does the id count");
assert_eq!(guard.suppressed_other_count, 0);
assert!(
guard.suppressed_ids.is_empty(),
"ids are elided when the knob is off: {:?}",
guard.suppressed_ids
);
}
#[tokio::test]
async fn quicksearch_clean_scan_records_zero_drops_and_stays_served() {
let mock = MockServer::start().await;
mount_search_corpus(&mock, vec![world_bug(1), world_bug(2), world_bug(3)]).await;
let audited = audited_client_for(HIDE_SECRET_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bugs_quicksearch", json!({ "query": "q" })).await;
assert_ne!(result.is_error, Some(true), "the search is served");
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events).guard.as_ref().unwrap();
assert_eq!(
guard.scan,
Some(bugwarden::audit::ScanInfo {
scanned: 3,
dropped: 0
})
);
assert_eq!(guard.verdict, Verdict::Served, "a clean scan stays served");
assert!(guard.suppressed_ids.is_empty());
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
}
#[tokio::test]
async fn quicksearch_over_summary_views_records_the_redaction_it_applied() {
let mock = MockServer::start().await;
mount_search_corpus(
&mock,
(1..=3)
.map(|id| {
let mut bug = world_bug(id);
bug["assigned_to"] = json!("canary-assignee-9e73@example.org");
bug
})
.collect(),
)
.await;
let audited = audited_client_for(SUMMARY_ONLY_POLICY, &mock, "test-key").await;
let result = call(&audited.client, "bugs_quicksearch", json!({ "query": "q" })).await;
assert_ne!(result.is_error, Some(true), "the search is served");
let envelope = serde_json::to_string(&result).unwrap();
assert!(
envelope.contains("_redacted"),
"every row the policy kept is a summary view: {envelope}"
);
assert!(
!envelope.contains("canary-assignee-9e73"),
"and a projected row carries no field outside SUMMARY_FIELDS, \
whatever the client asked for: {envelope}"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bugs_quicksearch");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.redacted_fields,
vec!["summary_view".to_string()],
"the record names the projection every served row went through, \
once for the window however many rows it covers: {guard:?}"
);
assert_eq!(
guard.verdict,
Verdict::ServedFiltered,
"a window of projections is a filtered serve: {guard:?}"
);
assert_eq!(
guard.scan,
Some(bugwarden::audit::ScanInfo {
scanned: 3,
dropped: 0
}),
"and it is not the scan that filtered it — every row was kept"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
assert!(guard.suppressed_ids.is_empty());
}
#[tokio::test]
async fn quicksearch_records_no_rule_even_when_a_named_rule_judged_every_row() {
let policy = concat!(
"default_action = \"deny\"\n",
"[[rule]]\nname = \"allow-openSUSE\"\naction = \"allow\"\n",
"[rule.match]\nproducts = [\"openSUSE\"]\n",
);
let mock = MockServer::start().await;
mount_search_corpus(&mock, vec![world_bug(1), world_bug(2), world_bug(3)]).await;
let audited = audited_client_for(policy, &mock, "test-key").await;
let result = call(&audited.client, "bugs_quicksearch", json!({ "query": "q" })).await;
assert_ne!(result.is_error, Some(true), "the search is served");
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events)
.guard
.as_ref()
.expect("guard info recorded");
assert_eq!(guard.verdict, Verdict::Served, "a clean scan stays served");
assert_eq!(guard.scan.expect("a search records its scan").dropped, 0);
assert_eq!(
guard.rule.as_deref(),
None,
"no single rule decided the window"
);
}
#[tokio::test]
async fn canary_content_never_reaches_the_audit_file() {
const KEY_CANARY: &str = "canary-api-key-5c9e02";
const SUMMARY_CANARY: &str = "canary-summary-77aa01";
const COMMENT_CANARY: &str = "canary-comment-b3f604";
const OUTGOING_CANARY: &str = "canary-outgoing-19dd08";
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": [] })))
.mount(&mock)
.await;
let mut bug = world_bug(7);
bug["summary"] = json!(SUMMARY_CANARY);
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [bug] })))
.mount(&mock)
.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, "is_private": false, "text": COMMENT_CANARY },
] } }
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/rest/bug/7/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": 11 })))
.mount(&mock)
.await;
let audited = audited_client_for("", &mock, KEY_CANARY).await;
let _ = call(&audited.client, "bug_info", json!({ "bug_ids": [7] })).await;
let _ = call(&audited.client, "bug_comments", json!({ "id": 7 })).await;
let _ = call(
&audited.client,
"add_comment",
json!({ "bug_id": 7, "comment": OUTGOING_CANARY }),
)
.await;
let raw = std::fs::read_to_string(&audited.audit_path).expect("audit file readable");
for canary in [KEY_CANARY, SUMMARY_CANARY, COMMENT_CANARY, OUTGOING_CANARY] {
assert!(
!raw.contains(canary),
"canary {canary} leaked into the audit file"
);
}
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "add_comment");
let expected_len = serde_json::to_vec(&json!(OUTGOING_CANARY)).unwrap().len();
assert_eq!(
tc.request.params["comment"],
json!({ "_len": expected_len })
);
}
#[tokio::test]
async fn a_client_sent_len_marker_is_distinguishable_on_the_served_path() {
let mock = MockServer::start().await;
mount_fixture(&mock).await;
let audited = audited_client_for("", &mock, "test-key").await;
let _ = call(
&audited.client,
"bug_url",
json!({
"bug_id": 7,
"groups": { "_len": 5 },
"custom_fields": { "_len": 5 },
}),
)
.await;
let events = read_events(&audited.audit_path);
let calls: Vec<&bugwarden::audit::ToolCallEvent> = events
.iter()
.filter_map(|e| match &e.kind {
AuditEventKind::ToolCall(tc) => Some(&**tc),
_ => None,
})
.collect();
let [tc] = calls.as_slice() else {
panic!("exactly one tool-call record, got {}", calls.len());
};
assert_eq!(tc.request.tool, "bug_url");
assert_eq!(tc.outcome.class, OutcomeClass::Ok, "the served path");
let allowlisted = &tc.request.params["groups"];
assert!(
allowlisted.get("_len").is_none(),
"a client-sent marker would be indistinguishable from the reduction"
);
let Some([entry]) = allowlisted
.get("_overlong_keys")
.and_then(|v| v.as_array())
.map(Vec::as_slice)
else {
panic!("the client's own _len key belongs under _overlong_keys, alone");
};
assert_eq!(
entry,
&json!({ "prefix": "_len", "key_chars": 4, "value": 5 }),
"it is routed like any reserved name, keeping its true length"
);
let reduced = serde_json::to_vec(&json!({ "_len": 5 })).unwrap().len();
assert_eq!(
tc.request.params["custom_fields"],
json!({ "_len": reduced }),
"the server's own reduction, the only way _len reaches a record"
);
}
#[tokio::test]
async fn initialize_writes_exactly_one_record_with_the_client_identity() {
let mock = MockServer::start().await;
let audited = audited_client_for("", &mock, "test-key").await;
let events = read_events(&audited.audit_path);
assert_eq!(events.len(), 1, "the handshake writes exactly one record");
let init = match &events[0].kind {
AuditEventKind::Initialize(init) => init,
other => panic!("expected an initialize record, got {other:?}"),
};
assert!(
init.client.name.as_deref().is_some_and(|n| !n.is_empty()),
"client name recorded: {init:?}"
);
assert!(
init.client.version.is_some(),
"client version recorded: {init:?}"
);
assert!(
init.protocol_version.is_some(),
"protocol version recorded: {init:?}"
);
assert_eq!(events[0].session.transport, TransportKind::Stdio);
let id = events[0].session.id.as_deref().expect("stdio session id");
let (pid, _) = id.split_once('-').expect("session id is <pid>-<epoch>");
assert_eq!(pid, std::process::id().to_string());
assert_eq!(events[0].session.remote, None);
}
async fn mount_bug7_with_comments(mock: &MockServer, comments: Vec<Value>) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_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;
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 six_public_comments() -> Vec<Value> {
(1..=6)
.map(|i| {
json!({ "id": i, "bug_id": 7, "is_private": false, "text": format!("comment {i}") })
})
.collect()
}
const ALLOW_WORLD_POLICY: &str = concat!(
"default_action = \"deny\"\n",
"[[rule]]\nname = \"allow-openSUSE\"\naction = \"allow\"\n",
"[rule.match]\nproducts = [\"openSUSE\"]\n",
);
#[tokio::test]
async fn bug_comments_window_keeps_the_named_rule_that_granted_the_call() {
let mock = MockServer::start().await;
mount_bug7_with_comments(&mock, six_public_comments()).await;
let audited = audited_client_for(ALLOW_WORLD_POLICY, &mock, "test-key").await;
let result = call(
&audited.client,
"bug_comments",
json!({ "id": 7, "head": 1, "tail": 2 }),
)
.await;
assert_ne!(result.is_error, Some(true), "the window is served");
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events)
.guard
.clone()
.expect("guard info recorded");
assert_eq!(guard.redacted_fields, vec!["comments_window".to_string()]);
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.rule.as_deref(),
Some("allow-openSUSE"),
"the granting rule by name, so a fallback to `default` fails here: {guard:?}"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(guard.suppressed_other_count, 0);
}
#[tokio::test]
async fn bug_comments_window_beside_an_i5_suppression_records_no_rule() {
let mock = MockServer::start().await;
let mut comments = six_public_comments();
comments[5]["is_private"] = json!(true);
mount_bug7_with_comments(&mock, comments).await;
let audited = audited_client_for("", &mock, "test-key").await;
let result = call(
&audited.client,
"bug_comments",
json!({ "id": 7, "head": 1, "tail": 2 }),
)
.await;
assert_ne!(result.is_error, Some(true), "the window is served");
let events = read_events(&audited.audit_path);
let guard = last_tool_call(&events)
.guard
.clone()
.expect("guard info recorded");
assert_eq!(guard.redacted_fields, vec!["comments_window".to_string()]);
assert_eq!(guard.verdict, Verdict::ServedFiltered);
assert_eq!(
guard.suppressed_other_count, 1,
"the private comment is counted, id-lessly: {guard:?}"
);
assert_eq!(guard.suppressed_ids_count, 0);
assert_eq!(
guard.rule, None,
"a GUARD suppression upgraded the verdict, so the rule goes with \
it — the client window beside it changes nothing: {guard:?}"
);
}
#[tokio::test]
async fn bug_comments_window_records_the_redaction_it_applied() {
let mock = MockServer::start().await;
mount_bug7_with_comments(&mock, six_public_comments()).await;
let audited = audited_client_for("", &mock, "test-key").await;
let result = call(
&audited.client,
"bug_comments",
json!({ "id": 7, "head": 1, "tail": 2 }),
)
.await;
assert_ne!(result.is_error, Some(true), "the window is served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_comments");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert_eq!(
guard.redacted_fields,
vec!["comments_window".to_string()],
"the record names the windowing that shaped the response: {guard:?}"
);
assert_eq!(
guard.rule.as_deref(),
Some("default"),
"and the granting rule survives: the CLIENT asked for a window, so \
no rule decided the omission: {guard:?}"
);
}
#[tokio::test]
async fn bug_comments_window_with_nothing_omitted_records_no_redaction() {
let mock = MockServer::start().await;
mount_bug7_with_comments(&mock, six_public_comments()).await;
let audited = audited_client_for("", &mock, "test-key").await;
let result = call(
&audited.client,
"bug_comments",
json!({ "id": 7, "head": 4, "tail": 4 }),
)
.await;
assert_ne!(result.is_error, Some(true), "the window is served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.request.tool, "bug_comments");
let guard = tc.guard.as_ref().expect("guard info recorded");
assert!(
guard.redacted_fields.is_empty(),
"nothing omitted, nothing capped: no redaction is recorded: {guard:?}"
);
}
fn content_len(result: &CallToolResult) -> u64 {
serde_json::to_vec(&result.content)
.expect("served content must serialize")
.len() as u64
}
#[tokio::test]
async fn response_bytes_is_the_served_payload_length() {
let mock = MockServer::start().await;
mount_fixture(&mock).await;
let audited = audited_client_for("", &mock, "test-key").await;
let result = call(&audited.client, "bug_info", json!({ "bug_ids": [7] })).await;
assert_ne!(result.is_error, Some(true), "bug 7 is served");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(
tc.outcome.response_bytes,
Some(content_len(&result)),
"the record sizes the content the client received"
);
let text = match result.content.first() {
Some(block) => block.as_text().expect("one text block").text.clone(),
None => panic!("a served bug_info carries content"),
};
let bytes = tc.outcome.response_bytes.expect("measured");
assert!(
bytes > text.len() as u64,
"{bytes} must exceed the {} bytes of text it wraps",
text.len()
);
}
#[tokio::test]
async fn a_wholly_denied_call_is_sized_by_its_id_width_and_nothing_else() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
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": [] })))
.mount(&mock)
.await;
for id in [7_u64, 8, 4_242_424] {
let mut secret = world_bug(id);
secret["product"] = json!("SecretSauce");
if id == 8 {
secret["summary"] = json!("x".repeat(4096));
secret["whiteboard"] = json!("y".repeat(512));
secret["keywords"] = json!(["alpha", "beta", "gamma"]);
}
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": [secret] })))
.mount(&mock)
.await;
}
let audited = audited_client_for(policy, &mock, "test-key").await;
let mut sizes = Vec::new();
for id in [7_u64, 8, 4_242_424] {
let denied = call(&audited.client, "bug_history", json!({ "id": id })).await;
assert_eq!(denied.is_error, Some(true), "bug {id} is denied");
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(
tc.guard.as_ref().expect("the guard ran").verdict,
Verdict::Denied
);
let bytes = tc
.outcome
.response_bytes
.expect("a denial is still content");
assert_eq!(
bytes,
content_len(&denied),
"a denial is sized like any other served content"
);
sizes.push(bytes);
}
assert_eq!(
sizes[0], sizes[1],
"a 4KiB bug and a minimal one deny at the same size: the bug does not \
reach the denial"
);
assert_eq!(
sizes[2] - sizes[0],
6,
"4242424 is six digits wider than 7, and that is the whole difference"
);
}
#[tokio::test]
async fn a_partial_denial_is_sized_by_what_was_served() {
let policy = concat!(
"[[rule]]\nname = \"hide-secret\"\naction = \"deny\"\n",
"[rule.match]\nproducts = [\"Secret*\"]\n",
);
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": [] })))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [world_bug(7)] })))
.mount(&mock)
.await;
let mut secret = world_bug(8);
secret["product"] = json!("SecretSauce");
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "8"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "bugs": [secret] })))
.mount(&mock)
.await;
let audited = audited_client_for(policy, &mock, "test-key").await;
let whole = call(&audited.client, "bug_info", json!({ "bug_ids": [8] })).await;
let events = read_events(&audited.audit_path);
let denied_only = last_tool_call(&events)
.outcome
.response_bytes
.expect("measured");
drop(whole);
let partial = call(&audited.client, "bug_info", json!({ "bug_ids": [7, 8] })).await;
assert_ne!(
partial.is_error,
Some(true),
"bug 7 is still served alongside the denial"
);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(
tc.guard.as_ref().expect("the guard ran").verdict,
Verdict::Denied,
"worst-wins: one denied id stamps the whole record"
);
let bytes = tc.outcome.response_bytes.expect("measured");
assert_eq!(
bytes,
content_len(&partial),
"the record sizes the envelope"
);
assert!(
bytes > denied_only,
"{bytes} carries bug 7's body on top of the same denial ({denied_only}): a \
`denied` record is not bounded by the refusal's width"
);
}
#[tokio::test]
async fn a_protocol_error_records_no_response_size() {
let mock = MockServer::start().await;
mount_fixture(&mock).await;
let audited = audited_client_for("", &mock, "test-key").await;
let err = bounded(
"the no_such_tool call",
audited
.client
.call_tool(CallToolRequestParams::new("no_such_tool".to_string())),
)
.await
.expect_err("an unknown tool is a protocol error");
drop(err);
let events = read_events(&audited.audit_path);
let tc = last_tool_call(&events);
assert_eq!(tc.outcome.class, OutcomeClass::Error);
assert_eq!(tc.outcome.response_bytes, None, "absent, not zero");
}