| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/config" |
| 14 | ) |
| 15 | |
| 16 | func setupTestConfig() *config.Config { |
| 17 | cfg := config.Default() |
| 18 | cfg.Providers = []config.ProviderEntry{ |
| 19 | {Name: "desktop-provider", Kind: "openai", BaseURL: "https://desktop.example/v1", Model: "desktop-model", APIKeyEnv: "SHARED_API_KEY"}, |
| 20 | {Name: "cli-provider", Kind: "openai", BaseURL: "https://cli.example/v1", Model: "cli-model"}, |
| 21 | } |
| 22 | cfg.DefaultModel = "desktop-provider" |
| 23 | cfg.Agent.Temperature = 0.77 |
| 24 | cfg.Desktop.ProviderAccess = []string{"desktop-provider", "cli-provider"} |
| 25 | return cfg |
| 26 | } |
| 27 | |
| 28 | func TestProviderSetupSessionAddPreservesExistingProvidersAndSettings(t *testing.T) { |
| 29 | cfg := setupTestConfig() |
| 30 | s := newProviderSetupSession(cfg) |
| 31 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"} |
| 32 | if err := s.upsert([]config.ProviderEntry{added}); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if len(cfg.Providers) != 3 || cfg.Providers[0].Name != "desktop-provider" || cfg.Providers[1].Name != "cli-provider" { |
| 36 | t.Fatalf("existing providers were not preserved: %+v", cfg.Providers) |
| 37 | } |
| 38 | if cfg.DefaultModel != "desktop-provider" || cfg.Agent.Temperature != 0.77 { |
| 39 | t.Fatalf("unrelated settings changed: default=%q temperature=%v", cfg.DefaultModel, cfg.Agent.Temperature) |
| 40 | } |
| 41 | s.addProviderAccess([]config.ProviderEntry{added}) |
| 42 | if got := cfg.Desktop.ProviderAccess; !containsString(got, "desktop-provider") || !containsString(got, "cli-provider") || !containsString(got, "grok-relay") { |
| 43 | t.Fatalf("desktop provider access was not preserved and extended: %v", got) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | func TestProviderSetupSessionEditPreservesSiblingAndAdvancedFields(t *testing.T) { |
| 48 | cfg := setupTestConfig() |
| 49 | cfg.Providers[0].Headers = map[string]string{"X-Relay": "yes"} |
| 50 | s := newProviderSetupSession(cfg) |
| 51 | edited := cfg.Providers[0] |
| 52 | edited.Models = []string{"desktop-model", "desktop-model-2"} |
| 53 | edited.Model = "" |
| 54 | if err := s.upsert([]config.ProviderEntry{edited}); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if cfg.Providers[1].Name != "cli-provider" { |
| 58 | t.Fatalf("sibling provider changed: %+v", cfg.Providers[1]) |
| 59 | } |
| 60 | if cfg.Providers[0].Headers["X-Relay"] != "yes" { |
| 61 | t.Fatalf("advanced provider fields were lost: %+v", cfg.Providers[0]) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestProviderSetupSessionAddRejectsExistingProviderWithoutChangingIt(t *testing.T) { |
| 66 | cfg := setupTestConfig() |
| 67 | baseURL := "https://desktop.example/v1" |
| 68 | name := providerSlug("custom", baseURL) |
| 69 | cfg.Providers[0].Name = name |
| 70 | cfg.DefaultModel = name |
| 71 | cfg.Providers[0].Headers = map[string]string{"X-Relay": "yes"} |
| 72 | cfg.Providers[0].NoProxy = true |
| 73 | want := cfg.Providers[0] |
| 74 | s := newProviderSetupSession(cfg) |
| 75 | replacement := config.ProviderEntry{ |
| 76 | Name: providerSlug("custom", baseURL), Kind: "openai", BaseURL: baseURL, |
| 77 | Model: "new-model", APIKeyEnv: "SHARED_API_KEY", |
| 78 | } |
| 79 | if err := s.add([]config.ProviderEntry{replacement}); err == nil { |
| 80 | t.Fatal("adding an existing provider should require the edit flow") |
| 81 | } |
| 82 | if !reflect.DeepEqual(cfg.Providers[0], want) { |
| 83 | t.Fatalf("existing provider changed after rejected add:\n got: %+v\nwant: %+v", cfg.Providers[0], want) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestProviderSetupSessionRemovalIsExplicitAndRepairsDefault(t *testing.T) { |
| 88 | cfg := setupTestConfig() |
| 89 | s := newProviderSetupSession(cfg) |
| 90 | if len(cfg.Providers) != 2 { |
| 91 | t.Fatal("provider changed before explicit remove") |
| 92 | } |
| 93 | if err := s.remove("desktop-provider"); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "cli-provider" { |
| 97 | t.Fatalf("remove result = %+v", cfg.Providers) |
| 98 | } |
| 99 | if cfg.DefaultModel != "cli-provider" { |
| 100 | t.Fatalf("default fallback = %q, want cli-provider", cfg.DefaultModel) |
| 101 | } |
| 102 | if containsString(cfg.Desktop.ProviderAccess, "desktop-provider") || !containsString(cfg.Desktop.ProviderAccess, "cli-provider") { |
| 103 | t.Fatalf("desktop provider access was not cleaned safely: %v", cfg.Desktop.ProviderAccess) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestProviderSetupSessionAddPromotesDefaultWhenCurrentDefaultUnusable(t *testing.T) { |
| 108 | isolateUserConfig(t) |
| 109 | cfg := setupTestConfig() |
| 110 | // desktop-provider names SHARED_API_KEY, which is neither stored nor staged. |
| 111 | s := newProviderSetupSession(cfg) |
| 112 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"} |
| 113 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if err := s.setCredential("GROK_RELAY_API_KEY", "staged-secret"); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | s.promoteDefaultToNewProviders([]config.ProviderEntry{added}) |
| 120 | if cfg.DefaultModel != "grok-relay" { |
| 121 | t.Fatalf("default = %q, want promotion to grok-relay", cfg.DefaultModel) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestProviderSetupSessionAddKeepsUsableDefault(t *testing.T) { |
| 126 | isolateUserConfig(t) |
| 127 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"} |
| 128 | |
| 129 | // cli-provider needs no key, so the default is usable and must not move. |
| 130 | cfg := setupTestConfig() |
| 131 | cfg.DefaultModel = "cli-provider" |
| 132 | s := newProviderSetupSession(cfg) |
| 133 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if err := s.setCredential("GROK_RELAY_API_KEY", "staged-secret"); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | s.promoteDefaultToNewProviders([]config.ProviderEntry{added}) |
| 140 | if cfg.DefaultModel != "cli-provider" { |
| 141 | t.Fatalf("keyless default was hijacked: %q", cfg.DefaultModel) |
| 142 | } |
| 143 | |
| 144 | // A default whose key was staged earlier in this session is usable too. |
| 145 | cfg = setupTestConfig() |
| 146 | s = newProviderSetupSession(cfg) |
| 147 | if err := s.setCredential("SHARED_API_KEY", "staged-secret"); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | s.promoteDefaultToNewProviders([]config.ProviderEntry{added}) |
| 154 | if cfg.DefaultModel != "desktop-provider" { |
| 155 | t.Fatalf("staged-key default was hijacked: %q", cfg.DefaultModel) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestFirstRunAddCustomProviderPersistsPromotedDefault(t *testing.T) { |
| 160 | isolateUserConfig(t) |
| 161 | path := config.UserConfigPath() |
| 162 | if _, err := os.Stat(path); !os.IsNotExist(err) { |
| 163 | t.Fatalf("first-run precondition: config already exists at %s", path) |
| 164 | } |
| 165 | cfg := config.LoadForEdit(path) |
| 166 | s := newProviderSetupSessionForPath(cfg, path) |
| 167 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"} |
| 168 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | s.addProviderAccess([]config.ProviderEntry{added}) |
| 172 | if err := s.setCredential("GROK_RELAY_API_KEY", "staged-secret"); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | s.promoteDefaultToNewProviders([]config.ProviderEntry{added}) |
| 176 | if _, err := commitProviderSetupSession(s, path); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | got := config.LoadForEdit(path) |
| 180 | if got.DefaultModel != "grok-relay" { |
| 181 | t.Fatalf("persisted default = %q, want grok-relay (built-in default has no key)", got.DefaultModel) |
| 182 | } |
| 183 | if _, ok := got.ResolveModel(got.DefaultModel); !ok { |
| 184 | t.Fatalf("persisted default %q does not resolve", got.DefaultModel) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestProviderSetupSessionUpsertRepairsDanglingDefaultRef(t *testing.T) { |
| 189 | isolateUserConfig(t) |
| 190 | cfg := setupTestConfig() |
| 191 | cfg.DefaultModel = "cli-provider/cli-model" |
| 192 | s := newProviderSetupSession(cfg) |
| 193 | refreshed := cfg.Providers[1] |
| 194 | refreshed.Model = "" |
| 195 | refreshed.Models = []string{"cli-model-2"} |
| 196 | if err := s.upsert([]config.ProviderEntry{refreshed}); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | if cfg.DefaultModel != "cli-provider" { |
| 200 | t.Fatalf("dangling default = %q, want repair to cli-provider", cfg.DefaultModel) |
| 201 | } |
| 202 | if _, ok := cfg.ResolveModel(cfg.DefaultModel); !ok { |
| 203 | t.Fatalf("repaired default %q does not resolve", cfg.DefaultModel) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestProviderSetupSessionAddAccessRespectsExplicitEmptyList(t *testing.T) { |
| 208 | cfg := setupTestConfig() |
| 209 | cfg.Desktop.ProviderAccess = nil |
| 210 | s := newProviderSetupSession(cfg) |
| 211 | s.accessDeclared = true |
| 212 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_API_KEY"} |
| 213 | s.addProviderAccess([]config.ProviderEntry{added}) |
| 214 | if got := cfg.Desktop.ProviderAccess; len(got) != 1 || got[0] != "grok-relay" { |
| 215 | t.Fatalf("explicit empty access should enable only the added provider, got %v", got) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | func TestProviderSetupSessionAddAccessSeedsUndeclaredLegacyProviders(t *testing.T) { |
| 220 | cfg := setupTestConfig() |
| 221 | cfg.Desktop.ProviderAccess = nil |
| 222 | s := newProviderSetupSession(cfg) |
| 223 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_API_KEY"} |
| 224 | s.addProviderAccess([]config.ProviderEntry{added, added}) |
| 225 | if got := cfg.Desktop.ProviderAccess; !containsString(got, "cli-provider") || !containsString(got, "grok-relay") { |
| 226 | t.Fatalf("undeclared legacy access should preserve configured siblings and add the new provider: %v", got) |
| 227 | } |
| 228 | count := 0 |
| 229 | for _, name := range cfg.Desktop.ProviderAccess { |
| 230 | if name == "grok-relay" { |
| 231 | count++ |
| 232 | } |
| 233 | } |
| 234 | if count != 1 { |
| 235 | t.Fatalf("added provider access should be deduplicated: %v", cfg.Desktop.ProviderAccess) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestLocalProviderSetupAccessOnlySeedsProjectProviders(t *testing.T) { |
| 240 | isolateUserConfig(t) |
| 241 | path := filepath.Join(t.TempDir(), "reasonix.toml") |
| 242 | if err := os.WriteFile(path, []byte(` |
| 243 | [[providers]] |
| 244 | name = "project-relay" |
| 245 | kind = "openai" |
| 246 | base_url = "https://project.example/v1" |
| 247 | model = "project-model" |
| 248 | api_key_env = "" |
| 249 | `), 0o600); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | cfg := config.LoadForEdit(path) |
| 253 | s := newProviderSetupSessionForPath(cfg, path) |
| 254 | added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5"} |
| 255 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | s.addProviderAccess([]config.ProviderEntry{added}) |
| 259 | got := cfg.Desktop.ProviderAccess |
| 260 | if !containsString(got, "project-relay") || !containsString(got, "grok-relay") { |
| 261 | t.Fatalf("project provider access = %v, want existing and added project providers", got) |
| 262 | } |
| 263 | for _, forbidden := range []string{"deepseek", "deepseek-flash", "deepseek-pro"} { |
| 264 | if containsString(got, forbidden) { |
| 265 | t.Fatalf("project provider access unexpectedly enabled built-in %q: %v", forbidden, got) |
| 266 | } |
| 267 | } |
| 268 | if err := cfg.SaveTo(path); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | reloaded := config.LoadForEditWithoutCredentials(path) |
| 272 | if got := reloaded.Desktop.ProviderAccess; !containsString(got, "project-relay") || !containsString(got, "grok-relay") { |
| 273 | t.Fatalf("reloaded project provider access = %v", got) |
| 274 | } else { |
| 275 | for _, forbidden := range []string{"deepseek", "deepseek-flash", "deepseek-pro"} { |
| 276 | if containsString(got, forbidden) { |
| 277 | t.Fatalf("reloaded project access unexpectedly enabled %q: %v", forbidden, got) |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func TestNewProviderSetupSessionDetectsExplicitProviderAccess(t *testing.T) { |
| 284 | path := filepath.Join(t.TempDir(), "config.toml") |
| 285 | if err := os.WriteFile(path, []byte("[desktop]\nprovider_access = []\n"), 0o600); err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | s := newProviderSetupSessionForPath(setupTestConfig(), path) |
| 289 | if !s.accessDeclared { |
| 290 | t.Fatal("explicit empty desktop.provider_access was treated as undeclared") |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | func TestProviderSetupSessionPersistsEmptyAccessAfterLastRemoval(t *testing.T) { |
| 295 | isolateUserConfig(t) |
| 296 | cfg := setupTestConfig() |
| 297 | cfg.Desktop.ProviderAccess = []string{"desktop-provider"} |
| 298 | s := newProviderSetupSession(cfg) |
| 299 | s.accessDeclared = true |
| 300 | if err := s.remove("desktop-provider"); err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | if cfg.Desktop.ProviderAccess == nil || len(cfg.Desktop.ProviderAccess) != 0 { |
| 304 | t.Fatalf("last removal should retain an explicit empty access list: %#v", cfg.Desktop.ProviderAccess) |
| 305 | } |
| 306 | path := config.UserConfigPath() |
| 307 | if err := cfg.SaveTo(path); err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | body, err := os.ReadFile(path) |
| 311 | if err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | if !strings.Contains(string(body), "provider_access = []") { |
| 315 | t.Fatalf("explicit empty provider access was omitted from saved config:\n%s", body) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestProviderSetupSessionAllowsSharedCredentialName(t *testing.T) { |
| 320 | cfg := setupTestConfig() |
| 321 | s := newProviderSetupSession(cfg) |
| 322 | shared := config.ProviderEntry{Name: "second-relay", Kind: "openai", BaseURL: "https://other.example/v1", Model: "grok-4.5", APIKeyEnv: "SHARED_API_KEY"} |
| 323 | if err := s.upsert([]config.ProviderEntry{shared}); err != nil { |
| 324 | t.Fatalf("intentional shared api_key_env should be valid: %v", err) |
| 325 | } |
| 326 | if err := s.setCredential("SHARED_API_KEY", "shared-secret"); err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | if got := s.credentialLines(); len(got) != 1 || got[0] != "SHARED_API_KEY=shared-secret" { |
| 330 | t.Fatalf("credential lines = %v", got) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | func TestProviderSetupSessionCancelDoesNotWriteFiles(t *testing.T) { |
| 335 | dir := t.TempDir() |
| 336 | path := filepath.Join(dir, "config.toml") |
| 337 | original := []byte("default_model = \"keep\"\n") |
| 338 | if err := os.WriteFile(path, original, 0o600); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | cfg := setupTestConfig() |
| 342 | s := newProviderSetupSession(cfg) |
| 343 | if err := s.upsert([]config.ProviderEntry{{Name: "staged", Kind: "openai", BaseURL: "https://staged.example/v1", Model: "staged-model", APIKeyEnv: "STAGED_API_KEY"}}); err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | if err := s.setCredential("STAGED_API_KEY", "not-written"); err != nil { |
| 347 | t.Fatal(err) |
| 348 | } |
| 349 | got, err := os.ReadFile(path) |
| 350 | if err != nil { |
| 351 | t.Fatal(err) |
| 352 | } |
| 353 | if string(got) != string(original) { |
| 354 | t.Fatalf("staging changed config on disk: %q", got) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | func TestPromptOptionalAPIKeyEnvNameAllowsNoAuthProvider(t *testing.T) { |
| 359 | var out bytes.Buffer |
| 360 | got := promptOptionalAPIKeyEnvName(bufio.NewScanner(strings.NewReader("\n")), &out, "API key variable", "") |
| 361 | if got != "" { |
| 362 | t.Fatalf("optional API key variable = %q, want empty", got) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestProviderSetupSessionSummaryReportsChanges(t *testing.T) { |
| 367 | cfg := setupTestConfig() |
| 368 | s := newProviderSetupSession(cfg) |
| 369 | if err := s.remove("cli-provider"); err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | if err := s.upsert([]config.ProviderEntry{{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_API_KEY"}}); err != nil { |
| 373 | t.Fatal(err) |
| 374 | } |
| 375 | if err := s.setCredential("GROK_API_KEY", "secret"); err != nil { |
| 376 | t.Fatal(err) |
| 377 | } |
| 378 | text := strings.Join(s.summary(), "\n") |
| 379 | for _, want := range []string{"grok-relay", "cli-provider", "1"} { |
| 380 | if !strings.Contains(text, want) { |
| 381 | t.Fatalf("summary %q missing %q", text, want) |
| 382 | } |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | func TestProviderSetupOperationReplayPreservesConcurrentUnrelatedChanges(t *testing.T) { |
| 387 | isolateUserConfig(t) |
| 388 | path := config.UserConfigPath() |
| 389 | initial := setupTestConfig() |
| 390 | if err := initial.SaveTo(path); err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | |
| 394 | working := config.LoadForEdit(path) |
| 395 | s := newProviderSetupSessionForPath(working, path) |
| 396 | s.setLanguage("en") |
| 397 | edited := working.Providers[0] |
| 398 | edited.Models = []string{"desktop-model", "desktop-model-2"} |
| 399 | edited.Model = "" |
| 400 | if err := s.upsert([]config.ProviderEntry{edited}); err != nil { |
| 401 | t.Fatal(err) |
| 402 | } |
| 403 | |
| 404 | external := config.LoadForEdit(path) |
| 405 | external.Agent.Temperature = 0.23 |
| 406 | external.DefaultModel = "cli-provider" |
| 407 | external.Providers[1].Headers = map[string]string{"X-External": "keep"} |
| 408 | if err := external.SaveTo(path); err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | |
| 412 | if _, err := commitProviderSetupSession(s, path); err != nil { |
| 413 | t.Fatalf("commitProviderSetupSession: %v", err) |
| 414 | } |
| 415 | got := config.LoadForEdit(path) |
| 416 | if got.Agent.Temperature != 0.23 || got.DefaultModel != "cli-provider" || got.Language != "en" { |
| 417 | t.Fatalf("scalar replay lost data: temperature=%v default=%q language=%q", got.Agent.Temperature, got.DefaultModel, got.Language) |
| 418 | } |
| 419 | if got.Providers[1].Headers["X-External"] != "keep" { |
| 420 | t.Fatalf("concurrent sibling provider edit was lost: %+v", got.Providers[1]) |
| 421 | } |
| 422 | if models := got.Providers[0].ModelList(); !containsString(models, "desktop-model-2") { |
| 423 | t.Fatalf("setup provider edit was not replayed: %v", models) |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | func TestProviderSetupCommitWithNoOperationsDoesNotRewriteConfig(t *testing.T) { |
| 428 | isolateUserConfig(t) |
| 429 | path := config.UserConfigPath() |
| 430 | initial := setupTestConfig() |
| 431 | if err := initial.SaveTo(path); err != nil { |
| 432 | t.Fatal(err) |
| 433 | } |
| 434 | before, err := os.ReadFile(path) |
| 435 | if err != nil { |
| 436 | t.Fatal(err) |
| 437 | } |
| 438 | s := newProviderSetupSessionForPath(config.LoadForEdit(path), path) |
| 439 | written, err := commitProviderSetupSession(s, path) |
| 440 | if err != nil { |
| 441 | t.Fatal(err) |
| 442 | } |
| 443 | if written { |
| 444 | t.Fatal("no-op setup reported a config write") |
| 445 | } |
| 446 | after, err := os.ReadFile(path) |
| 447 | if err != nil { |
| 448 | t.Fatal(err) |
| 449 | } |
| 450 | if string(after) != string(before) { |
| 451 | t.Fatal("no-op setup rewrote config") |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | func TestProviderSetupOperationReplayRejectsConcurrentSameProviderEdit(t *testing.T) { |
| 456 | isolateUserConfig(t) |
| 457 | path := config.UserConfigPath() |
| 458 | initial := setupTestConfig() |
| 459 | if err := initial.SaveTo(path); err != nil { |
| 460 | t.Fatal(err) |
| 461 | } |
| 462 | |
| 463 | working := config.LoadForEdit(path) |
| 464 | s := newProviderSetupSessionForPath(working, path) |
| 465 | edited := working.Providers[0] |
| 466 | edited.Model = "setup-model" |
| 467 | edited.Models = nil |
| 468 | if err := s.upsert([]config.ProviderEntry{edited}); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | |
| 472 | external := config.LoadForEdit(path) |
| 473 | external.Providers[0].BaseURL = "https://external.example/v1" |
| 474 | if err := external.SaveTo(path); err != nil { |
| 475 | t.Fatal(err) |
| 476 | } |
| 477 | |
| 478 | _, err := commitProviderSetupSession(s, path) |
| 479 | var conflict *providerSetupConflictError |
| 480 | if !errors.As(err, &conflict) || !strings.Contains(conflict.field, "desktop-provider") { |
| 481 | t.Fatalf("commit conflict = %v, want provider conflict", err) |
| 482 | } |
| 483 | got := config.LoadForEdit(path) |
| 484 | if got.Providers[0].BaseURL != "https://external.example/v1" || got.Providers[0].Model != "desktop-model" { |
| 485 | t.Fatalf("conflicting setup edit changed disk config: %+v", got.Providers[0]) |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | func TestProviderSetupSaveConflictDoesNotWriteStagedCredentials(t *testing.T) { |
| 490 | isolateUserConfig(t) |
| 491 | path := config.UserConfigPath() |
| 492 | initial := setupTestConfig() |
| 493 | if err := initial.SaveTo(path); err != nil { |
| 494 | t.Fatal(err) |
| 495 | } |
| 496 | |
| 497 | working := config.LoadForEdit(path) |
| 498 | s := newProviderSetupSessionForPath(working, path) |
| 499 | edited := working.Providers[0] |
| 500 | edited.Model = "setup-model" |
| 501 | edited.Models = nil |
| 502 | if err := s.upsert([]config.ProviderEntry{edited}); err != nil { |
| 503 | t.Fatal(err) |
| 504 | } |
| 505 | if err := s.setCredential("STAGED_API_KEY", "must-not-be-written"); err != nil { |
| 506 | t.Fatal(err) |
| 507 | } |
| 508 | |
| 509 | external := config.LoadForEdit(path) |
| 510 | external.Providers[0].BaseURL = "https://external.example/v1" |
| 511 | if err := external.SaveTo(path); err != nil { |
| 512 | t.Fatal(err) |
| 513 | } |
| 514 | |
| 515 | readEnd, writeEnd, err := os.Pipe() |
| 516 | if err != nil { |
| 517 | t.Fatal(err) |
| 518 | } |
| 519 | oldStdin := os.Stdin |
| 520 | os.Stdin = readEnd |
| 521 | t.Cleanup(func() { |
| 522 | os.Stdin = oldStdin |
| 523 | _ = readEnd.Close() |
| 524 | _ = writeEnd.Close() |
| 525 | }) |
| 526 | if _, err := writeEnd.WriteString("\n"); err != nil { |
| 527 | t.Fatal(err) |
| 528 | } |
| 529 | if err := writeEnd.Close(); err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | |
| 533 | if rc := saveProviderSetupSession(s, path, config.UserCredentialsPath()); rc != 1 { |
| 534 | t.Fatalf("saveProviderSetupSession return code = %d, want 1", rc) |
| 535 | } |
| 536 | if config.CredentialStored("STAGED_API_KEY") { |
| 537 | t.Fatal("config conflict wrote staged credentials") |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | func TestProviderSetupOperationReplayRejectsConcurrentDefaultEdit(t *testing.T) { |
| 542 | isolateUserConfig(t) |
| 543 | path := config.UserConfigPath() |
| 544 | initial := setupTestConfig() |
| 545 | if err := initial.SaveTo(path); err != nil { |
| 546 | t.Fatal(err) |
| 547 | } |
| 548 | working := config.LoadForEdit(path) |
| 549 | s := newProviderSetupSessionForPath(working, path) |
| 550 | if err := s.setDefaultModel("cli-provider"); err != nil { |
| 551 | t.Fatal(err) |
| 552 | } |
| 553 | external := config.LoadForEdit(path) |
| 554 | external.DefaultModel = "desktop-provider/desktop-model" |
| 555 | if err := external.SaveTo(path); err != nil { |
| 556 | t.Fatal(err) |
| 557 | } |
| 558 | _, err := commitProviderSetupSession(s, path) |
| 559 | var conflict *providerSetupConflictError |
| 560 | if !errors.As(err, &conflict) || conflict.field != "default_model" { |
| 561 | t.Fatalf("commit conflict = %v, want default_model conflict", err) |
| 562 | } |
| 563 | if got := config.LoadForEdit(path).DefaultModel; got != "desktop-provider/desktop-model" { |
| 564 | t.Fatalf("external default_model was overwritten: %q", got) |
| 565 | } |
| 566 | } |
| 567 | |
| 568 | func TestProviderSetupOperationReplayMergesConcurrentAccessAddition(t *testing.T) { |
| 569 | isolateUserConfig(t) |
| 570 | path := config.UserConfigPath() |
| 571 | initial := setupTestConfig() |
| 572 | if err := initial.SaveTo(path); err != nil { |
| 573 | t.Fatal(err) |
| 574 | } |
| 575 | |
| 576 | working := config.LoadForEdit(path) |
| 577 | s := newProviderSetupSessionForPath(working, path) |
| 578 | setupProvider := config.ProviderEntry{Name: "setup-provider", Kind: "openai", BaseURL: "https://setup.example/v1", Model: "setup-model"} |
| 579 | if err := s.add([]config.ProviderEntry{setupProvider}); err != nil { |
| 580 | t.Fatal(err) |
| 581 | } |
| 582 | s.addProviderAccess([]config.ProviderEntry{setupProvider}) |
| 583 | |
| 584 | external := config.LoadForEdit(path) |
| 585 | externalProvider := config.ProviderEntry{Name: "external-provider", Kind: "openai", BaseURL: "https://external.example/v1", Model: "external-model"} |
| 586 | if err := external.UpsertProvider(externalProvider); err != nil { |
| 587 | t.Fatal(err) |
| 588 | } |
| 589 | external.Desktop.ProviderAccess = append(external.Desktop.ProviderAccess, externalProvider.Name) |
| 590 | if err := external.SaveTo(path); err != nil { |
| 591 | t.Fatal(err) |
| 592 | } |
| 593 | |
| 594 | if _, err := commitProviderSetupSession(s, path); err != nil { |
| 595 | t.Fatalf("commitProviderSetupSession: %v", err) |
| 596 | } |
| 597 | got := config.LoadForEdit(path) |
| 598 | for _, name := range []string{setupProvider.Name, externalProvider.Name} { |
| 599 | if _, ok := got.Provider(name); !ok || !containsString(got.Desktop.ProviderAccess, name) { |
| 600 | t.Fatalf("provider/access %q missing after replay: providers=%v access=%v", name, got.Providers, got.Desktop.ProviderAccess) |
| 601 | } |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | func TestProviderSetupOperationReplayRejectsConcurrentAccessDeclaration(t *testing.T) { |
| 606 | isolateUserConfig(t) |
| 607 | path := config.UserConfigPath() |
| 608 | initial := setupTestConfig() |
| 609 | initial.Desktop.ProviderAccess = nil |
| 610 | if err := initial.SaveTo(path); err != nil { |
| 611 | t.Fatal(err) |
| 612 | } |
| 613 | |
| 614 | working := config.LoadForEdit(path) |
| 615 | s := newProviderSetupSessionForPath(working, path) |
| 616 | added := config.ProviderEntry{Name: "setup-provider", Kind: "openai", BaseURL: "https://setup.example/v1", Model: "setup-model"} |
| 617 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 618 | t.Fatal(err) |
| 619 | } |
| 620 | s.addProviderAccess([]config.ProviderEntry{added}) |
| 621 | |
| 622 | external := config.LoadForEdit(path) |
| 623 | external.Desktop.ProviderAccess = []string{} |
| 624 | if err := external.SaveTo(path); err != nil { |
| 625 | t.Fatal(err) |
| 626 | } |
| 627 | |
| 628 | _, err := commitProviderSetupSession(s, path) |
| 629 | var conflict *providerSetupConflictError |
| 630 | if !errors.As(err, &conflict) || conflict.field != "desktop.provider_access" { |
| 631 | t.Fatalf("commit conflict = %v, want provider_access declaration conflict", err) |
| 632 | } |
| 633 | declared, err := config.DesktopProviderAccessDeclared(path) |
| 634 | if err != nil { |
| 635 | t.Fatal(err) |
| 636 | } |
| 637 | got := config.LoadForEdit(path) |
| 638 | if !declared || got.Desktop.ProviderAccess == nil || len(got.Desktop.ProviderAccess) != 0 { |
| 639 | t.Fatalf("external explicit empty access was overwritten: declared=%v access=%#v", declared, got.Desktop.ProviderAccess) |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | func TestProviderSetupOperationReplayMaterializesLatestProjectProviders(t *testing.T) { |
| 644 | isolateUserConfig(t) |
| 645 | path := filepath.Join(t.TempDir(), "reasonix.toml") |
| 646 | initialBody := ` |
| 647 | [[providers]] |
| 648 | name = "initial-project" |
| 649 | kind = "openai" |
| 650 | base_url = "https://initial.example/v1" |
| 651 | model = "initial-model" |
| 652 | api_key_env = "" |
| 653 | ` |
| 654 | if err := os.WriteFile(path, []byte(initialBody), 0o644); err != nil { |
| 655 | t.Fatal(err) |
| 656 | } |
| 657 | working := config.LoadForEdit(path) |
| 658 | s := newProviderSetupSessionForPath(working, path) |
| 659 | added := config.ProviderEntry{Name: "setup-provider", Kind: "openai", BaseURL: "https://setup.example/v1", Model: "setup-model"} |
| 660 | if err := s.add([]config.ProviderEntry{added}); err != nil { |
| 661 | t.Fatal(err) |
| 662 | } |
| 663 | s.addProviderAccess([]config.ProviderEntry{added}) |
| 664 | |
| 665 | externalBody := initialBody + ` |
| 666 | [[providers]] |
| 667 | name = "external-project" |
| 668 | kind = "openai" |
| 669 | base_url = "https://external.example/v1" |
| 670 | model = "external-model" |
| 671 | api_key_env = "" |
| 672 | ` |
| 673 | if err := os.WriteFile(path, []byte(externalBody), 0o644); err != nil { |
| 674 | t.Fatal(err) |
| 675 | } |
| 676 | if _, err := commitProviderSetupSession(s, path); err != nil { |
| 677 | t.Fatalf("commitProviderSetupSession: %v", err) |
| 678 | } |
| 679 | got := config.LoadForEditWithoutCredentials(path) |
| 680 | for _, name := range []string{"initial-project", "external-project", "setup-provider"} { |
| 681 | if _, ok := got.Provider(name); !ok || !containsString(got.Desktop.ProviderAccess, name) { |
| 682 | t.Fatalf("latest project provider/access %q missing: providers=%v access=%v", name, got.Providers, got.Desktop.ProviderAccess) |
| 683 | } |
| 684 | } |
| 685 | for _, forbidden := range []string{"deepseek", "deepseek-flash", "deepseek-pro"} { |
| 686 | if containsString(got.Desktop.ProviderAccess, forbidden) { |
| 687 | t.Fatalf("materialized project access unexpectedly enabled %q: %v", forbidden, got.Desktop.ProviderAccess) |
| 688 | } |
| 689 | } |
| 690 | } |
| 691 | |
| 692 | func TestProviderSetupCommitDoesNotOverwriteMalformedConcurrentConfig(t *testing.T) { |
| 693 | isolateUserConfig(t) |
| 694 | path := config.UserConfigPath() |
| 695 | initial := setupTestConfig() |
| 696 | if err := initial.SaveTo(path); err != nil { |
| 697 | t.Fatal(err) |
| 698 | } |
| 699 | working := config.LoadForEdit(path) |
| 700 | s := newProviderSetupSessionForPath(working, path) |
| 701 | s.setLanguage("en") |
| 702 | malformed := []byte("[[providers]\nname =") |
| 703 | if err := os.WriteFile(path, malformed, 0o600); err != nil { |
| 704 | t.Fatal(err) |
| 705 | } |
| 706 | if _, err := commitProviderSetupSession(s, path); err == nil { |
| 707 | t.Fatal("commit should reject malformed concurrent config") |
| 708 | } |
| 709 | after, err := os.ReadFile(path) |
| 710 | if err != nil { |
| 711 | t.Fatal(err) |
| 712 | } |
| 713 | if string(after) != string(malformed) { |
| 714 | t.Fatalf("malformed concurrent config was overwritten:\n%s", after) |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | func TestResolveSetupTargetsLocalKeepsGlobalCredentialTarget(t *testing.T) { |
| 719 | targets := resolveSetupTargets([]string{"--local"}) |
| 720 | if targets.config != "reasonix.toml" { |
| 721 | t.Fatalf("local config target = %q", targets.config) |
| 722 | } |
| 723 | if targets.env != config.CredentialsTargetDescription() { |
| 724 | t.Fatalf("credential target = %q, want global %q", targets.env, config.CredentialsTargetDescription()) |
| 725 | } |
| 726 | } |
| 727 | |
| 728 | func TestLocalSetupPersistsWorkspaceProviderAccess(t *testing.T) { |
| 729 | cfg := setupTestConfig() |
| 730 | cfg.Desktop.ProviderAccess = []string{"grok-relay"} |
| 731 | path := filepath.Join(t.TempDir(), "reasonix.toml") |
| 732 | if err := cfg.SaveTo(path); err != nil { |
| 733 | t.Fatal(err) |
| 734 | } |
| 735 | body, err := os.ReadFile(path) |
| 736 | if err != nil { |
| 737 | t.Fatal(err) |
| 738 | } |
| 739 | text := string(body) |
| 740 | if !strings.Contains(text, "[desktop]") || !strings.Contains(text, `provider_access = ["grok-relay"]`) { |
| 741 | t.Fatalf("local setup omitted workspace desktop access:\n%s", text) |
| 742 | } |
| 743 | if strings.Contains(text, "theme_style") || strings.Contains(text, "default_tool_approval_mode") { |
| 744 | t.Fatalf("local setup leaked user-global desktop preferences:\n%s", text) |
| 745 | } |
| 746 | } |
| 747 |