use proptest::prelude::*;
use serde_json::{Map, Value, json};
use super::{BINARY_DATA_NOTE, sanitize_event};
use crate::pipeline::EventObject;
use crate::test_support::json::index_keys_first;
use crate::test_support::strategies::{edge_string, tool_call_event};
use crate::test_support::{assert_same_json_text, config_capped, markers, vectors};
fn input_of(case: &Map<String, Value>, context: &str) -> EventObject {
let value = match (case.get("input"), case.get("input_text")) {
(Some(input), None) => input.clone(),
(None, Some(Value::String(text))) => {
markers::expand(serde_json::from_str(text).expect("input_text"))
}
_ => panic!("{context}: needs exactly one of input and input_text"),
};
match value {
Value::Object(event) => event,
other => panic!("{context}: the input is not an event: {other}"),
}
}
#[test]
fn fixture_cases_hold() {
let cases = vectors::cases("sanitize");
assert!(cases.len() >= 100, "sanitize.json has its cases");
for case in &cases {
let name = vectors::name(case);
let context = format!("sanitize.json {name}");
assert!(
!case.contains_key("error"),
"{context}: sanitize never fails, so a case holds no error"
);
let actual = Value::Object(sanitize_event(&input_of(case, &context)));
assert_same_json_text(&actual, &case["output"], &context);
}
}
#[test]
fn sanitize_matches_sanitize_cases() {
let cases = vectors::cases("sanitize_cases");
assert!(cases.len() >= 20, "sanitize_cases.json has its cases");
for case in &cases {
let name = vectors::name(case);
let actual = Value::Object(sanitize_event(&input_of(case, name)));
assert_eq!(
serde_json::to_string(&actual).unwrap(),
serde_json::to_string(&case["output"]).unwrap(),
"{name}"
);
}
}
fn is_large_base64(text: &str) -> bool {
let body = text.trim_end_matches('=');
text.encode_utf16().count() >= 10_240
&& !body.is_empty()
&& body
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+/\n\r".contains(c))
}
fn check_walked(input: &Value, output: &Value) -> Result<(), TestCaseError> {
match (input, output) {
(Value::String(text), _) if is_large_base64(text) => {
prop_assert_eq!(output, BINARY_DATA_NOTE);
}
(Value::Array(items), Value::Array(out)) => {
prop_assert_eq!(items.len(), out.len());
for (item, out) in items.iter().zip(out) {
check_walked(item, out)?;
}
}
(Value::Object(map), Value::Object(out)) => {
let keys: Vec<&str> = out.keys().map(String::as_str).collect();
prop_assert_eq!(keys, index_keys_first(map.keys().map(String::as_str)));
for (key, item) in map {
check_walked(item, &out[key])?;
}
}
_ => prop_assert_eq!(output, input),
}
Ok(())
}
fn keeps_block(block: &Value) -> bool {
let Value::Object(map) = block else {
return !block.is_array();
};
match map.get("type").and_then(Value::as_str) {
Some("text" | "resource_link") => true,
Some("resource") => !map
.get("resource")
.and_then(Value::as_object)
.is_some_and(|resource| resource.contains_key("blob")),
_ => false,
}
}
fn base64_run() -> impl Strategy<Value = String> + Clone {
let alphabet: Vec<char> = ('A'..='Z')
.chain('a'..='z')
.chain('0'..='9')
.chain(['+', '/', '\n', '\r'])
.collect();
(
prop::collection::vec(prop::sample::select(alphabet), 1..=64),
10_230usize..=10_262,
0usize..=3,
prop::sample::select(vec!["", "", "", "-", "_", " ", "é", "x", "=\n", "\n"]),
)
.prop_map(|(seed, len, padding, tail)| {
let mut text: String = seed.iter().cycle().take(len).collect();
text.push_str(&"=".repeat(padding));
text.push_str(tail);
text
})
}
fn text() -> impl Strategy<Value = String> + Clone {
prop_oneof![30 => edge_string(8), 1 => base64_run()]
}
fn odd_block() -> impl Strategy<Value = Value> {
prop_oneof![
prop::sample::select(vec![
json!({"type": 5, "text": "t"}),
json!({"type": 1.5}),
json!({"type": null}),
json!({"type": true}),
json!({"text": "no type"}),
json!({"type": ["a", null, [1]]}),
json!({"type": {"a": 1}}),
json!({"type": ""}),
json!("text"),
json!(3),
Value::Null,
json!([]),
json!([{"type": "image"}]),
json!({"type": "resource"}),
json!({"type": "resource", "resource": "blob"}),
json!({"type": "resource", "resource": [{"blob": "x"}]}),
json!({"type": "resource", "resource": null}),
json!({"type": "resource", "resource": {}}),
json!({"type": "resource", "resource": {"blob": null}}),
json!({"type": "resource", "resource": {"blob": 0, "text": "t"}}),
]),
text().prop_map(|data| json!({"type": "resource", "resource": {"blob": data}})),
text().prop_map(|data| json!({"type": "text", "text": data})),
]
}
fn sanitize_event_input() -> impl Strategy<Value = EventObject> {
(
tool_call_event(text()),
prop::collection::vec(odd_block(), 0..=3),
0u8..10,
prop::option::of(base64_run()),
)
.prop_map(|(mut event, blocks, replace, run)| {
if let Some(Value::Object(response)) = event.get_mut("response")
&& let Some(Value::Array(content)) = response.get_mut("content")
{
content.extend(blocks);
}
match (replace, run) {
(0, _) => {
event.insert("response".to_owned(), json!("done"));
}
(1, _) => {
event.insert("response".to_owned(), json!(7));
}
(2, Some(run)) => {
event.insert("parameters".to_owned(), Value::String(run));
}
(3, Some(run)) => {
event.insert("parameters".to_owned(), json!([run, "x", [null]]));
}
(4, Some(run)) => {
event.insert(
"response".to_owned(),
json!({"content": [], "structuredContent": {"k": [run]}}),
);
}
(5, Some(run)) => {
event.insert(
"response".to_owned(),
json!({"content": [], "structuredContent": run}),
);
}
_ => {}
}
event
})
}
proptest! {
#![proptest_config(config_capped(300))]
#[test]
fn sanitize_is_idempotent(event in sanitize_event_input()) {
let once = sanitize_event(&event);
let twice = sanitize_event(&once);
prop_assert_eq!(serde_json::to_string(&twice).unwrap(), serde_json::to_string(&once).unwrap());
}
#[test]
fn sanitize_replaces_only_large_base64_strings_in_the_tool_values(
event in sanitize_event_input(),
) {
let sanitized = sanitize_event(&event);
let keys: Vec<&str> = sanitized.keys().map(String::as_str).collect();
prop_assert_eq!(keys, index_keys_first(event.keys().map(String::as_str)));
for (key, value) in &event {
let out = &sanitized[key];
match (key.as_str(), value) {
("parameters", _) => check_walked(value, out)?,
("response", Value::Object(response)) => {
let out = out.as_object().unwrap();
let keys: Vec<&str> = out.keys().map(String::as_str).collect();
prop_assert_eq!(keys, index_keys_first(response.keys().map(String::as_str)));
for (key, value) in response {
match (key.as_str(), value) {
("structuredContent", Value::Object(_) | Value::Array(_)) => {
check_walked(value, &out[key])?;
}
("content", Value::Array(_)) => {}
_ => prop_assert_eq!(&out[key], value),
}
}
}
_ => prop_assert_eq!(out, value),
}
}
}
#[test]
fn sanitize_keeps_the_blocks_it_can_show_and_replaces_the_others_with_a_note(
event in sanitize_event_input(),
) {
let sanitized = sanitize_event(&event);
let Some(Value::Array(blocks)) = event.get("response").and_then(|response| response.get("content")) else {
return Ok(());
};
let out = sanitized["response"]["content"].as_array().unwrap();
prop_assert_eq!(blocks.len(), out.len());
for (block, out) in blocks.iter().zip(out) {
if keeps_block(block) {
prop_assert_eq!(out, block);
} else {
let note = out["text"].as_str().unwrap();
prop_assert_eq!(out.as_object().unwrap().len(), 2);
prop_assert_eq!(&out["type"], "text");
prop_assert!(note.starts_with('[') && note.ends_with(" redacted - not supported by AgentCat]"), "{}", note);
}
}
}
}