clawspec_core/client/response/redaction/mod.rs
1//! JSON response redaction support using JSON Pointer (RFC 6901) and JSONPath (RFC 9535).
2//!
3//! This module provides functionality to redact sensitive or dynamic values
4//! in JSON responses for snapshot testing. It allows you to replace or remove
5//! values at specific paths while preserving the original data for test assertions.
6//!
7//! # Path Syntax
8//!
9//! The path syntax is auto-detected based on the prefix:
10//! - Paths starting with `$` use JSONPath (RFC 9535) - supports wildcards
11//! - Paths starting with `/` use JSON Pointer (RFC 6901) - exact paths only
12//!
13//! ## JSONPath Examples (wildcards)
14//!
15//! - `$.items[*].id` - all `id` fields in the `items` array
16//! - `$..id` - all `id` fields anywhere in the document (recursive descent)
17//! - `$.users[0:3].email` - `email` in first 3 users
18//!
19//! ## JSON Pointer Examples (exact paths)
20//!
21//! - `/id` - top-level `id` field
22//! - `/user/email` - nested field
23//! - `/items/0/id` - specific array index
24//!
25//! # Redactor Types
26//!
27//! The [`redact`](RedactionBuilder::redact) method accepts any type implementing [`Redactor`]:
28//!
29//! - **Static values**: `&str`, `String`, `serde_json::Value`
30//! - **Functions**: `Fn(&str, &Value) -> Value` - transform based on path and/or value
31//!
32//! # Example
33//!
34//! ```ignore
35//! use clawspec_core::ApiClient;
36//! use serde::{Deserialize, Serialize};
37//!
38//! #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
39//! struct User {
40//! id: String,
41//! name: String,
42//! created_at: String,
43//! }
44//!
45//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
46//! let client = ApiClient::builder().with_base_path("http://localhost:8080".parse()?).build()?;
47//!
48//! // Using static values
49//! let result = client
50//! .get("/api/users/123")?
51//! .await?
52//! .as_json_redacted::<User>().await?
53//! .redact("/id", "stable-uuid")?
54//! .redact("/created_at", "2024-01-01T00:00:00Z")?
55//! .finish()
56//! .await;
57//!
58//! // Using JSONPath wildcards with static values
59//! let result = client
60//! .get("/api/users")?
61//! .await?
62//! .as_json_redacted::<Vec<User>>().await?
63//! .redact("$[*].id", "redacted-uuid")?
64//! .redact("$[*].created_at", "2024-01-01T00:00:00Z")?
65//! .finish()
66//! .await;
67//!
68//! // Using closure for index-based IDs
69//! let result = client
70//! .get("/api/users")?
71//! .await?
72//! .as_json_redacted::<Vec<User>>().await?
73//! .redact("$[*].id", |path, _val| {
74//! let idx = path.split('/').nth(1).unwrap_or("0");
75//! serde_json::json!(format!("user-{idx}"))
76//! })?
77//! .finish()
78//! .await;
79//!
80//! // Test assertions use the real value
81//! assert!(!result.value.is_empty());
82//!
83//! // Snapshots use the redacted value (stable ids and timestamps)
84//! insta::assert_yaml_snapshot!(result.redacted);
85//! # Ok(())
86//! # }
87//! ```
88
89use std::any::{TypeId, type_name};
90
91use headers::ContentType;
92use http::StatusCode;
93use jsonptr::{Pointer, assign::Assign, delete::Delete, resolve::Resolve};
94use serde::de::DeserializeOwned;
95use serde_json::Deserializer;
96use utoipa::ToSchema;
97use utoipa::openapi::{RefOr, Schema};
98
99use super::output::Output;
100
101mod path_selector;
102mod redactor;
103
104use self::path_selector::PathSelector;
105pub use self::redactor::Redactor;
106use crate::client::CallResult;
107use crate::client::error::ApiClientError;
108use crate::client::openapi::channel::{CollectorMessage, CollectorSender};
109use crate::client::openapi::schema::{SchemaEntry, compute_schema_ref};
110
111impl CallResult {
112 /// Deserializes the JSON response and returns a builder for applying redactions.
113 ///
114 /// This method is similar to [`as_json()`](CallResult::as_json) but returns a
115 /// [`RedactionBuilder`](super::redaction::RedactionBuilder) that allows you to apply redactions
116 /// to the JSON before finalizing the result.
117 ///
118 /// The original value is preserved for test assertions, while the redacted
119 /// JSON can be used for snapshot testing with stable values.
120 ///
121 /// # Type Parameters
122 ///
123 /// * `T` - The type to deserialize into. Must implement [`DeserializeOwned`],
124 /// [`ToSchema`], and have a `'static` lifetime.
125 ///
126 /// # Errors
127 ///
128 /// Returns an error if:
129 /// - The response is not JSON
130 /// - JSON deserialization fails
131 ///
132 /// # Example
133 ///
134 /// ```ignore
135 /// # use clawspec_core::ApiClient;
136 /// # use serde::{Deserialize, Serialize};
137 /// # #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
138 /// # struct User { id: String, name: String }
139 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
140 /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
141 /// let result = client
142 /// .get("/api/users/123")?
143 /// .await?
144 /// .as_json_redacted::<User>().await?
145 /// .redact("/id", "stable-uuid")?
146 /// .finish()
147 /// .await;
148 ///
149 /// // Use real value for assertions
150 /// assert!(!result.value.id.is_empty());
151 ///
152 /// // Use redacted value for snapshots
153 /// insta::assert_yaml_snapshot!(result.redacted);
154 /// # Ok(())
155 /// # }
156 /// ```
157 pub async fn as_json_redacted<T>(&mut self) -> Result<RedactionBuilder<T>, ApiClientError>
158 where
159 T: DeserializeOwned + ToSchema + 'static,
160 {
161 // Compute schema reference locally (no lock needed)
162 let schema = compute_schema_ref::<T>();
163
164 // Register the schema entry via channel
165 let entry = SchemaEntry::of::<T>();
166 self.collector_sender
167 .send(CollectorMessage::AddSchemaEntry(entry))
168 .await;
169
170 // Access output directly without calling get_output() to defer response registration
171 let Output::Json(json) = self.output() else {
172 return Err(ApiClientError::UnsupportedJsonOutput {
173 output: self.output().clone(),
174 name: type_name::<T>(),
175 });
176 };
177
178 // Delegate to redaction module with deferred registration data
179 // Response will be registered in finish() with the redacted example
180 let builder = super::redaction::create_redaction_builder::<T>(
181 json,
182 self.collector_sender.clone(),
183 self.operation_id().to_string(),
184 self.status(),
185 self.content_type().cloned(),
186 schema,
187 )?;
188
189 Ok(builder)
190 }
191}
192
193/// Result of a redacted JSON response containing both the real and redacted values.
194///
195/// This struct is returned by [`CallResult::as_json_redacted()`] and provides
196/// access to both the original deserialized value and the redacted JSON for
197/// snapshot testing.
198#[derive(Debug, Clone)]
199#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
200pub struct RedactedResult<T> {
201 /// The real deserialized value for test assertions.
202 pub value: T,
203 /// The redacted JSON value for snapshot testing.
204 pub redacted: serde_json::Value,
205}
206
207/// Options for configuring redaction behavior.
208///
209/// Use this struct with [`RedactionBuilder::redact_with_options`] and
210/// [`RedactionBuilder::redact_remove_with`] to customize how redaction
211/// handles edge cases.
212///
213/// # Example
214///
215/// ```ignore
216/// use clawspec_core::RedactOptions;
217///
218/// // Allow empty matches (useful for optional fields)
219/// let options = RedactOptions { allow_empty_match: true };
220///
221/// builder
222/// .redact_with_options("$.optional[*].field", "value", options)?
223/// .finish()
224/// .await;
225/// ```
226#[derive(Debug, Clone, Default)]
227#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
228pub struct RedactOptions {
229 /// If true, matching zero paths is not an error (silent no-op).
230 ///
231 /// By default (`false`), if a path matches nothing, an error is returned.
232 /// This helps catch typos in path expressions. Set to `true` when redacting
233 /// optional fields that may not always be present.
234 pub allow_empty_match: bool,
235}
236
237/// Builder for applying redactions to JSON responses.
238///
239/// This builder allows you to chain multiple redaction operations before
240/// finalizing the result. Paths can use either JSON Pointer (RFC 6901) or
241/// JSONPath (RFC 9535) syntax.
242///
243/// # Path Syntax
244///
245/// The syntax is auto-detected based on the path prefix:
246///
247/// ## JSON Pointer (starts with `/`)
248///
249/// - `/field` - top-level field
250/// - `/field/subfield` - nested field
251/// - `/array/0` - array index
252/// - `/field~1with~1slashes` - `~1` escapes `/`
253/// - `/field~0with~0tildes` - `~0` escapes `~`
254///
255/// ## JSONPath (starts with `$`)
256///
257/// - `$.field` - top-level field
258/// - `$.items[*].id` - all `id` fields in array
259/// - `$..id` - all `id` fields anywhere (recursive)
260/// - `$[0:3]` - array slice
261#[derive(derive_more::Debug)]
262#[cfg_attr(docsrs, doc(cfg(feature = "redaction")))]
263pub struct RedactionBuilder<T> {
264 value: T,
265 redacted: serde_json::Value,
266 #[debug(skip)]
267 collector_sender: CollectorSender,
268 // Deferred response registration data
269 operation_id: String,
270 status: StatusCode,
271 content_type: Option<ContentType>,
272 schema: RefOr<Schema>,
273}
274
275impl<T> RedactionBuilder<T> {
276 /// Creates a new redaction builder with the original value and JSON.
277 pub(in crate::client) fn new(
278 value: T,
279 json: serde_json::Value,
280 collector_sender: CollectorSender,
281 operation_id: String,
282 status: StatusCode,
283 content_type: Option<ContentType>,
284 schema: RefOr<Schema>,
285 ) -> Self {
286 Self {
287 value,
288 redacted: json,
289 collector_sender,
290 operation_id,
291 status,
292 content_type,
293 schema,
294 }
295 }
296
297 /// Redacts values at the specified path using a redactor.
298 ///
299 /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
300 /// The syntax is auto-detected based on the prefix:
301 /// - `$...` → JSONPath (supports wildcards)
302 /// - `/...` → JSON Pointer (exact path)
303 ///
304 /// The redactor can be:
305 /// - A static value: `"replacement"` or `serde_json::json!(...)`
306 /// - A closure: `|path, val| transform(path, val)`
307 ///
308 /// # Arguments
309 ///
310 /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
311 /// * `redactor` - The redactor to apply (static value or closure)
312 ///
313 /// # Errors
314 ///
315 /// Returns an error if:
316 /// - The path is invalid
317 /// - The path matches no values
318 ///
319 /// # Example
320 ///
321 /// ```ignore
322 /// # use clawspec_core::ApiClient;
323 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
324 /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
325 /// // Static value
326 /// let result = client
327 /// .get("/api/users/123")?
328 /// .await?
329 /// .as_json_redacted::<serde_json::Value>().await?
330 /// .redact("/id", "test-uuid")?
331 /// .finish()
332 /// .await;
333 ///
334 /// // Closure for index-based IDs
335 /// let result = client
336 /// .get("/api/users")?
337 /// .await?
338 /// .as_json_redacted::<Vec<serde_json::Value>>().await?
339 /// .redact("$[*].id", |path, _val| {
340 /// let idx = path.split('/').nth(1).unwrap_or("0");
341 /// serde_json::json!(format!("user-{idx}"))
342 /// })?
343 /// .finish()
344 /// .await;
345 /// # Ok(())
346 /// # }
347 /// ```
348 pub fn redact<R: Redactor>(self, path: &str, redactor: R) -> Result<Self, ApiClientError> {
349 self.redact_with_options(path, redactor, RedactOptions::default())
350 }
351
352 /// Redacts values at the specified path with configurable options.
353 ///
354 /// This is like [`redact`](Self::redact) but allows customizing
355 /// behavior through [`RedactOptions`].
356 ///
357 /// # Arguments
358 ///
359 /// * `path` - Path expression (e.g., `/id`, `$.items[*].id`)
360 /// * `redactor` - The redactor to apply
361 /// * `options` - Configuration options
362 ///
363 /// # Example
364 ///
365 /// ```ignore
366 /// use clawspec_core::RedactOptions;
367 ///
368 /// // Allow empty matches for optional fields
369 /// let options = RedactOptions { allow_empty_match: true };
370 ///
371 /// builder
372 /// .redact_with_options("$.optional[*].field", "value", options)?
373 /// .finish()
374 /// .await;
375 /// ```
376 pub fn redact_with_options<R: Redactor>(
377 mut self,
378 path: &str,
379 redactor: R,
380 options: RedactOptions,
381 ) -> Result<Self, ApiClientError> {
382 // Parse the path (auto-detect JSONPath vs JSON Pointer)
383 let selector = PathSelector::parse(path)?;
384
385 // Resolve to concrete JSON Pointer paths
386 let concrete_paths = selector.resolve(&self.redacted);
387
388 // Check for empty matches
389 if concrete_paths.is_empty() && !options.allow_empty_match {
390 return Err(ApiClientError::RedactionError {
391 message: format!("Path '{path}' matched no values"),
392 });
393 }
394
395 // Apply redactor to each matched path
396 for pointer in concrete_paths {
397 let ptr = Pointer::parse(&pointer).map_err(|e| ApiClientError::RedactionError {
398 message: format!("Invalid JSON Pointer '{pointer}': {e}"),
399 })?;
400
401 // Get current value
402 let current_value =
403 self.redacted
404 .resolve(ptr)
405 .map_err(|e| ApiClientError::RedactionError {
406 message: format!("Failed to resolve path '{pointer}': {e}"),
407 })?;
408
409 // Apply redactor transformation
410 let new_value = redactor.apply(&pointer, current_value);
411
412 // Assign new value
413 self.redacted
414 .assign(ptr, new_value)
415 .map_err(|e| ApiClientError::RedactionError {
416 message: format!("Failed to assign value at path '{pointer}': {e}"),
417 })?;
418 }
419
420 Ok(self)
421 }
422
423 /// Removes values at the specified path.
424 ///
425 /// This completely removes the field from objects or the element from arrays,
426 /// unlike setting it to `null`.
427 ///
428 /// The path can be either JSON Pointer (RFC 6901) or JSONPath (RFC 9535).
429 ///
430 /// # Arguments
431 ///
432 /// * `path` - Path expression to remove
433 ///
434 /// # Errors
435 ///
436 /// Returns an error if:
437 /// - The path is invalid
438 /// - The path matches no values
439 ///
440 /// # Example
441 ///
442 /// ```ignore
443 /// # use clawspec_core::ApiClient;
444 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
445 /// # let client = ApiClient::builder().with_base_path("http://localhost".parse()?).build()?;
446 /// // Remove specific field
447 /// let result = client
448 /// .get("/api/users/123")?
449 /// .await?
450 /// .as_json_redacted::<serde_json::Value>().await?
451 /// .redact_remove("/password")?
452 /// .finish()
453 /// .await;
454 ///
455 /// // Remove field from all array elements
456 /// let result = client
457 /// .get("/api/users")?
458 /// .await?
459 /// .as_json_redacted::<Vec<serde_json::Value>>().await?
460 /// .redact_remove("$[*].password")?
461 /// .finish()
462 /// .await;
463 /// # Ok(())
464 /// # }
465 /// ```
466 pub fn redact_remove(self, path: &str) -> Result<Self, ApiClientError> {
467 self.redact_remove_with(path, RedactOptions::default())
468 }
469
470 /// Removes values at the specified path with configurable options.
471 ///
472 /// This is like [`redact_remove`](Self::redact_remove) but allows customizing
473 /// behavior through [`RedactOptions`].
474 ///
475 /// # Arguments
476 ///
477 /// * `path` - Path expression to remove
478 /// * `options` - Configuration options
479 ///
480 /// # Example
481 ///
482 /// ```ignore
483 /// use clawspec_core::RedactOptions;
484 ///
485 /// // Allow empty matches for optional fields
486 /// let options = RedactOptions { allow_empty_match: true };
487 ///
488 /// builder
489 /// .redact_remove_with("$.optional[*].field", options)?
490 /// .finish()
491 /// .await;
492 /// ```
493 pub fn redact_remove_with(
494 mut self,
495 path: &str,
496 options: RedactOptions,
497 ) -> Result<Self, ApiClientError> {
498 // Parse the path (auto-detect JSONPath vs JSON Pointer)
499 let selector = PathSelector::parse(path)?;
500
501 // Resolve to concrete JSON Pointer paths
502 let concrete_paths = selector.resolve(&self.redacted);
503
504 // Check for empty matches
505 if concrete_paths.is_empty() && !options.allow_empty_match {
506 return Err(ApiClientError::RedactionError {
507 message: format!("Path '{path}' matched no values"),
508 });
509 }
510
511 // Apply deletion to each matched path
512 for pointer in concrete_paths {
513 let ptr = Pointer::parse(&pointer).map_err(|e| ApiClientError::RedactionError {
514 message: format!("Invalid JSON Pointer '{pointer}': {e}"),
515 })?;
516
517 // Delete returns None if the pointer doesn't exist, which is fine
518 let _ = self.redacted.delete(ptr);
519 }
520
521 Ok(self)
522 }
523
524 /// Finalizes the redaction and returns the result.
525 ///
526 /// This consumes the builder and returns a [`RedactedResult`] containing
527 /// both the original value and the redacted JSON.
528 ///
529 /// The redacted JSON value is recorded as an example in both the OpenAPI
530 /// schema for type `T` and in the response content for this operation.
531 pub async fn finish(self) -> RedactedResult<T>
532 where
533 T: ToSchema + 'static,
534 {
535 // Add example to schemas via channel
536 self.collector_sender
537 .send(CollectorMessage::AddExample {
538 type_id: TypeId::of::<T>(),
539 type_name: type_name::<T>(),
540 example: self.redacted.clone(),
541 })
542 .await;
543
544 // Register response with the redacted example via channel
545 self.collector_sender
546 .send(CollectorMessage::RegisterResponseWithExample {
547 operation_id: self.operation_id.clone(),
548 status: self.status,
549 content_type: self.content_type.clone(),
550 schema: self.schema.clone(),
551 example: self.redacted.clone(),
552 })
553 .await;
554
555 RedactedResult {
556 value: self.value,
557 redacted: self.redacted,
558 }
559 }
560}
561
562/// Creates a RedactionBuilder from a JSON string.
563///
564/// This is a helper function used internally by `CallResult::as_json_redacted()`.
565/// It deserializes the JSON into the target type and prepares it for redaction.
566///
567/// # Arguments
568///
569/// * `json` - The JSON string to deserialize and prepare for redaction
570/// * `collector_sender` - The channel sender to record the redacted example to
571/// * `operation_id` - The operation ID for deferred response registration
572/// * `status` - The HTTP status code of the response
573/// * `content_type` - The content type of the response
574/// * `schema` - The OpenAPI schema reference for the response type
575///
576/// # Type Parameters
577///
578/// * `T` - The type to deserialize into. Must implement [`DeserializeOwned`],
579/// [`ToSchema`], and have a `'static` lifetime.
580///
581/// # Errors
582///
583/// Returns an error if:
584/// - JSON deserialization fails
585/// - JSON parsing fails for the redaction copy
586pub(crate) fn create_redaction_builder<T>(
587 json: &str,
588 collector_sender: CollectorSender,
589 operation_id: String,
590 status: StatusCode,
591 content_type: Option<ContentType>,
592 schema: RefOr<Schema>,
593) -> Result<RedactionBuilder<T>, ApiClientError>
594where
595 T: DeserializeOwned + ToSchema + 'static,
596{
597 // Deserialize the original value
598 let deserializer = &mut Deserializer::from_str(json);
599 let value: T = serde_path_to_error::deserialize(deserializer).map_err(|err| {
600 ApiClientError::JsonError {
601 path: err.path().to_string(),
602 error: err.into_inner(),
603 body: json.to_string(),
604 }
605 })?;
606
607 // Parse JSON for redaction
608 let json_value = serde_json::from_str::<serde_json::Value>(json).map_err(|error| {
609 ApiClientError::JsonError {
610 path: String::new(),
611 error,
612 body: json.to_string(),
613 }
614 })?;
615
616 Ok(RedactionBuilder::new(
617 value,
618 json_value,
619 collector_sender,
620 operation_id,
621 status,
622 content_type,
623 schema,
624 ))
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use http::StatusCode;
631 use serde::{Deserialize, Serialize};
632 use serde_json::json;
633 use utoipa::openapi::RefOr;
634
635 #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
636 struct TestStruct {
637 id: String,
638 name: String,
639 items: Vec<TestItem>,
640 }
641
642 #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
643 struct TestItem {
644 id: String,
645 value: i32,
646 }
647
648 fn create_test_builder() -> RedactionBuilder<TestStruct> {
649 let value = TestStruct {
650 id: "real-uuid".to_string(),
651 name: "Test".to_string(),
652 items: vec![
653 TestItem {
654 id: "item-1".to_string(),
655 value: 10,
656 },
657 TestItem {
658 id: "item-2".to_string(),
659 value: 20,
660 },
661 ],
662 };
663
664 let json = json!({
665 "id": "real-uuid",
666 "name": "Test",
667 "items": [
668 {"id": "item-1", "value": 10},
669 {"id": "item-2", "value": 20}
670 ]
671 });
672
673 let sender = CollectorSender::dummy();
674 let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
675
676 RedactionBuilder::new(
677 value,
678 json,
679 sender,
680 "test_op".to_string(),
681 StatusCode::OK,
682 None,
683 schema,
684 )
685 }
686
687 #[test]
688 fn test_redact_options_default() {
689 let options = RedactOptions::default();
690
691 assert!(!options.allow_empty_match);
692 }
693
694 #[test]
695 fn test_redact_options_allow_empty_match() {
696 let options = RedactOptions {
697 allow_empty_match: true,
698 };
699
700 assert!(options.allow_empty_match);
701 }
702
703 #[test]
704 fn test_redact_options_debug() {
705 let options = RedactOptions {
706 allow_empty_match: true,
707 };
708 let debug_str = format!("{options:?}");
709
710 assert!(debug_str.contains("RedactOptions"));
711 assert!(debug_str.contains("allow_empty_match"));
712 }
713
714 #[test]
715 fn test_redact_options_clone() {
716 let original = RedactOptions {
717 allow_empty_match: true,
718 };
719 let cloned = original.clone();
720
721 assert_eq!(original.allow_empty_match, cloned.allow_empty_match);
722 }
723
724 #[test]
725 fn test_redaction_builder_redact_success() {
726 let builder = create_test_builder();
727 let result = builder.redact("/id", "stable-uuid");
728
729 assert!(result.is_ok());
730 let builder = result.expect("redaction should succeed");
731 assert_eq!(
732 builder.redacted.get("id").and_then(|v| v.as_str()),
733 Some("stable-uuid")
734 );
735 }
736
737 #[test]
738 fn test_redaction_builder_redact_jsonpath_wildcard() {
739 let builder = create_test_builder();
740 let result = builder.redact("$.items[*].id", "redacted-id");
741
742 assert!(result.is_ok());
743 let builder = result.expect("redaction should succeed");
744 let items = builder.redacted.get("items").expect("should have items");
745 let items_array = items.as_array().expect("should be array");
746
747 for item in items_array {
748 assert_eq!(item.get("id").and_then(|v| v.as_str()), Some("redacted-id"));
749 }
750 }
751
752 #[test]
753 fn test_redaction_builder_redact_with_closure() {
754 let builder = create_test_builder();
755 let result = builder.redact("$.items[*].id", |path: &str, _val: &serde_json::Value| {
756 let idx = path.split('/').nth(2).unwrap_or("?");
757 json!(format!("item-idx-{idx}"))
758 });
759
760 assert!(result.is_ok());
761 let builder = result.expect("redaction should succeed");
762 let items = builder.redacted.get("items").expect("should have items");
763 let items_array = items.as_array().expect("should be array");
764
765 assert_eq!(
766 items_array[0].get("id").and_then(|v| v.as_str()),
767 Some("item-idx-0")
768 );
769 assert_eq!(
770 items_array[1].get("id").and_then(|v| v.as_str()),
771 Some("item-idx-1")
772 );
773 }
774
775 #[test]
776 fn test_redaction_builder_redact_error_no_match() {
777 let builder = create_test_builder();
778 let result = builder.redact("$.nonexistent", "value");
779
780 assert!(result.is_err());
781 }
782
783 #[test]
784 fn test_redaction_builder_redact_with_options_allow_empty() {
785 let builder = create_test_builder();
786 let options = RedactOptions {
787 allow_empty_match: true,
788 };
789 let result = builder.redact_with_options("$.nonexistent", "value", options);
790
791 assert!(result.is_ok());
792 }
793
794 #[test]
795 fn test_redaction_builder_redact_remove_success() {
796 let builder = create_test_builder();
797 let result = builder.redact_remove("/id");
798
799 assert!(result.is_ok());
800 let builder = result.expect("removal should succeed");
801 assert!(builder.redacted.get("id").is_none());
802 }
803
804 #[test]
805 fn test_redaction_builder_redact_remove_jsonpath() {
806 let builder = create_test_builder();
807 let result = builder.redact_remove("$.items[*].id");
808
809 assert!(result.is_ok());
810 let builder = result.expect("removal should succeed");
811 let items = builder.redacted.get("items").expect("should have items");
812 let items_array = items.as_array().expect("should be array");
813
814 for item in items_array {
815 assert!(item.get("id").is_none());
816 }
817 }
818
819 #[test]
820 fn test_redaction_builder_redact_remove_error_no_match() {
821 let builder = create_test_builder();
822 let result = builder.redact_remove("$.nonexistent");
823
824 assert!(result.is_err());
825 }
826
827 #[test]
828 fn test_redaction_builder_redact_remove_with_allow_empty() {
829 let builder = create_test_builder();
830 let options = RedactOptions {
831 allow_empty_match: true,
832 };
833 let result = builder.redact_remove_with("$.nonexistent", options);
834
835 assert!(result.is_ok());
836 }
837
838 #[test]
839 fn test_redaction_builder_chained_redactions() {
840 let builder = create_test_builder();
841 let result = builder
842 .redact("/id", "stable-id")
843 .and_then(|b| b.redact("/name", "Redacted Name"))
844 .and_then(|b| b.redact("$.items[*].id", "item-redacted"));
845
846 assert!(result.is_ok());
847 let builder = result.expect("chained redactions should succeed");
848 assert_eq!(
849 builder.redacted.get("id").and_then(|v| v.as_str()),
850 Some("stable-id")
851 );
852 assert_eq!(
853 builder.redacted.get("name").and_then(|v| v.as_str()),
854 Some("Redacted Name")
855 );
856 }
857
858 #[test]
859 fn test_redaction_builder_preserves_original_value() {
860 let builder = create_test_builder();
861 let original_id = builder.value.id.clone();
862 let result = builder.redact("/id", "stable-id");
863
864 assert!(result.is_ok());
865 let builder = result.expect("redaction should succeed");
866 // Original value should be preserved
867 assert_eq!(builder.value.id, original_id);
868 // Redacted value should be different
869 assert_eq!(
870 builder.redacted.get("id").and_then(|v| v.as_str()),
871 Some("stable-id")
872 );
873 }
874
875 #[test]
876 fn test_redacted_result_fields() {
877 let result = RedactedResult {
878 value: TestStruct {
879 id: "real".to_string(),
880 name: "Test".to_string(),
881 items: vec![],
882 },
883 redacted: json!({"id": "fake", "name": "Test", "items": []}),
884 };
885
886 assert_eq!(result.value.id, "real");
887 assert_eq!(
888 result.redacted.get("id").and_then(|v| v.as_str()),
889 Some("fake")
890 );
891 }
892
893 #[test]
894 fn test_redacted_result_debug() {
895 let result = RedactedResult {
896 value: "test".to_string(),
897 redacted: json!("redacted"),
898 };
899 let debug_str = format!("{result:?}");
900
901 assert!(debug_str.contains("RedactedResult"));
902 }
903
904 #[test]
905 fn test_redacted_result_clone() {
906 let original = RedactedResult {
907 value: "test".to_string(),
908 redacted: json!("redacted"),
909 };
910 let cloned = original.clone();
911
912 assert_eq!(original.value, cloned.value);
913 assert_eq!(original.redacted, cloned.redacted);
914 }
915
916 #[test]
917 fn test_redaction_builder_debug() {
918 let builder = create_test_builder();
919 let debug_str = format!("{builder:?}");
920
921 assert!(debug_str.contains("RedactionBuilder"));
922 assert!(debug_str.contains("operation_id"));
923 }
924
925 #[test]
926 fn test_create_redaction_builder_success() {
927 let json = r#"{"id": "uuid", "name": "Test"}"#;
928 let sender = CollectorSender::dummy();
929 let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
930
931 let result = create_redaction_builder::<serde_json::Value>(
932 json,
933 sender,
934 "op_id".to_string(),
935 StatusCode::OK,
936 None,
937 schema,
938 );
939
940 assert!(result.is_ok());
941 }
942
943 #[test]
944 fn test_create_redaction_builder_invalid_json() {
945 let json = r#"{"invalid json"#;
946 let sender = CollectorSender::dummy();
947 let schema = RefOr::T(utoipa::openapi::ObjectBuilder::new().build().into());
948
949 let result = create_redaction_builder::<serde_json::Value>(
950 json,
951 sender,
952 "op_id".to_string(),
953 StatusCode::OK,
954 None,
955 schema,
956 );
957
958 assert!(result.is_err());
959 }
960
961 #[test]
962 fn test_invalid_path_syntax_error() {
963 let builder = create_test_builder();
964 // Path without $ or / prefix should fail
965 let result = builder.redact("invalid_path", "value");
966
967 assert!(result.is_err());
968 }
969
970 #[test]
971 fn test_invalid_jsonpath_syntax() {
972 let builder = create_test_builder();
973 // Invalid JSONPath syntax
974 let result = builder.redact("$[[[invalid", "value");
975
976 assert!(result.is_err());
977 }
978}