use super::*;
use serde_json::json;
use tonic_types::{BadRequest, DebugInfo, ErrorDetail, ErrorInfo, FieldViolation, StatusExt};
#[test]
fn test_grpc_to_http_mapping() {
assert_eq!(grpc_to_http_status(tonic::Code::Ok), StatusCode::OK);
assert_eq!(
grpc_to_http_status(tonic::Code::InvalidArgument),
StatusCode::BAD_REQUEST
);
assert_eq!(
grpc_to_http_status(tonic::Code::NotFound),
StatusCode::NOT_FOUND
);
assert_eq!(
grpc_to_http_status(tonic::Code::AlreadyExists),
StatusCode::CONFLICT
);
assert_eq!(
grpc_to_http_status(tonic::Code::PermissionDenied),
StatusCode::FORBIDDEN
);
assert_eq!(
grpc_to_http_status(tonic::Code::Unauthenticated),
StatusCode::UNAUTHORIZED
);
assert_eq!(
grpc_to_http_status(tonic::Code::ResourceExhausted),
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(
grpc_to_http_status(tonic::Code::Unimplemented),
StatusCode::NOT_IMPLEMENTED
);
assert_eq!(
grpc_to_http_status(tonic::Code::Internal),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
grpc_to_http_status(tonic::Code::Unavailable),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
grpc_to_http_status(tonic::Code::DeadlineExceeded),
StatusCode::GATEWAY_TIMEOUT
);
}
#[test]
fn test_grpc_code_name() {
assert_eq!(grpc_code_name(tonic::Code::Ok), "OK");
assert_eq!(grpc_code_name(tonic::Code::NotFound), "NOT_FOUND");
assert_eq!(
grpc_code_name(tonic::Code::Unauthenticated),
"UNAUTHENTICATED"
);
}
#[test]
fn test_status_to_response() {
let status = tonic::Status::not_found("user not found");
let response = status_to_response(status);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
fn canonical_only() -> StatusDetails {
StatusDetails::new(&DescriptorPool::new())
}
fn canonical_with_opaque() -> StatusDetails {
canonical_only().with_opaque_details(true)
}
fn rich_status() -> tonic::Status {
tonic::Status::with_error_details_vec(
tonic::Code::InvalidArgument,
"invalid email",
[
ErrorDetail::from(ErrorInfo::new(
"EMAIL_TAKEN",
"identity.example.com",
[("email".to_string(), "a@b.c".to_string())]
.into_iter()
.collect::<std::collections::HashMap<_, _>>(),
)),
ErrorDetail::from(BadRequest::new(vec![FieldViolation::new(
"email",
"already registered",
)])),
],
)
}
fn status_with_raw_details(details: &[(&str, Vec<u8>)]) -> tonic::Status {
let mut rpc = tonic_types::pb::Status {
code: tonic::Code::FailedPrecondition as i32,
message: "raw".into(),
..Default::default()
};
for (type_url, value) in details {
rpc.details.push(Default::default());
let any = rpc.details.last_mut().expect("just pushed");
any.type_url = (*type_url).to_string();
any.value = value.clone();
}
tonic::Status::with_details(
tonic::Code::FailedPrecondition,
"raw",
bytes::Bytes::from(rpc.encode_to_vec()),
)
}
fn product_pool(name: &str, source: &str) -> DescriptorPool {
struct OneFile {
name: String,
source: String,
}
impl protox::file::FileResolver for OneFile {
fn open_file(&self, name: &str) -> Result<protox::file::File, protox::Error> {
if name == self.name {
protox::file::File::from_source(name, &self.source)
} else {
protox::file::GoogleFileResolver::new().open_file(name)
}
}
}
protox::Compiler::with_file_resolver(OneFile {
name: name.to_owned(),
source: source.to_owned(),
})
.open_file(name)
.expect("test proto compiles")
.descriptor_pool()
}
#[test]
fn body_without_renderer_keeps_the_details_key_absent() {
let body = error_body(&rich_status(), None);
assert_eq!(
body,
json!({"error": "INVALID_ARGUMENT", "message": "invalid email", "code": 3})
);
}
#[test]
fn body_without_trailer_has_empty_details() {
let status = tonic::Status::not_found("user not found");
let body = error_body(&status, Some(&canonical_only()));
assert_eq!(
body,
json!({"error": "NOT_FOUND", "message": "user not found", "code": 5, "details": []})
);
}
#[test]
fn error_info_and_bad_request_render_as_canonical_json() {
let body = error_body(&rich_status(), Some(&canonical_only()));
assert_eq!(
body,
json!({
"error": "INVALID_ARGUMENT",
"message": "invalid email",
"code": 3,
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "EMAIL_TAKEN",
"domain": "identity.example.com",
"metadata": {"email": "a@b.c"}
},
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{"field": "email", "description": "already registered"}
]
}
]
})
);
}
#[test]
fn status_to_response_keeps_http_mapping_with_details() {
let resp = status_to_response_with_details(&rich_status(), Some(&canonical_only()));
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn debug_info_is_never_rendered() {
let status = tonic::Status::with_error_details_vec(
tonic::Code::Internal,
"boom",
[
ErrorDetail::from(ErrorInfo::new(
"DB_DOWN",
"store.example.com",
std::collections::HashMap::new(),
)),
ErrorDetail::from(DebugInfo::new(
vec!["at store::write (store.rs:42)".to_string()],
"connection refused to 10.0.0.7:5432",
)),
],
);
let details = canonical_only().render(&status).unwrap().details;
assert_eq!(
details,
vec![json!({
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "DB_DOWN",
"domain": "store.example.com"
})]
);
let text = serde_json::to_string(&details).unwrap();
assert!(!text.contains("store.rs:42") && !text.contains("10.0.0.7"));
}
#[test]
fn debug_info_is_dropped_under_any_type_url_prefix() {
let debug = tonic_types::pb::DebugInfo {
stack_entries: vec!["secret frame".into()],
detail: "secret".into(),
};
let status = status_with_raw_details(&[(
"example.com/types/google.rpc.DebugInfo",
debug.encode_to_vec(),
)]);
let rendered = canonical_only().render(&status).unwrap();
assert!(rendered.details.is_empty() && rendered.opaque.is_empty());
}
#[test]
fn unknown_detail_type_is_withheld_by_default() {
let debug = tonic_types::pb::DebugInfo {
detail: "password=hunter2".into(),
..Default::default()
};
let status =
status_with_raw_details(&[("type.googleapis.com/acme.v1.Wrapper", debug.encode_to_vec())]);
assert_eq!(
error_body(&status, Some(&canonical_only())),
json!({"error": "FAILED_PRECONDITION", "message": "raw", "code": 9, "details": []})
);
}
#[test]
fn unknown_detail_type_goes_to_opaque_details() {
let status = status_with_raw_details(&[(
"type.googleapis.com/acme.v1.Unknown",
vec![0x08, 0x96, 0x01],
)]);
assert_eq!(
error_body(&status, Some(&canonical_with_opaque())),
json!({
"error": "FAILED_PRECONDITION",
"message": "raw",
"code": 9,
"details": [],
"opaqueDetails": [{
"index": 0,
"typeUrl": "type.googleapis.com/acme.v1.Unknown",
"bytes": "CJYB"
}]
})
);
}
#[test]
fn opaque_index_is_the_position_among_forwarded_details() {
let info = |reason: &str| {
tonic_types::pb::ErrorInfo {
reason: reason.into(),
..Default::default()
}
.encode_to_vec()
};
let debug = tonic_types::pb::DebugInfo {
detail: "secret".into(),
..Default::default()
};
let status = status_with_raw_details(&[
("type.googleapis.com/google.rpc.ErrorInfo", info("FIRST")),
(
"type.googleapis.com/google.rpc.DebugInfo",
debug.encode_to_vec(),
),
("type.googleapis.com/acme.v1.Unknown", vec![0x08, 0x01]),
("type.googleapis.com/google.rpc.ErrorInfo", info("LAST")),
]);
let body = error_body(&status, Some(&canonical_with_opaque()));
assert_eq!(
body["details"],
json!([
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "FIRST"},
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "LAST"}
])
);
assert_eq!(
body["opaqueDetails"],
json!([{"index": 1, "typeUrl": "type.googleapis.com/acme.v1.Unknown", "bytes": "CAE="}])
);
}
#[test]
fn object_valued_well_known_details_go_under_value() {
use prost_reflect::prost_types::{value::Kind, Struct, Value as PbValue};
let mut fields = std::collections::BTreeMap::new();
fields.insert(
"@type".to_string(),
PbValue {
kind: Some(Kind::StringValue("forged".into())),
},
);
fields.insert(
"retry".to_string(),
PbValue {
kind: Some(Kind::BoolValue(true)),
},
);
let status = status_with_raw_details(&[
(
"type.googleapis.com/google.protobuf.Struct",
Struct { fields }.encode_to_vec(),
),
("type.googleapis.com/google.protobuf.Empty", Vec::new()),
]);
assert_eq!(
canonical_only().render(&status).unwrap().details,
vec![
json!({
"@type": "type.googleapis.com/google.protobuf.Struct",
"value": {"@type": "forged", "retry": true}
}),
json!({"@type": "type.googleapis.com/google.protobuf.Empty", "value": {}}),
]
);
}
#[test]
fn debug_info_behind_a_malformed_type_url_is_not_forwarded() {
let debug = tonic_types::pb::DebugInfo {
detail: "connection refused to 10.0.0.7:5432".into(),
..Default::default()
};
for type_url in [
"type.googleapis.com/google.rpc.DebugInfo/",
"type.googleapis.com/google.rpc.DebugInfo?v=1",
"",
"type.googleapis.com/google..rpc.DebugInfo",
] {
let status = status_with_raw_details(&[(type_url, debug.encode_to_vec())]);
let body = error_body(&status, Some(&canonical_only()));
assert_eq!(body, malformed_upstream_status_body(), "{type_url:?}");
}
}
#[test]
fn trailer_code_or_message_disagreeing_with_the_status_fails_safely() {
let details = canonical_only();
for (code, message) in [
(tonic::Code::NotFound, "raw"),
(tonic::Code::FailedPrecondition, "something else"),
] {
let rpc = tonic_types::pb::Status {
code: code as i32,
message: message.into(),
..Default::default()
};
let status = tonic::Status::with_details(
tonic::Code::FailedPrecondition,
"raw",
bytes::Bytes::from(rpc.encode_to_vec()),
);
assert_eq!(
error_body(&status, Some(&details)),
malformed_upstream_status_body(),
"{code:?} {message:?}"
);
}
}
fn wrapper_pool() -> DescriptorPool {
product_pool(
"acme.proto",
"syntax = \"proto3\"; package acme.v1; import \"google/protobuf/any.proto\"; \
message Wrapper { google.protobuf.Any cause = 1; repeated google.protobuf.Any causes = 2; }",
)
}
fn packed(pool: &DescriptorPool, type_url: &str, message: Vec<u8>) -> prost_reflect::Value {
let mut any = DynamicMessage::new(pool.get_message_by_name("google.protobuf.Any").unwrap());
any.set_field_by_name("type_url", prost_reflect::Value::String(type_url.into()));
any.set_field_by_name("value", prost_reflect::Value::Bytes(message.into()));
prost_reflect::Value::Message(any)
}
fn secret_debug_info() -> Vec<u8> {
tonic_types::pb::DebugInfo {
stack_entries: vec!["at db::write (db.rs:7)".into()],
detail: "password=hunter2".into(),
}
.encode_to_vec()
}
fn error_info(reason: &str) -> Vec<u8> {
tonic_types::pb::ErrorInfo {
reason: reason.into(),
..Default::default()
}
.encode_to_vec()
}
#[test]
fn nested_canonical_type_resolves_inside_a_product_detail() {
let pool = wrapper_pool();
let mut wrapper = DynamicMessage::new(pool.get_message_by_name("acme.v1.Wrapper").unwrap());
wrapper.set_field_by_name(
"cause",
packed(
&pool,
"type.googleapis.com/google.rpc.ErrorInfo",
error_info("NESTED"),
),
);
let status = status_with_raw_details(&[(
"type.googleapis.com/acme.v1.Wrapper",
wrapper.encode_to_vec(),
)]);
assert_eq!(
StatusDetails::new(&pool).render(&status).unwrap().details,
vec![json!({
"@type": "type.googleapis.com/acme.v1.Wrapper",
"cause": {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "NESTED"}
})]
);
}
#[test]
fn debug_info_nested_in_a_product_detail_is_removed() {
let pool = wrapper_pool();
let mut wrapper = DynamicMessage::new(pool.get_message_by_name("acme.v1.Wrapper").unwrap());
wrapper.set_field_by_name(
"cause",
packed(
&pool,
"type.googleapis.com/google.rpc.DebugInfo",
secret_debug_info(),
),
);
wrapper.set_field_by_name(
"causes",
prost_reflect::Value::List(vec![
packed(
&pool,
"type.googleapis.com/google.rpc.ErrorInfo",
error_info("KEPT"),
),
packed(
&pool,
"type.googleapis.com/google.rpc.DebugInfo",
secret_debug_info(),
),
]),
);
let status = status_with_raw_details(&[(
"type.googleapis.com/acme.v1.Wrapper",
wrapper.encode_to_vec(),
)]);
let body = error_body(&status, Some(&StatusDetails::new(&pool)));
assert_eq!(
body["details"],
json!([{
"@type": "type.googleapis.com/acme.v1.Wrapper",
"causes": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "KEPT"}]
}])
);
let text = body.to_string();
assert!(
!text.contains("hunter2") && !text.contains("db.rs"),
"{text}"
);
}
#[test]
fn debug_info_packed_in_an_any_detail_is_dropped() {
let pool = wrapper_pool();
let DynamicMessageValue(any) = DynamicMessageValue::from(packed(
&pool,
"type.googleapis.com/google.rpc.DebugInfo",
secret_debug_info(),
));
let status = status_with_raw_details(&[
(
"type.googleapis.com/google.protobuf.Any",
any.encode_to_vec(),
),
("type.googleapis.com/acme.v1.Unknown", vec![0x08, 0x01]),
]);
let renderer = StatusDetails::new(&pool).with_opaque_details(true);
let body = error_body(&status, Some(&renderer));
assert_eq!(body["details"], json!([]));
assert_eq!(body["opaqueDetails"][0]["index"], 0);
let text = body.to_string();
assert!(
!text.contains("hunter2") && !text.contains("db.rs"),
"{text}"
);
}
fn bad_request(field: &str) -> Vec<u8> {
tonic_types::pb::BadRequest {
field_violations: vec![tonic_types::pb::bad_request::FieldViolation {
field: field.into(),
description: "bad".into(),
..Default::default()
}],
}
.encode_to_vec()
}
#[test]
fn canonical_types_stay_available_when_the_product_defines_one_elsewhere() {
let pool = product_pool(
"acme/errors.proto",
"syntax = \"proto3\"; package google.rpc; message ErrorInfo { string reason = 1; string tenant = 9; }",
);
let desc = pool.get_message_by_name("google.rpc.ErrorInfo").unwrap();
let mut info = DynamicMessage::new(desc);
info.set_field_by_name("tenant", prost_reflect::Value::String("t-7".into()));
let status = status_with_raw_details(&[
(
"type.googleapis.com/google.rpc.ErrorInfo",
info.encode_to_vec(),
),
(
"type.googleapis.com/google.rpc.BadRequest",
bad_request("email"),
),
]);
let body = error_body(&status, Some(&StatusDetails::new(&pool)));
assert_eq!(
body["details"],
json!([
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "tenant": "t-7"},
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [{"field": "email", "description": "bad"}]
}
])
);
assert!(body.get("opaqueDetails").is_none(), "{body}");
}
#[test]
fn canonical_types_missing_from_a_shadowed_file_stay_available() {
let pool = product_pool(
"google/rpc/error_details.proto",
"syntax = \"proto3\"; package google.rpc; message ErrorInfo { string reason = 1; string tenant = 9; }",
);
let status = status_with_raw_details(&[(
"type.googleapis.com/google.rpc.BadRequest",
bad_request("name"),
)]);
let body = error_body(&status, Some(&StatusDetails::new(&pool)));
assert_eq!(
body["details"],
json!([{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [{"field": "name", "description": "bad"}]
}])
);
}
#[test]
fn type_url_without_a_slash_fails_safely() {
let status = status_with_raw_details(&[("google.rpc.ErrorInfo", error_info("R"))]);
assert_eq!(
error_body(&status, Some(&canonical_only())),
malformed_upstream_status_body()
);
}
#[test]
fn field_json_named_at_type_fails_safely() {
let pool = product_pool(
"acme.proto",
"syntax = \"proto3\"; package acme.v1; message Tagged { string kind = 1 [json_name = \"@type\"]; }",
);
let desc = pool.get_message_by_name("acme.v1.Tagged").unwrap();
let mut tagged = DynamicMessage::new(desc);
tagged.set_field_by_name(
"kind",
prost_reflect::Value::String("type.googleapis.com/google.rpc.ErrorInfo".into()),
);
let status =
status_with_raw_details(&[("type.googleapis.com/acme.v1.Tagged", tagged.encode_to_vec())]);
assert_eq!(
error_body(&status, Some(&StatusDetails::new(&pool))),
malformed_upstream_status_body()
);
}
#[test]
fn struct_keys_that_look_like_debug_info_are_kept() {
use prost_reflect::prost_types::{value::Kind, Struct, Value as PbValue};
let mut fields = std::collections::BTreeMap::new();
fields.insert(
"@type".to_string(),
PbValue {
kind: Some(Kind::StringValue(
"type.googleapis.com/google.rpc.DebugInfo".into(),
)),
},
);
fields.insert(
"note".to_string(),
PbValue {
kind: Some(Kind::StringValue("keep".into())),
},
);
let status = status_with_raw_details(&[(
"type.googleapis.com/google.protobuf.Struct",
Struct { fields }.encode_to_vec(),
)]);
assert_eq!(
canonical_only().render(&status).unwrap().details,
vec![json!({
"@type": "type.googleapis.com/google.protobuf.Struct",
"value": {"@type": "type.googleapis.com/google.rpc.DebugInfo", "note": "keep"}
})]
);
}
#[test]
fn debug_info_typed_fields_are_removed() {
let pool = product_pool(
"google/rpc/debug_holder.proto",
"syntax = \"proto3\"; package google.rpc; \
message DebugInfo { repeated string stack_entries = 1; string detail = 2; } \
message Holder { DebugInfo one = 1; repeated DebugInfo many = 2; \
map<string, DebugInfo> by_key = 3; string keep = 4; }",
);
let debug_desc = pool.get_message_by_name("google.rpc.DebugInfo").unwrap();
let secret = || {
let mut debug = DynamicMessage::new(debug_desc.clone());
debug.set_field_by_name(
"detail",
prost_reflect::Value::String("password=hunter2".into()),
);
prost_reflect::Value::Message(debug)
};
let holder_desc = pool.get_message_by_name("google.rpc.Holder").unwrap();
let mut holder = DynamicMessage::new(holder_desc);
holder.set_field_by_name("one", secret());
holder.set_field_by_name("many", prost_reflect::Value::List(vec![secret(), secret()]));
let mut by_key = std::collections::HashMap::new();
by_key.insert(prost_reflect::MapKey::String("k".into()), secret());
holder.set_field_by_name("by_key", prost_reflect::Value::Map(by_key));
holder.set_field_by_name("keep", prost_reflect::Value::String("kept".into()));
let status = status_with_raw_details(&[(
"type.googleapis.com/google.rpc.Holder",
holder.encode_to_vec(),
)]);
let body = error_body(&status, Some(&StatusDetails::new(&pool)));
assert_eq!(
body["details"],
json!([{"@type": "type.googleapis.com/google.rpc.Holder", "keep": "kept"}])
);
assert!(!body.to_string().contains("hunter2"), "{body}");
}
#[test]
fn debug_info_extensions_are_removed() {
let pool = product_pool(
"google/rpc/debug_extension.proto",
"syntax = \"proto2\"; package google.rpc; \
message DebugInfo { repeated string stack_entries = 1; optional string detail = 2; } \
message Extendable { optional string keep = 1; extensions 100 to 199; } \
extend Extendable { optional DebugInfo debug = 100; }",
);
let mut debug = DynamicMessage::new(pool.get_message_by_name("google.rpc.DebugInfo").unwrap());
debug.set_field_by_name(
"detail",
prost_reflect::Value::String("password=hunter2".into()),
);
let mut extendable =
DynamicMessage::new(pool.get_message_by_name("google.rpc.Extendable").unwrap());
extendable.set_field_by_name("keep", prost_reflect::Value::String("kept".into()));
extendable.set_extension(
&pool.get_extension_by_name("google.rpc.debug").unwrap(),
prost_reflect::Value::Message(debug),
);
let status = status_with_raw_details(&[(
"type.googleapis.com/google.rpc.Extendable",
extendable.encode_to_vec(),
)]);
let body = error_body(&status, Some(&StatusDetails::new(&pool)));
assert_eq!(
body["details"],
json!([{"@type": "type.googleapis.com/google.rpc.Extendable", "keep": "kept"}])
);
assert!(!body.to_string().contains("hunter2"), "{body}");
}
#[test]
fn detail_with_required_debug_info_field_is_withheld() {
let pool = product_pool(
"google/rpc/debug_required.proto",
"syntax = \"proto2\"; package google.rpc; \
message DebugInfo { repeated string stack_entries = 1; optional string detail = 2; } \
message Wrapper { required DebugInfo info = 1; optional string keep = 2; } \
message Outer { optional Wrapper wrapper = 1; optional string keep = 2; }",
);
let debug_desc = pool.get_message_by_name("google.rpc.DebugInfo").unwrap();
let wrapper_desc = pool.get_message_by_name("google.rpc.Wrapper").unwrap();
let wrapper = || {
let mut debug = DynamicMessage::new(debug_desc.clone());
debug.set_field_by_name(
"detail",
prost_reflect::Value::String("password=hunter2".into()),
);
let mut wrapper = DynamicMessage::new(wrapper_desc.clone());
wrapper.set_field_by_name("info", prost_reflect::Value::Message(debug));
wrapper.set_field_by_name("keep", prost_reflect::Value::String("kept".into()));
wrapper
};
let mut outer = DynamicMessage::new(pool.get_message_by_name("google.rpc.Outer").unwrap());
outer.set_field_by_name("wrapper", prost_reflect::Value::Message(wrapper()));
outer.set_field_by_name("keep", prost_reflect::Value::String("outer".into()));
let status = status_with_raw_details(&[
(
"type.googleapis.com/google.rpc.Wrapper",
wrapper().encode_to_vec(),
),
(
"type.googleapis.com/google.rpc.Outer",
outer.encode_to_vec(),
),
("type.googleapis.com/acme.Unknown", vec![1, 2, 3]),
]);
let renderer = StatusDetails::new(&pool).with_opaque_details(true);
let body = error_body(&status, Some(&renderer));
assert_eq!(
body["details"],
json!([{"@type": "type.googleapis.com/google.rpc.Outer", "keep": "outer"}])
);
assert_eq!(body["opaqueDetails"][0]["index"], json!(1));
assert!(!body.to_string().contains("hunter2"), "{body}");
}
#[test]
fn detail_missing_a_required_field_is_malformed() {
let pool = product_pool(
"acme/required.proto",
"syntax = \"proto2\"; package acme; \
message Strict { required string id = 1; optional string note = 2; } \
message Holder { optional Strict strict = 1; }",
);
let mut strict = DynamicMessage::new(pool.get_message_by_name("acme.Strict").unwrap());
strict.set_field_by_name("note", prost_reflect::Value::String("no id".into()));
let mut holder = DynamicMessage::new(pool.get_message_by_name("acme.Holder").unwrap());
holder.set_field_by_name("strict", prost_reflect::Value::Message(strict.clone()));
for (type_url, bytes) in [
("type.googleapis.com/acme.Strict", strict.encode_to_vec()),
("type.googleapis.com/acme.Holder", holder.encode_to_vec()),
] {
let status = status_with_raw_details(&[(type_url, bytes)]);
let body = error_body(&status, Some(&StatusDetails::new(&pool)));
assert_eq!(body, malformed_upstream_status_body(), "{type_url}");
}
}
struct DynamicMessageValue(DynamicMessage);
impl From<prost_reflect::Value> for DynamicMessageValue {
fn from(value: prost_reflect::Value) -> Self {
match value {
prost_reflect::Value::Message(message) => Self(message),
other => panic!("expected a message, got {other:?}"),
}
}
}
#[test]
fn opaque_details_key_is_absent_when_every_type_resolves() {
let body = error_body(&rich_status(), Some(&canonical_only()));
assert!(body.get("opaqueDetails").is_none(), "{body}");
}
fn malformed_upstream_status_body() -> Value {
json!({
"error": "INTERNAL",
"message": "upstream returned a malformed error status",
"code": 13,
"details": []
})
}
#[test]
fn corrupt_known_detail_fails_the_error_safely() {
let valid = tonic_types::pb::ErrorInfo {
reason: "OK_ONE".into(),
..Default::default()
};
let status = status_with_raw_details(&[
(
"type.googleapis.com/google.rpc.ErrorInfo",
valid.encode_to_vec(),
),
(
"type.googleapis.com/google.rpc.ErrorInfo",
vec![0x0a, 0x05, b'a'],
),
]);
let details = canonical_only();
assert_eq!(
error_body(&status, Some(&details)),
malformed_upstream_status_body()
);
let resp = status_to_response_with_details(&status, Some(&details));
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn corrupt_well_known_detail_is_not_rendered_as_that_type() {
let status =
status_with_raw_details(&[("type.googleapis.com/google.protobuf.Duration", vec![0x08])]);
assert_eq!(
error_body(&status, Some(&canonical_only())),
malformed_upstream_status_body()
);
}
#[test]
fn out_of_range_well_known_value_fails_the_error_safely() {
let duration = prost_reflect::prost_types::Duration {
seconds: 400_000_000_000,
nanos: 0,
};
let status = status_with_raw_details(&[(
"type.googleapis.com/google.protobuf.Duration",
duration.encode_to_vec(),
)]);
assert_eq!(
error_body(&status, Some(&canonical_only())),
malformed_upstream_status_body()
);
}
#[test]
fn well_known_type_detail_goes_under_value() {
let duration = prost_reflect::prost_types::Duration {
seconds: 1,
nanos: 500_000_000,
};
let status = status_with_raw_details(&[(
"type.googleapis.com/google.protobuf.Duration",
duration.encode_to_vec(),
)]);
assert_eq!(
canonical_only().render(&status).unwrap().details,
vec![json!({"@type": "type.googleapis.com/google.protobuf.Duration", "value": "1.500s"})]
);
}
#[test]
fn product_defined_detail_type_renders_its_fields() {
let pool = product_pool(
"acme.proto",
"syntax = \"proto3\"; package acme.v1; message QuotaTicket { string ticket = 1; int64 wait_ms = 2; }",
);
let ticket = pool.get_message_by_name("acme.v1.QuotaTicket").unwrap();
let mut msg = DynamicMessage::new(ticket);
msg.set_field_by_name("ticket", prost_reflect::Value::String("T-1".into()));
msg.set_field_by_name("wait_ms", prost_reflect::Value::I64(1500));
let status = status_with_raw_details(&[(
"type.googleapis.com/acme.v1.QuotaTicket",
msg.encode_to_vec(),
)]);
assert_eq!(
StatusDetails::new(&pool).render(&status).unwrap().details,
vec![json!({
"@type": "type.googleapis.com/acme.v1.QuotaTicket",
"ticket": "T-1",
"waitMs": "1500"
})]
);
}
#[test]
fn product_revision_of_a_canonical_type_wins() {
let pool = product_pool(
"google/rpc/error_details.proto",
"syntax = \"proto3\"; package google.rpc; message ErrorInfo { string reason = 1; string domain = 2; string tenant = 9; }",
);
let desc = pool.get_message_by_name("google.rpc.ErrorInfo").unwrap();
let mut msg = DynamicMessage::new(desc);
msg.set_field_by_name("reason", prost_reflect::Value::String("R".into()));
msg.set_field_by_name("tenant", prost_reflect::Value::String("t-7".into()));
let status = status_with_raw_details(&[(
"type.googleapis.com/google.rpc.ErrorInfo",
msg.encode_to_vec(),
)]);
assert_eq!(
StatusDetails::new(&pool).render(&status).unwrap().details,
vec![json!({
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "R",
"tenant": "t-7"
})]
);
}
#[test]
fn malformed_trailer_fails_the_error_safely() {
let status = tonic::Status::with_details(
tonic::Code::NotFound,
"boom",
bytes::Bytes::from_static(&[0x1a, 0xff]),
);
let details = canonical_only();
assert_eq!(
error_body(&status, Some(&details)),
malformed_upstream_status_body()
);
let resp = status_to_response_with_details(&status, Some(&details));
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn details_off_leaves_a_malformed_trailer_unread() {
let status = tonic::Status::with_details(
tonic::Code::NotFound,
"boom",
bytes::Bytes::from_static(&[0x1a, 0xff]),
);
assert_eq!(
error_body(&status, None),
json!({"error": "NOT_FOUND", "message": "boom", "code": 5})
);
assert_eq!(
status_to_response_with_details(&status, None).status(),
StatusCode::NOT_FOUND
);
}
fn policy(enabled: bool, routes: &[(&str, bool)]) -> Result<ErrorDetailsPolicy, String> {
let base = if enabled {
ErrorDetailsPolicy::default()
} else {
ErrorDetailsPolicy::disabled()
};
routes.iter().try_fold(base, |policy, (pattern, enabled)| {
policy.route(pattern, *enabled)
})
}
#[test]
fn policy_defaults_to_enabled_everywhere() {
let p = ErrorDetailsPolicy::default();
assert!(p.enabled_for("/v1/users/{id}"));
assert!(p.enabled_for("/anything"));
}
#[test]
fn policy_global_off_disables_every_route() {
let p = policy(false, &[]).unwrap();
assert!(!p.enabled_for("/v1/users/{id}"));
}
#[test]
fn policy_route_rule_overrides_global_in_both_directions() {
let p = policy(
false,
&[("/v1/public/internal/*", false), ("/v1/public/**", true)],
)
.unwrap();
assert!(p.enabled_for("/v1/public/items/{id}"));
assert!(!p.enabled_for("/v1/public/internal/{id}"));
assert!(!p.enabled_for("/v1/admin/items"));
}
#[test]
fn policy_first_matching_rule_wins() {
let p = policy(true, &[("/v1/**", false), ("/v1/public/**", true)]).unwrap();
assert!(!p.enabled_for("/v1/public/items"));
}
#[test]
fn policy_opaque_is_off_by_default_and_decided_per_switch() {
assert!(!ErrorDetailsPolicy::default().opaque_for("/v1/users/{id}"));
assert!(!ErrorDetailsPolicy::disabled().opaque_for("/v1/users/{id}"));
let p = ErrorDetailsPolicy::default()
.opaque_route("/v1/partner/**", true)
.unwrap()
.route("/v1/partner/internal/**", false)
.unwrap();
assert!(p.opaque_for("/v1/partner/orders"));
assert!(!p.enabled_for("/v1/partner/internal/x"));
assert!(p.enabled_for("/v1/partner/orders"));
assert!(!p.opaque_for("/v1/users/{id}"));
let p = ErrorDetailsPolicy::default()
.with_opaque_details(true)
.opaque_route("/v1/public/**", false)
.unwrap();
assert!(p.opaque_for("/v1/users/{id}"));
assert!(!p.opaque_for("/v1/public/items"));
}
#[test]
fn policy_star_matches_one_parameter_segment_only() {
let p = policy(true, &[("/v1/users/*", false), ("/v1/files/**", false)]).unwrap();
assert!(!p.enabled_for("/v1/users/{id}"));
assert!(p.enabled_for("/v1/users/{id}/keys"));
assert!(!p.enabled_for("/v1/files/{*path}"));
}
#[test]
fn policy_rejects_relative_pattern() {
let err = policy(true, &[("v1/admin/**", false)]).unwrap_err();
assert!(err.contains("must start with '/'"), "{err}");
}
#[test]
fn policy_rejects_invalid_glob() {
let err = policy(true, &[("/v1/[admin", false)]).unwrap_err();
assert!(err.contains("invalid glob pattern"), "{err}");
}
#[test]
fn http_body_detail_renders_without_product_descriptors() {
let http_body = b"\x0a\x0atext/plain\x12\x02hi".to_vec();
let status = status_with_raw_details(&[("type.googleapis.com/google.api.HttpBody", http_body)]);
let body = error_body(&status, Some(&canonical_only()));
assert_eq!(
body["details"],
json!([{
"@type": "type.googleapis.com/google.api.HttpBody",
"contentType": "text/plain",
"data": "aGk="
}])
);
}
#[test]
fn render_response_reports_whether_the_error_is_the_upstreams() {
let (response, faithful) = render_response(&rich_status(), Some(&canonical_only()));
assert!(faithful);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let broken = tonic::Status::with_details(
tonic::Code::NotFound,
"gone",
bytes::Bytes::from_static(b"\xff\xff"),
);
let (response, faithful) = render_response(&broken, None);
assert!(faithful);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let (response, faithful) = render_response(&broken, Some(&canonical_only()));
assert!(!faithful);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
async fn json_of(response: Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn malformed_response_is_an_internal_in_the_route_error_body() {
let response = malformed_response(Some(&canonical_only()));
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
json_of(response).await,
json!({
"error": "INTERNAL",
"message": "upstream returned a malformed response",
"code": 13,
"details": []
})
);
let response = malformed_response(None);
assert_eq!(
json_of(response).await,
json!({
"error": "INTERNAL",
"message": "upstream returned a malformed response",
"code": 13
})
);
}