package config import ( "os" "path/filepath" "strings" "testing" ) func TestSaveConfigPreserveComments_ClaudeCloakUpdates(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yaml") initialYAML := `claude-api-key: - api-key: sk-ant-test cloak: mode: always strict-mode: true cache-user-id: true ` if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { t.Fatalf("os.WriteFile() error = %v", errWrite) } // Test case 1: Disable strict-mode, clear mode, remove cache-user-id. cfg := &Config{ ClaudeKey: []ClaudeKey{ { APIKey: "sk-ant-test", Cloak: &CloakConfig{ Mode: "", StrictMode: false, CacheUserID: nil, }, }, }, } if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil { t.Fatalf("SaveConfigPreserveComments() error = %v", errSave) } savedBytes, errRead := os.ReadFile(configPath) if errRead != nil { t.Fatalf("os.ReadFile() error = %v", errRead) } savedText := string(savedBytes) // In issue #5935, mode: always, strict-mode: true, and cache-user-id: true remained in config.yaml // even when strict-mode was set to false, mode was cleared, and cache-user-id was disabled. if strings.Contains(savedText, "mode: always") { t.Errorf("saved YAML still contains 'mode: always' after clearing mode; got:\n%s", savedText) } if strings.Contains(savedText, "strict-mode: true") { t.Errorf("saved YAML still contains 'strict-mode: true' after setting strict-mode to false; got:\n%s", savedText) } if strings.Contains(savedText, "cache-user-id: true") { t.Errorf("saved YAML still contains 'cache-user-id: true' after disabling cache-user-id; got:\n%s", savedText) } // Loading the saved config must reflect the updated false/empty values, while preserving explicit Cloak. loadedCfg, errLoad := LoadConfigOptional(configPath, false) if errLoad != nil { t.Fatalf("LoadConfigOptional() error = %v", errLoad) } if len(loadedCfg.ClaudeKey) == 0 { t.Fatalf("loaded ClaudeKey is empty") } cloak := loadedCfg.ClaudeKey[0].Cloak if cloak == nil { t.Fatalf("loaded Cloak is nil, want non-nil explicit cloak config") } if cloak.Mode != "" { t.Errorf("loaded Cloak.Mode = %q, want empty", cloak.Mode) } if cloak.StrictMode { t.Errorf("loaded Cloak.StrictMode = true, want false") } if cloak.CacheUserID != nil && *cloak.CacheUserID { t.Errorf("loaded Cloak.CacheUserID = true, want nil or false") } } func TestSaveConfigPreserveComments_ClaudeCloakExplicitFalse(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yaml") initialYAML := `claude-api-key: - api-key: sk-ant-test cloak: mode: always strict-mode: true cache-user-id: true ` if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { t.Fatalf("os.WriteFile() error = %v", errWrite) } cacheFalse := false cfg := &Config{ ClaudeKey: []ClaudeKey{ { APIKey: "sk-ant-test", Cloak: &CloakConfig{ Mode: "auto", StrictMode: false, CacheUserID: &cacheFalse, }, }, }, } if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil { t.Fatalf("SaveConfigPreserveComments() error = %v", errSave) } savedBytes, errRead := os.ReadFile(configPath) if errRead != nil { t.Fatalf("os.ReadFile() error = %v", errRead) } savedText := string(savedBytes) if !strings.Contains(savedText, "mode: auto") { t.Errorf("saved YAML missing 'mode: auto'; got:\n%s", savedText) } if strings.Contains(savedText, "strict-mode: true") { t.Errorf("saved YAML still contains 'strict-mode: true'; got:\n%s", savedText) } if !strings.Contains(savedText, "cache-user-id: false") { t.Errorf("saved YAML missing explicit 'cache-user-id: false'; got:\n%s", savedText) } loadedCfg, errLoad := LoadConfigOptional(configPath, false) if errLoad != nil { t.Fatalf("LoadConfigOptional() error = %v", errLoad) } cloak := loadedCfg.ClaudeKey[0].Cloak if cloak == nil { t.Fatalf("loaded Cloak is nil, want non-nil") } if cloak.Mode != "auto" { t.Errorf("loaded Cloak.Mode = %q, want %q", cloak.Mode, "auto") } if cloak.StrictMode { t.Errorf("loaded Cloak.StrictMode = true, want false") } if cloak.CacheUserID == nil || *cloak.CacheUserID != false { t.Errorf("loaded Cloak.CacheUserID = %v, want false", cloak.CacheUserID) } } func TestSaveConfigPreserveComments_ClaudeCloakExplicitFalseWhenFieldPreviouslyAbsent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yaml") // Initial YAML only has mode: always. cache-user-id is completely absent. initialYAML := `claude-api-key: - api-key: sk-ant-test cloak: mode: always ` if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { t.Fatalf("os.WriteFile() error = %v", errWrite) } cacheFalse := false cfg := &Config{ ClaudeKey: []ClaudeKey{ { APIKey: "sk-ant-test", Cloak: &CloakConfig{ Mode: "always", CacheUserID: &cacheFalse, }, }, }, } if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil { t.Fatalf("SaveConfigPreserveComments() error = %v", errSave) } savedBytes, errRead := os.ReadFile(configPath) if errRead != nil { t.Fatalf("os.ReadFile() error = %v", errRead) } savedText := string(savedBytes) // An explicit pointer false for cache-user-id must be added to YAML even if previously absent. if !strings.Contains(savedText, "cache-user-id: false") { t.Errorf("saved YAML missing newly-added 'cache-user-id: false'; got:\n%s", savedText) } loadedCfg, errLoad := LoadConfigOptional(configPath, false) if errLoad != nil { t.Fatalf("LoadConfigOptional() error = %v", errLoad) } cloak := loadedCfg.ClaudeKey[0].Cloak if cloak == nil { t.Fatalf("loaded Cloak is nil, want non-nil") } if cloak.CacheUserID == nil || *cloak.CacheUserID != false { t.Errorf("loaded Cloak.CacheUserID = %v, want explicit false", cloak.CacheUserID) } } func TestSaveConfigPreserveComments_ClaudeCloakRemovedWhenNil(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yaml") initialYAML := `claude-api-key: - api-key: sk-ant-test cloak: mode: always strict-mode: true cache-user-id: true ` if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { t.Fatalf("os.WriteFile() error = %v", errWrite) } // Cloak explicitly set to nil (removed) cfg := &Config{ ClaudeKey: []ClaudeKey{ { APIKey: "sk-ant-test", Cloak: nil, }, }, } if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil { t.Fatalf("SaveConfigPreserveComments() error = %v", errSave) } savedBytes, errRead := os.ReadFile(configPath) if errRead != nil { t.Fatalf("os.ReadFile() error = %v", errRead) } savedText := string(savedBytes) if strings.Contains(savedText, "cloak:") { t.Errorf("saved YAML still contains 'cloak:' after nil removal; got:\n%s", savedText) } loadedCfg, errLoad := LoadConfigOptional(configPath, false) if errLoad != nil { t.Fatalf("LoadConfigOptional() error = %v", errLoad) } if loadedCfg.ClaudeKey[0].Cloak != nil { t.Errorf("loaded Cloak = %+v, want nil", loadedCfg.ClaudeKey[0].Cloak) } } func TestSaveConfigPreserveComments_HeadersPrunedAndNonTargetMappingsPreserved(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yaml") initialYAML := `remote-management: allow-remote: true claude-api-key: - api-key: sk-ant-test headers: Custom-A: valA Custom-B: valB ` if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { t.Fatalf("os.WriteFile() error = %v", errWrite) } cfg := &Config{ RemoteManagement: RemoteManagement{ AllowRemote: true, }, ClaudeKey: []ClaudeKey{ { APIKey: "sk-ant-test", Headers: map[string]string{ "Custom-A": "valA", }, }, }, } if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil { t.Fatalf("SaveConfigPreserveComments() error = %v", errSave) } savedBytes, errRead := os.ReadFile(configPath) if errRead != nil { t.Fatalf("os.ReadFile() error = %v", errRead) } savedText := string(savedBytes) // Deleted header Custom-B must be pruned from claude-api-key.headers if strings.Contains(savedText, "Custom-B") { t.Errorf("saved YAML still contains deleted header 'Custom-B'; got:\n%s", savedText) } if !strings.Contains(savedText, "Custom-A: valA") { t.Errorf("saved YAML missing retained header 'Custom-A: valA'; got:\n%s", savedText) } // Non-target root mapping (remote-management) must be preserved if !strings.Contains(savedText, "allow-remote: true") { t.Errorf("saved YAML lost 'allow-remote: true'; got:\n%s", savedText) } } func TestSaveConfigPreserveComments_SensitiveWordsReplaceEmptyValueAndPruning(t *testing.T) { for _, v8Layout := range []bool{false, true} { name := "legacy" initialYAML := "port: 8317\nclaude-api-key:\n - api-key: sk-ant-test\n" if v8Layout { name = "v8" initialYAML = "config-version: 8\nserver:\n port: 8317\napi-keys:\n claude:\n - name: claude-1\n keys:\n - api-key: sk-ant-test\n" } t.Run(name, func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yaml") if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { t.Fatalf("os.WriteFile() error = %v", errWrite) } cfg, errLoad := LoadConfigOptional(configPath, false) if errLoad != nil { t.Fatalf("initial LoadConfigOptional() error = %v", errLoad) } // Step 1: Save empty-string deletion entries in all three scopes. cfg.Antigravity.SensitiveWordsReplace = map[string]string{ "You are Codex, an agent based on GPT-5.": "", "TempAntigrav": "Keep", } cfg.Devin.SensitiveWordsReplace = map[string]string{ "DropDevinPhrase": "", } cfg.ClaudeKey[0].Cloak = &CloakConfig{ SensitiveWordsReplace: map[string]string{ "DropClaudePhrase": "", "OldClaudeTarget": "OldRepl", }, } if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil { t.Fatalf("SaveConfigPreserveComments() step 1 error = %v", errSave) } reloaded, errReload := LoadConfigOptional(configPath, false) if errReload != nil { t.Fatalf("reload step 1 error = %v", errReload) } if val, ok := reloaded.Antigravity.SensitiveWordsReplace["You are Codex, an agent based on GPT-5."]; !ok || val != "" { t.Fatalf("Antigravity empty replacement lost: %#v", reloaded.Antigravity.SensitiveWordsReplace) } if val, ok := reloaded.Devin.SensitiveWordsReplace["DropDevinPhrase"]; !ok || val != "" { t.Fatalf("Devin empty replacement lost: %#v", reloaded.Devin.SensitiveWordsReplace) } if reloaded.ClaudeKey[0].Cloak == nil { t.Fatal("Claude Cloak is nil after step 1") } if val, ok := reloaded.ClaudeKey[0].Cloak.SensitiveWordsReplace["DropClaudePhrase"]; !ok || val != "" { t.Fatalf("Claude empty replacement lost: %#v", reloaded.ClaudeKey[0].Cloak.SensitiveWordsReplace) } // Step 2: Prune individual keys and clear Devin replacements completely. delete(reloaded.Antigravity.SensitiveWordsReplace, "TempAntigrav") reloaded.Devin.SensitiveWordsReplace = nil delete(reloaded.ClaudeKey[0].Cloak.SensitiveWordsReplace, "OldClaudeTarget") if errSave2 := SaveConfigPreserveComments(configPath, reloaded); errSave2 != nil { t.Fatalf("SaveConfigPreserveComments() step 2 error = %v", errSave2) } reloaded2, errReload2 := LoadConfigOptional(configPath, false) if errReload2 != nil { t.Fatalf("reload step 2 error = %v", errReload2) } if _, exists := reloaded2.Antigravity.SensitiveWordsReplace["TempAntigrav"]; exists { t.Fatalf("deleted Antigravity key persisted: %#v", reloaded2.Antigravity.SensitiveWordsReplace) } if len(reloaded2.Devin.SensitiveWordsReplace) != 0 { t.Fatalf("cleared Devin replacements persisted: %#v", reloaded2.Devin.SensitiveWordsReplace) } if _, exists := reloaded2.ClaudeKey[0].Cloak.SensitiveWordsReplace["OldClaudeTarget"]; exists { t.Fatalf("deleted Claude replacement key persisted: %#v", reloaded2.ClaudeKey[0].Cloak.SensitiveWordsReplace) } }) } }