qubit-value 0.12.1

Type-safe containers for single, multi-valued, and named runtime values
Documentation
// =============================================================================
//    Copyright (c) 2025 - 2026 Haixing Hu.
//
//    SPDX-License-Identifier: Apache-2.0
//
//    Licensed under the Apache License, Version 2.0.
// =============================================================================
//! External tests for policy-aware [`qubit_value::Value`] redaction.

use std::collections::HashMap;

use qubit_redact::MaskPolicy;
use qubit_redact::Redact;
use qubit_redact::RedactionCompletion;
use qubit_redact::RedactionPolicy;
use qubit_redact::Redactor;
use qubit_redact::Sensitivity;
use qubit_value::MultiValues;
use qubit_value::NamedMultiValues;
use qubit_value::NamedValue;
use qubit_value::Value;
use qubit_value::ValueContainer;
use serde_json::json;

/// Renders a domain value through one explicit policy snapshot.
fn redacted_text<T: Redact>(value: &T, policy: &RedactionPolicy) -> String {
    Redactor::new(policy.clone())
        .redact_text(value)
        .into_complete_text()
        .expect("test output must be complete")
        .into_string()
}

/// Builds a policy that classifies one field and limits domain nodes.
fn sensitive_policy_with_nodes(field: &str, max_nodes: usize) -> RedactionPolicy {
    RedactionPolicy::builder()
        .fields(|fields| {
            fields.raise(field, Sensitivity::Secret);
        })
        .expect("the test field rule should be valid")
        .limits(|limits_builder| {
            limits_builder
                .max_nodes(max_nodes)
                .max_collection_items(1024)
                .max_depth(32);
        })
        .expect("the domain limits should be valid")
        .build()
        .expect("policy should build")
}

#[test]
fn test_value_redacted_view_masks_sensitive_string_map_entries() {
    let value = Value::StringMap(HashMap::from([
        ("api_key".to_owned(), "raw-secret".to_owned()),
        ("label".to_owned(), "visible".to_owned()),
    ]));
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields.raise("api_key", Sensitivity::Secret);
        })
        .expect("the test builder input should be valid")
        .build()
        .expect("policy should build");

    let output = redacted_text(&value, &policy);

    assert!(!output.contains("raw-secret"));
    assert!(output.contains("visible"));
}

#[test]
fn test_multi_values_json_recursively_masks_sensitive_entries() {
    let values = MultiValues::Json(vec![json!({
        "profile": {
            "password": "raw-json-secret",
            "attempts": 3,
        },
        "label": "visible",
    })]);
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields.raise("password", Sensitivity::Secret);
        })
        .expect("the test field rule should be valid")
        .build()
        .expect("policy should build");

    let output = redacted_text(&values, &policy);

    assert!(!output.contains("raw-json-secret"), "{output}");
    assert!(output.contains("visible"), "{output}");
}

#[test]
fn test_multi_values_json_masks_sensitive_non_string_entries() {
    let values = MultiValues::Json(vec![
        json!({"password": 12345}),
        json!({"password": true, "label": "visible"}),
    ]);
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields.raise("password", Sensitivity::Secret);
        })
        .expect("the test field rule should be valid")
        .build()
        .expect("policy should build");

    let output = redacted_text(&values, &policy);

    assert!(!output.contains("12345"), "{output}");
    assert!(!output.contains("true"), "{output}");
    assert!(output.contains("visible"), "{output}");
}

#[test]
fn test_multi_values_empty_json_collection_is_preserved() {
    let values = MultiValues::Json(Vec::new());

    let output = redacted_text(&values, &RedactionPolicy::standard());

    assert_eq!(output, "[]");
}

#[test]
fn test_multi_values_json_stops_before_unadmitted_items() {
    let values = MultiValues::Json(vec![json!("first"), json!("second")]);
    let policy = RedactionPolicy::builder()
        .limits(|limits| {
            limits.max_nodes(64).max_collection_items(1).max_depth(8);
        })
        .expect("the test limits should be valid")
        .build()
        .expect("policy should build");

    let result = Redactor::new(policy).redact_text(&values);
    let output = result.text().as_str();

    assert_eq!(result.summary().completion(), RedactionCompletion::Truncated);
    assert!(output.contains("first"), "{output}");
    assert!(!output.contains("second"), "{output}");
    assert!(output.contains("<truncated>"), "{output}");
}

#[test]
fn test_value_redacted_view_preserves_scalar_without_key_context() {
    let value = Value::String("visible-without-key".to_owned());
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields.raise("password", Sensitivity::Secret);
        })
        .expect("the test builder input should be valid")
        .build()
        .expect("policy should build");

    let output = redacted_text(&value, &policy);

    assert!(output.contains("visible-without-key"));
}

#[test]
fn test_named_value_masks_non_strings_with_configured_opaque_value() {
    let value = NamedValue::new("token", Value::Int32(12345));
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields
                .disable_floor()
                .raise("token", Sensitivity::Low)
                .mask(Sensitivity::Low, MaskPolicy::preserve_edges(1, 1, "OPAQUE", 0));
        })
        .expect("the test policy should be valid")
        .build()
        .expect("policy should build");
    assert!(redacted_text(&value, &policy).contains("OPAQUE"));
}

#[test]
fn test_named_value_redaction_uses_text_masking_for_sensitive_strings() {
    let value = NamedValue::new("token", Value::String("secret-token".to_owned()));
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields
                .disable_floor()
                .raise("token", Sensitivity::Low)
                .mask(Sensitivity::Low, MaskPolicy::preserve_edges(1, 1, "MASK", 0));
        })
        .expect("the test policy should be valid")
        .build()
        .expect("policy should build");

    let output = redacted_text(&value, &policy);

    assert!(!output.contains("secret-token"));
    assert!(!output.contains("secret-token"));
}

#[test]
fn test_named_multi_values_redaction_masks_sensitive_collections_as_opaque() {
    let value = NamedMultiValues::new(
        "tokens",
        MultiValues::String(vec!["first-secret".to_owned(), "second-secret".to_owned()]),
    );
    let policy = RedactionPolicy::builder()
        .fields(|fields| {
            fields
                .raise("tokens", Sensitivity::Low)
                .mask(Sensitivity::Low, MaskPolicy::preserve_edges(1, 1, "OPAQUE", 0));
        })
        .expect("the test policy should be valid")
        .build()
        .expect("policy should build");

    let output = redacted_text(&value, &policy);

    assert!(!output.contains("first-secret"));
    assert!(!output.contains("second-secret"));
    assert!(output.contains("OPAQUE"));
}

#[test]
fn test_named_value_exact_wrapper_node_budget_is_complete() {
    let value = NamedValue::new("token", Value::String("secret-token".to_owned()));
    let policy = sensitive_policy_with_nodes("token", 5);

    let output = redacted_text(&value, &policy);

    assert!(!output.contains("secret-token"), "{output}");
    assert!(!output.contains("<truncated>"), "{output}");
}

#[test]
fn test_named_value_one_less_wrapper_node_truncates() {
    let value = NamedValue::new("token", Value::String("secret-token".to_owned()));
    let policy = sensitive_policy_with_nodes("token", 4);

    let output = redacted_text(&value, &policy);

    assert!(!output.contains("secret-token"), "{output}");
    assert!(!output.contains("secret-token"), "{output}");
}

#[test]
fn test_named_multi_values_exact_wrapper_node_budget_is_complete() {
    let value = NamedMultiValues::new("tokens", MultiValues::String(vec!["first-secret".to_owned()]));
    let policy = sensitive_policy_with_nodes("tokens", 5);

    let output = redacted_text(&value, &policy);

    assert!(!output.contains("first-secret"), "{output}");
    assert!(!output.contains("<truncated>"), "{output}");
}

/// The business name, rather than the literal display label `value`, selects
/// policy.
#[test]
fn test_named_values_classify_payload_by_business_name() {
    let redactor = Redactor::standard();
    let scalar = NamedValue::new("password", Value::String("raw-secret".to_owned()));
    let collection = NamedMultiValues::new("password", MultiValues::String(vec!["raw-secret".to_owned()]));

    for inspection in [redactor.inspect(&scalar), redactor.inspect(&collection)] {
        assert_eq!(
            inspection.expect("complete inspection").max_sensitivity(),
            Some(Sensitivity::Secret)
        );
    }
    assert!(
        redactor
            .redact_text(&scalar)
            .text()
            .as_str()
            .contains("value: \"<redacted>\"")
    );
    assert!(
        redactor
            .redact_text(&collection)
            .text()
            .as_str()
            .contains("value: \"<redacted>\"")
    );
}

/// Unknown business names remain visible under standard policy.
#[test]
fn test_named_values_preserve_public_payloads() {
    let redactor = Redactor::standard();
    let scalar = NamedValue::new("region", Value::String("eu-west".to_owned()));
    let collection = NamedMultiValues::new("region", MultiValues::String(vec!["eu-west".to_owned()]));

    assert!(redactor.redact_text(&scalar).text().as_str().contains("eu-west"));
    assert!(redactor.redact_text(&collection).text().as_str().contains("eu-west"));
    assert_eq!(redactor.inspect(&scalar).expect("inspection").max_sensitivity(), None);
    assert_eq!(
        redactor.inspect(&collection).expect("inspection").max_sensitivity(),
        None
    );
}

/// A public wrapper key must not bypass classification of fields in its JSON
/// payload. This is the regression for the former `Debug` fallback in keyed
/// wrappers.
#[test]
fn test_named_value_recursively_redacts_json_under_public_business_name() {
    let value = NamedValue::new(
        "region",
        Value::Json(json!({"password": "nested-secret", "label": "visible"})),
    );

    let output = Redactor::standard().redact_text(&value);

    assert!(!output.text().as_str().contains("nested-secret"));
    assert!(output.text().as_str().contains("visible"));
    assert_eq!(
        Redactor::standard()
            .inspect(&value)
            .expect("complete inspection")
            .max_sensitivity(),
        Some(Sensitivity::Secret)
    );
}

/// Disabled policy restores both scalar and collection payloads.
#[test]
fn test_named_values_disabled_policy_restores_payloads() {
    let redactor = Redactor::new(RedactionPolicy::disabled());
    let scalar = NamedValue::new("password", Value::String("raw-secret".to_owned()));
    let collection = NamedMultiValues::new("password", MultiValues::String(vec!["raw-secret".to_owned()]));

    assert!(redactor.redact_text(&scalar).text().as_str().contains("raw-secret"));
    assert!(redactor.redact_text(&collection).text().as_str().contains("raw-secret"));
    assert_eq!(redactor.inspect(&scalar).expect("inspection").max_sensitivity(), None);
    assert_eq!(
        redactor.inspect(&collection).expect("inspection").max_sensitivity(),
        None
    );
}

/// Map keys retain their classification inside a homogeneous collection.
#[test]
fn test_string_map_collection_redacts_keys_and_preserves_public_values() {
    let map = HashMap::from([
        ("password".to_owned(), "collection-secret".to_owned()),
        ("region".to_owned(), "eu-west".to_owned()),
    ]);
    let value = ValueContainer::Collection(MultiValues::StringMap(vec![map]));
    let redactor = Redactor::standard();
    let output = redactor.redact_text(&value);
    assert!(!output.text().as_str().contains("collection-secret"));
    assert!(output.text().as_str().contains("eu-west"));
    assert_eq!(
        redactor.inspect(&value).expect("complete inspection").max_sensitivity(),
        Some(Sensitivity::Secret)
    );
    let disabled = Redactor::new(RedactionPolicy::disabled()).redact_text(&value);
    assert!(disabled.text().as_str().contains("collection-secret"));
}

/// A nested map shares the outer sequence's item budget.
#[test]
fn test_string_map_collection_stops_before_unadmitted_maps() {
    let values = MultiValues::StringMap(vec![
        HashMap::new(),
        HashMap::from([("region".to_owned(), "must-not-be-visited".to_owned())]),
    ]);
    let policy = RedactionPolicy::builder()
        .limits(|limits| {
            limits.max_collection_items(1);
        })
        .expect("valid limits")
        .build()
        .expect("valid policy");
    let output = Redactor::new(policy).redact_text(&values);
    assert_eq!(output.summary().completion(), RedactionCompletion::Truncated);
    assert!(!output.text().as_str().contains("must-not-be-visited"));
}