use serde_json::{Map, Value};
use crate::Config;
pub fn is_masked_api_key(value: &str) -> bool {
let v = value.trim();
!v.is_empty() && v.chars().all(|c| c == '*' || c == '.')
}
fn is_secret_field_intent(obj: &Map<String, Value>, field: &str) -> bool {
match obj.get(field) {
None => false,
Some(Value::Null) => true,
Some(value) => match value.as_str() {
Some(s) => !is_masked_api_key(s),
None => false,
},
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderApiKeyIntents {
pub providers: std::collections::BTreeSet<String>,
pub provider_instances: std::collections::BTreeSet<String>,
}
pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
let mut intents = ProviderApiKeyIntents::default();
if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
for (provider_name, provider_patch) in root.iter() {
let Some(obj) = provider_patch.as_object() else {
continue;
};
if is_secret_field_intent(obj, "api_key") {
intents.providers.insert(provider_name.clone());
}
}
}
if let Some(root) = patch_obj
.get("provider_instances")
.and_then(|v| v.as_object())
{
for (instance_id, instance_patch) in root.iter() {
let Some(obj) = instance_patch.as_object() else {
continue;
};
if is_secret_field_intent(obj, "api_key") {
intents.provider_instances.insert(instance_id.clone());
}
}
}
intents
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReloadMode {
None,
BestEffort,
Strict,
}
#[derive(Debug, Clone, Copy)]
pub struct PatchEffects {
pub reload_provider: ReloadMode,
pub reconcile_mcp: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DomainChanges {
pub provider: bool,
pub proxy: bool,
pub setup: bool,
pub mcp: bool,
pub keyword_masking: bool,
pub hooks: bool,
pub model_mapping: bool,
}
pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
let mut changes = DomainChanges::default();
for key in patch_obj.keys() {
match key.as_str() {
"provider"
| "providers"
| "provider_instances"
| "default_provider_instance"
| "model"
| "defaults"
| "features" => changes.provider = true,
"http_proxy"
| "https_proxy"
| "proxy_auth"
| "proxy_auth_encrypted"
| "http_proxy_auth_encrypted"
| "https_proxy_auth_encrypted" => changes.proxy = true,
"setup" => changes.setup = true,
"mcp" | "mcpServers" => changes.mcp = true,
"keyword_masking" => changes.keyword_masking = true,
"hooks" => changes.hooks = true,
"anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
_ => {}
}
}
changes
}
pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
let domains = domains_for_root_patch(patch_obj);
let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
let touches_proxy = domains.proxy;
let touches_mcp = domains.mcp;
PatchEffects {
reload_provider: if touches_provider || touches_proxy {
ReloadMode::BestEffort
} else {
ReloadMode::None
},
reconcile_mcp: touches_mcp || touches_proxy,
}
}
pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
patch_obj.remove("proxy_auth");
patch_obj.remove("proxy_auth_encrypted");
patch_obj.remove("http_proxy_auth_encrypted");
patch_obj.remove("https_proxy_auth_encrypted");
patch_obj.remove("data_dir");
if let Some(providers) = patch_obj
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
for (_provider_name, provider_cfg) in providers.iter_mut() {
let Some(obj) = provider_cfg.as_object_mut() else {
continue;
};
obj.remove("api_key_encrypted");
}
}
if let Some(provider_instances) = patch_obj
.get_mut("provider_instances")
.and_then(|v| v.as_object_mut())
{
for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
let Some(obj) = instance_cfg.as_object_mut() else {
continue;
};
obj.remove("api_key_encrypted");
}
}
if let Some(notifications) = patch_obj
.get_mut("notifications")
.and_then(|v| v.as_object_mut())
{
if let Some(ntfy) = notifications
.get_mut("ntfy")
.and_then(|v| v.as_object_mut())
{
ntfy.remove("token_encrypted");
}
if let Some(bark) = notifications
.get_mut("bark")
.and_then(|v| v.as_object_mut())
{
bark.remove("device_key_encrypted");
}
}
if let Some(platforms) = patch_obj
.get_mut("connect")
.and_then(|c| c.get_mut("platforms"))
.and_then(|v| v.as_array_mut())
{
for platform in platforms.iter_mut() {
if let Some(obj) = platform.as_object_mut() {
obj.remove("token_encrypted");
obj.remove("app_secret_encrypted");
}
}
}
if let Some(mcp_servers) = patch_obj
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
{
for (_id, server) in mcp_servers.iter_mut() {
let Some(server_obj) = server.as_object_mut() else {
continue;
};
server_obj.remove("env_encrypted");
if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
for header in headers.iter_mut() {
let Some(header_obj) = header.as_object_mut() else {
continue;
};
header_obj.remove("value_encrypted");
}
}
}
}
if let Some(servers) = patch_obj
.get_mut("mcp")
.and_then(|m| m.get_mut("servers"))
.and_then(|v| v.as_array_mut())
{
for server in servers.iter_mut() {
let Some(server_obj) = server.as_object_mut() else {
continue;
};
let Some(transport) = server_obj
.get_mut("transport")
.and_then(|v| v.as_object_mut())
else {
continue;
};
match transport.get("type").and_then(|v| v.as_str()) {
Some("stdio") => {
transport.remove("env_encrypted");
}
Some("sse") => {
if let Some(headers) =
transport.get_mut("headers").and_then(|v| v.as_array_mut())
{
for header in headers.iter_mut() {
let Some(header_obj) = header.as_object_mut() else {
continue;
};
header_obj.remove("value_encrypted");
}
}
}
_ => {}
}
}
}
}
pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
if let Some(patch_providers) = patch_obj
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
for (provider_name, provider_patch) in patch_providers.iter_mut() {
let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
continue;
};
let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
if !is_masked_api_key(api_key) {
continue;
}
let existing_plain = match provider_name.as_str() {
"openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
"anthropic" => current
.providers
.anthropic
.as_ref()
.map(|c| c.api_key.clone()),
"gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
"bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
_ => None,
};
if let Some(existing_plain) = existing_plain {
if !existing_plain.trim().is_empty() {
patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
} else {
patch_cfg_obj.remove("api_key");
}
} else {
patch_cfg_obj.remove("api_key");
}
}
}
if let Some(patch_instances) = patch_obj
.get_mut("provider_instances")
.and_then(|v| v.as_object_mut())
{
for (instance_id, instance_patch) in patch_instances.iter_mut() {
let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
continue;
};
let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
continue;
};
if !is_masked_api_key(api_key) {
continue;
}
let existing_plain = current
.provider_instances
.get(instance_id)
.map(|instance| instance.api_key.clone());
if let Some(existing_plain) = existing_plain {
if !existing_plain.trim().is_empty() {
patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
} else {
patch_cfg_obj.remove("api_key");
}
} else {
patch_cfg_obj.remove("api_key");
}
}
}
}
pub fn preserve_unpatched_provider_secrets(
merged: &mut Config,
current: &Config,
intents: &ProviderApiKeyIntents,
) {
macro_rules! carry_forward {
($field:ident) => {
if !intents.providers.contains(stringify!($field)) {
if let (Some(new_cfg), Some(prev)) = (
merged.providers.$field.as_mut(),
current.providers.$field.as_ref(),
) {
if new_cfg.api_key.trim().is_empty()
&& new_cfg.api_key_encrypted.is_none()
&& (!prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some())
{
new_cfg.api_key = prev.api_key.clone();
new_cfg.api_key_encrypted = prev.api_key_encrypted.clone();
}
}
}
};
}
carry_forward!(openai);
carry_forward!(anthropic);
carry_forward!(gemini);
carry_forward!(bodhi);
for (id, instance) in merged.provider_instances.iter_mut() {
if intents.provider_instances.contains(id) {
continue;
}
if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
continue;
}
if let Some(prev) = current.provider_instances.get(id) {
if !prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some() {
instance.api_key = prev.api_key.clone();
instance.api_key_encrypted = prev.api_key_encrypted.clone();
}
}
}
}
pub fn clear_provider_ciphertext_for_explicit_clears(
merged: &mut Config,
intents: &ProviderApiKeyIntents,
) {
macro_rules! clear_ciphertext {
($field:ident) => {
if intents.providers.contains(stringify!($field)) {
if let Some(cfg) = merged.providers.$field.as_mut() {
if cfg.api_key.trim().is_empty() {
cfg.api_key_encrypted = None;
}
}
}
};
}
clear_ciphertext!(openai);
clear_ciphertext!(anthropic);
clear_ciphertext!(gemini);
clear_ciphertext!(bodhi);
for id in intents.provider_instances.iter() {
if let Some(instance) = merged.provider_instances.get_mut(id) {
if instance.api_key.trim().is_empty() {
instance.api_key_encrypted = None;
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NotificationSecretIntents {
pub ntfy_token: bool,
pub bark_device_key: bool,
}
pub fn notification_secret_intents(patch_obj: &Map<String, Value>) -> NotificationSecretIntents {
let mut intents = NotificationSecretIntents::default();
let Some(notifications) = patch_obj.get("notifications").and_then(|v| v.as_object()) else {
return intents;
};
if let Some(ntfy) = notifications.get("ntfy").and_then(|v| v.as_object()) {
intents.ntfy_token = is_secret_field_intent(ntfy, "token");
}
if let Some(bark) = notifications.get("bark").and_then(|v| v.as_object()) {
intents.bark_device_key = is_secret_field_intent(bark, "device_key");
}
intents
}
pub fn clear_notification_ciphertext_for_explicit_clears(
merged: &mut Config,
intents: &NotificationSecretIntents,
) {
if intents.ntfy_token
&& merged
.notifications
.ntfy
.token
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
merged.notifications.ntfy.token_encrypted = None;
}
if intents.bark_device_key
&& merged
.notifications
.bark
.device_key
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
merged.notifications.bark.device_key_encrypted = None;
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectSecretIntents {
pub token: std::collections::BTreeSet<usize>,
pub app_secret: std::collections::BTreeSet<usize>,
}
pub fn connect_secret_intents(patch_obj: &Map<String, Value>) -> ConnectSecretIntents {
let mut intents = ConnectSecretIntents::default();
let Some(platforms) = patch_obj
.get("connect")
.and_then(|c| c.get("platforms"))
.and_then(|v| v.as_array())
else {
return intents;
};
for (index, platform) in platforms.iter().enumerate() {
let Some(obj) = platform.as_object() else {
continue;
};
if is_secret_field_intent(obj, "token") {
intents.token.insert(index);
}
if is_secret_field_intent(obj, "app_secret") {
intents.app_secret.insert(index);
}
}
intents
}
pub fn clear_connect_ciphertext_for_explicit_clears(
merged: &mut Config,
intents: &ConnectSecretIntents,
) {
for &index in intents.token.iter() {
if let Some(platform) = merged.connect.platforms.get_mut(index) {
if platform.token.as_deref().unwrap_or("").trim().is_empty() {
platform.token_encrypted = None;
}
}
}
for &index in intents.app_secret.iter() {
if let Some(platform) = merged.connect.platforms.get_mut(index) {
if platform
.app_secret
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
platform.app_secret_encrypted = None;
}
}
}
}
pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
let Some(notifications) = patch_obj
.get_mut("notifications")
.and_then(|v| v.as_object_mut())
else {
return;
};
if let Some(ntfy) = notifications
.get_mut("ntfy")
.and_then(|v| v.as_object_mut())
{
preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
}
if let Some(bark) = notifications
.get_mut("bark")
.and_then(|v| v.as_object_mut())
{
preserve_masked_secret_field(
bark,
"device_key",
current.notifications.bark.device_key.as_deref(),
);
}
}
pub fn preserve_masked_connect_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
let Some(platforms) = patch_obj
.get_mut("connect")
.and_then(|c| c.get_mut("platforms"))
.and_then(|v| v.as_array_mut())
else {
return;
};
for (index, platform) in platforms.iter_mut().enumerate() {
let Some(obj) = platform.as_object_mut() else {
continue;
};
let patch_type = obj.get("type").and_then(|v| v.as_str());
let patch_id = obj.get("id").and_then(|v| v.as_str());
let by_id = patch_id.and_then(|patch_id| {
current.connect.platforms.iter().find(|p| {
p.id.as_deref() == Some(patch_id)
&& match patch_type {
Some(patch_type) => patch_type == p.platform_type,
None => true,
}
})
});
let existing = current.connect.platforms.get(index);
let guarded = by_id.or_else(|| {
existing
.filter(|p| match patch_type {
Some(patch_type) => patch_type == p.platform_type,
None => true,
})
.or_else(|| {
patch_type.and_then(|patch_type| {
current
.connect
.platforms
.iter()
.find(|p| p.platform_type == patch_type)
})
})
});
preserve_masked_secret_field(obj, "token", guarded.and_then(|p| p.token.as_deref()));
preserve_masked_secret_field(
obj,
"app_secret",
guarded.and_then(|p| p.app_secret.as_deref()),
);
}
}
fn preserve_masked_secret_field(
obj: &mut Map<String, Value>,
field: &str,
existing_plain: Option<&str>,
) {
let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
return;
};
if !is_masked_api_key(value) {
return;
}
match existing_plain {
Some(plain) if !plain.trim().is_empty() => {
obj.insert(field.to_string(), Value::String(plain.to_string()));
}
_ => {
obj.remove(field);
}
}
}
pub fn deep_merge_json(dst: &mut Value, src: Value) {
match (dst, src) {
(Value::Object(dst_map), Value::Object(src_map)) => {
for (key, value) in src_map {
if value.is_null() {
dst_map.remove(&key);
continue;
}
match dst_map.get_mut(&key) {
Some(existing) if existing.is_object() && value.is_object() => {
deep_merge_json(existing, value);
}
_ => {
dst_map.insert(key, value);
}
}
}
}
(dst_slot, src_value) => {
*dst_slot = src_value;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn domains_for_root_patch_detects_proxy_and_provider() {
let patch = json!({
"provider": "openai",
"http_proxy": "http://proxy:8080",
"setup": { "completed": false },
"mcpServers": {}
});
let domains = domains_for_root_patch(patch.as_object().unwrap());
assert!(domains.provider);
assert!(domains.proxy);
assert!(domains.setup);
assert!(domains.mcp);
}
#[test]
fn domains_for_root_patch_detects_provider_instances() {
let patch = json!({
"provider_instances": {
"openai-work": { "provider_type": "openai" }
},
"default_provider_instance": "openai-work",
"defaults": {
"chat": { "provider": "openai-work", "model": "gpt-4o" }
},
"features": {
"provider_model_ref": true
}
});
let domains = domains_for_root_patch(patch.as_object().unwrap());
assert!(domains.provider);
}
#[test]
fn provider_api_key_intents_ignores_masked_placeholders() {
let patch = json!({
"providers": {
"openai": { "api_key": "****...****" },
"gemini": { "api_key": "sk-real" }
},
"provider_instances": {
"work-openai": { "api_key": "****...****" },
"personal-openai": { "api_key": "sk-live" }
}
});
let intents = provider_api_key_intents(patch.as_object().unwrap());
assert!(intents.providers.contains("gemini"));
assert!(!intents.providers.contains("openai"));
assert!(intents.provider_instances.contains("personal-openai"));
assert!(!intents.provider_instances.contains("work-openai"));
}
fn instance(api_key: &str, encrypted: Option<&str>) -> crate::ProviderInstanceConfig {
serde_json::from_value(json!({
"provider_type": "openai",
"api_key": api_key,
"api_key_encrypted": encrypted,
}))
.expect("valid instance")
}
#[test]
fn preserve_unpatched_provider_secrets_restores_roundtrip_dropped_keys() {
let mut current = Config::default();
current
.provider_instances
.insert("uuid-1".to_string(), instance("sk-instance-live", None));
current.providers.openai = Some(crate::OpenAIConfig {
api_key: "sk-legacy-live".to_string(),
..Default::default()
});
let mut merged = Config::default();
merged
.provider_instances
.insert("uuid-1".to_string(), instance("", None));
merged.providers.openai = Some(crate::OpenAIConfig::default());
preserve_unpatched_provider_secrets(
&mut merged,
¤t,
&ProviderApiKeyIntents::default(),
);
assert_eq!(
merged.provider_instances["uuid-1"].api_key,
"sk-instance-live"
);
assert_eq!(
merged.providers.openai.as_ref().unwrap().api_key,
"sk-legacy-live"
);
}
#[test]
fn preserve_unpatched_provider_secrets_carries_ciphertext_only_keys() {
let mut current = Config::default();
current
.provider_instances
.insert("uuid-1".to_string(), instance("", Some("preexisting-ct")));
let mut merged = Config::default();
merged
.provider_instances
.insert("uuid-1".to_string(), instance("", None));
preserve_unpatched_provider_secrets(
&mut merged,
¤t,
&ProviderApiKeyIntents::default(),
);
assert_eq!(
merged.provider_instances["uuid-1"]
.api_key_encrypted
.as_deref(),
Some("preexisting-ct")
);
}
#[test]
fn preserve_unpatched_provider_secrets_respects_explicit_intents() {
let mut current = Config::default();
current
.provider_instances
.insert("uuid-1".to_string(), instance("sk-old", None));
let mut merged = Config::default();
merged
.provider_instances
.insert("uuid-1".to_string(), instance("", None));
let mut intents = ProviderApiKeyIntents::default();
intents.provider_instances.insert("uuid-1".to_string());
preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
let cleared = &merged.provider_instances["uuid-1"];
assert!(cleared.api_key.is_empty(), "explicit clear must win");
assert!(cleared.api_key_encrypted.is_none());
}
#[test]
fn clear_provider_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
let mut merged = Config::default();
merged
.provider_instances
.insert("uuid-1".to_string(), instance("", Some("roundtripped-ct")));
merged.providers.openai = Some(crate::OpenAIConfig {
api_key_encrypted: Some("legacy-ct".to_string()),
..Default::default()
});
let mut intents = ProviderApiKeyIntents::default();
intents.provider_instances.insert("uuid-1".to_string());
intents.providers.insert("openai".to_string());
clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
assert!(merged.provider_instances["uuid-1"]
.api_key_encrypted
.is_none());
assert!(merged
.providers
.openai
.as_ref()
.unwrap()
.api_key_encrypted
.is_none());
}
#[test]
fn clear_provider_ciphertext_leaves_set_intents_and_unpatched_alone() {
let mut merged = Config::default();
merged
.provider_instances
.insert("uuid-1".to_string(), instance("sk-new", Some("stale-ct")));
merged
.provider_instances
.insert("uuid-2".to_string(), instance("", Some("kept-ct")));
let mut intents = ProviderApiKeyIntents::default();
intents.provider_instances.insert("uuid-1".to_string());
clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
assert!(merged.provider_instances["uuid-1"]
.api_key_encrypted
.is_some());
assert_eq!(
merged.provider_instances["uuid-2"]
.api_key_encrypted
.as_deref(),
Some("kept-ct")
);
}
#[test]
fn notification_secret_intents_ignores_masked_placeholders() {
let patch = json!({
"notifications": {
"ntfy": { "token": "****...****" },
"bark": { "device_key": "tk-real-new-value" }
}
});
let intents = notification_secret_intents(patch.as_object().unwrap());
assert!(!intents.ntfy_token, "masked placeholder is not an intent");
assert!(intents.bark_device_key, "a real value is an intent");
}
#[test]
fn notification_secret_intents_detects_explicit_clear() {
let patch = json!({
"notifications": {
"ntfy": { "token": "" },
"bark": { "device_key": "" }
}
});
let intents = notification_secret_intents(patch.as_object().unwrap());
assert!(intents.ntfy_token, "empty string is a clear intent");
assert!(intents.bark_device_key, "empty string is a clear intent");
}
#[test]
fn notification_secret_intents_empty_when_untouched() {
let patch = json!({ "notifications": { "ntfy": { "enabled": true } } });
let intents = notification_secret_intents(patch.as_object().unwrap());
assert!(!intents.ntfy_token);
assert!(!intents.bark_device_key);
}
#[test]
fn clear_notification_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
let mut merged = Config::default();
merged.notifications.ntfy.token = None;
merged.notifications.ntfy.token_encrypted = Some("roundtripped-ntfy-ct".to_string());
merged.notifications.bark.device_key = None;
merged.notifications.bark.device_key_encrypted = Some("roundtripped-bark-ct".to_string());
let intents = NotificationSecretIntents {
ntfy_token: true,
bark_device_key: true,
};
clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
assert!(merged.notifications.ntfy.token_encrypted.is_none());
assert!(merged.notifications.bark.device_key_encrypted.is_none());
}
#[test]
fn clear_notification_ciphertext_leaves_set_intents_and_unpatched_alone() {
let mut merged = Config::default();
merged.notifications.ntfy.token = Some("brand-new-token".to_string());
merged.notifications.ntfy.token_encrypted = Some("stale-ct".to_string());
merged.notifications.bark.device_key = None;
merged.notifications.bark.device_key_encrypted = Some("kept-ct".to_string());
let intents = NotificationSecretIntents {
ntfy_token: true,
bark_device_key: false,
};
clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
assert_eq!(
merged.notifications.ntfy.token_encrypted.as_deref(),
Some("stale-ct")
);
assert_eq!(
merged.notifications.bark.device_key_encrypted.as_deref(),
Some("kept-ct")
);
}
#[test]
fn connect_secret_intents_ignores_masked_and_detects_clear_by_position() {
let patch = json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": "****...****" },
{ "type": "telegram", "token": "" },
{ "type": "feishu", "app_secret": "real-new-secret" }
]
}
});
let intents = connect_secret_intents(patch.as_object().unwrap());
assert!(
!intents.token.contains(&0),
"masked placeholder is not an intent"
);
assert!(intents.token.contains(&1), "empty string is a clear intent");
assert!(intents.app_secret.contains(&2), "a real value is an intent");
}
#[test]
fn connect_secret_intents_empty_when_no_platforms_patched() {
let patch = json!({ "http_proxy": "http://example.invalid:8080" });
let intents = connect_secret_intents(patch.as_object().unwrap());
assert!(intents.token.is_empty());
assert!(intents.app_secret.is_empty());
}
#[test]
fn clear_connect_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
let mut merged = Config::default();
merged.connect.platforms = vec![
connect_platform("telegram", ""), feishu_platform(""),
];
merged.connect.platforms[0].token = None;
merged.connect.platforms[0].token_encrypted = Some("roundtripped-token-ct".to_string());
merged.connect.platforms[1].app_secret = None;
merged.connect.platforms[1].app_secret_encrypted =
Some("roundtripped-secret-ct".to_string());
let mut intents = ConnectSecretIntents::default();
intents.token.insert(0);
intents.app_secret.insert(1);
clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
assert!(merged.connect.platforms[0].token_encrypted.is_none());
assert!(merged.connect.platforms[1].app_secret_encrypted.is_none());
}
#[test]
fn clear_connect_ciphertext_leaves_set_intents_and_unpatched_alone() {
let mut merged = Config::default();
merged.connect.platforms = vec![connect_platform("telegram", "brand-new-token")];
merged.connect.platforms[0].token_encrypted = Some("stale-ct".to_string());
merged
.connect
.platforms
.push(connect_platform("feishu", ""));
merged.connect.platforms[1].token = None;
merged.connect.platforms[1].token_encrypted = Some("kept-ct".to_string());
let mut intents = ConnectSecretIntents::default();
intents.token.insert(0);
clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
assert_eq!(
merged.connect.platforms[0].token_encrypted.as_deref(),
Some("stale-ct")
);
assert_eq!(
merged.connect.platforms[1].token_encrypted.as_deref(),
Some("kept-ct")
);
}
#[test]
fn is_masked_api_key_requires_placeholder_only_values() {
assert!(is_masked_api_key("****...****"));
assert!(is_masked_api_key("********"));
assert!(is_masked_api_key(" ****...**** "));
assert!(!is_masked_api_key(""));
assert!(!is_masked_api_key(" "));
assert!(!is_masked_api_key("****...****sk-newkey123"));
assert!(!is_masked_api_key("sk-newkey123****...****"));
assert!(!is_masked_api_key("id.secret...suffix"));
assert!(!is_masked_api_key("sk-live-abc"));
}
#[test]
fn sanitize_root_patch_strips_notification_encrypted_fields() {
let mut patch = json!({
"notifications": {
"ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
"bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
}
});
let obj = patch.as_object_mut().unwrap();
sanitize_root_patch(obj);
assert!(!obj["notifications"]["ntfy"]
.as_object()
.unwrap()
.contains_key("token_encrypted"));
assert!(!obj["notifications"]["bark"]
.as_object()
.unwrap()
.contains_key("device_key_encrypted"));
assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
}
#[test]
fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
let mut current = Config::default();
current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
current.notifications.bark.device_key = Some("existing-bark-key".to_string());
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
)
.unwrap();
preserve_masked_notification_secrets(&mut patch, ¤t);
assert_eq!(
patch["notifications"]["ntfy"]["token"],
"existing-ntfy-token"
);
assert_eq!(
patch["notifications"]["bark"]["device_key"],
"existing-bark-key"
);
}
#[test]
fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
let current = Config::default();
let mut patch: Map<String, Value> =
serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
preserve_masked_notification_secrets(&mut patch, ¤t);
assert!(!patch["notifications"]["ntfy"]
.as_object()
.unwrap()
.contains_key("token"));
}
#[test]
fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
let current = Config::default();
let mut patch: Map<String, Value> =
serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
.unwrap();
preserve_masked_notification_secrets(&mut patch, ¤t);
assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
}
fn connect_platform(platform_type: &str, token: &str) -> crate::ConnectPlatformConfig {
crate::ConnectPlatformConfig {
id: None,
platform_type: platform_type.to_string(),
token: Some(token.to_string()),
token_encrypted: None,
app_id: None,
app_secret: None,
app_secret_encrypted: None,
domain: None,
allow_from: Vec::new(),
admin_from: Vec::new(),
}
}
fn connect_platform_with_id(
id: &str,
platform_type: &str,
token: &str,
) -> crate::ConnectPlatformConfig {
crate::ConnectPlatformConfig {
id: Some(id.to_string()),
..connect_platform(platform_type, token)
}
}
#[test]
fn sanitize_root_patch_strips_connect_platform_encrypted_field() {
let mut patch = json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": "new-token", "token_encrypted": "client-supplied-cipher" }
]
}
});
let obj = patch.as_object_mut().unwrap();
sanitize_root_patch(obj);
let platform = &obj["connect"]["platforms"][0];
assert!(!platform
.as_object()
.unwrap()
.contains_key("token_encrypted"));
assert_eq!(platform["token"], "new-token");
}
#[test]
fn sanitize_root_patch_strips_connect_platform_app_secret_encrypted_field() {
let mut patch = json!({
"connect": {
"platforms": [
{
"type": "feishu",
"app_id": "cli_x",
"app_secret": "new-secret",
"app_secret_encrypted": "client-supplied-cipher",
"domain": "feishu"
}
]
}
});
let obj = patch.as_object_mut().unwrap();
sanitize_root_patch(obj);
let platform = &obj["connect"]["platforms"][0];
assert!(!platform
.as_object()
.unwrap()
.contains_key("app_secret_encrypted"));
assert_eq!(platform["app_secret"], "new-secret");
assert_eq!(platform["app_id"], "cli_x");
}
#[test]
fn preserve_masked_connect_secrets_keeps_existing_plaintext_by_position() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"],
"existing-bot-token"
);
}
#[test]
fn preserve_masked_connect_secrets_drops_mask_when_nothing_configured() {
let current = Config::default();
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert!(!patch["connect"]["platforms"][0]
.as_object()
.unwrap()
.contains_key("token"));
}
#[test]
fn preserve_masked_connect_secrets_leaves_real_values_untouched() {
let current = Config::default();
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-real-new-value"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"],
"tg-real-new-value"
);
}
#[test]
fn preserve_masked_connect_secrets_drops_mask_when_type_at_index_disagrees() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform("telegram", "telegram-secret-token")];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"feishu","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert!(
!patch["connect"]["platforms"][0]
.as_object()
.unwrap()
.contains_key("token"),
"masked token must not be resolved against a different platform's secret"
);
}
#[test]
fn preserve_masked_connect_secrets_type_mismatch_falls_back_to_type_lookup() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform("telegram", "bot-a-token"),
connect_platform("feishu", "feishu-token"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[
{"type":"telegram","token":"tg-real-value"},
{"type":"telegram","token":"****...****"}
]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(patch["connect"]["platforms"][0]["token"], "tg-real-value");
assert_eq!(
patch["connect"]["platforms"][1]["token"], "bot-a-token",
"index 1's mismatched type must fall back to the same-typed entry found elsewhere in current"
);
}
#[test]
fn preserve_masked_connect_secrets_keeps_plaintext_when_type_at_index_matches() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform("telegram", "bot-a-token"),
connect_platform("telegram", "bot-b-token"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[
{"type":"telegram","token":"****...****"},
{"type":"telegram","token":"****...****"}
]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(patch["connect"]["platforms"][0]["token"], "bot-a-token");
assert_eq!(patch["connect"]["platforms"][1]["token"], "bot-b-token");
}
#[test]
fn preserve_masked_connect_secrets_falls_back_to_positional_when_type_is_absent() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
let mut patch: Map<String, Value> =
serde_json::from_str(r#"{"connect":{"platforms":[{"token":"****...****"}]}}"#).unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"],
"existing-bot-token"
);
}
#[test]
fn preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform_with_id("id-a", "telegram", "bot-a-token"),
connect_platform_with_id("id-b", "telegram", "bot-b-token"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[
{"id":"id-b","type":"telegram","token":"****...****"},
{"id":"id-a","type":"telegram","token":"****...****"}
]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"], "bot-b-token",
"index 0 (id-b) must resolve to id-b's own token, not the positionally-co-located id-a"
);
assert_eq!(
patch["connect"]["platforms"][1]["token"], "bot-a-token",
"index 1 (id-a) must resolve to id-a's own token"
);
}
#[test]
fn preserve_masked_connect_secrets_falls_back_when_id_not_found_in_current() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"id":"unknown-id","type":"telegram","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"], "existing-bot-token",
"an id absent from `current` falls back to the positional+type guard"
);
}
#[test]
fn preserve_masked_connect_secrets_rejects_id_match_when_type_differs() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform_with_id(
"id-a",
"telegram",
"telegram-secret-token",
)];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert!(
!patch["connect"]["platforms"][0]
.as_object()
.unwrap()
.contains_key("token"),
"an id match with a disagreeing type must not resolve the mask from that entry"
);
}
#[test]
fn preserve_masked_connect_secrets_type_mismatched_id_still_falls_through_to_type_lookup() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform_with_id("id-a", "telegram", "telegram-secret-token"),
connect_platform_with_id("id-b", "feishu", "feishu-secret-token"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"], "feishu-secret-token",
"the fall-through must resolve via the type lookup, never via the mismatched id"
);
}
#[test]
fn preserve_masked_connect_secrets_id_without_type_resolves_on_id_alone() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform_with_id("id-a", "telegram", "bot-a-token"),
connect_platform_with_id("id-b", "telegram", "bot-b-token"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"id":"id-b","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"], "bot-b-token",
"with no type to check, the id match resolves to its own entry, not the positional one"
);
}
#[test]
fn preserve_masked_connect_secrets_ignores_id_branch_when_patch_omits_id() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform_with_id(
"id-a",
"telegram",
"existing-bot-token",
)];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["token"],
"existing-bot-token"
);
}
fn feishu_platform(app_secret: &str) -> crate::ConnectPlatformConfig {
crate::ConnectPlatformConfig {
id: None,
platform_type: "feishu".to_string(),
token: None,
token_encrypted: None,
app_id: Some("cli_x".to_string()),
app_secret: Some(app_secret.to_string()),
app_secret_encrypted: None,
domain: Some("lark".to_string()),
allow_from: Vec::new(),
admin_from: Vec::new(),
}
}
#[test]
fn preserve_masked_connect_secrets_keeps_existing_app_secret_by_position() {
let mut current = Config::default();
current.connect.platforms = vec![feishu_platform("existing-app-secret")];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["app_secret"],
"existing-app-secret"
);
assert_eq!(patch["connect"]["platforms"][0]["app_id"], "cli_x");
assert_eq!(patch["connect"]["platforms"][0]["domain"], "lark");
}
#[test]
fn preserve_masked_connect_secrets_drops_app_secret_mask_when_nothing_configured() {
let current = Config::default();
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert!(!patch["connect"]["platforms"][0]
.as_object()
.unwrap()
.contains_key("app_secret"));
}
#[test]
fn preserve_masked_connect_secrets_leaves_real_app_secret_untouched() {
let current = Config::default();
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"feishu-real-new-value"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["app_secret"],
"feishu-real-new-value"
);
}
#[test]
fn preserve_masked_connect_secrets_drops_app_secret_mask_when_type_at_index_disagrees() {
let mut current = Config::default();
current.connect.platforms = vec![feishu_platform("feishu-secret")];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[{"type":"telegram","app_secret":"****...****"}]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert!(
!patch["connect"]["platforms"][0]
.as_object()
.unwrap()
.contains_key("app_secret"),
"masked app_secret must not be resolved against a different platform's secret"
);
}
#[test]
fn preserve_masked_connect_secrets_resolves_by_type_when_preceding_entry_removed() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform("telegram", "telegram-token"),
feishu_platform("existing-app-secret"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[
{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][0]["app_secret"], "existing-app-secret",
"masked app_secret must resolve via type fallback after a preceding entry was removed"
);
}
#[test]
fn preserve_masked_connect_secrets_resolves_by_type_when_index_out_of_range() {
let mut current = Config::default();
current.connect.platforms = vec![
connect_platform("telegram", "telegram-token"),
feishu_platform("existing-app-secret"),
];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[
{"type":"telegram","token":"tg-real-value"},
{"type":"telegram","token":"new-bot-token"},
{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert_eq!(
patch["connect"]["platforms"][2]["app_secret"], "existing-app-secret",
"masked app_secret at an out-of-range index must resolve via type fallback"
);
}
#[test]
fn preserve_masked_connect_secrets_drops_mask_when_type_absent_from_current_entirely() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform("telegram", "telegram-token")];
let mut patch: Map<String, Value> = serde_json::from_str(
r#"{"connect":{"platforms":[
{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
]}}"#,
)
.unwrap();
preserve_masked_connect_secrets(&mut patch, ¤t);
assert!(
!patch["connect"]["platforms"][0]
.as_object()
.unwrap()
.contains_key("app_secret"),
"mask must still drop when no entry of that type exists anywhere in current"
);
}
fn merge_and_deserialize(current: &Config, patch: Value) -> Config {
let mut merged = serde_json::to_value(current).unwrap();
deep_merge_json(&mut merged, patch);
serde_json::from_value(merged).expect("merged config should deserialize")
}
#[test]
fn null_deletes_option_scalar_leaf() {
let mut current = Config::default();
current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
current.subagents.claude_code_model = Some("claude-sonnet".to_string());
let merged = merge_and_deserialize(
¤t,
json!({ "subagents": { "claude_code_binary": null } }),
);
assert_eq!(merged.subagents.claude_code_binary, None);
assert_eq!(
merged.subagents.claude_code_model,
Some("claude-sonnet".to_string())
);
}
#[test]
fn absent_key_leaves_value_unchanged_back_compat() {
let mut current = Config::default();
current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
current.subagents.max_concurrent = Some(4);
let merged =
merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": 16 } }));
assert_eq!(
merged.subagents.claude_code_binary,
Some("/usr/local/bin/claude".to_string()),
"an omitted key must leave the existing value untouched"
);
assert_eq!(merged.subagents.max_concurrent, Some(16));
}
#[test]
fn null_on_whole_object_subtree_resets_it_to_defaults() {
let mut current = Config::default();
current.notifications.ntfy.token = Some("existing-token".to_string());
current.notifications.ntfy.enabled = true;
let merged = merge_and_deserialize(¤t, json!({ "notifications": null }));
assert_eq!(merged.notifications, crate::NotificationsConfig::default());
}
#[test]
fn null_deletes_one_hashmap_entry_keeps_siblings() {
fn labeled_instance(label: &str) -> crate::ProviderInstanceConfig {
serde_json::from_value(json!({
"provider_type": "openai",
"label": label,
}))
.expect("valid instance")
}
let mut current = Config::default();
current
.provider_instances
.insert("uuid-1".to_string(), labeled_instance("Work"));
current
.provider_instances
.insert("uuid-2".to_string(), labeled_instance("Personal"));
let merged = merge_and_deserialize(
¤t,
json!({ "provider_instances": { "uuid-1": null } }),
);
assert!(!merged.provider_instances.contains_key("uuid-1"));
assert_eq!(
merged
.provider_instances
.get("uuid-2")
.and_then(|i| i.label.as_deref()),
Some("Personal"),
"sibling map entries the patch didn't touch must survive"
);
}
#[test]
fn null_on_whole_array_field_resets_it_to_empty() {
let mut current = Config::default();
current.connect.platforms = vec![connect_platform("telegram", "tok")];
let merged = merge_and_deserialize(¤t, json!({ "connect": { "platforms": null } }));
assert!(merged.connect.platforms.is_empty());
}
#[test]
fn null_inside_a_surviving_array_is_a_literal_element_not_a_delete_marker() {
let mut current = Config::default();
current.subagents.worker_args = Some(vec!["subagent-worker".to_string()]);
let mut merged = serde_json::to_value(¤t).unwrap();
deep_merge_json(
&mut merged,
json!({ "subagents": { "worker_args": ["a", null, "b"] } }),
);
assert_eq!(
merged["subagents"]["worker_args"],
json!(["a", null, "b"]),
"arrays are leaf-replaced verbatim; a null element is not deleted"
);
let result: Result<Config, _> = serde_json::from_value(merged);
assert!(
result.is_err(),
"a literal null inside a Vec<String> is a type error, not an element delete"
);
}
#[test]
fn sentinel_string_values_still_work_after_null_delete_support() {
let current = Config::default();
let merged = merge_and_deserialize(
¤t,
json!({ "subagents": { "executor": "bamboo_runtime" } }),
);
assert_eq!(
merged.subagents.executor,
Some("bamboo_runtime".to_string())
);
}
#[test]
fn subagents_max_concurrent_null_clears_it_like_todays_lotus_ui() {
let mut current = Config::default();
current.subagents.max_concurrent = Some(4);
let merged =
merge_and_deserialize(¤t, json!({ "subagents": { "max_concurrent": null } }));
assert_eq!(merged.subagents.max_concurrent, None);
}
#[test]
fn provider_api_key_intents_treats_null_as_clear_intent() {
let patch = json!({
"providers": { "openai": { "api_key": null } },
"provider_instances": { "uuid-1": { "api_key": null } }
});
let intents = provider_api_key_intents(patch.as_object().unwrap());
assert!(
intents.providers.contains("openai"),
"a null api_key must register as an explicit clear intent, \
same as an empty string — otherwise preserve_unpatched_provider_secrets \
would resurrect the deleted key"
);
assert!(intents.provider_instances.contains("uuid-1"));
}
#[test]
fn provider_api_key_intents_null_and_empty_string_are_equivalent_intents() {
let null_patch = json!({ "providers": { "openai": { "api_key": null } } });
let empty_patch = json!({ "providers": { "openai": { "api_key": "" } } });
assert_eq!(
provider_api_key_intents(null_patch.as_object().unwrap()),
provider_api_key_intents(empty_patch.as_object().unwrap()),
"null and \"\" must be recognized as the same clear intent"
);
}
#[test]
fn null_api_key_does_not_get_resurrected_by_preserve_unpatched_secrets() {
let mut current = Config::default();
current
.provider_instances
.insert("uuid-1".to_string(), instance("sk-old", None));
let patch = json!({ "provider_instances": { "uuid-1": { "api_key": null } } });
let intents = provider_api_key_intents(patch.as_object().unwrap());
assert!(intents.provider_instances.contains("uuid-1"));
let mut merged = merge_and_deserialize(¤t, patch);
preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
assert_eq!(
merged.provider_instances["uuid-1"].api_key, "",
"a null-delete of api_key must stick, not get resurrected from `current`"
);
}
#[test]
fn notification_secret_intents_treats_null_as_clear_intent() {
let patch = json!({
"notifications": { "ntfy": { "token": null }, "bark": { "device_key": null } }
});
let intents = notification_secret_intents(patch.as_object().unwrap());
assert!(intents.ntfy_token);
assert!(intents.bark_device_key);
}
#[test]
fn connect_secret_intents_treats_null_as_clear_intent() {
let patch = json!({
"connect": { "platforms": [ { "type": "telegram", "token": null } ] }
});
let intents = connect_secret_intents(patch.as_object().unwrap());
assert!(intents.token.contains(&0));
}
#[test]
fn null_ntfy_token_clears_roundtripped_ciphertext_via_clear_intents() {
let mut current = Config::default();
current.notifications.ntfy.token = Some("existing-token".to_string());
current.notifications.ntfy.token_encrypted = Some("existing-ct".to_string());
let patch = json!({ "notifications": { "ntfy": { "token": null } } });
let intents = notification_secret_intents(patch.as_object().unwrap());
assert!(intents.ntfy_token);
let mut merged = merge_and_deserialize(¤t, patch);
assert_eq!(
merged.notifications.ntfy.token_encrypted.as_deref(),
Some("existing-ct"),
"the merge alone leaves the round-tripped ciphertext behind"
);
clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
assert_eq!(merged.notifications.ntfy.token, None);
assert!(
merged.notifications.ntfy.token_encrypted.is_none(),
"the clear-intent pass must drop the stale ciphertext so hydration can't refill it"
);
}
#[test]
fn whole_providers_null_wipes_everything_without_resurrecting_secrets() {
let mut current = Config::default();
current.providers.openai = Some(crate::OpenAIConfig {
api_key: "sk-legacy-live".to_string(),
..Default::default()
});
let patch = json!({ "providers": null });
let intents = provider_api_key_intents(patch.as_object().unwrap());
assert!(intents.providers.is_empty());
let mut merged = merge_and_deserialize(¤t, patch);
assert!(
merged.providers.openai.is_none(),
"the whole providers subtree must reset to default"
);
preserve_unpatched_provider_secrets(&mut merged, ¤t, &intents);
assert!(
merged.providers.openai.is_none(),
"carry-forward must not resurrect a provider the patch wiped out entirely"
);
}
}