1use serde_json::{Map, Value};
21
22use crate::error::AppError;
23use bamboo_config::patch::ProviderApiKeyIntents;
24use bamboo_llm::Config;
25
26pub use bamboo_config::patch::{
29 clear_connect_ciphertext_for_explicit_clears,
30 clear_notification_ciphertext_for_explicit_clears,
31 clear_provider_ciphertext_for_explicit_clears, connect_secret_intents, deep_merge_json,
32 domains_for_root_patch, effects_for_root_patch, is_masked_api_key, notification_secret_intents,
33 preserve_masked_connect_secrets, preserve_masked_notification_secrets,
34 preserve_masked_provider_api_keys, preserve_unpatched_notification_secrets,
35 preserve_unpatched_provider_secrets, provider_api_key_intents, sanitize_root_patch,
36 ConnectSecretIntents, DomainChanges, NotificationSecretIntents, PatchEffects, ReloadMode,
37};
38
39pub fn sync_provider_api_keys_encrypted_for_patch(
40 config: &mut Config,
41 intents: &ProviderApiKeyIntents,
42) -> Result<(), AppError> {
43 for name in intents.providers.iter() {
44 match name.as_str() {
45 "openai" => {
46 if let Some(openai) = config.providers_mut().openai.as_mut() {
47 if !openai.api_key_from_env {
55 let api_key = openai.api_key.trim();
56 openai.api_key_encrypted = if api_key.is_empty() {
57 None
58 } else {
59 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
60 AppError::InternalError(anyhow::anyhow!(
61 "Failed to encrypt OpenAI api_key: {e}"
62 ))
63 })?)
64 };
65 }
66 }
67 }
68 "anthropic" => {
69 if let Some(anthropic) = config.providers_mut().anthropic.as_mut() {
70 if !anthropic.api_key_from_env {
72 let api_key = anthropic.api_key.trim();
73 anthropic.api_key_encrypted = if api_key.is_empty() {
74 None
75 } else {
76 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
77 AppError::InternalError(anyhow::anyhow!(
78 "Failed to encrypt Anthropic api_key: {e}"
79 ))
80 })?)
81 };
82 }
83 }
84 }
85 "gemini" => {
86 if let Some(gemini) = config.providers_mut().gemini.as_mut() {
87 if !gemini.api_key_from_env {
89 let api_key = gemini.api_key.trim();
90 gemini.api_key_encrypted = if api_key.is_empty() {
91 None
92 } else {
93 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
94 AppError::InternalError(anyhow::anyhow!(
95 "Failed to encrypt Gemini api_key: {e}"
96 ))
97 })?)
98 };
99 }
100 }
101 }
102 "bodhi" => {
103 if let Some(bodhi) = config.providers_mut().bodhi.as_mut() {
104 let api_key = bodhi.api_key.trim();
105 bodhi.api_key_encrypted = if api_key.is_empty() {
106 None
107 } else {
108 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
109 AppError::InternalError(anyhow::anyhow!(
110 "Failed to encrypt Bodhi api_key: {e}"
111 ))
112 })?)
113 };
114 }
115 }
116 _ => {}
117 }
118 }
119
120 for instance_id in intents.provider_instances.iter() {
121 if let Some(instance) = config.provider_instances.get_mut(instance_id) {
122 let api_key = instance.api_key.trim();
123 instance.api_key_encrypted = if api_key.is_empty() {
124 None
125 } else {
126 Some(bamboo_config::encryption::encrypt(api_key).map_err(|e| {
127 AppError::InternalError(anyhow::anyhow!(
128 "Failed to encrypt provider instance api_key for '{instance_id}': {e}"
129 ))
130 })?)
131 };
132 }
133 }
134
135 Ok(())
136}
137
138pub fn assert_json_object(value: Value) -> Result<Map<String, Value>, AppError> {
139 match value {
140 Value::Object(map) => Ok(map),
141 _ => Err(AppError::BadRequest(
142 "config.json must be a JSON object".to_string(),
143 )),
144 }
145}
146
147pub fn remove_unchanged_core_proxy_echo(
154 current: &Config,
155 patch_obj: &mut Map<String, Value>,
156) -> Result<(), AppError> {
157 if ["proxy_auth", "proxy_auth_encrypted"]
158 .iter()
159 .any(|field| patch_obj.contains_key(*field))
160 {
161 return Err(core_proxy_patch_error());
162 }
163 let current_value = current.to_compatibility_value()?;
164 for field in ["http_proxy", "https_proxy", "proxy_auth_credential_ref"] {
165 let Some(incoming) = patch_obj.get(field) else {
166 continue;
167 };
168 if current_value.get(field) != Some(incoming) {
169 return Err(core_proxy_patch_error());
170 }
171 patch_obj.remove(field);
172 }
173 Ok(())
174}
175
176fn core_proxy_patch_error() -> AppError {
177 AppError::BadRequest(
178 "proxy configuration must be changed through the dedicated revisioned Core and proxy-auth APIs"
179 .to_string(),
180 )
181}
182
183pub fn build_merged_config(
184 current: &Config,
185 patch_obj: Map<String, Value>,
186) -> Result<Config, AppError> {
187 let api_key_intents = provider_api_key_intents(&patch_obj);
191 let notification_intents = notification_secret_intents(&patch_obj);
195 let connect_intents = connect_secret_intents(&patch_obj);
196
197 let mut merged = current
198 .to_compatibility_value()
199 .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to serialize config: {e}")))?;
200
201 deep_merge_json(&mut merged, Value::Object(patch_obj));
202
203 let mut new_config: Config = serde_json::from_value(merged)
204 .map_err(|e| AppError::BadRequest(format!("Invalid configuration JSON: {e}")))?;
205 clear_provider_ciphertext_for_explicit_clears(&mut new_config, &api_key_intents);
209 clear_notification_ciphertext_for_explicit_clears(&mut new_config, ¬ification_intents);
212 clear_connect_ciphertext_for_explicit_clears(&mut new_config, &connect_intents);
213 new_config.hydrate_proxy_auth_from_encrypted();
214 if new_config.proxy_auth_credential_ref == current.proxy_auth_credential_ref {
219 new_config.proxy_auth = current.proxy_auth.clone();
220 }
221 new_config.hydrate_provider_api_keys_from_encrypted();
222 new_config.hydrate_provider_instance_api_keys_from_encrypted();
223 new_config.hydrate_mcp_secrets_from_encrypted();
224 new_config.hydrate_env_vars_from_encrypted();
225 new_config.hydrate_notifications_from_encrypted();
226 preserve_unpatched_notification_secrets(&mut new_config, current, ¬ification_intents);
227 new_config.hydrate_connect_platform_tokens_from_encrypted();
228 new_config.preserve_env_sourced_provider_keys(current);
233 preserve_unpatched_provider_secrets(&mut new_config, current, &api_key_intents);
239 new_config.preserve_provider_instance_plaintext_keys(current, &api_key_intents);
243 new_config.normalize_tool_settings();
244 new_config.normalize_skill_settings();
245 new_config.normalize_plugin_trust_settings();
246
247 Ok(new_config)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use bamboo_config::{credential_ref, OpenAIConfig, ProxyAuth};
254
255 #[test]
256 fn unrelated_root_patch_preserves_store_hydrated_proxy_auth() {
257 let mut current = Config::default();
258 current.proxy_auth_credential_ref =
259 Some(credential_ref("proxy", "default", "auth").unwrap());
260 current.proxy_auth = Some(ProxyAuth {
261 username: "proxy-user".to_string(),
262 password: "proxy-password".to_string(),
263 });
264 let patch: Map<String, Value> =
265 serde_json::from_str(r#"{"http_proxy":"http://proxy.example:8080"}"#).unwrap();
266
267 let merged = build_merged_config(¤t, patch).expect("merge");
268
269 assert_eq!(
270 merged.proxy_auth_credential_ref,
271 current.proxy_auth_credential_ref
272 );
273 let auth = merged
274 .proxy_auth
275 .as_ref()
276 .expect("live proxy auth must survive");
277 assert_eq!(auth.username, "proxy-user");
278 assert_eq!(auth.password, "proxy-password");
279 }
280
281 fn env_sourced_openai_config() -> Config {
282 let mut config = Config::default();
283 config.providers_mut().openai = Some(OpenAIConfig {
284 api_key: "sk-env-secret".to_string(),
285 api_key_from_env: true,
286 ..Default::default()
287 });
288 config
289 }
290
291 #[test]
296 fn clearing_env_sourced_key_does_not_persist_the_secret() {
297 let current = env_sourced_openai_config();
298 let patch: Map<String, Value> =
299 serde_json::from_str(r#"{"providers":{"openai":{"api_key":""}}}"#).unwrap();
300 let intents = provider_api_key_intents(&patch);
301 assert!(
302 intents.providers.contains("openai"),
303 "empty string is a clear intent"
304 );
305
306 let mut merged = build_merged_config(¤t, patch).expect("merge");
307 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
308
309 let openai = merged.providers().openai.as_ref().unwrap();
310 assert!(
311 openai.api_key_encrypted.is_none(),
312 "env secret must NOT be encrypted to disk on a clear"
313 );
314 assert!(openai.api_key_from_env, "still flagged env-sourced");
315 assert_eq!(openai.api_key, "sk-env-secret", "live env key preserved");
316 }
317
318 #[test]
321 fn explicit_new_key_overrides_env_and_persists() {
322 let current = env_sourced_openai_config();
323 let patch: Map<String, Value> =
324 serde_json::from_str(r#"{"providers":{"openai":{"api_key":"sk-brand-new"}}}"#).unwrap();
325 let intents = provider_api_key_intents(&patch);
326
327 let mut merged = build_merged_config(¤t, patch).expect("merge");
328 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
329
330 let openai = merged.providers().openai.as_ref().unwrap();
331 assert_eq!(openai.api_key, "sk-brand-new", "explicit override wins");
332 assert!(!openai.api_key_from_env, "override clears the env flag");
333 assert!(
334 openai.api_key_encrypted.is_some(),
335 "a real override is encrypted/persisted"
336 );
337 }
338
339 #[test]
341 fn unrelated_patch_preserves_env_key() {
342 let current = env_sourced_openai_config();
343 let patch: Map<String, Value> =
344 serde_json::from_str(r#"{"providers":{"openai":{"model":"gpt-x"}}}"#).unwrap();
345 let intents = provider_api_key_intents(&patch);
346 assert!(
347 !intents.providers.contains("openai"),
348 "no api_key in patch → no intent"
349 );
350
351 let mut merged = build_merged_config(¤t, patch).expect("merge");
352 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
353
354 let openai = merged.providers().openai.as_ref().unwrap();
355 assert_eq!(
356 openai.api_key, "sk-env-secret",
357 "env key preserved across unrelated patch"
358 );
359 assert!(openai.api_key_from_env);
360 assert!(openai.api_key_encrypted.is_none(), "still not persisted");
361 }
362
363 fn config_with_plaintext_only_instance(api_key: &str) -> Config {
367 let mut config = Config::default();
368 let instance: bamboo_config::ProviderInstanceConfig =
369 serde_json::from_value(serde_json::json!({
370 "provider_type": "openai",
371 "api_key": api_key,
372 }))
373 .expect("valid instance");
374 config
375 .provider_instances
376 .insert("uuid-1".to_string(), instance);
377 config
378 }
379
380 #[test]
386 fn unrelated_patch_preserves_plaintext_only_instance_key() {
387 let current = config_with_plaintext_only_instance("sk-instance-live");
388
389 let patch: Map<String, Value> =
390 serde_json::from_str(r#"{"features":{"provider_model_ref":true}}"#).unwrap();
391 let intents = provider_api_key_intents(&patch);
392 assert!(intents.provider_instances.is_empty());
393
394 let mut merged = build_merged_config(¤t, patch).expect("merge");
395 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
396
397 let instance = merged.provider_instances.get("uuid-1").expect("instance");
398 assert_eq!(
399 instance.api_key, "sk-instance-live",
400 "an unrelated settings PATCH must not lose the instance key (#516)"
401 );
402 }
403
404 #[test]
405 fn unrelated_patch_preserves_fresh_instance_plaintext_key() {
406 let current = config_with_plaintext_only_instance("sk-instance-fresh");
407
408 let patch: Map<String, Value> = serde_json::from_str(
409 r#"{"defaults":{"chat":{"provider":"openai","model":"gpt-4o-mini","temperature":1}}}"#,
410 )
411 .unwrap();
412 let intents = provider_api_key_intents(&patch);
413 assert!(intents.provider_instances.is_empty());
414
415 let mut merged = build_merged_config(¤t, patch).expect("merge");
416 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
417
418 let instance = merged.provider_instances.get("uuid-1").expect("instance");
419 assert_eq!(instance.api_key, "sk-instance-fresh");
420 }
421
422 #[test]
423 fn explicit_instance_key_in_patch_wins_over_preserved_key() {
424 let current = config_with_plaintext_only_instance("sk-instance-live");
425 let patch: Map<String, Value> = serde_json::from_str(
426 r#"{"provider_instances":{"uuid-1":{"api_key":"sk-instance-updated"}}}"#,
427 )
428 .unwrap();
429 let intents = provider_api_key_intents(&patch);
430 assert!(intents.provider_instances.contains("uuid-1"));
431
432 let mut merged = build_merged_config(¤t, patch).expect("merge");
433 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
434
435 let instance = merged.provider_instances.get("uuid-1").expect("instance");
436 assert_eq!(instance.api_key, "sk-instance-updated");
437 assert!(
438 instance
439 .api_key_encrypted
440 .as_deref()
441 .is_some_and(|cipher| !cipher.is_empty()),
442 "explicit instance key must be encrypted after sync"
443 );
444 }
445
446 #[test]
448 fn explicit_instance_key_clear_still_clears() {
449 let current = config_with_plaintext_only_instance("sk-old");
450
451 let patch: Map<String, Value> =
452 serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
453 let intents = provider_api_key_intents(&patch);
454 assert!(
455 intents.provider_instances.contains("uuid-1"),
456 "empty string is a clear intent"
457 );
458
459 let mut merged = build_merged_config(¤t, patch).expect("merge");
460 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
461
462 let instance = merged.provider_instances.get("uuid-1").expect("instance");
463 assert!(instance.api_key.is_empty(), "explicit clear must win");
464 assert!(instance.api_key_encrypted.is_none());
465 }
466
467 #[test]
473 fn explicit_instance_key_clear_wins_over_in_memory_ciphertext() {
474 let mut current = config_with_plaintext_only_instance("sk-old");
475 current.refresh_encrypted_secrets().expect("refresh");
476 assert!(
477 current.provider_instances["uuid-1"]
478 .api_key_encrypted
479 .is_some(),
480 "precondition: live config holds ciphertext"
481 );
482
483 let patch: Map<String, Value> =
484 serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":""}}}"#).unwrap();
485 let intents = provider_api_key_intents(&patch);
486
487 let mut merged = build_merged_config(¤t, patch).expect("merge");
488 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
489
490 let instance = merged.provider_instances.get("uuid-1").expect("instance");
491 assert!(instance.api_key.is_empty(), "explicit clear must win");
492 assert!(
493 instance.api_key_encrypted.is_none(),
494 "ciphertext must be cleared too"
495 );
496 }
497
498 #[test]
503 fn unrelated_patch_preserves_provider_instance_ciphertext() {
504 let mut current = config_with_plaintext_only_instance("sk-instance-secret");
505 current.refresh_encrypted_secrets().expect("refresh");
506 let prev_ciphertext = current.provider_instances["uuid-1"]
507 .api_key_encrypted
508 .clone()
509 .expect("current should have ciphertext");
510
511 let patch: Map<String, Value> =
512 serde_json::from_str(r#"{"http_proxy":"http://example.invalid:8080"}"#).unwrap();
513 let intents = provider_api_key_intents(&patch);
514 assert!(intents.provider_instances.is_empty());
515
516 let mut merged = build_merged_config(¤t, patch).expect("merge");
517 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
518
519 let instance = &merged.provider_instances["uuid-1"];
520 assert_eq!(
521 instance.api_key, "sk-instance-secret",
522 "plaintext must survive an unrelated save"
523 );
524 assert_eq!(
525 instance.api_key_encrypted.as_deref(),
526 Some(prev_ciphertext.as_str()),
527 "ciphertext must survive an unrelated save"
528 );
529 }
530
531 fn config_with_notification_secrets(ntfy_token: &str, bark_key: &str) -> Config {
540 let mut config = Config::default();
541 config.notifications.ntfy.token = Some(ntfy_token.to_string());
542 config.notifications.bark.device_key = Some(bark_key.to_string());
543 config.refresh_encrypted_secrets().expect("refresh");
544 config
545 }
546
547 fn merge_notifications_patch(current: &Config, patch_json: &str) -> Config {
548 let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
549 preserve_masked_notification_secrets(&mut patch_obj, current);
550 let mut merged = build_merged_config(current, patch_obj).expect("merge");
551 merged.refresh_encrypted_secrets().expect("refresh");
552 merged
553 }
554
555 #[test]
556 fn explicit_notification_secret_clear_wins_over_in_memory_ciphertext() {
557 let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
558 assert!(current.notifications.ntfy.token_encrypted.is_some());
559 assert!(current.notifications.bark.device_key_encrypted.is_some());
560
561 let merged = merge_notifications_patch(
562 ¤t,
563 r#"{"notifications":{"ntfy":{"token":""},"bark":{"device_key":""}}}"#,
564 );
565
566 assert!(
567 merged
568 .notifications
569 .ntfy
570 .token
571 .as_deref()
572 .unwrap_or("")
573 .is_empty(),
574 "explicit clear must win"
575 );
576 assert!(
577 merged.notifications.ntfy.token_encrypted.is_none(),
578 "ciphertext must be cleared too (#521)"
579 );
580 assert!(merged
581 .notifications
582 .bark
583 .device_key
584 .as_deref()
585 .unwrap_or("")
586 .is_empty());
587 assert!(merged.notifications.bark.device_key_encrypted.is_none());
588 }
589
590 #[test]
591 fn unrelated_patch_preserves_notification_secrets() {
592 let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
595
596 let merged =
597 merge_notifications_patch(¤t, r#"{"http_proxy":"http://example.invalid:8080"}"#);
598
599 assert_eq!(
600 merged.notifications.ntfy.token.as_deref(),
601 Some("ntfy-secret"),
602 "an unrelated settings PATCH must not lose the ntfy token"
603 );
604 assert!(merged.notifications.ntfy.token_encrypted.is_some());
605 assert_eq!(
606 merged.notifications.bark.device_key.as_deref(),
607 Some("bark-secret"),
608 "an unrelated settings PATCH must not lose the Bark device key"
609 );
610 assert!(merged.notifications.bark.device_key_encrypted.is_some());
611 }
612
613 #[test]
614 fn masked_notification_secret_placeholder_preserves_value() {
615 let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
616
617 let merged = merge_notifications_patch(
618 ¤t,
619 r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
620 );
621
622 assert_eq!(
623 merged.notifications.ntfy.token.as_deref(),
624 Some("ntfy-secret")
625 );
626 assert!(merged.notifications.ntfy.token_encrypted.is_some());
627 assert_eq!(
628 merged.notifications.bark.device_key.as_deref(),
629 Some("bark-secret")
630 );
631 assert!(merged.notifications.bark.device_key_encrypted.is_some());
632 }
633
634 #[test]
635 fn new_notification_secret_value_replaces_and_encrypts() {
636 let current = config_with_notification_secrets("ntfy-old", "bark-old");
637
638 let merged = merge_notifications_patch(
639 ¤t,
640 r#"{"notifications":{"ntfy":{"token":"ntfy-new"},"bark":{"device_key":"bark-new"}}}"#,
641 );
642
643 assert_eq!(merged.notifications.ntfy.token.as_deref(), Some("ntfy-new"));
644 assert!(merged.notifications.ntfy.token_encrypted.is_some());
645 assert_eq!(
646 merged.notifications.bark.device_key.as_deref(),
647 Some("bark-new")
648 );
649 assert!(merged.notifications.bark.device_key_encrypted.is_some());
650 }
651
652 fn config_with_connect_platform(platform_type: &str, token: &str) -> Config {
655 let mut config = Config::default();
656 let platform: bamboo_config::ConnectPlatformConfig =
657 serde_json::from_value(serde_json::json!({
658 "type": platform_type,
659 "token": token,
660 }))
661 .expect("valid platform");
662 config.connect.platforms = vec![platform];
663 config.refresh_encrypted_secrets().expect("refresh");
664 config
665 }
666
667 fn merge_connect_patch(current: &Config, patch_json: &str) -> Config {
668 let mut patch_obj: Map<String, Value> = serde_json::from_str(patch_json).unwrap();
669 preserve_masked_connect_secrets(&mut patch_obj, current);
670 let mut merged = build_merged_config(current, patch_obj).expect("merge");
671 merged.refresh_encrypted_secrets().expect("refresh");
672 merged
673 }
674
675 #[test]
676 fn explicit_connect_token_clear_wins_over_in_memory_ciphertext() {
677 let current = config_with_connect_platform("telegram", "tg-secret-token");
678 assert!(current.connect.platforms[0].token_encrypted.is_some());
679
680 let merged = merge_connect_patch(
681 ¤t,
682 r#"{"connect":{"platforms":[{"type":"telegram","token":""}]}}"#,
683 );
684
685 assert!(
686 merged.connect.platforms[0]
687 .token
688 .as_deref()
689 .unwrap_or("")
690 .is_empty(),
691 "explicit clear must win"
692 );
693 assert!(
694 merged.connect.platforms[0].token_encrypted.is_none(),
695 "ciphertext must be cleared too (#521)"
696 );
697 }
698
699 #[test]
700 fn explicit_connect_app_secret_clear_wins_over_in_memory_ciphertext() {
701 let mut current = Config::default();
702 let platform: bamboo_config::ConnectPlatformConfig =
703 serde_json::from_value(serde_json::json!({
704 "type": "feishu",
705 "app_id": "cli_x",
706 "app_secret": "feishu-secret",
707 }))
708 .expect("valid platform");
709 current.connect.platforms = vec![platform];
710 current.refresh_encrypted_secrets().expect("refresh");
711 assert!(current.connect.platforms[0].app_secret_encrypted.is_some());
712
713 let merged = merge_connect_patch(
714 ¤t,
715 r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":""}]}}"#,
716 );
717
718 assert!(
719 merged.connect.platforms[0]
720 .app_secret
721 .as_deref()
722 .unwrap_or("")
723 .is_empty(),
724 "explicit clear must win"
725 );
726 assert!(
727 merged.connect.platforms[0].app_secret_encrypted.is_none(),
728 "ciphertext must be cleared too (#521)"
729 );
730 }
731
732 #[test]
733 fn unrelated_patch_preserves_connect_token() {
734 let current = config_with_connect_platform("telegram", "tg-secret-token");
739
740 let merged =
741 merge_connect_patch(¤t, r#"{"http_proxy":"http://example.invalid:8080"}"#);
742
743 assert_eq!(
744 merged.connect.platforms[0].token.as_deref(),
745 Some("tg-secret-token"),
746 "an unrelated settings PATCH must not lose the connect platform token"
747 );
748 assert!(merged.connect.platforms[0].token_encrypted.is_some());
749 }
750
751 #[test]
752 fn masked_connect_token_placeholder_preserves_value() {
753 let current = config_with_connect_platform("telegram", "tg-secret-token");
754
755 let merged = merge_connect_patch(
756 ¤t,
757 r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
758 );
759
760 assert_eq!(
761 merged.connect.platforms[0].token.as_deref(),
762 Some("tg-secret-token")
763 );
764 assert!(merged.connect.platforms[0].token_encrypted.is_some());
765 }
766
767 #[test]
768 fn new_connect_token_value_replaces_and_encrypts() {
769 let current = config_with_connect_platform("telegram", "tg-old-token");
770
771 let merged = merge_connect_patch(
772 ¤t,
773 r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-new-token"}]}}"#,
774 );
775
776 assert_eq!(
777 merged.connect.platforms[0].token.as_deref(),
778 Some("tg-new-token")
779 );
780 assert!(merged.connect.platforms[0].token_encrypted.is_some());
781 }
782
783 #[test]
793 fn null_instance_api_key_clear_wins_over_in_memory_ciphertext() {
794 let mut current = config_with_plaintext_only_instance("sk-old");
797 current.refresh_encrypted_secrets().expect("refresh");
798 assert!(
799 current.provider_instances["uuid-1"]
800 .api_key_encrypted
801 .is_some(),
802 "precondition: live config holds ciphertext"
803 );
804
805 let patch: Map<String, Value> =
806 serde_json::from_str(r#"{"provider_instances":{"uuid-1":{"api_key":null}}}"#).unwrap();
807 let intents = provider_api_key_intents(&patch);
808 assert!(
809 intents.provider_instances.contains("uuid-1"),
810 "null must register as a clear intent, same as \"\""
811 );
812
813 let mut merged = build_merged_config(¤t, patch).expect("merge");
814 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
815
816 let instance = merged.provider_instances.get("uuid-1").expect("instance");
817 assert!(instance.api_key.is_empty(), "null clear must win");
818 assert!(
819 instance.api_key_encrypted.is_none(),
820 "ciphertext must be cleared too, not resurrected via hydration"
821 );
822 }
823
824 #[test]
825 fn null_deletes_a_whole_provider_instance_entry() {
826 let mut current = config_with_plaintext_only_instance("sk-keep-me");
830 let second: bamboo_config::ProviderInstanceConfig =
831 serde_json::from_value(serde_json::json!({
832 "provider_type": "anthropic",
833 "label": "Delete Me",
834 }))
835 .expect("valid instance");
836 current
837 .provider_instances
838 .insert("uuid-2".to_string(), second);
839
840 let patch: Map<String, Value> =
841 serde_json::from_str(r#"{"provider_instances":{"uuid-2":null}}"#).unwrap();
842 let intents = provider_api_key_intents(&patch);
843
844 let mut merged = build_merged_config(¤t, patch).expect("merge");
845 sync_provider_api_keys_encrypted_for_patch(&mut merged, &intents).expect("sync");
846
847 assert!(
848 !merged.provider_instances.contains_key("uuid-2"),
849 "the null-targeted instance must be gone"
850 );
851 assert_eq!(
852 merged
853 .provider_instances
854 .get("uuid-1")
855 .map(|i| &i.provider_type),
856 Some(&"openai".to_string()),
857 "the untouched sibling instance must survive"
858 );
859 }
860
861 #[test]
862 fn null_ntfy_token_clear_wins_over_in_memory_ciphertext() {
863 let current = config_with_notification_secrets("ntfy-secret", "bark-secret");
866
867 let merged =
868 merge_notifications_patch(¤t, r#"{"notifications":{"ntfy":{"token":null}}}"#);
869
870 assert!(
871 merged
872 .notifications
873 .ntfy
874 .token
875 .as_deref()
876 .unwrap_or("")
877 .is_empty(),
878 "null clear must win"
879 );
880 assert!(
881 merged.notifications.ntfy.token_encrypted.is_none(),
882 "ciphertext must be cleared too, not resurrected via hydration"
883 );
884 assert_eq!(
886 merged.notifications.bark.device_key.as_deref(),
887 Some("bark-secret")
888 );
889 assert!(merged.notifications.bark.device_key_encrypted.is_some());
890 }
891
892 #[test]
893 fn null_connect_token_clear_wins_over_in_memory_ciphertext() {
894 let current = config_with_connect_platform("telegram", "tg-old-token");
895
896 let merged = merge_connect_patch(
897 ¤t,
898 r#"{"connect":{"platforms":[{"type":"telegram","token":null}]}}"#,
899 );
900
901 assert!(
902 merged.connect.platforms[0]
903 .token
904 .as_deref()
905 .unwrap_or("")
906 .is_empty(),
907 "null clear must win"
908 );
909 assert!(
910 merged.connect.platforms[0].token_encrypted.is_none(),
911 "ciphertext must be cleared too, not resurrected via hydration"
912 );
913 }
914
915 #[test]
916 fn null_subagents_claude_code_binary_is_unset_and_does_not_crash_the_patch() {
917 let mut current = Config::default();
922 current.subagents_mut().claude_code_binary = Some("/usr/local/bin/claude".to_string());
923 current.subagents_mut().executor = Some("claude_code".to_string());
924
925 let patch: Map<String, Value> =
926 serde_json::from_str(r#"{"subagents":{"claude_code_binary":null}}"#).unwrap();
927 let merged = build_merged_config(¤t, patch).expect("merge must not error");
928
929 assert_eq!(merged.subagents().claude_code_binary, None);
930 assert_eq!(merged.subagents().executor, Some("claude_code".to_string()));
933 }
934}