Skip to main content

bamboo_config/
patch.rs

1//! Config patch domain logic.
2//!
3//! Pure business rules for interpreting, sanitizing, and merging
4//! partial config patches. Used by the server's config management endpoints.
5
6use serde_json::{Map, Value};
7
8use crate::Config;
9
10/// Detect whether a string value looks like a masked/placeholder API key.
11///
12/// Only a value consisting entirely of `*`/`.` characters counts (the redaction
13/// placeholder `****...****`, or truncated/retyped variants of it). Substring
14/// matching is deliberately avoided: the UI prefills the placeholder into the
15/// editable field, so a paste that doesn't fully clear it yields values like
16/// `****...****sk-new…` — treating those as "keep existing key" silently
17/// discards the user's new token (#430).
18pub fn is_masked_api_key(value: &str) -> bool {
19    let v = value.trim();
20    // Empty string is treated as an explicit "clear" signal (we control all clients).
21    !v.is_empty() && v.chars().all(|c| c == '*' || c == '.')
22}
23
24/// Decide whether `obj[field]` expresses a secret update **intent** — a
25/// genuine new value or an explicit clear — as opposed to "leave alone"
26/// (field absent) or "keep existing" (a masked placeholder string).
27///
28/// Three ways a client can write a secret field, and what each means:
29/// - absent → not an intent (existing #521/#516 behavior, unchanged).
30/// - a masked placeholder string (`is_masked_api_key`) → not an intent, the
31///   caller resolves it back to the live plaintext (`preserve_masked_*`).
32/// - anything else — a real new value, an explicit `""`, OR an explicit
33///   JSON `null` (#505's RFC-7386-style delete) → **is** an intent. `""`
34///   and `null` are equivalent clear signals here: [`deep_merge_json`]
35///   removes a `null` field from the merge target the same way it would
36///   settle on an empty string for a plain scalar, and treating both as
37///   "the caller explicitly asked to clear this" keeps the intent set in
38///   sync with what the merge is about to do — a `null` clear must be
39///   registered as an intent or `preserve_unpatched_provider_secrets` (which
40///   only skips fields the intents mark as touched) would resurrect the
41///   value the caller just deleted.
42fn is_secret_field_intent(obj: &Map<String, Value>, field: &str) -> bool {
43    match obj.get(field) {
44        None => false,
45        Some(Value::Null) => true,
46        Some(value) => match value.as_str() {
47            Some(s) => !is_masked_api_key(s),
48            // Not a string and not null (shouldn't happen from a
49            // well-behaved client) — no coherent intent to extract.
50            None => false,
51        },
52    }
53}
54
55/// Extract API-key update intents from a config patch.
56///
57/// Masked placeholders are ignored — they signal "keep existing key". An
58/// explicit `null` is treated the same as an explicit `""` clear (#505) —
59/// see [`is_secret_field_intent`].
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ProviderApiKeyIntents {
62    pub providers: std::collections::BTreeSet<String>,
63    pub provider_instances: std::collections::BTreeSet<String>,
64}
65
66pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
67    let mut intents = ProviderApiKeyIntents::default();
68
69    if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
70        for (provider_name, provider_patch) in root.iter() {
71            let Some(obj) = provider_patch.as_object() else {
72                continue;
73            };
74            if is_secret_field_intent(obj, "api_key") {
75                intents.providers.insert(provider_name.clone());
76            }
77        }
78    }
79
80    if let Some(root) = patch_obj
81        .get("provider_instances")
82        .and_then(|v| v.as_object())
83    {
84        for (instance_id, instance_patch) in root.iter() {
85            if instance_patch.is_null() {
86                intents.provider_instances.insert(instance_id.clone());
87                continue;
88            }
89            let Some(obj) = instance_patch.as_object() else {
90                continue;
91            };
92            if is_secret_field_intent(obj, "api_key") {
93                intents.provider_instances.insert(instance_id.clone());
94            }
95        }
96    }
97
98    intents
99}
100
101/// Reload strategy to apply after a config patch.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ReloadMode {
104    None,
105    /// Attempt reload, but do not fail the request if reload fails.
106    BestEffort,
107    /// Reload must succeed; otherwise the request fails.
108    Strict,
109}
110
111/// Side-effects determined from a config patch.
112#[derive(Debug, Clone, Copy)]
113pub struct PatchEffects {
114    pub reload_provider: ReloadMode,
115    pub reconcile_mcp: bool,
116}
117
118/// Which config domains are touched by a patch.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct DomainChanges {
121    pub provider: bool,
122    pub proxy: bool,
123    pub setup: bool,
124    pub mcp: bool,
125    pub keyword_masking: bool,
126    pub hooks: bool,
127    pub lifecycle_hooks: bool,
128    pub model_mapping: bool,
129}
130
131/// Classify which config domains are affected by a patch.
132pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
133    let mut changes = DomainChanges::default();
134
135    for key in patch_obj.keys() {
136        match key.as_str() {
137            // Provider domain
138            "provider"
139            | "providers"
140            | "provider_instances"
141            | "default_provider_instance"
142            | "model"
143            | "defaults"
144            | "features" => changes.provider = true,
145
146            // Proxy domain
147            "http_proxy"
148            | "https_proxy"
149            | "proxy_auth"
150            | "proxy_auth_encrypted"
151            | "http_proxy_auth_encrypted"
152            | "https_proxy_auth_encrypted" => changes.proxy = true,
153
154            // Setup domain (stored under Config.extra via serde flatten)
155            "setup" => changes.setup = true,
156
157            // MCP domain
158            "mcp" | "mcpServers" => changes.mcp = true,
159
160            // Other known config domains
161            "keyword_masking" => changes.keyword_masking = true,
162            "hooks" => changes.hooks = true,
163            "lifecycle_hooks" => changes.lifecycle_hooks = true,
164            "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
165
166            _ => {}
167        }
168    }
169
170    changes
171}
172
173/// Determine what side-effects a config patch should trigger.
174pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
175    let domains = domains_for_root_patch(patch_obj);
176
177    let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
178    let touches_proxy = domains.proxy;
179    let touches_mcp = domains.mcp;
180
181    PatchEffects {
182        reload_provider: if touches_provider || touches_proxy {
183            ReloadMode::BestEffort
184        } else {
185            ReloadMode::None
186        },
187        // SSE-based MCP servers are HTTP clients and must respect proxy settings.
188        // Reconcile so proxy changes take effect without a restart.
189        reconcile_mcp: touches_mcp || touches_proxy,
190    }
191}
192
193/// Remove forbidden fields from a config patch before application.
194///
195/// Strips encrypted auth material, data_dir, and MCP secret fields
196/// that should never be set directly by clients.
197pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
198    // Never allow clients to modify proxy auth fields or data_dir via this endpoint.
199    patch_obj.remove("proxy_auth");
200    patch_obj.remove("proxy_auth_encrypted");
201    patch_obj.remove("proxy_auth_credential_ref");
202    // Legacy/compat proxy auth keys (written by older Bodhi/Tauri builds).
203    patch_obj.remove("http_proxy_auth_encrypted");
204    patch_obj.remove("https_proxy_auth_encrypted");
205    patch_obj.remove("data_dir");
206    // Env values and storage metadata are managed by the revisioned env API.
207    // Dropping the whole domain prevents mask/ref/configured spoofing through
208    // the permissive root PATCH surface.
209    patch_obj.remove("env_vars");
210
211    // Cluster credential refs/configured state are server-owned metadata. Keep
212    // ordinary node edits compatible, but refuse client-selected references or
213    // legacy ciphertext injection through the permissive root PATCH surface.
214    if let Some(cluster_fabric) = patch_obj
215        .get_mut("cluster_fabric")
216        .and_then(|value| value.as_object_mut())
217    {
218        cluster_fabric.remove("credential_refs");
219        if let Some(nodes) = cluster_fabric
220            .get_mut("nodes")
221            .and_then(|value| value.as_array_mut())
222        {
223            for node in nodes {
224                let Some(auth) = node
225                    .get_mut("placement")
226                    .and_then(|value| value.as_object_mut())
227                    .and_then(|placement| placement.get_mut("auth"))
228                    .and_then(|value| value.as_object_mut())
229                else {
230                    continue;
231                };
232                auth.remove("password_encrypted");
233                auth.remove("private_key_encrypted");
234                auth.remove("passphrase_encrypted");
235            }
236        }
237    }
238
239    // Never allow clients to set encrypted key material directly.
240    if let Some(providers) = patch_obj
241        .get_mut("providers")
242        .and_then(|v| v.as_object_mut())
243    {
244        for (_provider_name, provider_cfg) in providers.iter_mut() {
245            let Some(obj) = provider_cfg.as_object_mut() else {
246                continue;
247            };
248            obj.remove("api_key_encrypted");
249        }
250    }
251
252    if let Some(provider_instances) = patch_obj
253        .get_mut("provider_instances")
254        .and_then(|v| v.as_object_mut())
255    {
256        for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
257            let Some(obj) = instance_cfg.as_object_mut() else {
258                continue;
259            };
260            obj.remove("api_key_encrypted");
261            obj.remove("credential_ref");
262        }
263    }
264
265    // Never allow clients to set encrypted notification-channel secrets directly.
266    if let Some(notifications) = patch_obj
267        .get_mut("notifications")
268        .and_then(|v| v.as_object_mut())
269    {
270        if let Some(ntfy) = notifications
271            .get_mut("ntfy")
272            .and_then(|v| v.as_object_mut())
273        {
274            ntfy.remove("token_encrypted");
275        }
276        if let Some(bark) = notifications
277            .get_mut("bark")
278            .and_then(|v| v.as_object_mut())
279        {
280            bark.remove("device_key_encrypted");
281        }
282    }
283
284    // Never allow clients to set encrypted bamboo-connect platform secrets
285    // (token, Feishu app_secret) directly.
286    if let Some(platforms) = patch_obj
287        .get_mut("connect")
288        .and_then(|c| c.get_mut("platforms"))
289        .and_then(|v| v.as_array_mut())
290    {
291        for platform in platforms.iter_mut() {
292            if let Some(obj) = platform.as_object_mut() {
293                obj.remove("token_encrypted");
294                obj.remove("app_secret_encrypted");
295            }
296        }
297    }
298
299    // Never allow clients to set encrypted secret material directly.
300    //
301    // Canonical MCP format:
302    //   "mcpServers": { "<id>": { env_encrypted, headers[*].value_encrypted, ... } }
303    if let Some(mcp_servers) = patch_obj
304        .get_mut("mcpServers")
305        .and_then(|v| v.as_object_mut())
306    {
307        for (_id, server) in mcp_servers.iter_mut() {
308            let Some(server_obj) = server.as_object_mut() else {
309                continue;
310            };
311            server_obj.remove("env_encrypted");
312            if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
313                for header in headers.iter_mut() {
314                    let Some(header_obj) = header.as_object_mut() else {
315                        continue;
316                    };
317                    header_obj.remove("value_encrypted");
318                }
319            }
320        }
321    }
322
323    // Legacy MCP shape:
324    //   "mcp": { "servers": [ { transport: { env_encrypted / headers[*].value_encrypted } } ] }
325    if let Some(servers) = patch_obj
326        .get_mut("mcp")
327        .and_then(|m| m.get_mut("servers"))
328        .and_then(|v| v.as_array_mut())
329    {
330        for server in servers.iter_mut() {
331            let Some(server_obj) = server.as_object_mut() else {
332                continue;
333            };
334            let Some(transport) = server_obj
335                .get_mut("transport")
336                .and_then(|v| v.as_object_mut())
337            else {
338                continue;
339            };
340
341            match transport.get("type").and_then(|v| v.as_str()) {
342                Some("stdio") => {
343                    transport.remove("env_encrypted");
344                }
345                Some("sse") => {
346                    if let Some(headers) =
347                        transport.get_mut("headers").and_then(|v| v.as_array_mut())
348                    {
349                        for header in headers.iter_mut() {
350                            let Some(header_obj) = header.as_object_mut() else {
351                                continue;
352                            };
353                            header_obj.remove("value_encrypted");
354                        }
355                    }
356                }
357                _ => {}
358            }
359        }
360    }
361}
362
363/// Replace masked API key placeholders in a patch with the current config's plain keys.
364///
365/// The UI sends masked values (e.g. `****...****`) to indicate "do not change this key".
366/// This function resolves those back to the existing plain-text key from the live config.
367pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
368    if let Some(patch_providers) = patch_obj
369        .get_mut("providers")
370        .and_then(|v| v.as_object_mut())
371    {
372        for (provider_name, provider_patch) in patch_providers.iter_mut() {
373            let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
374                continue;
375            };
376
377            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
378                continue;
379            };
380            if !is_masked_api_key(api_key) {
381                continue;
382            }
383
384            let existing_plain = match provider_name.as_str() {
385                "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
386                "anthropic" => current
387                    .providers
388                    .anthropic
389                    .as_ref()
390                    .map(|c| c.api_key.clone()),
391                "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
392                "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
393                _ => None,
394            };
395
396            if let Some(existing_plain) = existing_plain {
397                if !existing_plain.trim().is_empty() {
398                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
399                } else {
400                    patch_cfg_obj.remove("api_key");
401                }
402            } else {
403                patch_cfg_obj.remove("api_key");
404            }
405        }
406    }
407
408    if let Some(patch_instances) = patch_obj
409        .get_mut("provider_instances")
410        .and_then(|v| v.as_object_mut())
411    {
412        for (instance_id, instance_patch) in patch_instances.iter_mut() {
413            let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
414                continue;
415            };
416
417            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
418                continue;
419            };
420            if !is_masked_api_key(api_key) {
421                continue;
422            }
423
424            let existing_plain = current
425                .provider_instances
426                .get(instance_id)
427                .map(|instance| instance.api_key.clone());
428
429            if let Some(existing_plain) = existing_plain {
430                if !existing_plain.trim().is_empty() {
431                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
432                } else {
433                    patch_cfg_obj.remove("api_key");
434                }
435            } else {
436                patch_cfg_obj.remove("api_key");
437            }
438        }
439    }
440}
441
442/// Carry provider secrets that the merge round-trip dropped forward from the
443/// live config.
444///
445/// `config_manager::build_merged_config` serializes the live config before
446/// merging a patch, which drops every `#[serde(skip_serializing)]` plaintext
447/// `api_key`. Hydration afterwards only restores ciphertext-backed keys — but
448/// the live config can legitimately hold a plaintext-only secret whose
449/// `api_key_encrypted` is still `None` (ciphertext is only ever computed on
450/// `save_to_dir`'s save-time clone; a provider instance freshly created via
451/// the instance CRUD endpoints stays plaintext-only in memory). The merged
452/// config then ends up with NEITHER field and the key is silently lost on the
453/// next persist: config.json loses `api_key_encrypted` while config.json.bak
454/// keeps it (#516).
455///
456/// Restores `api_key`/`api_key_encrypted` from `current` for every legacy
457/// provider and provider instance that the patch did not explicitly set or
458/// clear (per `intents`), whenever the merge left neither field behind.
459/// Generalizes the env-sourced rescue of
460/// [`Config::preserve_env_sourced_provider_keys`] (#373).
461pub fn preserve_unpatched_provider_secrets(
462    merged: &mut Config,
463    current: &Config,
464    intents: &ProviderApiKeyIntents,
465) {
466    macro_rules! carry_forward {
467        ($field:ident) => {
468            if !intents.providers.contains(stringify!($field)) {
469                if let (Some(new_cfg), Some(prev)) = (
470                    merged.providers.$field.as_mut(),
471                    current.providers.$field.as_ref(),
472                ) {
473                    if new_cfg.api_key.trim().is_empty()
474                        && new_cfg.api_key_encrypted.is_none()
475                        && (!prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some())
476                    {
477                        new_cfg.api_key = prev.api_key.clone();
478                        new_cfg.api_key_encrypted = prev.api_key_encrypted.clone();
479                    }
480                }
481            }
482        };
483    }
484    carry_forward!(openai);
485    carry_forward!(anthropic);
486    carry_forward!(gemini);
487    carry_forward!(bodhi);
488
489    for (id, instance) in merged.provider_instances.iter_mut() {
490        if intents.provider_instances.contains(id) {
491            continue;
492        }
493        if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
494            continue;
495        }
496        if let Some(prev) = current.provider_instances.get(id) {
497            if !prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some() {
498                instance.api_key = prev.api_key.clone();
499                instance.api_key_encrypted = prev.api_key_encrypted.clone();
500            }
501        }
502    }
503}
504
505/// Make an explicit `api_key: ""` clear actually clear.
506///
507/// The merge round-trip carries the live config's `api_key_encrypted` into the
508/// merged value; hydration would then refill the plaintext from it and the
509/// subsequent sync/save would re-encrypt — silently undoing the clear. For
510/// every provider/instance the patch explicitly touched whose merged plaintext
511/// is empty (a clear, not a set), drop the round-tripped ciphertext BEFORE
512/// hydration so nothing refills the key (#516).
513pub fn clear_provider_ciphertext_for_explicit_clears(
514    merged: &mut Config,
515    intents: &ProviderApiKeyIntents,
516) {
517    macro_rules! clear_ciphertext {
518        ($field:ident) => {
519            if intents.providers.contains(stringify!($field)) {
520                if let Some(cfg) = merged.providers.$field.as_mut() {
521                    if cfg.api_key.trim().is_empty() {
522                        cfg.api_key_encrypted = None;
523                    }
524                }
525            }
526        };
527    }
528    clear_ciphertext!(openai);
529    clear_ciphertext!(anthropic);
530    clear_ciphertext!(gemini);
531    clear_ciphertext!(bodhi);
532
533    for id in intents.provider_instances.iter() {
534        if let Some(instance) = merged.provider_instances.get_mut(id) {
535            if instance.api_key.trim().is_empty() {
536                instance.api_key_encrypted = None;
537            }
538        }
539    }
540}
541
542/// Extract notification-channel secret update intents (ntfy `token`, Bark
543/// `device_key`) from a config patch.
544///
545/// Mirrors [`provider_api_key_intents`]: masked placeholders are ignored
546/// (they signal "keep existing secret"); an explicit empty string OR an
547/// explicit JSON `null` (#505) is a genuine intent (a clear), same as a
548/// genuine new value (a set) — see [`is_secret_field_intent`]. Must be
549/// read from the patch AFTER [`preserve_masked_notification_secrets`] has
550/// resolved masked placeholders — same calling convention as the provider
551/// intents (#521).
552#[derive(Debug, Clone, Default, PartialEq, Eq)]
553pub struct NotificationSecretIntents {
554    pub ntfy_token: bool,
555    pub bark_device_key: bool,
556}
557
558pub fn notification_secret_intents(patch_obj: &Map<String, Value>) -> NotificationSecretIntents {
559    let mut intents = NotificationSecretIntents::default();
560
561    let Some(notifications) = patch_obj.get("notifications").and_then(|v| v.as_object()) else {
562        return intents;
563    };
564
565    if let Some(ntfy) = notifications.get("ntfy").and_then(|v| v.as_object()) {
566        intents.ntfy_token = is_secret_field_intent(ntfy, "token");
567    }
568
569    if let Some(bark) = notifications.get("bark").and_then(|v| v.as_object()) {
570        intents.bark_device_key = is_secret_field_intent(bark, "device_key");
571    }
572
573    intents
574}
575
576/// Make an explicit `token: ""` / `device_key: ""` clear actually clear when
577/// processing legacy in-memory ciphertext. New writes never serialize those
578/// ciphertext fields, but an old loaded config can still carry them until its
579/// migration completes.
580pub fn clear_notification_ciphertext_for_explicit_clears(
581    merged: &mut Config,
582    intents: &NotificationSecretIntents,
583) {
584    if intents.ntfy_token
585        && merged
586            .notifications
587            .ntfy
588            .token
589            .as_deref()
590            .unwrap_or("")
591            .trim()
592            .is_empty()
593    {
594        merged.notifications.ntfy.token_encrypted = None;
595    }
596
597    if intents.bark_device_key
598        && merged
599            .notifications
600            .bark
601            .device_key
602            .as_deref()
603            .unwrap_or("")
604            .trim()
605            .is_empty()
606    {
607        merged.notifications.bark.device_key_encrypted = None;
608    }
609}
610
611/// Restore store-hydrated notification plaintext after a compatibility JSON
612/// round-trip for fields the patch did not explicitly replace or clear.
613/// Credential references and configured metadata are serialized, but secret
614/// plaintext is deliberately not.
615pub fn preserve_unpatched_notification_secrets(
616    merged: &mut Config,
617    current: &Config,
618    intents: &NotificationSecretIntents,
619) {
620    if !intents.ntfy_token {
621        merged.notifications.ntfy.token = current.notifications.ntfy.token.clone();
622    }
623    if !intents.bark_device_key {
624        merged.notifications.bark.device_key = current.notifications.bark.device_key.clone();
625    }
626}
627
628/// Extract bamboo-connect platform secret update intents (`token`,
629/// `app_secret`) from a config patch, keyed by the platform's position in the
630/// patch's `connect.platforms` array.
631///
632/// `connect.platforms` is a full-array replace (the settings UI always
633/// round-trips the whole list back in the same order it was fetched in — see
634/// [`preserve_masked_connect_secrets`]'s module docs), so
635/// `config_manager::build_merged_config`'s merged `connect.platforms[i]`
636/// always originates verbatim from the patch's `connect.platforms[i]` —
637/// position `i` in the patch and position `i` in the merged config always
638/// refer to the same logical entry, regardless of how that entry's position
639/// relates to `current.connect.platforms` (id/type resolution, #490/#492/#496,
640/// happens earlier and only rewrites the VALUE at a given patch index, never
641/// its position). Masked placeholders are ignored — mirrors
642/// [`provider_api_key_intents`]; an explicit `null` is treated the same as
643/// an explicit `""` clear (#505) — see [`is_secret_field_intent`]. Must be
644/// read from the patch AFTER [`preserve_masked_connect_secrets`] has
645/// resolved masked placeholders (#521).
646#[derive(Debug, Clone, Default, PartialEq, Eq)]
647pub struct ConnectSecretIntents {
648    pub token: std::collections::BTreeSet<usize>,
649    pub app_secret: std::collections::BTreeSet<usize>,
650}
651
652pub fn connect_secret_intents(patch_obj: &Map<String, Value>) -> ConnectSecretIntents {
653    let mut intents = ConnectSecretIntents::default();
654
655    let Some(platforms) = patch_obj
656        .get("connect")
657        .and_then(|c| c.get("platforms"))
658        .and_then(|v| v.as_array())
659    else {
660        return intents;
661    };
662
663    for (index, platform) in platforms.iter().enumerate() {
664        let Some(obj) = platform.as_object() else {
665            continue;
666        };
667
668        if is_secret_field_intent(obj, "token") {
669            intents.token.insert(index);
670        }
671
672        if is_secret_field_intent(obj, "app_secret") {
673            intents.app_secret.insert(index);
674        }
675    }
676
677    intents
678}
679
680/// Make an explicit `token: ""` / `app_secret: ""` clear actually clear.
681///
682/// Generalizes [`clear_provider_ciphertext_for_explicit_clears`] (#521) to
683/// bamboo-connect platform secrets — same rationale as
684/// [`clear_notification_ciphertext_for_explicit_clears`]:
685/// `token_encrypted`/`app_secret_encrypted` are not `skip_serializing`, so an
686/// explicit clear's round-tripped ciphertext must be dropped BEFORE
687/// hydration or it silently refills the plaintext hydration cleared.
688pub fn clear_connect_ciphertext_for_explicit_clears(
689    merged: &mut Config,
690    intents: &ConnectSecretIntents,
691) {
692    for &index in intents.token.iter() {
693        if let Some(platform) = merged.connect.platforms.get_mut(index) {
694            if platform.token.as_deref().unwrap_or("").trim().is_empty() {
695                platform.token_encrypted = None;
696            }
697        }
698    }
699
700    for &index in intents.app_secret.iter() {
701        if let Some(platform) = merged.connect.platforms.get_mut(index) {
702            if platform
703                .app_secret
704                .as_deref()
705                .unwrap_or("")
706                .trim()
707                .is_empty()
708            {
709                platform.app_secret_encrypted = None;
710            }
711        }
712    }
713}
714
715/// Replace masked notification-channel secret placeholders (ntfy `token`, Bark
716/// `device_key`) in a patch with the current config's plaintext values.
717///
718/// Mirrors [`preserve_masked_provider_api_keys`]: the UI sends the masked
719/// placeholder to mean "do not change this secret"; this resolves that back to
720/// the live plaintext so the merge doesn't wipe it. A masked value with no
721/// existing plaintext (nothing configured yet) is dropped from the patch
722/// entirely, same as an unset key.
723pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
724    let Some(notifications) = patch_obj
725        .get_mut("notifications")
726        .and_then(|v| v.as_object_mut())
727    else {
728        return;
729    };
730
731    if let Some(ntfy) = notifications
732        .get_mut("ntfy")
733        .and_then(|v| v.as_object_mut())
734    {
735        preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
736    }
737
738    if let Some(bark) = notifications
739        .get_mut("bark")
740        .and_then(|v| v.as_object_mut())
741    {
742        preserve_masked_secret_field(
743            bark,
744            "device_key",
745            current.notifications.bark.device_key.as_deref(),
746        );
747    }
748}
749
750/// Replace masked bamboo-connect platform `token` placeholders in a patch with
751/// the current config's plaintext values.
752///
753/// Mirrors [`preserve_masked_notification_secrets`]. `connect.platforms` is a
754/// list (not a single object like ntfy/bark), so each patch entry is resolved
755/// against a `current.connect.platforms` entry via three strategies, tried in
756/// order:
757///
758/// 1. **Id match (#496)** — if the patch entry carries an `id` and some entry
759///    in `current` has the same [`crate::ConnectPlatformConfig::id`] AND a
760///    consistent `platform_type`, that entry is authoritative. A stable
761///    server-assigned id unambiguously identifies the same logical platform
762///    regardless of position — this is the only strategy that correctly
763///    disambiguates two entries that share the same `platform_type` and have
764///    been reordered relative to each other (the scenario #490/#492's
765///    type+positional fallback cannot fully resolve; see the regression test
766///    `preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered`
767///    below). The id match is guarded by the same type-consistency check as
768///    strategy 2: a patch entry whose `type` DISAGREES with the id-matched
769///    entry's `platform_type` (e.g. a stale/reused id after a client-side
770///    bug, or a flow that repurposes an entry's identity for a different
771///    platform) must not inherit the differently-typed entry's secret — a
772///    Telegram bot token pasted into a Feishu adapter is never right. Such
773///    a mismatch falls through to strategies 2/3 (which will also refuse a
774///    cross-type resolution, dropping the mask — same as "nothing configured
775///    yet"). A patch entry with an `id` but no `type` at all can't be
776///    checked and resolves on id alone, mirroring strategy 2's handling of
777///    a missing `type`. Legacy entries without an id (or a patch from a
778///    client that doesn't echo it back) simply fall through to strategy 2.
779/// 2. **Positional + type guard** — patch index `i` is resolved against
780///    `current.connect.platforms[i]`, guarded by `type` equality at that
781///    index. This mirrors how the settings UI round-trips the list (it
782///    always sends the full array back in the same order it was fetched in
783///    — the same convention `env_vars`' full-array replace relies on).
784/// 3. **Type-based fallback (#490)** — when the positional entry's type
785///    disagrees with the patch entry's `type` (or the patch index is beyond
786///    `current.connect.platforms`), fall back to
787///    `current.connect.platforms.iter().find(|p| p.platform_type == patch_type)`.
788///    This is safe because `multi_bot_guard` (#462) means only the FIRST
789///    entry of a given type is ever started, so resolving to any same-typed
790///    entry is strictly better than silently wiping the secret. Only when no
791///    entry of that type exists anywhere in `current` does the mask get
792///    dropped, same as "nothing configured yet".
793pub fn preserve_masked_connect_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
794    let Some(platforms) = patch_obj
795        .get_mut("connect")
796        .and_then(|c| c.get_mut("platforms"))
797        .and_then(|v| v.as_array_mut())
798    else {
799        return;
800    };
801
802    for (index, platform) in platforms.iter_mut().enumerate() {
803        let Some(obj) = platform.as_object_mut() else {
804            continue;
805        };
806        let patch_type = obj.get("type").and_then(|v| v.as_str());
807        let patch_id = obj.get("id").and_then(|v| v.as_str());
808
809        // Strategy 1 (#496): an id match is position-independent, but it is
810        // still guarded by the SAME type-consistency check strategies 2/3
811        // enforce — an id pointing at a differently-typed entry (stale or
812        // repurposed id) must not leak that entry's secret into an adapter
813        // of another type. A patch entry with no "type" can't be checked and
814        // resolves on id alone (mirrors strategy 2's missing-"type" case).
815        let by_id = patch_id.and_then(|patch_id| {
816            current.connect.platforms.iter().find(|p| {
817                p.id.as_deref() == Some(patch_id)
818                    && match patch_type {
819                        Some(patch_type) => patch_type == p.platform_type,
820                        None => true,
821                    }
822            })
823        });
824
825        // Strategies 2/3 (#490/#492): positional + type guard, falling back
826        // to a type-only lookup — unchanged, and only consulted when there
827        // was no id, the id didn't match anything in `current` (e.g. a
828        // stale id echoed after the entry was removed), or the id-matched
829        // entry failed the type-consistency guard above.
830        let existing = current.connect.platforms.get(index);
831        // A patch entry that names a "type" disagreeing with `current`'s
832        // entry at the same index (or an index beyond `current`'s array)
833        // means the array was reordered/shrunk since the client fetched it
834        // — the position no longer identifies the same logical platform, so
835        // don't resolve the mask against it positionally. A patch entry with
836        // no "type" at all (shouldn't happen with a well-behaved client,
837        // which always echoes the whole object back) can't be checked and
838        // falls back to the pre-existing positional-only behavior (no
839        // type-based fallback either, since there's no type to search by).
840        let guarded = by_id.or_else(|| {
841            existing
842                .filter(|p| match patch_type {
843                    Some(patch_type) => patch_type == p.platform_type,
844                    None => true,
845                })
846                .or_else(|| {
847                    patch_type.and_then(|patch_type| {
848                        current
849                            .connect
850                            .platforms
851                            .iter()
852                            .find(|p| p.platform_type == patch_type)
853                    })
854                })
855        });
856        preserve_masked_secret_field(obj, "token", guarded.and_then(|p| p.token.as_deref()));
857        preserve_masked_secret_field(
858            obj,
859            "app_secret",
860            guarded.and_then(|p| p.app_secret.as_deref()),
861        );
862    }
863}
864
865/// Resolve a single masked secret field in place: replace a masked placeholder
866/// with `existing_plain`, or drop the field if nothing is configured yet.
867/// A non-masked value (a genuine new secret, or an explicit empty-string
868/// clear) is left untouched.
869fn preserve_masked_secret_field(
870    obj: &mut Map<String, Value>,
871    field: &str,
872    existing_plain: Option<&str>,
873) {
874    let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
875        return;
876    };
877    if !is_masked_api_key(value) {
878        return;
879    }
880
881    match existing_plain {
882        Some(plain) if !plain.trim().is_empty() => {
883            obj.insert(field.to_string(), Value::String(plain.to_string()));
884        }
885        _ => {
886            obj.remove(field);
887        }
888    }
889}
890
891/// Deep merge `src` into `dst`, recursively combining objects and replacing leaf
892/// values — [RFC 7386 JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7386)
893/// semantics, generalized to arbitrary depth (#505).
894///
895/// ## Semantics (opt-in per value)
896///
897/// | Patch value at key `k`                        | Effect on `dst`                                    |
898/// |------------------------------------------------|-----------------------------------------------------|
899/// | key `k` absent from the patch object            | `dst[k]` unchanged (back-compat, unaffected by this)|
900/// | `k: null`                                       | `dst[k]` **removed** — see below for what that means|
901/// | `k: <object>`, `dst[k]` also an object          | recursively merged (this table applied one level down)|
902/// | `k: <object>`, `dst[k]` absent/non-object       | `dst[k]` set verbatim to the patch object            |
903/// | `k: <scalar \| array>`                          | `dst[k]` replaced verbatim (arrays are leaf values — RFC 7386 never merges into an array; a `null` *inside* an array is a literal element, not a delete marker)|
904///
905/// Removing `dst[k]` (the `null` row) has an effect that depends on what `k`
906/// deserializes into on the Rust side, because "removed" means the merged
907/// JSON object no longer carries that key at all — the subsequent
908/// `serde_json::from_value::<Config>` falls back to that field's own
909/// `#[serde(default)]`:
910/// - `Option<T>` field → default is `None` → **the value is cleared/unset**.
911///   This is the fix issue #505 asks for (e.g. `subagents.claude_code_binary:
912///   null` un-sets a previously-written override).
913/// - `Vec<T>` / `HashMap<K, V>` field reached directly (i.e. `null` sits at
914///   the position of the *whole* collection, not one of its entries) →
915///   default is empty → **the whole collection resets to empty/default**.
916///   Per the design note in #505: a `null` *inside* an array never reaches
917///   this path (arrays are leaf-replaced, previous row), so "does a null
918///   inside an array delete that element" does not apply — only "does a
919///   null in place of the whole array delete the array" does, and the
920///   answer is yes.
921/// - Plain non-`Option` scalar/struct field with a `Default` impl → resets
922///   to that type's default (e.g. `notifications: null` resets the entire
923///   notifications subtree to defaults, `http_proxy: null` resets it to
924///   `""`).
925/// - A key one level inside a map keyed by dynamic strings (e.g.
926///   `provider_instances`, `mcpServers`) → since JSON can't distinguish a
927///   struct's named fields from a `HashMap`'s dynamic keys, the same rule
928///   removes just that one entry — **this is how a client deletes a single
929///   provider instance or MCP server** (`provider_instances: { "<id>": null
930///   }`).
931///
932/// A patch that wants to clear ONE field of a nested config (e.g. just
933/// `providers.openai.api_key`) should send `null` at that leaf, not at an
934/// enclosing object — `providers: { openai: null }` wipes the *entire*
935/// openai provider config (model, base_url, api_key, everything), not just
936/// the key. Both are valid, opt-in RFC 7386 semantics; callers choose their
937/// blast radius by choosing which level they null out.
938///
939/// ## Secret-field composition (must NOT be bypassed)
940///
941/// This function is intentionally unaware of which fields are secrets. The
942/// server layer (`config_manager::build_merged_config`) MUST extract secret
943/// clear/set *intents* from the RAW patch object (via
944/// [`provider_api_key_intents`], [`notification_secret_intents`],
945/// [`connect_secret_intents`]) **before** calling this function — those
946/// intent extractors now treat `null` on `api_key` / `token` / `device_key`
947/// / `app_secret` identically to an explicit `""`: both are a "clear"
948/// intent, distinct from an absent field ("leave alone") and from a masked
949/// placeholder ("keep existing"). This preserves the precedence chain
950/// established by #516/#517/#521/#522:
951/// 1. masked placeholder → resolved back to the current plaintext (a `null`
952///    is never masked — `is_masked_api_key` only matches strings — so this
953///    step is a no-op for a `null` clear, which is correct: nothing should
954///    resurrect a value the caller explicitly asked to delete).
955/// 2. explicit clear intent (`""` OR `null`) → the merge (this function)
956///    removes/empties the field, then `clear_*_ciphertext_for_explicit_clears`
957///    drops the round-tripped ciphertext so hydration can't refill it.
958/// 3. no intent at all (key absent from the patch) → `preserve_unpatched_*`
959///    carries the live secret forward across the serde round-trip.
960///
961/// Without step 2 recognizing `null` as a clear intent, a `null`-delete of a
962/// secret field would silently get UNDONE by step 3 (which only skips
963/// providers/instances the intents mark as explicitly touched) — the merge
964/// would drop the key, but `preserve_unpatched_provider_secrets` would then
965/// think the field was untouched and copy the old ciphertext straight back.
966pub fn deep_merge_json(dst: &mut Value, src: Value) {
967    match (dst, src) {
968        (Value::Object(dst_map), Value::Object(src_map)) => {
969            for (key, value) in src_map {
970                if value.is_null() {
971                    // RFC 7386: `null` deletes the member from the target
972                    // object. Absent from `dst` afterwards → the eventual
973                    // `serde_json::from_value` falls back to that field's
974                    // own `#[serde(default)]` (None for Option<T>, empty for
975                    // Vec/HashMap, the type default otherwise). A key that
976                    // was never in `dst` to begin with is simply a no-op,
977                    // matching RFC 7386 (deleting a non-existent member is
978                    // not an error).
979                    dst_map.remove(&key);
980                    continue;
981                }
982                match dst_map.get_mut(&key) {
983                    Some(existing) if existing.is_object() && value.is_object() => {
984                        deep_merge_json(existing, value);
985                    }
986                    _ => {
987                        dst_map.insert(key, value);
988                    }
989                }
990            }
991        }
992        (dst_slot, src_value) => {
993            *dst_slot = src_value;
994        }
995    }
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use serde_json::json;
1002
1003    #[test]
1004    fn domains_for_root_patch_detects_proxy_and_provider() {
1005        let patch = json!({
1006            "provider": "openai",
1007            "http_proxy": "http://proxy:8080",
1008            "setup": { "completed": false },
1009            "mcpServers": {}
1010        });
1011
1012        let domains = domains_for_root_patch(patch.as_object().unwrap());
1013        assert!(domains.provider);
1014        assert!(domains.proxy);
1015        assert!(domains.setup);
1016        assert!(domains.mcp);
1017    }
1018
1019    #[test]
1020    fn domains_for_root_patch_detects_provider_instances() {
1021        let patch = json!({
1022            "provider_instances": {
1023                "openai-work": { "provider_type": "openai" }
1024            },
1025            "default_provider_instance": "openai-work",
1026            "defaults": {
1027                "chat": { "provider": "openai-work", "model": "gpt-4o" }
1028            },
1029            "features": {
1030                "provider_model_ref": true
1031            }
1032        });
1033
1034        let domains = domains_for_root_patch(patch.as_object().unwrap());
1035        assert!(domains.provider);
1036    }
1037
1038    #[test]
1039    fn domains_for_root_patch_detects_lifecycle_hooks() {
1040        let patch = json!({"lifecycle_hooks": {"enabled": true}});
1041
1042        let domains = domains_for_root_patch(patch.as_object().unwrap());
1043
1044        assert!(domains.lifecycle_hooks);
1045        assert!(
1046            !domains.hooks,
1047            "request hooks and lifecycle hooks are distinct"
1048        );
1049    }
1050
1051    #[test]
1052    fn provider_api_key_intents_ignores_masked_placeholders() {
1053        let patch = json!({
1054            "providers": {
1055                "openai": { "api_key": "****...****" },
1056                "gemini": { "api_key": "sk-real" }
1057            },
1058            "provider_instances": {
1059                "work-openai": { "api_key": "****...****" },
1060                "personal-openai": { "api_key": "sk-live" }
1061            }
1062        });
1063        let intents = provider_api_key_intents(patch.as_object().unwrap());
1064        assert!(intents.providers.contains("gemini"));
1065        assert!(!intents.providers.contains("openai"));
1066        assert!(intents.provider_instances.contains("personal-openai"));
1067        assert!(!intents.provider_instances.contains("work-openai"));
1068    }
1069
1070    /// Build a provider instance via serde (the struct has no `Default`), so
1071    /// the tests stay robust to new fields.
1072    fn instance(api_key: &str, encrypted: Option<&str>) -> crate::ProviderInstanceConfig {
1073        serde_json::from_value(json!({
1074            "provider_type": "openai",
1075            "api_key": api_key,
1076            "api_key_encrypted": encrypted,
1077        }))
1078        .expect("valid instance")
1079    }
1080
1081    #[test]
1082    fn preserve_unpatched_provider_secrets_restores_roundtrip_dropped_keys() {
1083        // #516: the live config can hold a plaintext-only secret (ciphertext is
1084        // computed only on save_to_dir's save-time clone). The merge round-trip
1085        // drops the `skip_serializing` plaintext, leaving neither field.
1086        let mut current = Config::default();
1087        current
1088            .provider_instances
1089            .insert("uuid-1".to_string(), instance("sk-instance-live", None));
1090        current.providers.openai = Some(crate::OpenAIConfig {
1091            api_key: "sk-legacy-live".to_string(),
1092            ..Default::default()
1093        });
1094
1095        // Simulate the post-round-trip merge result: both fields lost.
1096        let mut merged = Config::default();
1097        merged
1098            .provider_instances
1099            .insert("uuid-1".to_string(), instance("", None));
1100        merged.providers.openai = Some(crate::OpenAIConfig::default());
1101
1102        preserve_unpatched_provider_secrets(
1103            &mut merged,
1104            &current,
1105            &ProviderApiKeyIntents::default(),
1106        );
1107
1108        assert_eq!(
1109            merged.provider_instances["uuid-1"].api_key,
1110            "sk-instance-live"
1111        );
1112        assert_eq!(
1113            merged.providers.openai.as_ref().unwrap().api_key,
1114            "sk-legacy-live"
1115        );
1116    }
1117
1118    #[test]
1119    fn preserve_unpatched_provider_secrets_carries_ciphertext_only_keys() {
1120        // A key whose plaintext failed to hydrate (#268) must still carry its
1121        // ciphertext forward instead of being dropped.
1122        let mut current = Config::default();
1123        current
1124            .provider_instances
1125            .insert("uuid-1".to_string(), instance("", Some("preexisting-ct")));
1126
1127        let mut merged = Config::default();
1128        merged
1129            .provider_instances
1130            .insert("uuid-1".to_string(), instance("", None));
1131
1132        preserve_unpatched_provider_secrets(
1133            &mut merged,
1134            &current,
1135            &ProviderApiKeyIntents::default(),
1136        );
1137
1138        assert_eq!(
1139            merged.provider_instances["uuid-1"]
1140                .api_key_encrypted
1141                .as_deref(),
1142            Some("preexisting-ct")
1143        );
1144    }
1145
1146    #[test]
1147    fn preserve_unpatched_provider_secrets_respects_explicit_intents() {
1148        // A provider/instance the patch explicitly set or cleared must not have
1149        // the old key resurrected.
1150        let mut current = Config::default();
1151        current
1152            .provider_instances
1153            .insert("uuid-1".to_string(), instance("sk-old", None));
1154
1155        let mut merged = Config::default();
1156        merged
1157            .provider_instances
1158            .insert("uuid-1".to_string(), instance("", None));
1159
1160        let mut intents = ProviderApiKeyIntents::default();
1161        intents.provider_instances.insert("uuid-1".to_string());
1162
1163        preserve_unpatched_provider_secrets(&mut merged, &current, &intents);
1164
1165        let cleared = &merged.provider_instances["uuid-1"];
1166        assert!(cleared.api_key.is_empty(), "explicit clear must win");
1167        assert!(cleared.api_key_encrypted.is_none());
1168    }
1169
1170    #[test]
1171    fn clear_provider_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1172        // Merged state right after deserialization of a clear patch: empty
1173        // plaintext from the patch, ciphertext carried over by the round-trip
1174        // of the live config. Hydration must find nothing to refill.
1175        let mut merged = Config::default();
1176        merged
1177            .provider_instances
1178            .insert("uuid-1".to_string(), instance("", Some("roundtripped-ct")));
1179        merged.providers.openai = Some(crate::OpenAIConfig {
1180            api_key_encrypted: Some("legacy-ct".to_string()),
1181            ..Default::default()
1182        });
1183
1184        let mut intents = ProviderApiKeyIntents::default();
1185        intents.provider_instances.insert("uuid-1".to_string());
1186        intents.providers.insert("openai".to_string());
1187
1188        clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1189
1190        assert!(merged.provider_instances["uuid-1"]
1191            .api_key_encrypted
1192            .is_none());
1193        assert!(merged
1194            .providers
1195            .openai
1196            .as_ref()
1197            .unwrap()
1198            .api_key_encrypted
1199            .is_none());
1200    }
1201
1202    #[test]
1203    fn clear_provider_ciphertext_leaves_set_intents_and_unpatched_alone() {
1204        // A SET intent (non-empty merged plaintext) keeps its ciphertext (the
1205        // later sync overwrites it), and an instance without any intent is
1206        // untouched.
1207        let mut merged = Config::default();
1208        merged
1209            .provider_instances
1210            .insert("uuid-1".to_string(), instance("sk-new", Some("stale-ct")));
1211        merged
1212            .provider_instances
1213            .insert("uuid-2".to_string(), instance("", Some("kept-ct")));
1214
1215        let mut intents = ProviderApiKeyIntents::default();
1216        intents.provider_instances.insert("uuid-1".to_string());
1217
1218        clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1219
1220        assert!(merged.provider_instances["uuid-1"]
1221            .api_key_encrypted
1222            .is_some());
1223        assert_eq!(
1224            merged.provider_instances["uuid-2"]
1225                .api_key_encrypted
1226                .as_deref(),
1227            Some("kept-ct")
1228        );
1229    }
1230
1231    // ── #521: notification-secret clear intent ─────────────────────────
1232
1233    #[test]
1234    fn notification_secret_intents_ignores_masked_placeholders() {
1235        let patch = json!({
1236            "notifications": {
1237                "ntfy": { "token": "****...****" },
1238                "bark": { "device_key": "tk-real-new-value" }
1239            }
1240        });
1241        let intents = notification_secret_intents(patch.as_object().unwrap());
1242        assert!(!intents.ntfy_token, "masked placeholder is not an intent");
1243        assert!(intents.bark_device_key, "a real value is an intent");
1244    }
1245
1246    #[test]
1247    fn notification_secret_intents_detects_explicit_clear() {
1248        let patch = json!({
1249            "notifications": {
1250                "ntfy": { "token": "" },
1251                "bark": { "device_key": "" }
1252            }
1253        });
1254        let intents = notification_secret_intents(patch.as_object().unwrap());
1255        assert!(intents.ntfy_token, "empty string is a clear intent");
1256        assert!(intents.bark_device_key, "empty string is a clear intent");
1257    }
1258
1259    #[test]
1260    fn notification_secret_intents_empty_when_untouched() {
1261        let patch = json!({ "notifications": { "ntfy": { "enabled": true } } });
1262        let intents = notification_secret_intents(patch.as_object().unwrap());
1263        assert!(!intents.ntfy_token);
1264        assert!(!intents.bark_device_key);
1265    }
1266
1267    #[test]
1268    fn clear_notification_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1269        // Merged state right after deserialization of a clear patch: empty
1270        // plaintext from the patch, ciphertext carried over by the round-trip
1271        // of the live config. Hydration must find nothing to refill (#521).
1272        let mut merged = Config::default();
1273        merged.notifications.ntfy.token = None;
1274        merged.notifications.ntfy.token_encrypted = Some("roundtripped-ntfy-ct".to_string());
1275        merged.notifications.bark.device_key = None;
1276        merged.notifications.bark.device_key_encrypted = Some("roundtripped-bark-ct".to_string());
1277
1278        let intents = NotificationSecretIntents {
1279            ntfy_token: true,
1280            bark_device_key: true,
1281        };
1282        clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1283
1284        assert!(merged.notifications.ntfy.token_encrypted.is_none());
1285        assert!(merged.notifications.bark.device_key_encrypted.is_none());
1286    }
1287
1288    #[test]
1289    fn clear_notification_ciphertext_leaves_set_intents_and_unpatched_alone() {
1290        let mut merged = Config::default();
1291        // A SET intent (non-empty merged plaintext) keeps its ciphertext — the
1292        // later refresh overwrites it with the new value's encryption.
1293        merged.notifications.ntfy.token = Some("brand-new-token".to_string());
1294        merged.notifications.ntfy.token_encrypted = Some("stale-ct".to_string());
1295        // No intent at all: untouched regardless of plaintext/ciphertext state.
1296        merged.notifications.bark.device_key = None;
1297        merged.notifications.bark.device_key_encrypted = Some("kept-ct".to_string());
1298
1299        let intents = NotificationSecretIntents {
1300            ntfy_token: true,
1301            bark_device_key: false,
1302        };
1303        clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1304
1305        assert_eq!(
1306            merged.notifications.ntfy.token_encrypted.as_deref(),
1307            Some("stale-ct")
1308        );
1309        assert_eq!(
1310            merged.notifications.bark.device_key_encrypted.as_deref(),
1311            Some("kept-ct")
1312        );
1313    }
1314
1315    // ── #521: connect-secret clear intent ───────────────────────────────
1316
1317    #[test]
1318    fn connect_secret_intents_ignores_masked_and_detects_clear_by_position() {
1319        let patch = json!({
1320            "connect": {
1321                "platforms": [
1322                    { "type": "telegram", "token": "****...****" },
1323                    { "type": "telegram", "token": "" },
1324                    { "type": "feishu", "app_secret": "real-new-secret" }
1325                ]
1326            }
1327        });
1328        let intents = connect_secret_intents(patch.as_object().unwrap());
1329        assert!(
1330            !intents.token.contains(&0),
1331            "masked placeholder is not an intent"
1332        );
1333        assert!(intents.token.contains(&1), "empty string is a clear intent");
1334        assert!(intents.app_secret.contains(&2), "a real value is an intent");
1335    }
1336
1337    #[test]
1338    fn connect_secret_intents_empty_when_no_platforms_patched() {
1339        let patch = json!({ "http_proxy": "http://example.invalid:8080" });
1340        let intents = connect_secret_intents(patch.as_object().unwrap());
1341        assert!(intents.token.is_empty());
1342        assert!(intents.app_secret.is_empty());
1343    }
1344
1345    #[test]
1346    fn clear_connect_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1347        // Merged state right after deserialization of a clear patch: empty
1348        // plaintext, ciphertext carried over by the round-trip of the live
1349        // config at the SAME position the patch's full-array-replace put it
1350        // (#521).
1351        let mut merged = Config::default();
1352        merged.connect.platforms = vec![
1353            connect_platform("telegram", ""), // will be overwritten below
1354            feishu_platform(""),
1355        ];
1356        merged.connect.platforms[0].token = None;
1357        merged.connect.platforms[0].token_encrypted = Some("roundtripped-token-ct".to_string());
1358        merged.connect.platforms[1].app_secret = None;
1359        merged.connect.platforms[1].app_secret_encrypted =
1360            Some("roundtripped-secret-ct".to_string());
1361
1362        let mut intents = ConnectSecretIntents::default();
1363        intents.token.insert(0);
1364        intents.app_secret.insert(1);
1365
1366        clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1367
1368        assert!(merged.connect.platforms[0].token_encrypted.is_none());
1369        assert!(merged.connect.platforms[1].app_secret_encrypted.is_none());
1370    }
1371
1372    #[test]
1373    fn clear_connect_ciphertext_leaves_set_intents_and_unpatched_alone() {
1374        let mut merged = Config::default();
1375        merged.connect.platforms = vec![connect_platform("telegram", "brand-new-token")];
1376        merged.connect.platforms[0].token_encrypted = Some("stale-ct".to_string());
1377        // A second platform with no clear intent (untouched by the patch)
1378        // must keep its ciphertext regardless of its plaintext state.
1379        merged
1380            .connect
1381            .platforms
1382            .push(connect_platform("feishu", ""));
1383        merged.connect.platforms[1].token = None;
1384        merged.connect.platforms[1].token_encrypted = Some("kept-ct".to_string());
1385
1386        let mut intents = ConnectSecretIntents::default();
1387        intents.token.insert(0);
1388
1389        clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1390
1391        assert_eq!(
1392            merged.connect.platforms[0].token_encrypted.as_deref(),
1393            Some("stale-ct")
1394        );
1395        assert_eq!(
1396            merged.connect.platforms[1].token_encrypted.as_deref(),
1397            Some("kept-ct")
1398        );
1399    }
1400
1401    #[test]
1402    fn is_masked_api_key_requires_placeholder_only_values() {
1403        // The redaction placeholder and all-asterisk/dot variants are masked.
1404        assert!(is_masked_api_key("****...****"));
1405        assert!(is_masked_api_key("********"));
1406        assert!(is_masked_api_key("  ****...****  "));
1407
1408        // Empty is a "clear" signal, not a mask.
1409        assert!(!is_masked_api_key(""));
1410        assert!(!is_masked_api_key("   "));
1411
1412        // A placeholder with a real key pasted after it must NOT be treated as
1413        // masked — that silently discards the user's new token (#430).
1414        assert!(!is_masked_api_key("****...****sk-newkey123"));
1415        assert!(!is_masked_api_key("sk-newkey123****...****"));
1416
1417        // Real keys containing dots or asterisks among other characters are keys.
1418        assert!(!is_masked_api_key("id.secret...suffix"));
1419        assert!(!is_masked_api_key("sk-live-abc"));
1420    }
1421
1422    #[test]
1423    fn sanitize_root_patch_strips_notification_encrypted_fields() {
1424        let mut patch = json!({
1425            "notifications": {
1426                "ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
1427                "bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
1428            }
1429        });
1430        let obj = patch.as_object_mut().unwrap();
1431        sanitize_root_patch(obj);
1432
1433        assert!(!obj["notifications"]["ntfy"]
1434            .as_object()
1435            .unwrap()
1436            .contains_key("token_encrypted"));
1437        assert!(!obj["notifications"]["bark"]
1438            .as_object()
1439            .unwrap()
1440            .contains_key("device_key_encrypted"));
1441        // Plaintext fields the client legitimately sent are untouched.
1442        assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
1443        assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
1444    }
1445
1446    #[test]
1447    fn sanitize_root_patch_strips_provider_instance_storage_metadata() {
1448        let mut patch = json!({
1449            "provider_instances": {
1450                "work": {
1451                    "provider_type": "openai",
1452                    "api_key": "sk-user-value",
1453                    "api_key_encrypted": "client-ciphertext",
1454                    "credential_ref": "attacker.chosen.ref"
1455                }
1456            }
1457        });
1458        let obj = patch.as_object_mut().unwrap();
1459        sanitize_root_patch(obj);
1460
1461        let instance = obj["provider_instances"]["work"].as_object().unwrap();
1462        assert_eq!(instance["api_key"], "sk-user-value");
1463        assert!(!instance.contains_key("api_key_encrypted"));
1464        assert!(!instance.contains_key("credential_ref"));
1465    }
1466
1467    #[test]
1468    fn sanitize_root_patch_strips_proxy_credential_metadata() {
1469        let mut patch = json!({
1470            "http_proxy": "http://proxy.example:8080",
1471            "proxy_auth": {"username": "attacker", "password": "secret"},
1472            "proxy_auth_encrypted": "client-ciphertext",
1473            "proxy_auth_credential_ref": "attacker.chosen.ref"
1474        });
1475        let obj = patch.as_object_mut().unwrap();
1476        sanitize_root_patch(obj);
1477        assert_eq!(obj["http_proxy"], "http://proxy.example:8080");
1478        assert!(!obj.contains_key("proxy_auth"));
1479        assert!(!obj.contains_key("proxy_auth_encrypted"));
1480        assert!(!obj.contains_key("proxy_auth_credential_ref"));
1481    }
1482
1483    #[test]
1484    fn sanitize_root_patch_strips_cluster_storage_metadata_and_ciphertext() {
1485        let mut patch = json!({
1486            "cluster_fabric": {
1487                "credential_refs": {
1488                    "node-a": {
1489                        "password_credential_ref": "attacker.chosen.ref",
1490                        "password_configured": true
1491                    }
1492                },
1493                "nodes": [{
1494                    "id": "node-a",
1495                    "placement": {
1496                        "type": "ssh",
1497                        "auth": {
1498                            "method": "private_key",
1499                            "private_key": "new-user-value",
1500                            "private_key_encrypted": "client-ciphertext",
1501                            "passphrase": "new-passphrase",
1502                            "passphrase_encrypted": "client-ciphertext"
1503                        }
1504                    }
1505                }]
1506            }
1507        });
1508        let obj = patch.as_object_mut().unwrap();
1509        sanitize_root_patch(obj);
1510
1511        let cluster = obj["cluster_fabric"].as_object().unwrap();
1512        assert!(!cluster.contains_key("credential_refs"));
1513        let auth = cluster["nodes"][0]["placement"]["auth"]
1514            .as_object()
1515            .unwrap();
1516        assert_eq!(auth["private_key"], "new-user-value");
1517        assert_eq!(auth["passphrase"], "new-passphrase");
1518        assert!(!auth.contains_key("private_key_encrypted"));
1519        assert!(!auth.contains_key("passphrase_encrypted"));
1520    }
1521
1522    #[test]
1523    fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
1524        let mut current = Config::default();
1525        current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
1526        current.notifications.bark.device_key = Some("existing-bark-key".to_string());
1527
1528        let mut patch: Map<String, Value> = serde_json::from_str(
1529            r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
1530        )
1531        .unwrap();
1532
1533        preserve_masked_notification_secrets(&mut patch, &current);
1534
1535        assert_eq!(
1536            patch["notifications"]["ntfy"]["token"],
1537            "existing-ntfy-token"
1538        );
1539        assert_eq!(
1540            patch["notifications"]["bark"]["device_key"],
1541            "existing-bark-key"
1542        );
1543    }
1544
1545    #[test]
1546    fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
1547        let current = Config::default();
1548        let mut patch: Map<String, Value> =
1549            serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
1550
1551        preserve_masked_notification_secrets(&mut patch, &current);
1552
1553        assert!(!patch["notifications"]["ntfy"]
1554            .as_object()
1555            .unwrap()
1556            .contains_key("token"));
1557    }
1558
1559    #[test]
1560    fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
1561        let current = Config::default();
1562        let mut patch: Map<String, Value> =
1563            serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
1564                .unwrap();
1565
1566        preserve_masked_notification_secrets(&mut patch, &current);
1567
1568        assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
1569    }
1570
1571    fn connect_platform(platform_type: &str, token: &str) -> crate::ConnectPlatformConfig {
1572        crate::ConnectPlatformConfig {
1573            id: None,
1574            platform_type: platform_type.to_string(),
1575            token: Some(token.to_string()),
1576            token_encrypted: None,
1577            token_credential_ref: None,
1578            token_configured: false,
1579            app_id: None,
1580            app_secret: None,
1581            app_secret_encrypted: None,
1582            app_secret_credential_ref: None,
1583            app_secret_configured: false,
1584            domain: None,
1585            allow_from: Vec::new(),
1586            admin_from: Vec::new(),
1587        }
1588    }
1589
1590    fn connect_platform_with_id(
1591        id: &str,
1592        platform_type: &str,
1593        token: &str,
1594    ) -> crate::ConnectPlatformConfig {
1595        crate::ConnectPlatformConfig {
1596            id: Some(id.to_string()),
1597            ..connect_platform(platform_type, token)
1598        }
1599    }
1600
1601    #[test]
1602    fn sanitize_root_patch_strips_connect_platform_encrypted_field() {
1603        let mut patch = json!({
1604            "connect": {
1605                "platforms": [
1606                    { "type": "telegram", "token": "new-token", "token_encrypted": "client-supplied-cipher" }
1607                ]
1608            }
1609        });
1610        let obj = patch.as_object_mut().unwrap();
1611        sanitize_root_patch(obj);
1612
1613        let platform = &obj["connect"]["platforms"][0];
1614        assert!(!platform
1615            .as_object()
1616            .unwrap()
1617            .contains_key("token_encrypted"));
1618        assert_eq!(platform["token"], "new-token");
1619    }
1620
1621    #[test]
1622    fn sanitize_root_patch_strips_connect_platform_app_secret_encrypted_field() {
1623        let mut patch = json!({
1624            "connect": {
1625                "platforms": [
1626                    {
1627                        "type": "feishu",
1628                        "app_id": "cli_x",
1629                        "app_secret": "new-secret",
1630                        "app_secret_encrypted": "client-supplied-cipher",
1631                        "domain": "feishu"
1632                    }
1633                ]
1634            }
1635        });
1636        let obj = patch.as_object_mut().unwrap();
1637        sanitize_root_patch(obj);
1638
1639        let platform = &obj["connect"]["platforms"][0];
1640        assert!(!platform
1641            .as_object()
1642            .unwrap()
1643            .contains_key("app_secret_encrypted"));
1644        assert_eq!(platform["app_secret"], "new-secret");
1645        assert_eq!(platform["app_id"], "cli_x");
1646    }
1647
1648    #[test]
1649    fn preserve_masked_connect_secrets_keeps_existing_plaintext_by_position() {
1650        let mut current = Config::default();
1651        current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1652
1653        let mut patch: Map<String, Value> = serde_json::from_str(
1654            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1655        )
1656        .unwrap();
1657
1658        preserve_masked_connect_secrets(&mut patch, &current);
1659
1660        assert_eq!(
1661            patch["connect"]["platforms"][0]["token"],
1662            "existing-bot-token"
1663        );
1664    }
1665
1666    #[test]
1667    fn preserve_masked_connect_secrets_drops_mask_when_nothing_configured() {
1668        let current = Config::default();
1669        let mut patch: Map<String, Value> = serde_json::from_str(
1670            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1671        )
1672        .unwrap();
1673
1674        preserve_masked_connect_secrets(&mut patch, &current);
1675
1676        assert!(!patch["connect"]["platforms"][0]
1677            .as_object()
1678            .unwrap()
1679            .contains_key("token"));
1680    }
1681
1682    #[test]
1683    fn preserve_masked_connect_secrets_leaves_real_values_untouched() {
1684        let current = Config::default();
1685        let mut patch: Map<String, Value> = serde_json::from_str(
1686            r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-real-new-value"}]}}"#,
1687        )
1688        .unwrap();
1689
1690        preserve_masked_connect_secrets(&mut patch, &current);
1691
1692        assert_eq!(
1693            patch["connect"]["platforms"][0]["token"],
1694            "tg-real-new-value"
1695        );
1696    }
1697
1698    /// Issue #454 follow-up: if the array was reordered/edited since the
1699    /// client fetched it — detectable here because the patch entry's "type"
1700    /// disagrees with `current`'s entry at the same index — a masked token
1701    /// must NOT be resolved against the wrong (now co-located) platform's
1702    /// plaintext; it must drop, exactly like "nothing configured yet".
1703    #[test]
1704    fn preserve_masked_connect_secrets_drops_mask_when_type_at_index_disagrees() {
1705        let mut current = Config::default();
1706        current.connect.platforms = vec![connect_platform("telegram", "telegram-secret-token")];
1707
1708        // The patch's entry at index 0 claims to be a DIFFERENT platform type
1709        // than what's actually at `current.connect.platforms[0]` — e.g. a
1710        // reorder/insert raced the fetch that pre-filled this mask.
1711        let mut patch: Map<String, Value> = serde_json::from_str(
1712            r#"{"connect":{"platforms":[{"type":"feishu","token":"****...****"}]}}"#,
1713        )
1714        .unwrap();
1715
1716        preserve_masked_connect_secrets(&mut patch, &current);
1717
1718        assert!(
1719            !patch["connect"]["platforms"][0]
1720                .as_object()
1721                .unwrap()
1722                .contains_key("token"),
1723            "masked token must not be resolved against a different platform's secret"
1724        );
1725    }
1726
1727    /// #490: when the type at an index mismatches, the guard now falls back
1728    /// to a type-based lookup across all of `current.connect.platforms`
1729    /// rather than dropping outright. Here index 1's patch entry claims
1730    /// "telegram" (matching index 0's type, not index 1's) — the mismatch at
1731    /// index 1 is detected, but since a "telegram" entry DOES exist
1732    /// elsewhere in `current` (at index 0), the mask resolves to it. This is
1733    /// safe because `multi_bot_guard` (#462) only ever starts the first
1734    /// entry of a given type, so resolving to any same-typed entry is
1735    /// strictly better than wiping the secret.
1736    #[test]
1737    fn preserve_masked_connect_secrets_type_mismatch_falls_back_to_type_lookup() {
1738        let mut current = Config::default();
1739        current.connect.platforms = vec![
1740            connect_platform("telegram", "bot-a-token"),
1741            connect_platform("feishu", "feishu-token"),
1742        ];
1743
1744        let mut patch: Map<String, Value> = serde_json::from_str(
1745            r#"{"connect":{"platforms":[
1746                {"type":"telegram","token":"tg-real-value"},
1747                {"type":"telegram","token":"****...****"}
1748            ]}}"#,
1749        )
1750        .unwrap();
1751
1752        preserve_masked_connect_secrets(&mut patch, &current);
1753
1754        assert_eq!(patch["connect"]["platforms"][0]["token"], "tg-real-value");
1755        assert_eq!(
1756            patch["connect"]["platforms"][1]["token"], "bot-a-token",
1757            "index 1's mismatched type must fall back to the same-typed entry found elsewhere in current"
1758        );
1759    }
1760
1761    /// A patch entry with a matching "type" at its index is unaffected by
1762    /// the new guard — this is the common case and must keep working exactly
1763    /// as before.
1764    #[test]
1765    fn preserve_masked_connect_secrets_keeps_plaintext_when_type_at_index_matches() {
1766        let mut current = Config::default();
1767        current.connect.platforms = vec![
1768            connect_platform("telegram", "bot-a-token"),
1769            connect_platform("telegram", "bot-b-token"),
1770        ];
1771
1772        let mut patch: Map<String, Value> = serde_json::from_str(
1773            r#"{"connect":{"platforms":[
1774                {"type":"telegram","token":"****...****"},
1775                {"type":"telegram","token":"****...****"}
1776            ]}}"#,
1777        )
1778        .unwrap();
1779
1780        preserve_masked_connect_secrets(&mut patch, &current);
1781
1782        assert_eq!(patch["connect"]["platforms"][0]["token"], "bot-a-token");
1783        assert_eq!(patch["connect"]["platforms"][1]["token"], "bot-b-token");
1784    }
1785
1786    /// A patch entry missing "type" entirely (shouldn't happen with a
1787    /// well-behaved client) falls back to the pre-existing positional-only
1788    /// behavior rather than being treated as an automatic mismatch.
1789    #[test]
1790    fn preserve_masked_connect_secrets_falls_back_to_positional_when_type_is_absent() {
1791        let mut current = Config::default();
1792        current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1793
1794        let mut patch: Map<String, Value> =
1795            serde_json::from_str(r#"{"connect":{"platforms":[{"token":"****...****"}]}}"#).unwrap();
1796
1797        preserve_masked_connect_secrets(&mut patch, &current);
1798
1799        assert_eq!(
1800            patch["connect"]["platforms"][0]["token"],
1801            "existing-bot-token"
1802        );
1803    }
1804
1805    /// #496 — the exact scenario the issue is about: two entries share the
1806    /// same `platform_type` ("telegram") AND have been reordered, so the
1807    /// type+positional guard alone can't tell they were swapped (the type at
1808    /// each index still matches "telegram" either way). Without an id, this
1809    /// class of reorder can attach the wrong sibling's secret. With a
1810    /// server-assigned id present on both, resolution is unambiguous
1811    /// regardless of position.
1812    #[test]
1813    fn preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered() {
1814        let mut current = Config::default();
1815        current.connect.platforms = vec![
1816            connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1817            connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1818        ];
1819
1820        // The client echoes the array back SWAPPED: id-b now at index 0,
1821        // id-a now at index 1. Positional+type resolution alone would
1822        // (wrongly) resolve index 0 against current[0] (id-a's token) since
1823        // both are "telegram" — the id lets us catch the swap.
1824        let mut patch: Map<String, Value> = serde_json::from_str(
1825            r#"{"connect":{"platforms":[
1826                {"id":"id-b","type":"telegram","token":"****...****"},
1827                {"id":"id-a","type":"telegram","token":"****...****"}
1828            ]}}"#,
1829        )
1830        .unwrap();
1831
1832        preserve_masked_connect_secrets(&mut patch, &current);
1833
1834        assert_eq!(
1835            patch["connect"]["platforms"][0]["token"], "bot-b-token",
1836            "index 0 (id-b) must resolve to id-b's own token, not the positionally-co-located id-a"
1837        );
1838        assert_eq!(
1839            patch["connect"]["platforms"][1]["token"], "bot-a-token",
1840            "index 1 (id-a) must resolve to id-a's own token"
1841        );
1842    }
1843
1844    /// A patch entry whose `id` doesn't match anything in `current` (e.g. a
1845    /// brand-new entry a client invented an id for, or a stale id echoed
1846    /// after the entry was removed) falls through to the existing
1847    /// positional/type-based resolution rather than treating the mismatch
1848    /// as fatal.
1849    #[test]
1850    fn preserve_masked_connect_secrets_falls_back_when_id_not_found_in_current() {
1851        let mut current = Config::default();
1852        current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1853
1854        let mut patch: Map<String, Value> = serde_json::from_str(
1855            r#"{"connect":{"platforms":[{"id":"unknown-id","type":"telegram","token":"****...****"}]}}"#,
1856        )
1857        .unwrap();
1858
1859        preserve_masked_connect_secrets(&mut patch, &current);
1860
1861        assert_eq!(
1862            patch["connect"]["platforms"][0]["token"], "existing-bot-token",
1863            "an id absent from `current` falls back to the positional+type guard"
1864        );
1865    }
1866
1867    /// PR #510 review: the id branch is guarded by the same type-consistency
1868    /// check as the positional branch. A patch entry whose `id` matches a
1869    /// `current` entry of a DIFFERENT `platform_type` (stale/reused id, or a
1870    /// flow repurposing an entry's identity for another platform) must NOT
1871    /// inherit that entry's secret — with no same-typed entry anywhere in
1872    /// `current`, the mask drops, same as "nothing configured yet".
1873    #[test]
1874    fn preserve_masked_connect_secrets_rejects_id_match_when_type_differs() {
1875        let mut current = Config::default();
1876        current.connect.platforms = vec![connect_platform_with_id(
1877            "id-a",
1878            "telegram",
1879            "telegram-secret-token",
1880        )];
1881
1882        // The patch reuses stored entry id-a's id but claims to be a Feishu
1883        // adapter — resolving the mask against the Telegram entry would paste
1884        // a Telegram bot token into a Feishu config.
1885        let mut patch: Map<String, Value> = serde_json::from_str(
1886            r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1887        )
1888        .unwrap();
1889
1890        preserve_masked_connect_secrets(&mut patch, &current);
1891
1892        assert!(
1893            !patch["connect"]["platforms"][0]
1894                .as_object()
1895                .unwrap()
1896                .contains_key("token"),
1897            "an id match with a disagreeing type must not resolve the mask from that entry"
1898        );
1899    }
1900
1901    /// The type-mismatched-id fall-through still reaches strategies 2/3: if a
1902    /// same-typed entry DOES exist elsewhere in `current`, the type-based
1903    /// fallback (#490) resolves the mask against it — the bad id only
1904    /// disqualifies the id branch, it doesn't poison the whole resolution.
1905    #[test]
1906    fn preserve_masked_connect_secrets_type_mismatched_id_still_falls_through_to_type_lookup() {
1907        let mut current = Config::default();
1908        current.connect.platforms = vec![
1909            connect_platform_with_id("id-a", "telegram", "telegram-secret-token"),
1910            connect_platform_with_id("id-b", "feishu", "feishu-secret-token"),
1911        ];
1912
1913        // id-a belongs to the telegram entry, but the patch entry says
1914        // "feishu" (at index 0, where current has telegram): the id branch
1915        // and the positional branch both refuse, and the type-based fallback
1916        // resolves against the feishu entry at index 1.
1917        let mut patch: Map<String, Value> = serde_json::from_str(
1918            r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1919        )
1920        .unwrap();
1921
1922        preserve_masked_connect_secrets(&mut patch, &current);
1923
1924        assert_eq!(
1925            patch["connect"]["platforms"][0]["token"], "feishu-secret-token",
1926            "the fall-through must resolve via the type lookup, never via the mismatched id"
1927        );
1928    }
1929
1930    /// A patch entry with an `id` but no `type` at all can't be
1931    /// type-checked and resolves on id alone — mirrors the positional
1932    /// branch's handling of a missing `type` (documented in the id-branch
1933    /// guard). Placed at an out-of-range index to prove it's the id doing
1934    /// the work, not position.
1935    #[test]
1936    fn preserve_masked_connect_secrets_id_without_type_resolves_on_id_alone() {
1937        let mut current = Config::default();
1938        current.connect.platforms = vec![
1939            connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1940            connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1941        ];
1942
1943        let mut patch: Map<String, Value> = serde_json::from_str(
1944            r#"{"connect":{"platforms":[{"id":"id-b","token":"****...****"}]}}"#,
1945        )
1946        .unwrap();
1947
1948        preserve_masked_connect_secrets(&mut patch, &current);
1949
1950        assert_eq!(
1951            patch["connect"]["platforms"][0]["token"], "bot-b-token",
1952            "with no type to check, the id match resolves to its own entry, not the positional one"
1953        );
1954    }
1955
1956    /// Back-compat: a patch entry with no "id" field at all (legacy client,
1957    /// or an entry created before ids existed) is unaffected by the new
1958    /// id-matching branch and goes straight to the existing
1959    /// positional/type-based resolution — exercised here with `current`
1960    /// entries that DO have ids (post-migration) to prove the id branch is
1961    /// skipped cleanly rather than erroring on the missing field.
1962    #[test]
1963    fn preserve_masked_connect_secrets_ignores_id_branch_when_patch_omits_id() {
1964        let mut current = Config::default();
1965        current.connect.platforms = vec![connect_platform_with_id(
1966            "id-a",
1967            "telegram",
1968            "existing-bot-token",
1969        )];
1970
1971        let mut patch: Map<String, Value> = serde_json::from_str(
1972            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1973        )
1974        .unwrap();
1975
1976        preserve_masked_connect_secrets(&mut patch, &current);
1977
1978        assert_eq!(
1979            patch["connect"]["platforms"][0]["token"],
1980            "existing-bot-token"
1981        );
1982    }
1983
1984    fn feishu_platform(app_secret: &str) -> crate::ConnectPlatformConfig {
1985        crate::ConnectPlatformConfig {
1986            id: None,
1987            platform_type: "feishu".to_string(),
1988            token: None,
1989            token_encrypted: None,
1990            token_credential_ref: None,
1991            token_configured: false,
1992            app_id: Some("cli_x".to_string()),
1993            app_secret: Some(app_secret.to_string()),
1994            app_secret_encrypted: None,
1995            app_secret_credential_ref: None,
1996            app_secret_configured: false,
1997            domain: Some("lark".to_string()),
1998            allow_from: Vec::new(),
1999            admin_from: Vec::new(),
2000        }
2001    }
2002
2003    #[test]
2004    fn preserve_masked_connect_secrets_keeps_existing_app_secret_by_position() {
2005        let mut current = Config::default();
2006        current.connect.platforms = vec![feishu_platform("existing-app-secret")];
2007
2008        let mut patch: Map<String, Value> = serde_json::from_str(
2009            r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}]}}"#,
2010        )
2011        .unwrap();
2012
2013        preserve_masked_connect_secrets(&mut patch, &current);
2014
2015        assert_eq!(
2016            patch["connect"]["platforms"][0]["app_secret"],
2017            "existing-app-secret"
2018        );
2019        // app_id/domain are untouched by the secret-preserve pass.
2020        assert_eq!(patch["connect"]["platforms"][0]["app_id"], "cli_x");
2021        assert_eq!(patch["connect"]["platforms"][0]["domain"], "lark");
2022    }
2023
2024    #[test]
2025    fn preserve_masked_connect_secrets_drops_app_secret_mask_when_nothing_configured() {
2026        let current = Config::default();
2027        let mut patch: Map<String, Value> = serde_json::from_str(
2028            r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"****...****"}]}}"#,
2029        )
2030        .unwrap();
2031
2032        preserve_masked_connect_secrets(&mut patch, &current);
2033
2034        assert!(!patch["connect"]["platforms"][0]
2035            .as_object()
2036            .unwrap()
2037            .contains_key("app_secret"));
2038    }
2039
2040    #[test]
2041    fn preserve_masked_connect_secrets_leaves_real_app_secret_untouched() {
2042        let current = Config::default();
2043        let mut patch: Map<String, Value> = serde_json::from_str(
2044            r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"feishu-real-new-value"}]}}"#,
2045        )
2046        .unwrap();
2047
2048        preserve_masked_connect_secrets(&mut patch, &current);
2049
2050        assert_eq!(
2051            patch["connect"]["platforms"][0]["app_secret"],
2052            "feishu-real-new-value"
2053        );
2054    }
2055
2056    /// The #454 type-at-index guard applies to app_secret exactly as it does
2057    /// to token: a reordered array must not resolve a masked app_secret
2058    /// against whatever platform now sits at that index.
2059    #[test]
2060    fn preserve_masked_connect_secrets_drops_app_secret_mask_when_type_at_index_disagrees() {
2061        let mut current = Config::default();
2062        current.connect.platforms = vec![feishu_platform("feishu-secret")];
2063
2064        let mut patch: Map<String, Value> = serde_json::from_str(
2065            r#"{"connect":{"platforms":[{"type":"telegram","app_secret":"****...****"}]}}"#,
2066        )
2067        .unwrap();
2068
2069        preserve_masked_connect_secrets(&mut patch, &current);
2070
2071        assert!(
2072            !patch["connect"]["platforms"][0]
2073                .as_object()
2074                .unwrap()
2075                .contains_key("app_secret"),
2076            "masked app_secret must not be resolved against a different platform's secret"
2077        );
2078    }
2079
2080    /// #490 regression: the exact scenario from the issue. Stored platforms
2081    /// are `[telegram, feishu]`; the client disables telegram and echoes
2082    /// back only the (still-masked) feishu entry, which now shifts to index
2083    /// 0. The positional guard sees telegram≠feishu at index 0 and used to
2084    /// drop the mask outright, silently wiping the feishu app_secret on
2085    /// save. It must now fall back to a type-based lookup and resolve to the
2086    /// stored feishu plaintext.
2087    #[test]
2088    fn preserve_masked_connect_secrets_resolves_by_type_when_preceding_entry_removed() {
2089        let mut current = Config::default();
2090        current.connect.platforms = vec![
2091            connect_platform("telegram", "telegram-token"),
2092            feishu_platform("existing-app-secret"),
2093        ];
2094
2095        // telegram was removed client-side; only the feishu entry (still
2096        // masked) is echoed back, now at index 0.
2097        let mut patch: Map<String, Value> = serde_json::from_str(
2098            r#"{"connect":{"platforms":[
2099                {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2100            ]}}"#,
2101        )
2102        .unwrap();
2103
2104        preserve_masked_connect_secrets(&mut patch, &current);
2105
2106        assert_eq!(
2107            patch["connect"]["platforms"][0]["app_secret"], "existing-app-secret",
2108            "masked app_secret must resolve via type fallback after a preceding entry was removed"
2109        );
2110    }
2111
2112    /// #490: an index beyond `current.connect.platforms` (the patch array
2113    /// grew, e.g. a new platform was added client-side before saving) must
2114    /// also fall back to the type-based lookup rather than dropping the mask
2115    /// when a same-typed entry exists elsewhere in `current`.
2116    #[test]
2117    fn preserve_masked_connect_secrets_resolves_by_type_when_index_out_of_range() {
2118        let mut current = Config::default();
2119        current.connect.platforms = vec![
2120            connect_platform("telegram", "telegram-token"),
2121            feishu_platform("existing-app-secret"),
2122        ];
2123
2124        // Patch has 3 entries; index 2 is beyond `current`'s 2-entry array
2125        // (a new telegram entry was inserted at index 1 client-side), but
2126        // its masked feishu app_secret should still resolve by type.
2127        let mut patch: Map<String, Value> = serde_json::from_str(
2128            r#"{"connect":{"platforms":[
2129                {"type":"telegram","token":"tg-real-value"},
2130                {"type":"telegram","token":"new-bot-token"},
2131                {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2132            ]}}"#,
2133        )
2134        .unwrap();
2135
2136        preserve_masked_connect_secrets(&mut patch, &current);
2137
2138        assert_eq!(
2139            patch["connect"]["platforms"][2]["app_secret"], "existing-app-secret",
2140            "masked app_secret at an out-of-range index must resolve via type fallback"
2141        );
2142    }
2143
2144    /// #490: the type-based fallback only searches by type — if the patched
2145    /// type doesn't exist anywhere in `current`, the mask still drops, same
2146    /// as before. Unchanged behavior, exercised here with a multi-entry
2147    /// `current` (not just the empty-config case already covered above).
2148    #[test]
2149    fn preserve_masked_connect_secrets_drops_mask_when_type_absent_from_current_entirely() {
2150        let mut current = Config::default();
2151        current.connect.platforms = vec![connect_platform("telegram", "telegram-token")];
2152
2153        // No feishu entry exists anywhere in `current` — the type-based
2154        // fallback has nothing to resolve against.
2155        let mut patch: Map<String, Value> = serde_json::from_str(
2156            r#"{"connect":{"platforms":[
2157                {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2158            ]}}"#,
2159        )
2160        .unwrap();
2161
2162        preserve_masked_connect_secrets(&mut patch, &current);
2163
2164        assert!(
2165            !patch["connect"]["platforms"][0]
2166                .as_object()
2167                .unwrap()
2168                .contains_key("app_secret"),
2169            "mask must still drop when no entry of that type exists anywhere in current"
2170        );
2171    }
2172
2173    // ── #505: RFC 7386-style null-delete ────────────────────────────────
2174    //
2175    // Full round-trip helper: serialize `current`, deep-merge `patch` into
2176    // it, and deserialize the result back into a `Config` — exactly what
2177    // `config_manager::build_merged_config` does around `deep_merge_json`,
2178    // minus the secret-specific composition (covered separately below).
2179    fn merge_and_deserialize(current: &Config, patch: Value) -> Config {
2180        let mut merged = current.to_compatibility_value().unwrap();
2181        deep_merge_json(&mut merged, patch);
2182        serde_json::from_value(merged).expect("merged config should deserialize")
2183    }
2184
2185    #[test]
2186    fn null_deletes_option_scalar_leaf() {
2187        // The exact case #505 was filed for: an Option<String> field that
2188        // was written once can never be un-set through plain overwrite
2189        // semantics. A null leaf deletes just that field.
2190        let mut current = Config::default();
2191        current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2192        current.subagents.claude_code_model = Some("claude-sonnet".to_string());
2193
2194        let merged = merge_and_deserialize(
2195            &current,
2196            json!({ "subagents": { "claude_code_binary": null } }),
2197        );
2198
2199        assert_eq!(merged.subagents.claude_code_binary, None);
2200        // Surgical: a sibling Option field in the same object that the
2201        // patch didn't touch survives untouched.
2202        assert_eq!(
2203            merged.subagents.claude_code_model,
2204            Some("claude-sonnet".to_string())
2205        );
2206    }
2207
2208    #[test]
2209    fn absent_key_leaves_value_unchanged_back_compat() {
2210        // Back-compat is a hard requirement: omitting a key from the patch
2211        // must never be interpreted as a delete, regardless of the new null
2212        // semantics living right next to it.
2213        let mut current = Config::default();
2214        current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2215        current.subagents.max_concurrent = Some(4);
2216
2217        // The patch touches a sibling field only; claude_code_binary is
2218        // simply not mentioned.
2219        let merged =
2220            merge_and_deserialize(&current, json!({ "subagents": { "max_concurrent": 16 } }));
2221
2222        assert_eq!(
2223            merged.subagents.claude_code_binary,
2224            Some("/usr/local/bin/claude".to_string()),
2225            "an omitted key must leave the existing value untouched"
2226        );
2227        assert_eq!(merged.subagents.max_concurrent, Some(16));
2228    }
2229
2230    #[test]
2231    fn null_on_whole_object_subtree_resets_it_to_defaults() {
2232        // Before #505, `null` on a non-Option struct field (like
2233        // `notifications`) crashed the ENTIRE patch with a deserialize
2234        // error ("invalid type: null, expected struct..."). Deleting the
2235        // key now falls back to that field's own `#[serde(default)]`,
2236        // resetting the whole subtree rather than erroring.
2237        let mut current = Config::default();
2238        current.notifications.ntfy.token = Some("existing-token".to_string());
2239        current.notifications.ntfy.enabled = true;
2240
2241        let merged = merge_and_deserialize(&current, json!({ "notifications": null }));
2242
2243        assert_eq!(merged.notifications, crate::NotificationsConfig::default());
2244    }
2245
2246    #[test]
2247    fn null_deletes_one_hashmap_entry_keeps_siblings() {
2248        // The other half of the issue's motivating gap: a client could never
2249        // delete a single map entry (provider instance, MCP server, ...) —
2250        // sending null for one entry used to crash deserialization of the
2251        // WHOLE map. Now it deletes just that entry.
2252        //
2253        // Note: `api_key` is `#[serde(skip_serializing)]` (plaintext never
2254        // round-trips through `serde_json::to_value` — that's the unrelated
2255        // #516 quirk `preserve_unpatched_provider_secrets` exists to paper
2256        // over), so this test asserts survival via `label`, a plain
2257        // serialized field, to isolate the hashmap-entry-delete mechanic
2258        // being tested here from that separate secret-round-trip concern
2259        // (covered by its own tests below).
2260        fn labeled_instance(label: &str) -> crate::ProviderInstanceConfig {
2261            serde_json::from_value(json!({
2262                "provider_type": "openai",
2263                "label": label,
2264            }))
2265            .expect("valid instance")
2266        }
2267
2268        let mut current = Config::default();
2269        current
2270            .provider_instances
2271            .insert("uuid-1".to_string(), labeled_instance("Work"));
2272        current
2273            .provider_instances
2274            .insert("uuid-2".to_string(), labeled_instance("Personal"));
2275
2276        let merged = merge_and_deserialize(
2277            &current,
2278            json!({ "provider_instances": { "uuid-1": null } }),
2279        );
2280
2281        assert!(!merged.provider_instances.contains_key("uuid-1"));
2282        assert_eq!(
2283            merged
2284                .provider_instances
2285                .get("uuid-2")
2286                .and_then(|i| i.label.as_deref()),
2287            Some("Personal"),
2288            "sibling map entries the patch didn't touch must survive"
2289        );
2290    }
2291
2292    #[test]
2293    fn null_on_whole_array_field_resets_it_to_empty() {
2294        // Design decision (#505): arrays are leaf-replaced, never merged
2295        // element-by-element. A `null` standing in for the WHOLE array
2296        // deletes it (falls back to Vec's default, i.e. empty) — but this
2297        // is a distinct case from "null as one element inside a
2298        // surviving array" (covered by the next test), which is NOT a
2299        // delete marker.
2300        let mut current = Config::default();
2301        current.connect.platforms = vec![connect_platform("telegram", "tok")];
2302
2303        let merged = merge_and_deserialize(&current, json!({ "connect": { "platforms": null } }));
2304
2305        assert!(merged.connect.platforms.is_empty());
2306    }
2307
2308    #[test]
2309    fn null_inside_a_surviving_array_is_a_literal_element_not_a_delete_marker() {
2310        // RFC 7386 never recurses into arrays — they're leaf values, always
2311        // replaced wholesale. So a patch that sends `[..., null, ...]` does
2312        // NOT delete an element out of the existing array; the whole array
2313        // is replaced verbatim, null and all. Demonstrated here against a
2314        // `Vec<String>` field: since `String` (not `Option<String>`) can't
2315        // hold a null, the round-trip surfaces a normal type error instead
2316        // of silently dropping an element — proving the null was carried
2317        // through literally, not specially interpreted.
2318        let mut current = Config::default();
2319        current.subagents.worker_args = Some(vec!["subagent-worker".to_string()]);
2320
2321        let mut merged = serde_json::to_value(&current).unwrap();
2322        deep_merge_json(
2323            &mut merged,
2324            json!({ "subagents": { "worker_args": ["a", null, "b"] } }),
2325        );
2326
2327        // The array in the merged JSON is the patch's array verbatim,
2328        // literal null included — not silently filtered.
2329        assert_eq!(
2330            merged["subagents"]["worker_args"],
2331            json!(["a", null, "b"]),
2332            "arrays are leaf-replaced verbatim; a null element is not deleted"
2333        );
2334        let result: Result<Config, _> = serde_json::from_value(merged);
2335        assert!(
2336            result.is_err(),
2337            "a literal null inside a Vec<String> is a type error, not an element delete"
2338        );
2339    }
2340
2341    #[test]
2342    fn sentinel_string_values_still_work_after_null_delete_support() {
2343        // Lotus #80: `subagents.executor = "bamboo_runtime"` is a plain
2344        // string sentinel value (not related to null-delete at all) that
2345        // must keep working exactly as a normal overwrite.
2346        let current = Config::default();
2347
2348        let merged = merge_and_deserialize(
2349            &current,
2350            json!({ "subagents": { "executor": "bamboo_runtime" } }),
2351        );
2352
2353        assert_eq!(
2354            merged.subagents.executor,
2355            Some("bamboo_runtime".to_string())
2356        );
2357    }
2358
2359    #[test]
2360    fn subagents_max_concurrent_null_clears_it_like_todays_lotus_ui() {
2361        // Existing-null-usage survey finding: Lotus's SystemSettingsConfigTab
2362        // already sends `{"subagents":{"max_concurrent": null}}` today (the
2363        // AntD InputNumber reports `null` when the field is cleared — see
2364        // `SystemSettingsConfigTab.tsx`), relying on serde_json's built-in
2365        // `Option<T>` + `null` -> `None` handling as an ACCIDENT of the old
2366        // "just overwrite the leaf with whatever JSON value arrived"
2367        // catch-all. The new delete-the-key implementation must reproduce
2368        // that exact end result (None) so this already-shipped Lotus flow
2369        // keeps working unchanged.
2370        let mut current = Config::default();
2371        current.subagents.max_concurrent = Some(4);
2372
2373        let merged =
2374            merge_and_deserialize(&current, json!({ "subagents": { "max_concurrent": null } }));
2375
2376        assert_eq!(merged.subagents.max_concurrent, None);
2377    }
2378
2379    // ── #505: secret-field composition (intents recognize null as clear) ──
2380
2381    #[test]
2382    fn provider_api_key_intents_treats_null_as_clear_intent() {
2383        let patch = json!({
2384            "providers": { "openai": { "api_key": null } },
2385            "provider_instances": { "uuid-1": { "api_key": null } }
2386        });
2387        let intents = provider_api_key_intents(patch.as_object().unwrap());
2388        assert!(
2389            intents.providers.contains("openai"),
2390            "a null api_key must register as an explicit clear intent, \
2391             same as an empty string — otherwise preserve_unpatched_provider_secrets \
2392             would resurrect the deleted key"
2393        );
2394        assert!(intents.provider_instances.contains("uuid-1"));
2395    }
2396
2397    #[test]
2398    fn provider_api_key_intents_treats_whole_instance_null_as_delete_intent() {
2399        let patch = json!({ "provider_instances": { "uuid-1": null } });
2400        let intents = provider_api_key_intents(patch.as_object().unwrap());
2401        assert!(intents.provider_instances.contains("uuid-1"));
2402    }
2403
2404    #[test]
2405    fn provider_api_key_intents_null_and_empty_string_are_equivalent_intents() {
2406        let null_patch = json!({ "providers": { "openai": { "api_key": null } } });
2407        let empty_patch = json!({ "providers": { "openai": { "api_key": "" } } });
2408        assert_eq!(
2409            provider_api_key_intents(null_patch.as_object().unwrap()),
2410            provider_api_key_intents(empty_patch.as_object().unwrap()),
2411            "null and \"\" must be recognized as the same clear intent"
2412        );
2413    }
2414
2415    #[test]
2416    fn null_api_key_does_not_get_resurrected_by_preserve_unpatched_secrets() {
2417        // End-to-end composition proof for the precedence rule documented on
2418        // `deep_merge_json`: null-delete (step 2) must not be undone by the
2419        // unpatched-secret carry-forward (step 3). This mirrors
2420        // `preserve_unpatched_provider_secrets_respects_explicit_intents`
2421        // above, but with a `null` clear instead of `""`.
2422        let mut current = Config::default();
2423        current
2424            .provider_instances
2425            .insert("uuid-1".to_string(), instance("sk-old", None));
2426
2427        let patch = json!({ "provider_instances": { "uuid-1": { "api_key": null } } });
2428        let intents = provider_api_key_intents(patch.as_object().unwrap());
2429        assert!(intents.provider_instances.contains("uuid-1"));
2430
2431        let mut merged = merge_and_deserialize(&current, patch);
2432        preserve_unpatched_provider_secrets(&mut merged, &current, &intents);
2433
2434        assert_eq!(
2435            merged.provider_instances["uuid-1"].api_key, "",
2436            "a null-delete of api_key must stick, not get resurrected from `current`"
2437        );
2438    }
2439
2440    #[test]
2441    fn notification_secret_intents_treats_null_as_clear_intent() {
2442        let patch = json!({
2443            "notifications": { "ntfy": { "token": null }, "bark": { "device_key": null } }
2444        });
2445        let intents = notification_secret_intents(patch.as_object().unwrap());
2446        assert!(intents.ntfy_token);
2447        assert!(intents.bark_device_key);
2448    }
2449
2450    #[test]
2451    fn connect_secret_intents_treats_null_as_clear_intent() {
2452        let patch = json!({
2453            "connect": { "platforms": [ { "type": "telegram", "token": null } ] }
2454        });
2455        let intents = connect_secret_intents(patch.as_object().unwrap());
2456        assert!(intents.token.contains(&0));
2457    }
2458
2459    #[test]
2460    fn null_ntfy_token_clears_roundtripped_ciphertext_via_clear_intents() {
2461        // Full composition: neither plaintext nor legacy ciphertext survives
2462        // the JSON round-trip; the clear-intent pass remains idempotent for a
2463        // config loaded from older in-memory state.
2464        let mut current = Config::default();
2465        current.notifications.ntfy.token = Some("existing-token".to_string());
2466        current.notifications.ntfy.token_encrypted = Some("existing-ct".to_string());
2467
2468        let patch = json!({ "notifications": { "ntfy": { "token": null } } });
2469        let intents = notification_secret_intents(patch.as_object().unwrap());
2470        assert!(intents.ntfy_token);
2471
2472        let mut merged = merge_and_deserialize(&current, patch);
2473        assert!(
2474            merged.notifications.ntfy.token_encrypted.is_none(),
2475            "legacy notification ciphertext is no longer serialized through the merge"
2476        );
2477
2478        clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
2479
2480        assert_eq!(merged.notifications.ntfy.token, None);
2481        assert!(
2482            merged.notifications.ntfy.token_encrypted.is_none(),
2483            "the clear-intent pass must drop the stale ciphertext so hydration can't refill it"
2484        );
2485    }
2486
2487    #[test]
2488    fn whole_providers_null_wipes_everything_without_resurrecting_secrets() {
2489        // Coarse-grained blast radius: `{"providers": null}` deletes the
2490        // ENTIRE providers subtree (not just one provider's key). Confirms
2491        // this doesn't accidentally resurrect secrets: intents are empty
2492        // (the raw patch's "providers" is a scalar null, not an object, so
2493        // `provider_api_key_intents` finds no per-provider object to walk),
2494        // but `preserve_unpatched_provider_secrets`'s carry-forward only
2495        // fires when the MERGED side still has a `Some(..)` provider config
2496        // to carry into — which a whole-subtree wipe doesn't leave behind.
2497        let mut current = Config::default();
2498        current.providers.openai = Some(crate::OpenAIConfig {
2499            api_key: "sk-legacy-live".to_string(),
2500            ..Default::default()
2501        });
2502
2503        let patch = json!({ "providers": null });
2504        let intents = provider_api_key_intents(patch.as_object().unwrap());
2505        assert!(intents.providers.is_empty());
2506
2507        let mut merged = merge_and_deserialize(&current, patch);
2508        assert!(
2509            merged.providers.openai.is_none(),
2510            "the whole providers subtree must reset to default"
2511        );
2512
2513        preserve_unpatched_provider_secrets(&mut merged, &current, &intents);
2514
2515        assert!(
2516            merged.providers.openai.is_none(),
2517            "carry-forward must not resurrect a provider the patch wiped out entirely"
2518        );
2519    }
2520}