use crank_observability::{RedactionLimits, redact_value}; use serde_json::{Value, json}; const REDACTED: &str = "[REDACTED]"; const TRUNCATED: &str = "[TRUNCATED]"; #[test] fn sensitive_keys_are_redacted_case_insensitively() { let keys = [ "password", "PassWord", "api_key", "access-token", "authorization", "Proxy.Authorization", "cookie", "set_cookie", "payload", "request_body", "arguments", "result", "response", ]; for key in keys { let cleaned = redact_value(&json!({ key: "canary-secret" }), RedactionLimits::default()); assert_eq!(cleaned[key], REDACTED, "key {key} was not redacted"); assert!(!cleaned.to_string().contains("canary-secret")); } } #[test] fn generated_sensitive_key_variants_are_redacted() { for canonical in [ "password", "api_key", "access_key", "secret_key", "proxy_authorization", "set_cookie", "query_string", "request_body", ] { for separator in ["_", "-", "."] { let variant = canonical .split('_') .collect::>() .join(separator) .to_ascii_uppercase(); let cleaned = redact_value( &json!({ variant.clone(): "canary-secret" }), RedactionLimits::default(), ); assert_eq!( cleaned[&variant], REDACTED, "key {variant} was not redacted" ); assert!(!cleaned.to_string().contains("canary-secret")); } } } #[test] fn compound_sensitive_key_names_are_redacted() { for key in [ "client_api_key", "aws_access_key_id", "http_authorization_header", "response_body", "tool_arguments", "query_params", "secret_value", ] { let cleaned = redact_value(&json!({ key: "canary-secret" }), RedactionLimits::default()); assert_eq!(cleaned[key], REDACTED, "key {key} was not redacted"); assert!(!cleaned.to_string().contains("canary-secret")); } } #[test] fn url_query_and_fragment_are_removed_at_every_depth() { let input = json!({ "url": "https://example.test/path?token=canary-secret#fragment", "nested": [{ "endpoint_uri": "https://example.test/other?q=canary-secret" }] }); let cleaned = redact_value(&input, RedactionLimits::default()); assert_eq!(cleaned["url"], "https://example.test/path"); assert_eq!( cleaned["nested"][0]["endpoint_uri"], "https://example.test/other" ); assert!(!cleaned.to_string().contains("canary-secret")); } #[test] fn url_credentials_and_compound_endpoint_fields_are_removed() { let input = json!({ "upstream_endpoint": "https://user:canary-secret@example.test/path?token=canary-secret", }); let cleaned = redact_value(&input, RedactionLimits::default()); assert_eq!(cleaned["upstream_endpoint"], "https://example.test/path"); assert!(!cleaned.to_string().contains("user")); assert!(!cleaned.to_string().contains("canary-secret")); } #[test] fn nested_values_and_collections_respect_all_limits() { let limits = RedactionLimits { max_string_bytes: 16, max_array_items: 3, max_object_fields: 3, max_depth: 2, max_event_bytes: 256, }; let input = json!({ "long": "абвгдежзийклмнопрсту", "array": [1, 2, 3, 4, 5], "object": {"a": 1, "b": 2, "c": 3, "d": 4}, "nested": {"level2": {"level3": "must not survive"}} }); let cleaned = redact_value(&input, limits); let object = cleaned .as_object() .expect("cleaned root must remain an object"); assert!(object.len() <= limits.max_object_fields); assert!( cleaned["long"] .as_str() .map(|value| value.len() <= limits.max_string_bytes) .unwrap_or(true) ); assert!( cleaned["array"] .as_array() .map(|value| value.len() <= limits.max_array_items) .unwrap_or(true) ); assert!(!cleaned.to_string().contains("must not survive")); assert!(cleaned.to_string().contains(TRUNCATED)); } #[test] fn truncation_preserves_utf8_and_never_reveals_secret_fragments() { let input = json!({ "secret_key": "секретное-значение", "description": "я".repeat(2048), }); let cleaned = redact_value(&input, RedactionLimits::default()); let serialized = serde_json::to_string(&cleaned).expect("cleaned value must be valid JSON"); assert_eq!(cleaned["secret_key"], REDACTED); assert!(!serialized.contains("секретное")); assert!(cleaned["description"].as_str().is_some()); } #[test] fn redacted_value_does_not_mutate_input() { let input = json!({"password": "canary-secret"}); let original = input.clone(); let _ = redact_value(&input, RedactionLimits::default()); assert_eq!(input, original); } #[test] fn object_keys_respect_the_string_limit() { let limits = RedactionLimits { max_string_bytes: 16, ..RedactionLimits::default() }; let long_key = format!("field-{}", "x".repeat(128)); let cleaned = redact_value(&json!({ long_key: "value" }), limits); assert!( cleaned .as_object() .expect("cleaned object") .keys() .all(|key| key.len() <= limits.max_string_bytes) ); } #[test] fn limits_have_finite_safe_defaults() { let limits = RedactionLimits::default(); assert_eq!(limits.max_string_bytes, 1024); assert_eq!(limits.max_array_items, 32); assert_eq!(limits.max_object_fields, 64); assert_eq!(limits.max_depth, 8); assert_eq!(limits.max_event_bytes, 16 * 1024); assert!(Value::Null.is_null()); }