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