fix: restore security hardening compatibility and validation

Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
This commit is contained in:
elky
2026-09-07 21:14:27 +08:00
parent a5c3699ae9
commit a90d564931
191 changed files with 6785 additions and 1643 deletions
+68 -1
View File
@@ -10,7 +10,8 @@ use super::redaction::{
admin_restore_secret_safe_body_rules, admin_restore_secret_safe_header_rules,
admin_restore_secret_safe_json, admin_restore_secret_safe_proxy, admin_restore_secret_safe_url,
admin_secret_safe_body_rules, admin_secret_safe_header_rules, admin_secret_safe_json,
admin_secret_safe_proxy, admin_secret_safe_url,
admin_secret_safe_proxy, admin_secret_safe_url, admin_validate_retained_body_rule_secrets,
admin_validate_retained_header_rule_secrets,
};
pub fn normalize_endpoint_api_format(api_format: &str) -> String {
@@ -200,6 +201,55 @@ mod endpoint_key_count_tests {
assert_eq!(active, total);
}
#[test]
fn endpoint_updates_reject_unresolved_rule_masks_before_persistence() {
let header_rules = json!([{"action": "set", "key": "x-auth", "value": "header-secret"}]);
let body_rules = json!([{"action": "set", "path": "auth.token", "value": "body-secret"}]);
let mut endpoint = sample_endpoint("chat", "openai:chat");
endpoint.header_rules = Some(header_rules.clone());
endpoint.body_rules = Some(body_rules.clone());
endpoint.config = Some(json!({"response_header_rules": header_rules}));
let moved_header =
json!([{"action": "set", "key": "x-other-auth", "value": "***", "has_value": true}]);
let cases = [
(
"header_rules",
super::AdminProviderEndpointUpdateFields {
header_rules: Some(moved_header.clone()),
..Default::default()
},
),
(
"body_rules",
super::AdminProviderEndpointUpdateFields {
body_rules: Some(
json!([{"action": "set", "path": "auth.api_key", "value": "***", "has_value": true}]),
),
..Default::default()
},
),
(
"config",
super::AdminProviderEndpointUpdateFields {
config: Some(json!({"response_header_rules": moved_header})),
..Default::default()
},
),
];
for (field, payload) in cases {
let error = super::apply_admin_provider_endpoint_update_fields(
&endpoint,
|key| key == field,
|_| false,
&payload,
)
.expect_err("unresolved masks must not overwrite saved secrets");
assert!(error.contains("无法匹配"));
assert!(!error.contains("header-secret"));
assert!(!error.contains("body-secret"));
}
}
#[test]
fn inherited_endpoint_counts_only_include_active_formats() {
let responses_endpoint = sample_endpoint("responses", "openai:responses");
@@ -352,6 +402,10 @@ where
if !header_rules.is_array() {
return Err("header_rules 必须是数组或 null".to_string());
}
admin_validate_retained_header_rule_secrets(
existing_endpoint.header_rules.as_ref(),
header_rules,
)?;
Some(admin_restore_secret_safe_header_rules(
existing_endpoint.header_rules.as_ref(),
header_rules,
@@ -369,6 +423,10 @@ where
if !body_rules.is_array() {
return Err("body_rules 必须是数组或 null".to_string());
}
admin_validate_retained_body_rule_secrets(
existing_endpoint.body_rules.as_ref(),
body_rules,
)?;
Some(admin_restore_secret_safe_body_rules(
existing_endpoint.body_rules.as_ref(),
body_rules,
@@ -407,6 +465,15 @@ where
if !config.is_object() {
return Err("config 必须是对象或 null".to_string());
}
if let Some(rules) = config.get("response_header_rules") {
admin_validate_retained_header_rule_secrets(
existing_endpoint
.config
.as_ref()
.and_then(|config| config.get("response_header_rules")),
rules,
)?;
}
Some(admin_restore_secret_safe_json(
existing_endpoint.config.as_ref(),
config,
+454 -69
View File
@@ -1,5 +1,4 @@
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
const REDACTED_VALUE: &str = "***";
const REDACTED_UPSTREAM_DIAGNOSTIC: &str = "[REDACTED upstream diagnostic]";
@@ -190,6 +189,20 @@ pub fn admin_restore_secret_safe_body_rules(existing: Option<&Value>, incoming:
restore_rule_array(existing, incoming, RuleKind::Body)
}
pub fn admin_validate_retained_header_rule_secrets(
existing: Option<&Value>,
incoming: &Value,
) -> Result<(), String> {
validate_retained_rule_secrets(existing, incoming, RuleKind::Header)
}
pub fn admin_validate_retained_body_rule_secrets(
existing: Option<&Value>,
incoming: &Value,
) -> Result<(), String> {
validate_retained_rule_secrets(existing, incoming, RuleKind::Body)
}
pub fn admin_secret_safe_url(value: Option<&str>) -> Value {
value
.and_then(sanitize_network_url)
@@ -1234,11 +1247,7 @@ fn redact_proxy_json_value_for_key(key: &str, value: &Value) -> Value {
return redact_proxy_secret_value(value);
}
if json_url_key(&compact_key) {
return value
.as_str()
.and_then(sanitize_network_url)
.map(Value::String)
.unwrap_or(Value::Null);
return redact_json_url_value(value);
}
if compact_key == "proxy" {
return admin_secret_safe_proxy(Some(value));
@@ -1261,11 +1270,7 @@ fn redact_json_value_for_key(key: &str, value: &Value) -> Value {
return redact_secret_value(value);
}
if json_url_key(&compact_key) {
return value
.as_str()
.and_then(sanitize_network_url)
.map(Value::String)
.unwrap_or(Value::Null);
return redact_json_url_value(value);
}
if compact_key == "proxy" {
return admin_secret_safe_proxy(Some(value));
@@ -1282,6 +1287,17 @@ fn redact_json_value_for_key(key: &str, value: &Value) -> Value {
redact_json_value(value)
}
fn redact_json_url_value(value: &Value) -> Value {
match value {
Value::String(raw) if url::Url::parse(raw).is_ok_and(|url| url.scheme() == "data") => {
value.clone()
}
Value::String(raw) => admin_secret_safe_url(Some(raw)),
Value::Array(values) => Value::Array(values.iter().map(redact_json_url_value).collect()),
_ => redact_json_value(value),
}
}
fn redact_body_rule(rule: &Value) -> Value {
let Some(rule) = rule.as_object() else {
return redact_json_value(rule);
@@ -1320,7 +1336,12 @@ fn redact_header_rule(rule: &Value) -> Value {
.map(|(key, value)| (key.clone(), redact_json_value_for_key(key, value)))
.collect::<Map<_, _>>();
let is_set = normalized_string_field(rule, "action").as_deref() == Some("set");
if is_set {
if is_set
&& rule
.get("key")
.and_then(Value::as_str)
.is_none_or(header_value_is_secret)
{
redact_rule_secret_field(rule, &mut projected, "value", "has_value");
}
if let Some(condition) = rule.get("condition") {
@@ -1374,7 +1395,14 @@ fn redact_header_values(value: &Value) -> Value {
Value::Object(
headers
.iter()
.map(|(key, value)| (key.clone(), redact_secret_value(value)))
.map(|(key, value)| {
let value = if header_value_is_secret(key) {
redact_secret_value(value)
} else {
redact_json_value(value)
};
(key.clone(), value)
})
.collect(),
)
}
@@ -1395,10 +1423,25 @@ fn restore_json_value(existing: Option<&Value>, incoming: &Value, key: Option<&s
return restore_header_values(existing, incoming);
}
if json_url_key(&compact_key) {
return incoming
.as_str()
.map(|incoming_url| restore_url_value(existing, incoming_url))
.unwrap_or_else(|| incoming.clone());
if let Some(incoming_url) = incoming.as_str() {
return restore_url_value(existing, incoming_url);
}
if let Some(values) = incoming.as_array() {
let existing_values = existing.and_then(Value::as_array);
return Value::Array(
values
.iter()
.enumerate()
.map(|(index, value)| {
restore_json_value(
existing_values.and_then(|values| values.get(index)),
value,
Some(key),
)
})
.collect(),
);
}
}
if json_secret_key(&compact_key, incoming) {
return restore_masked_secret(existing, incoming, true);
@@ -1448,29 +1491,20 @@ fn restore_rule_array(existing: Option<&Value>, incoming: &Value, kind: RuleKind
let Some(incoming_values) = incoming.as_array() else {
return incoming.clone();
};
let existing_values = existing.and_then(Value::as_array);
let incoming_identities = identity_counts(incoming_values, kind);
let existing_identities = existing_values
.map(|values| identity_counts(values, kind))
if unchanged_projected_rules(existing, incoming, kind) {
return existing.cloned().unwrap_or_else(|| incoming.clone());
}
let existing_values = existing
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
Value::Array(
incoming_values
.iter()
.map(|incoming_rule| {
let identity = rule_identity(incoming_rule, kind);
let existing_rule = identity.as_ref().and_then(|identity| {
(incoming_identities.get(identity) == Some(&1)
&& existing_identities.get(identity) == Some(&1))
.then(|| {
existing_values.and_then(|values| {
values.iter().find(|candidate| {
rule_identity(candidate, kind).as_ref() == Some(identity)
})
})
})
.flatten()
});
let existing_rule =
match_existing_rule(existing_values, incoming_values, incoming_rule, kind);
restore_rule(existing_rule, incoming_rule, kind)
})
.collect(),
@@ -1546,7 +1580,10 @@ fn restore_condition(existing: Option<&Value>, incoming: &Value) -> Value {
continue;
}
let existing_value = existing_object.and_then(|object| object.get(key));
let value = if key == "value" && condition_value_is_secret(incoming_object) {
let value = if key == "value"
&& (condition_value_is_secret(incoming_object)
|| incoming_object.get("has_value").and_then(Value::as_bool) == Some(true))
{
let marker_set =
incoming_object.get("has_value").and_then(Value::as_bool) == Some(true);
restore_masked_secret(existing_value, incoming_value, marker_set)
@@ -1562,29 +1599,25 @@ fn restore_condition_array(existing: Option<&Value>, incoming: &Value) -> Value
let Some(incoming_values) = incoming.as_array() else {
return incoming.clone();
};
let existing_values = existing.and_then(Value::as_array);
let incoming_counts = condition_identity_counts(incoming_values);
let existing_counts = existing_values
.map(|values| condition_identity_counts(values))
let existing_values = existing
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if projected_conditions_unchanged(existing_values, incoming_values) {
return Value::Array(existing_values.to_vec());
}
Value::Array(
incoming_values
.iter()
.map(|incoming_condition| {
let identity = condition_identity(incoming_condition);
let existing_condition = identity.as_ref().and_then(|identity| {
(incoming_counts.get(identity) == Some(&1)
&& existing_counts.get(identity) == Some(&1))
.then(|| {
existing_values.and_then(|values| {
values.iter().find(|candidate| {
condition_identity(candidate).as_ref() == Some(identity)
})
})
})
.flatten()
});
let existing_condition = match_existing_entry(
existing_values,
incoming_values,
incoming_condition,
condition_identity,
redact_condition,
);
restore_condition(existing_condition, incoming_condition)
})
.collect(),
@@ -1633,20 +1666,174 @@ fn restore_url_value(existing: Option<&Value>, incoming_url: &str) -> Value {
Value::String(incoming_url.to_string())
}
fn identity_counts(values: &[Value], kind: RuleKind) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for identity in values.iter().filter_map(|value| rule_identity(value, kind)) {
*counts.entry(identity).or_insert(0) += 1;
fn project_rule(rule: &Value, kind: RuleKind) -> Value {
match kind {
RuleKind::Header => redact_header_rule(rule),
RuleKind::Body => redact_body_rule(rule),
}
counts
}
fn condition_identity_counts(values: &[Value]) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for identity in values.iter().filter_map(condition_identity) {
*counts.entry(identity).or_insert(0) += 1;
fn unchanged_projected_rules(existing: Option<&Value>, incoming: &Value, kind: RuleKind) -> bool {
existing.and_then(Value::as_array).is_some_and(|rules| {
Value::Array(rules.iter().map(|rule| project_rule(rule, kind)).collect()) == *incoming
})
}
fn rule_match_shape(rule: &Value, kind: RuleKind) -> Value {
let mut projected = project_rule(rule, kind);
if let Some(object) = projected.as_object_mut() {
for field in [
"enabled",
"value",
"has_value",
"pattern",
"has_pattern",
"replacement",
"has_replacement",
] {
object.remove(field);
}
}
counts
projected
}
fn match_existing_rule<'a>(
existing: &'a [Value],
incoming: &[Value],
rule: &Value,
kind: RuleKind,
) -> Option<&'a Value> {
match_existing_entry(
existing,
incoming,
rule,
|value| rule_identity(value, kind),
|value| rule_match_shape(value, kind),
)
}
fn match_existing_entry<'a>(
existing: &'a [Value],
incoming: &[Value],
entry: &Value,
identity: impl Fn(&Value) -> Option<String>,
project: impl Fn(&Value) -> Value,
) -> Option<&'a Value> {
let entry_identity = identity(entry)?;
let same_identity = |value: &&Value| identity(value).as_ref() == Some(&entry_identity);
let candidates = existing.iter().filter(same_identity).collect::<Vec<_>>();
if candidates.len() == 1 && incoming.iter().filter(same_identity).count() == 1 {
return candidates.first().copied();
}
let projected = project(entry);
let mut matching = candidates
.into_iter()
.filter(|candidate| project(candidate) == projected);
let matched = matching.next()?;
if matching.next().is_some()
|| incoming
.iter()
.filter(same_identity)
.filter(|value| project(value) == projected)
.count()
!= 1
{
return None;
}
Some(matched)
}
fn projected_conditions_unchanged(existing: &[Value], incoming: &[Value]) -> bool {
existing.len() == incoming.len()
&& existing
.iter()
.zip(incoming)
.all(|(existing, incoming)| redact_condition(existing) == *incoming)
}
fn validate_retained_rule_secrets(
existing: Option<&Value>,
incoming: &Value,
kind: RuleKind,
) -> Result<(), String> {
let Some(incoming_values) = incoming.as_array() else {
return Ok(());
};
if unchanged_projected_rules(existing, incoming, kind) {
return Ok(());
}
let existing_values = existing
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for rule in incoming_values {
let existing_rule = match_existing_rule(existing_values, incoming_values, rule, kind);
validate_retained_secret_fields(existing_rule, rule)?;
if let Some(condition) = rule.get("condition") {
validate_retained_condition_secrets(
existing_rule.and_then(|rule| rule.get("condition")),
condition,
)?;
}
}
Ok(())
}
fn validate_retained_secret_fields(
existing: Option<&Value>,
incoming: &Value,
) -> Result<(), String> {
for (field, marker) in [
("value", "has_value"),
("pattern", "has_pattern"),
("replacement", "has_replacement"),
] {
if incoming.get(marker).and_then(Value::as_bool) == Some(true)
&& incoming.get(field).and_then(Value::as_str) == Some(REDACTED_VALUE)
&& existing
.and_then(|value| value.get(field))
.filter(|value| secret_value_is_set(value))
.is_none()
{
return Err(
"无法匹配脱敏规则的原值,请查看原值后重新填写,避免将占位符保存为实际配置"
.to_string(),
);
}
}
Ok(())
}
fn validate_retained_condition_secrets(
existing: Option<&Value>,
incoming: &Value,
) -> Result<(), String> {
for group_key in ["all", "any"] {
if let Some(children) = incoming.get(group_key).and_then(Value::as_array) {
let existing_children = existing
.and_then(|value| value.get(group_key))
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if projected_conditions_unchanged(existing_children, children) {
return Ok(());
}
for child in children {
let existing_child = match_existing_entry(
existing_children,
children,
child,
condition_identity,
redact_condition,
);
validate_retained_condition_secrets(existing_child, child)?;
}
return Ok(());
}
}
let existing =
existing.filter(|value| condition_identity(value) == condition_identity(incoming));
validate_retained_secret_fields(existing, incoming)
}
fn rule_identity(value: &Value, kind: RuleKind) -> Option<String> {
@@ -1676,8 +1863,18 @@ fn rule_identity(value: &Value, kind: RuleKind) -> Option<String> {
fn condition_identity(value: &Value) -> Option<String> {
let value = value.as_object()?;
if value.contains_key("all") || value.contains_key("any") {
return None;
for group_key in ["all", "any"] {
if let Some(children) = value.get(group_key).and_then(Value::as_array) {
let mut identities = children
.iter()
.map(condition_identity)
.collect::<Option<Vec<_>>>()?;
identities.sort();
return Some(format!(
"condition:{group_key}:{}",
serde_json::to_string(&identities).ok()?
));
}
}
let path = trimmed_string_field(value, "path")?;
let op = normalized_string_field(value, "op")?;
@@ -1720,11 +1917,36 @@ fn is_rule_marker(key: &str) -> bool {
fn condition_value_is_secret(condition: &Map<String, Value>) -> bool {
let source = normalized_condition_source(condition.get("source").and_then(Value::as_str));
source == "request_headers"
|| condition
.get("path")
.and_then(Value::as_str)
.is_some_and(json_path_targets_secret)
let path = condition.get("path").and_then(Value::as_str);
if source == "request_headers" {
path.is_none_or(header_value_is_secret)
} else {
path.is_some_and(json_path_targets_secret)
}
}
fn header_value_is_secret(name: &str) -> bool {
!matches!(
name.trim().to_ascii_lowercase().as_str(),
"accept"
| "accept-encoding"
| "accept-language"
| "cache-control"
| "content-encoding"
| "content-type"
| "user-agent"
| "anthropic-version"
| "anthropic-beta"
| "openai-beta"
| "x-stainless-lang"
| "x-stainless-package-version"
| "x-stainless-os"
| "x-stainless-arch"
| "x-stainless-runtime"
| "x-stainless-runtime-version"
| "x-stainless-retry-count"
| "x-stainless-timeout"
)
}
fn normalized_condition_source(source: Option<&str>) -> String {
@@ -2542,4 +2764,167 @@ mod tests {
assert_eq!(projected, "https://api.example/v1");
assert_eq!(admin_secret_safe_url(Some("not a url")), json!(null));
}
#[test]
fn public_protocol_headers_and_conditions_remain_editable() {
let rules = json!([
{"action": "set", "key": "Content-Type", "value": "application/json"},
{"action": "set", "key": "User-Agent", "value": "client/1.0"},
{"action": "set", "key": "anthropic-version", "value": "2023-06-01"},
{"action": "set", "key": "OpenAI-Beta", "value": "responses=experimental", "condition": {
"source": "request_headers", "path": "Accept", "op": "eq", "value": "text/event-stream"
}}
]);
assert_eq!(admin_secret_safe_header_rules(Some(&rules)), rules);
let mut legacy_projection = rules.clone();
legacy_projection[0]["value"] = json!("***");
legacy_projection[0]["has_value"] = json!(true);
legacy_projection[3]["condition"]["value"] = json!("***");
legacy_projection[3]["condition"]["has_value"] = json!(true);
assert_eq!(
admin_restore_secret_safe_header_rules(Some(&rules), &legacy_projection),
rules
);
}
#[test]
fn header_maps_keep_protocol_values_but_hide_credentials_and_unknown_headers() {
let projected = admin_secret_safe_json(Some(&json!({"headers": {
"Content-Type": "application/json",
"User-Agent": "client/1.0",
"Authorization": "Bearer secret",
"Cookie": "session=secret",
"x-custom-auth": "custom-secret"
}})));
assert_eq!(projected["headers"]["Content-Type"], "application/json");
assert_eq!(projected["headers"]["User-Agent"], "client/1.0");
for header in ["Authorization", "Cookie", "x-custom-auth"] {
assert_eq!(projected["headers"][header], "***");
}
}
#[test]
fn duplicate_conditional_rules_retain_secrets_when_reordered_or_disabled() {
let existing = json!([
{"action": "set", "key": "x-auth", "value": "first-secret", "condition": {
"path": "model", "op": "eq", "value": "first-model"
}},
{"action": "set", "key": "x-auth", "value": "second-secret", "condition": {
"path": "model", "op": "eq", "value": "second-model"
}}
]);
let mut incoming = admin_secret_safe_header_rules(Some(&existing));
incoming.as_array_mut().unwrap().reverse();
incoming[0]["enabled"] = json!(false);
super::admin_validate_retained_header_rule_secrets(Some(&existing), &incoming).unwrap();
let restored = admin_restore_secret_safe_header_rules(Some(&existing), &incoming);
assert_eq!(restored[0]["value"], "second-secret");
assert_eq!(restored[0]["enabled"], false);
assert_eq!(restored[1]["value"], "first-secret");
assert!(restored[0].get("has_value").is_none());
}
#[test]
fn unchanged_duplicate_body_rules_preserve_their_original_values() {
let existing = json!([
{"action": "append", "path": "auth.cookies", "value": "first-secret"},
{"action": "append", "path": "auth.cookies", "value": "second-secret"}
]);
let incoming = admin_secret_safe_body_rules(Some(&existing));
super::admin_validate_retained_body_rule_secrets(Some(&existing), &incoming).unwrap();
assert_eq!(
admin_restore_secret_safe_body_rules(Some(&existing), &incoming),
existing
);
}
#[test]
fn nested_condition_groups_preserve_secrets_after_sibling_edits_and_reordering() {
let existing = json!([{
"action": "set", "key": "x-output", "value": "header-secret",
"condition": {"all": [
{"any": [
{"path": "auth.token", "op": "eq", "value": "condition-secret"},
{"path": "model", "op": "eq", "value": "old-model"}
]},
{"path": "metadata.enabled", "op": "eq", "value": true}
]}
}]);
let mut incoming = admin_secret_safe_header_rules(Some(&existing));
incoming[0]["condition"]["all"][0]["any"][1]["value"] = json!("new-model");
incoming[0]["condition"]["all"]
.as_array_mut()
.unwrap()
.reverse();
super::admin_validate_retained_header_rule_secrets(Some(&existing), &incoming).unwrap();
let restored = admin_restore_secret_safe_header_rules(Some(&existing), &incoming);
assert_eq!(restored[0]["value"], "header-secret");
assert_eq!(
restored[0]["condition"]["all"][1]["any"][0]["value"],
"condition-secret"
);
assert_eq!(
restored[0]["condition"]["all"][1]["any"][1]["value"],
"new-model"
);
assert!(!restored.to_string().contains("has_value"));
}
#[test]
fn retained_masks_cannot_silently_overwrite_changed_or_ambiguous_rules() {
let existing = json!([
{"action": "set", "key": "x-auth", "value": "first-secret"},
{"action": "set", "key": "x-auth", "value": "second-secret"}
]);
let mut incoming = admin_secret_safe_header_rules(Some(&existing));
incoming[0]["enabled"] = json!(false);
assert!(
super::admin_validate_retained_header_rule_secrets(Some(&existing), &incoming).is_err()
);
let existing = json!([{"action": "set", "path": "auth.token", "value": "secret"}]);
let mut incoming = admin_secret_safe_body_rules(Some(&existing));
incoming[0]["path"] = json!("auth.api_key");
assert!(
super::admin_validate_retained_body_rule_secrets(Some(&existing), &incoming).is_err()
);
incoming[0]["value"] = json!("replacement-secret");
incoming[0].as_object_mut().unwrap().remove("has_value");
assert!(
super::admin_validate_retained_body_rule_secrets(Some(&existing), &incoming).is_ok()
);
}
#[test]
fn body_rule_projection_preserves_structured_image_urls_and_inline_data() {
let existing = json!([{
"action": "append", "path": "messages[0].content",
"value": {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U=", "detail": "high"}}
}]);
let projected = admin_secret_safe_body_rules(Some(&existing));
assert_eq!(projected, existing);
assert_eq!(
admin_restore_secret_safe_body_rules(Some(&existing), &projected),
existing
);
}
#[test]
fn structured_url_values_still_hide_and_restore_network_credentials() {
let existing = json!({
"image_url": {"url": "https://user:[email protected]/image?token=secret", "detail": "auto"},
"url": ["https://example.test/file?token=secret", "data:image/png;base64,aW1hZ2U="]
});
let projected = admin_secret_safe_json(Some(&existing));
assert_eq!(projected["image_url"]["url"], "https://example.test/image");
assert_eq!(projected["image_url"]["detail"], "auto");
assert_eq!(projected["url"][0], "https://example.test/file");
assert_eq!(projected["url"][1], existing["url"][1]);
assert!(!projected.to_string().contains("secret"));
assert!(!projected.to_string().contains("password"));
assert_eq!(
admin_restore_secret_safe_json(Some(&existing), &projected),
existing
);
}
}