Files
codex/codex-rs/rmcp-client/src/user_verification_tests.rs
T
berkdeniz-oai b38942ce7c Preserve diagnostic reasons for MCP user verification failures (#51808)
## Why

User-verification cancellations previously discarded failure reasons, leaving MCP servers unable to distinguish user cancellation from unavailable approval, interruption, or invalid proof.

## What changed

- Carry a bounded `openai/userVerificationReason` value in response `_meta` for verification failures, declines, and cancellations across the TUI, app server, and MCP client.
- Preserve recognized reasons and classify invalid responses, missing or invalid proofs, response errors, closed response channels, interruptions, and unavailable approval.
- Discard unknown reasons and unrelated metadata. Keep proof material out of decline and cancellation responses, and keep display messages and raw error diagnostics out of response metadata.

## Testing

Add and update tests for typed failure propagation, metadata filtering, invalid proofs, response-channel failures, interrupted requests, and unavailable approval, including TUI RPC and native MCP response coverage.

GitOrigin-RevId: 6b7f855ce8eb13e759b5b86d0dc2b1afdfcff7b3
2026-10-07 19:28:14 +00:00

164 lines
5.3 KiB
Rust

use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn user_verification_request_preserves_signed_bytes_and_display_text() {
assert_eq!(
parse_request(CustomRequest::new(
"openai/elicitation/create",
Some(json!({
"mode": MODE,
"title": "Approve purchase",
"description": "Pay $200 to Example Store",
"challenge": "AQID",
})),
))
.unwrap(),
Elicitation::UserVerification {
meta: None,
title: "Approve purchase".to_string(),
description: "Pay $200 to Example Store".to_string(),
challenge: "AQID".to_string(),
}
);
}
#[test]
fn user_verification_request_metadata_matches_mcp_elicitation() {
for meta in [
None,
Some(json!(null)),
Some(json!({})),
Some(json!({"example/display": {"label": "Operation", "details": [1, true, null]}})),
Some(json!(true)),
Some(json!(7)),
Some(json!("scalar")),
Some(json!([])),
] {
let mut params = json!({
"mode": MODE,
"title": "Approve",
"description": "Review operation",
"challenge": "AQID",
});
let mut mcp_params = json!({
"mode": "form",
"message": "Approve",
"requestedSchema": {"type": "object", "properties": {}},
});
if let Some(meta) = &meta {
params["_meta"] = meta.clone();
mcp_params["_meta"] = meta.clone();
}
let mcp_request = serde_json::from_value::<rmcp::model::ElicitRequestParams>(mcp_params);
let expected = if mcp_request.is_ok() {
Ok(Elicitation::UserVerification {
meta: meta.filter(serde_json::Value::is_object),
title: "Approve".into(),
description: "Review operation".into(),
challenge: "AQID".into(),
})
} else {
Err(invalid_request())
};
assert_eq!(
parse_request(CustomRequest::new(
"openai/elicitation/create",
Some(params),
)),
expected,
);
}
}
#[test]
fn user_verification_rejects_invalid_or_unbounded_requests_without_echoing_values() {
for (field, value) in [
("mode", json!("unknown")),
("title", json!("")),
("title", json!("x".repeat(257))),
("description", json!("x".repeat(4097))),
("challenge", json!("")),
("challenge", json!("not base64url!")),
("challenge", json!(URL_SAFE_NO_PAD.encode(vec![0; 4097]))),
("unexpected", json!("secret")),
] {
let mut params = json!({
"mode": MODE, "title": "Approve", "description": "", "challenge": "AQID"
});
params[field] = value;
assert_eq!(
parse_request(CustomRequest::new(
"openai/elicitation/create",
Some(params)
)),
Err(invalid_request()),
);
}
}
#[test]
fn user_verification_acceptance_returns_proof_only_in_content() {
let content = Some(json!({"credentialId": "credential:用户/1=", "signature": "BAUG"}));
assert_eq!(
validate_response(ElicitationResponse {
action: ElicitationAction::Accept,
content: content.clone(),
meta: Some(json!({"untrusted": "ignored"})),
}),
ElicitationResponse {
action: ElicitationAction::Accept,
content,
meta: None,
}
);
}
#[test]
fn user_verification_cancels_acceptance_without_a_valid_bounded_proof() {
for content in [
None,
Some(json!({})),
Some(json!({"credentialId": "AQID", "signature": ""})),
Some(json!({"credentialId": "AQID", "signature": "BAUG="})),
Some(json!({"credentialId": "é".repeat(513), "signature": "BAUG"})),
Some(json!({"credentialId": "", "signature": "BAUG"})),
Some(json!({"credentialId": "AQID", "signature": URL_SAFE_NO_PAD.encode(vec![0; 129])})),
Some(json!({"credentialId": "AQID", "signature": "BAUG", "extra": true})),
] {
assert_eq!(
validate_response(ElicitationResponse {
action: ElicitationAction::Accept,
content,
meta: Some(json!({"openai/userVerificationReason": "userCancelled"})),
}),
ElicitationResponse {
action: ElicitationAction::Cancel,
content: None,
meta: Some(json!({"openai/userVerificationReason": "invalidProof"})),
}
);
}
}
#[test]
fn user_verification_decline_and_cancel_discard_proof_material() {
for action in [ElicitationAction::Decline, ElicitationAction::Cancel] {
assert_eq!(
validate_response(ElicitationResponse {
action: action.clone(),
content: Some(json!({"credentialId": "AQID", "signature": "BAUG"})),
meta: Some(
json!({"secret": "discarded", "openai/userVerificationReason": "timeout"})
),
}),
ElicitationResponse {
action,
content: None,
meta: Some(json!({"openai/userVerificationReason": "timeout"}))
}
);
}
}