#![cfg(feature = "http")]
use http::HeaderMap;
use http::HeaderValue;
use qubit_redact::RedactionCompletion;
use qubit_redact::RedactionPolicy;
use qubit_redact::RedactionReason;
use qubit_redact::Redactor;
use qubit_redact::formats::http::BodyCapture;
#[test]
fn test_http_aggregate_operations_share_the_parent_transaction_output() {
let mut headers = HeaderMap::new();
headers.insert("x-request-id", HeaderValue::from_static("request-42"));
let output = Redactor::standard()
.text_composer()
.literal("http=")
.http(|http| {
let _ = http
.url("https://example.test/path?token=raw")
.headers(&headers)
.body(BodyCapture::complete(br#"{"name":"Ada"}"#), None);
})
.finish();
assert!(output.text().as_str().starts_with("http=https://example.test"));
assert!(output.text().as_str().contains("x-request-id: [request-42]"));
assert!(output.text().as_str().contains("{\"name\":\"Ada\"}"));
assert_eq!(output.summary().usage().output_bytes(), output.text().as_str().len());
}
#[test]
fn test_http_handle_operations_publish_from_the_parent_transaction() {
let mut headers = HeaderMap::new();
headers.insert("x-request-id", HeaderValue::from_static("request-42"));
let mut batch = Redactor::standard().diagnostic_batch();
let url = batch.redact_http_url("https://example.test/path?token=raw");
let header = batch.redact_http_headers(&headers);
let body = batch.redact_http_body(BodyCapture::complete(br#"{"name":"Ada"}"#), None);
let output = batch.finish_with_marker("<redaction incomplete>");
assert!(output.text(url).as_str().contains("example.test"));
assert!(output.text(header).as_str().contains("x-request-id: [request-42]"));
assert_eq!(output.text(body).as_str(), "{\"name\":\"Ada\"}");
}
#[test]
fn test_http_direct_handle_and_redactor_convenience_operations() {
let redactor = Redactor::strict();
let url = redactor.redact_http_url("https://example.test/path?token=raw");
assert!(url.text().as_str().contains("example.test"));
assert!(!url.text().as_str().contains("token=raw"));
let body = redactor.redact_http_body(BodyCapture::complete(br#"{"password":"raw"}"#), None);
assert!(!body.text().as_str().contains("raw"));
let mut batch = redactor.diagnostic_batch();
let handle = batch.redact_http_url("https://example.test/path?token=raw");
let output = batch.finish_with_marker("<redaction incomplete>");
assert!(output.text(handle).as_str().contains("example.test"));
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Bearer raw-secret"));
let headers_output = redactor.redact_http_headers(&headers);
assert!(!headers_output.text().as_str().contains("raw-secret"));
let mut batch = redactor.diagnostic_batch();
let handle = batch.redact_http_headers(&headers);
let output = batch.finish_with_marker("<redaction incomplete>");
assert!(!output.text(handle).as_str().contains("raw-secret"));
assert!(output.text(handle).as_str().contains("authorization"));
}
#[test]
fn test_http_empty_url_reports_safe_invalid_uri_result() {
let output = Redactor::strict().redact_http_url("");
assert!(output.summary().reasons().contains(RedactionReason::InvalidUri));
}
#[test]
fn test_http_composer_empty_url_reports_safe_invalid_uri_result() {
let output = Redactor::strict()
.text_composer()
.http(|http| {
http.url("");
})
.finish();
assert!(output.summary().reasons().contains(RedactionReason::InvalidUri));
}
#[test]
fn test_http_batch_empty_url_reports_safe_invalid_uri_result() {
let mut batch = Redactor::strict().diagnostic_batch();
let handle = batch.redact_http_url("");
let output = batch.finish_with_marker("<redaction incomplete>");
assert!(output.summary().reasons().contains(RedactionReason::InvalidUri));
assert_eq!(output.text(handle).as_str(), "<redacted: invalid URL>");
}
#[test]
fn test_http_url_uses_the_session_remaining_output_budget() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_output_bytes(32);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let mut batch = Redactor::new(policy).diagnostic_batch();
let handle =
batch.redact_http_url("https://example.test/a-very-long-path?token=raw-secret-token&visible=long-value");
let output = batch.finish_with_marker("<truncated>");
assert!(output.text(handle).as_str().len() <= 32);
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(output.summary().reasons().contains(RedactionReason::OutputLimitReached));
assert!(output.summary().usage().output_bytes() <= 32);
}
#[test]
fn test_http_formats_share_the_transaction_structural_budget() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(3).max_collection_items(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let mut headers = HeaderMap::new();
headers.insert("x-request-id", HeaderValue::from_static("request-42"));
let output = Redactor::new(policy)
.text_composer()
.http(|http| {
http.url("https://example.test/");
http.headers(&headers);
let _ = http.body(BodyCapture::complete(br#"{"password":"must-not-be-traversed"}"#), None);
})
.finish();
assert!(output.text().as_str().contains("x-request-id"));
assert!(!output.text().as_str().contains("must-not-be-traversed"));
assert_eq!(output.summary().usage().visited_nodes(), 3);
assert_eq!(output.summary().usage().visited_collection_items(), 1);
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
}
#[test]
fn test_http_url_nested_traversal_uses_shared_structural_budget() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(2);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let nested = "https://outer.test/?next=https%3A%2F%2Finner.test%2F%3Ftoken%3Draw-secret";
let aggregate = Redactor::new(policy.clone())
.text_composer()
.http(|http| {
http.url(nested);
})
.finish();
assert_eq!(aggregate.text().as_str(), "<truncated>");
assert!(
aggregate
.summary()
.reasons()
.contains(RedactionReason::TraversalLimitReached)
);
assert!(!aggregate.text().as_str().contains("raw-secret"));
let mut batch = Redactor::new(policy).diagnostic_batch();
let handle = batch.redact_http_url(nested);
let output = batch.finish_with_marker("<truncated>");
assert_eq!(output.text(handle).as_str(), "<truncated>");
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(!output.text(handle).as_str().contains("raw-secret"));
}
#[test]
fn test_http_url_query_collection_limit_stops_before_later_pair() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(32).max_collection_items(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let output = Redactor::new(policy)
.text_composer()
.http(|http| {
http.url("https://example.test/?first=ok&later=raw-secret");
})
.finish();
assert_eq!(output.text().as_str(), "<truncated>");
assert_eq!(output.summary().usage().visited_collection_items(), 1);
assert!(!output.text().as_str().contains("raw-secret"));
}
#[test]
fn test_http_nested_url_uses_shared_depth_limit() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(32).max_collection_items(32).max_depth(2);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let output = Redactor::new(policy)
.text_composer()
.http(|http| {
http.url("https://outer.test/?next=https%3A%2F%2Finner.test%2F%3Ftoken%3Draw-secret");
})
.finish();
assert_eq!(output.text().as_str(), "<truncated>");
assert!(output.summary().reasons().contains(RedactionReason::DepthLimitReached));
assert!(!output.text().as_str().contains("raw-secret"));
}
#[test]
fn test_http_text_content_type_body_operations_publish_safe_results() {
let aggregate = Redactor::standard()
.text_composer()
.http(|http| {
let _ = http.body_with_content_type_text(
BodyCapture::complete(br#"{"password":"aggregate-secret"}"#),
Some("application/json; charset=utf-8"),
);
})
.finish();
let mut batch = Redactor::standard().diagnostic_batch();
let handle = batch.redact_http_body_with_content_type_text(
BodyCapture::complete(br#"{"token":"handle-secret"}"#),
Some("application/json"),
);
let output = batch.finish_with_marker("<redaction incomplete>");
assert!(!aggregate.text().as_str().contains("aggregate-secret"));
assert!(!output.text(handle).as_str().contains("handle-secret"));
assert!(output.text(handle).as_str().contains("token"));
}
#[test]
fn test_http_invalid_url_handle_is_safe_and_keeps_reason() {
let mut batch = Redactor::standard().diagnostic_batch();
let handle = batch.redact_http_url("https://[not-an-ipv6");
let output = batch.finish_with_marker("<redaction incomplete>");
assert!(!output.text(handle).as_str().contains("not-an-ipv6"));
assert!(output.summary().reasons().contains(RedactionReason::InvalidUri));
}
#[test]
fn test_http_header_handle_stops_before_later_header_at_collection_limit() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_collection_items(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let mut headers = HeaderMap::new();
headers.insert("x-first", HeaderValue::from_static("visible"));
headers.insert("authorization", HeaderValue::from_static("Bearer must-not-be-rendered"));
let mut batch = Redactor::new(policy).diagnostic_batch();
let handle = batch.redact_http_headers(&headers);
let output = batch.finish_with_marker("");
assert!(output.text(handle).as_str().is_empty());
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(
output
.summary()
.reasons()
.contains(RedactionReason::TraversalLimitReached)
);
assert!(!output.text(handle).as_str().contains("must-not-be-rendered"));
}
#[test]
fn test_http_inferred_json_body_uses_shared_structural_fallback() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let mut batch = Redactor::new(policy).diagnostic_batch();
let handle = batch.redact_http_body(BodyCapture::complete(br#"{"password":"must-not-be-rendered"}"#), None);
let output = batch.finish_with_marker("<truncated>");
assert_eq!(output.text(handle).as_str(), "<truncated>");
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(!output.text(handle).as_str().contains("must-not-be-rendered"));
}
#[test]
fn test_http_form_body_uses_shared_collection_budget() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(32).max_collection_items(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let content_type = HeaderValue::from_static("application/x-www-form-urlencoded");
let output = Redactor::new(policy).redact_http_body(
BodyCapture::complete(b"first=ok&password=must-not-be-rendered"),
Some(&content_type),
);
assert_eq!(output.text().as_str(), "<truncated>");
assert_eq!(output.summary().usage().visited_collection_items(), 1);
assert!(
output
.summary()
.reasons()
.contains(RedactionReason::TraversalLimitReached)
);
assert!(!output.text().as_str().contains("must-not-be-rendered"));
}
#[test]
fn test_http_multipart_body_uses_shared_structural_budget() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(32).max_collection_items(8).max_depth(2);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let content_type = HeaderValue::from_static("multipart/form-data; boundary=boundary");
let body = b"--boundary\r\nContent-Disposition: form-data; name=\"metadata\"\r\nContent-Type: application/json\r\n\r\n{\"password\":\"must-not-be-rendered\"}\r\n--boundary--\r\n";
let output = Redactor::new(policy).redact_http_body(BodyCapture::complete(body), Some(&content_type));
assert_eq!(output.text().as_str(), "<truncated>");
assert_eq!(output.summary().usage().max_depth(), 2);
assert!(output.summary().reasons().contains(RedactionReason::DepthLimitReached));
assert!(!output.text().as_str().contains("must-not-be-rendered"));
}
#[test]
fn test_http_multipart_parts_use_shared_collection_budget() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_nodes(32).max_collection_items(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let content_type = HeaderValue::from_static("multipart/form-data; boundary=boundary");
let body = b"--boundary\r\nContent-Disposition: form-data; name=\"first\"\r\n\r\nok\r\n--boundary\r\nContent-Disposition: form-data; name=\"password\"\r\n\r\nmust-not-be-rendered\r\n--boundary--\r\n";
let output = Redactor::new(policy).redact_http_body(BodyCapture::complete(body), Some(&content_type));
assert_eq!(output.text().as_str(), "<truncated>");
assert_eq!(output.summary().usage().visited_collection_items(), 1);
assert!(
output
.summary()
.reasons()
.contains(RedactionReason::TraversalLimitReached)
);
assert!(!output.text().as_str().contains("must-not-be-rendered"));
}
#[test]
fn test_http_known_source_truncation_has_truthful_summary_and_usage() {
let capture = BodyCapture::truncated(b"visible", 12).expect("total length exceeds capture");
let content_type = HeaderValue::from_static("text/plain");
let output = Redactor::standard().redact_http_body(capture, Some(&content_type));
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(output.summary().reasons().contains(RedactionReason::SourceTruncated));
assert!(!output.summary().reasons().contains(RedactionReason::OutputLimitReached));
assert_eq!(output.summary().usage().presented_input_bytes(), 22);
assert_eq!(output.summary().usage().inspected_input_bytes(), 17);
assert_eq!(output.summary().usage().omitted_input_bytes(), Some(5));
}
#[test]
fn test_http_unknown_source_truncation_keeps_omitted_usage_unknown() {
let capture = BodyCapture::truncated_unknown(b"visible");
let output = Redactor::standard().redact_http_body(capture, None);
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(output.summary().reasons().contains(RedactionReason::SourceTruncated));
assert!(!output.summary().reasons().contains(RedactionReason::OutputLimitReached));
assert_eq!(output.summary().usage().presented_input_bytes(), 7);
assert_eq!(output.summary().usage().inspected_input_bytes(), 7);
assert_eq!(output.summary().usage().omitted_input_bytes(), None);
}
#[test]
fn test_http_namespace_handle_tracks_its_own_input_rejection() {
let policy = RedactionPolicy::builder()
.limits(|limits| {
limits.max_input_bytes(1);
})
.expect("limit draft should build")
.build()
.expect("policy should build");
let mut batch = Redactor::new(policy).diagnostic_batch();
let handle = batch.redact_http_url("https://example.test/");
let output = batch.finish_with_marker("");
assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
assert!(output.summary().reasons().contains(RedactionReason::InputLimitReached));
assert!(!output.summary().reasons().contains(RedactionReason::OutputLimitReached));
assert_eq!(output.summary().usage().presented_input_bytes(), 21);
assert_eq!(output.summary().usage().inspected_input_bytes(), 0);
assert!(output.text(handle).as_str().is_empty());
}