1package config
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/oauth"
14 "github.com/stretchr/testify/require"
15 "github.com/tidwall/gjson"
16)
17
18func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
19 t.Parallel()
20
21 store := &ConfigStore{
22 globalDataPath: "/some/global/crush.json",
23 }
24
25 path, err := store.configPath(ScopeGlobal)
26 require.NoError(t, err)
27 require.Equal(t, "/some/global/crush.json", path)
28}
29
30func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
31 t.Parallel()
32
33 store := &ConfigStore{
34 workspacePath: "/some/workspace/.crush/crush.json",
35 }
36
37 path, err := store.configPath(ScopeWorkspace)
38 require.NoError(t, err)
39 require.Equal(t, "/some/workspace/.crush/crush.json", path)
40}
41
42func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
43 t.Parallel()
44
45 store := &ConfigStore{
46 globalDataPath: "/some/global/crush.json",
47 workspacePath: "",
48 }
49
50 _, err := store.configPath(ScopeWorkspace)
51 require.Error(t, err)
52 require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
53}
54
55func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
56 t.Parallel()
57
58 store := &ConfigStore{
59 config: &Config{},
60 globalDataPath: filepath.Join(t.TempDir(), "global.json"),
61 workspacePath: "",
62 }
63
64 err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
65 require.Error(t, err)
66 require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
67}
68
69func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
70 t.Parallel()
71
72 dir := t.TempDir()
73 globalPath := filepath.Join(dir, "crush.json")
74 store := &ConfigStore{
75 config: &Config{},
76 globalDataPath: globalPath,
77 }
78
79 err := store.SetConfigField(ScopeGlobal, "foo", "bar")
80 require.NoError(t, err)
81
82 data, err := os.ReadFile(globalPath)
83 require.NoError(t, err)
84 require.Contains(t, string(data), `"foo"`)
85}
86
87func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
88 t.Parallel()
89
90 store := &ConfigStore{
91 config: &Config{},
92 globalDataPath: filepath.Join(t.TempDir(), "global.json"),
93 workspacePath: "",
94 }
95
96 err := store.RemoveConfigField(ScopeWorkspace, "foo")
97 require.Error(t, err)
98 require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
99}
100
101func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
102 t.Parallel()
103
104 store := &ConfigStore{
105 config: &Config{},
106 globalDataPath: filepath.Join(t.TempDir(), "global.json"),
107 workspacePath: "",
108 }
109
110 has := store.HasConfigField(ScopeWorkspace, "foo")
111 require.False(t, has)
112}
113
114func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
115 t.Parallel()
116
117 store1 := &ConfigStore{config: &Config{}}
118 store2 := &ConfigStore{config: &Config{}}
119
120 require.False(t, store1.Overrides().SkipPermissionRequests)
121 require.False(t, store2.Overrides().SkipPermissionRequests)
122
123 store1.Overrides().SkipPermissionRequests = true
124
125 require.True(t, store1.Overrides().SkipPermissionRequests)
126 require.False(t, store2.Overrides().SkipPermissionRequests)
127}
128
129func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
130 t.Parallel()
131
132 store := &ConfigStore{config: &Config{}}
133 overrides := store.Overrides()
134
135 require.False(t, overrides.SkipPermissionRequests)
136
137 overrides.SkipPermissionRequests = true
138 require.True(t, store.Overrides().SkipPermissionRequests)
139}
140
141func TestGlobalWorkspaceDir(t *testing.T) {
142 dir := t.TempDir()
143 t.Setenv("CRUSH_GLOBAL_DATA", dir)
144
145 wsDir := GlobalWorkspaceDir()
146 globalData := GlobalConfigData()
147
148 require.Equal(t, filepath.Dir(globalData), wsDir)
149 require.Equal(t, dir, wsDir)
150}
151
152func TestScope_String(t *testing.T) {
153 t.Parallel()
154
155 require.Equal(t, "global", ScopeGlobal.String())
156 require.Equal(t, "workspace", ScopeWorkspace.String())
157 require.Contains(t, Scope(99).String(), "Scope(99)")
158}
159
160func TestConfigStaleness_CleanImmediatelyAfterSnapshot(t *testing.T) {
161 t.Parallel()
162
163 dir := t.TempDir()
164 configPath := filepath.Join(dir, "crush.json")
165
166 // Create a config file
167 content := []byte(`{"options": {"debug": true}}`)
168 require.NoError(t, os.WriteFile(configPath, content, 0o600))
169
170 store := &ConfigStore{
171 config: &Config{},
172 globalDataPath: configPath,
173 }
174 store.captureStalenessSnapshot([]string{configPath})
175
176 result := store.ConfigStaleness()
177 require.False(t, result.Dirty)
178 require.Empty(t, result.Changed)
179 require.Empty(t, result.Missing)
180}
181
182func TestConfigStaleness_DetectsFileContentChange(t *testing.T) {
183 t.Parallel()
184
185 dir := t.TempDir()
186 configPath := filepath.Join(dir, "crush.json")
187
188 // Create initial config file
189 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
190
191 store := &ConfigStore{
192 config: &Config{},
193 globalDataPath: configPath,
194 }
195 store.captureStalenessSnapshot([]string{configPath})
196
197 // Modify the file
198 time.Sleep(10 * time.Millisecond) // Ensure different mtime
199 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
200
201 result := store.ConfigStaleness()
202 require.True(t, result.Dirty)
203 require.Contains(t, result.Changed, configPath)
204 require.Empty(t, result.Missing)
205}
206
207func TestConfigStaleness_DetectsFileDeletion(t *testing.T) {
208 t.Parallel()
209
210 dir := t.TempDir()
211 configPath := filepath.Join(dir, "crush.json")
212
213 // Create initial config file
214 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
215
216 store := &ConfigStore{
217 config: &Config{},
218 globalDataPath: configPath,
219 }
220 store.captureStalenessSnapshot([]string{configPath})
221
222 // Delete the file
223 require.NoError(t, os.Remove(configPath))
224
225 result := store.ConfigStaleness()
226 require.True(t, result.Dirty)
227 require.Empty(t, result.Changed)
228 require.Contains(t, result.Missing, configPath)
229}
230
231func TestConfigStaleness_DetectsNewFile(t *testing.T) {
232 t.Parallel()
233
234 dir := t.TempDir()
235 configPath := filepath.Join(dir, "crush.json")
236
237 // Don't create file initially
238 store := &ConfigStore{
239 config: &Config{},
240 globalDataPath: configPath,
241 }
242 store.captureStalenessSnapshot([]string{configPath})
243
244 // Now create the file
245 time.Sleep(10 * time.Millisecond)
246 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
247
248 result := store.ConfigStaleness()
249 require.True(t, result.Dirty)
250 require.Contains(t, result.Changed, configPath)
251 require.Empty(t, result.Missing)
252}
253
254func TestConfigStaleness_SortedOutput(t *testing.T) {
255 t.Parallel()
256
257 dir := t.TempDir()
258 pathA := filepath.Join(dir, "a.json")
259 pathB := filepath.Join(dir, "b.json")
260 pathC := filepath.Join(dir, "c.json")
261
262 // Create all files
263 for _, p := range []string{pathA, pathB, pathC} {
264 require.NoError(t, os.WriteFile(p, []byte(`{}`), 0o600))
265 }
266
267 store := &ConfigStore{
268 config: &Config{},
269 globalDataPath: pathA,
270 }
271 // Add in reverse order to test sorting
272 store.captureStalenessSnapshot([]string{pathC, pathA, pathB})
273
274 // Modify all files
275 time.Sleep(10 * time.Millisecond)
276 for _, p := range []string{pathA, pathB, pathC} {
277 require.NoError(t, os.WriteFile(p, []byte(`{"changed": true}`), 0o600))
278 }
279
280 result := store.ConfigStaleness()
281 require.True(t, result.Dirty)
282 // Should be sorted alphabetically
283 require.Equal(t, []string{pathA, pathB, pathC}, result.Changed)
284}
285
286func TestConfigStaleness_RefreshClearsDirtyState(t *testing.T) {
287 t.Parallel()
288
289 dir := t.TempDir()
290 configPath := filepath.Join(dir, "crush.json")
291
292 // Create initial config file
293 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
294
295 store := &ConfigStore{
296 config: &Config{},
297 globalDataPath: configPath,
298 }
299 store.captureStalenessSnapshot([]string{configPath})
300
301 // Modify the file
302 time.Sleep(10 * time.Millisecond)
303 require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
304
305 // Verify dirty
306 result := store.ConfigStaleness()
307 require.True(t, result.Dirty)
308
309 // Refresh snapshot
310 require.NoError(t, store.RefreshStalenessSnapshot())
311
312 // Verify clean now
313 result = store.ConfigStaleness()
314 require.False(t, result.Dirty)
315 require.Empty(t, result.Changed)
316 require.Empty(t, result.Missing)
317}
318
319// TestReloadFromDisk_UsesNewConfigValues is a regression test ensuring that
320// ReloadFromDisk updates store state BEFORE running model/agent setup,
321// so the new config values are used rather than stale pre-reload values.
322func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
323 dir := t.TempDir()
324 configPath := filepath.Join(dir, "crush.json")
325
326 // Isolate from the host's global config so only test-provided
327 // providers are visible.
328 t.Setenv("CRUSH_GLOBAL_CONFIG", dir)
329 t.Setenv("CRUSH_GLOBAL_DATA", dir)
330 resetProviderState()
331 t.Cleanup(resetProviderState)
332
333 // Create initial config with one model preference
334 initialConfig := `{
335 "models": {
336 "large": {"provider": "openai", "model": "gpt-4"}
337 },
338 "providers": {
339 "openai": {
340 "api_key": "test-key",
341 "models": [{"id": "gpt-4", "name": "GPT-4"}]
342 }
343 }
344 }`
345 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
346
347 // Load initial config properly
348 store, err := Load(dir, dir, false)
349 require.NoError(t, err)
350
351 // Set globalDataPath for the test (Load doesn't set this directly)
352 store.globalDataPath = configPath
353 store.CaptureStalenessSnapshot([]string{configPath})
354
355 // Verify initial model
356 require.Equal(t, "openai", store.config.Models[SelectedModelTypeLarge].Provider)
357 require.Equal(t, "gpt-4", store.config.Models[SelectedModelTypeLarge].Model)
358
359 // Modify config on disk to change model
360 updatedConfig := `{
361 "models": {
362 "large": {"provider": "anthropic", "model": "claude-3"}
363 },
364 "providers": {
365 "openai": {
366 "api_key": "test-key",
367 "models": [{"id": "gpt-4", "name": "GPT-4"}]
368 },
369 "anthropic": {
370 "api_key": "test-key-2",
371 "models": [{"id": "claude-3", "name": "Claude 3"}]
372 }
373 }
374 }`
375 time.Sleep(10 * time.Millisecond)
376 require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0o600))
377
378 // Reload from disk
379 ctx := context.Background()
380 err = store.ReloadFromDisk(ctx)
381 require.NoError(t, err)
382
383 // Verify the NEW config values are now in effect (regression check)
384 require.Equal(t, "anthropic", store.config.Models[SelectedModelTypeLarge].Provider)
385 require.Equal(t, "claude-3", store.config.Models[SelectedModelTypeLarge].Model)
386}
387
388// TestSetConfigField_AutoReloads verifies that SetConfigField automatically
389// reloads config into memory after writing, so subsequent reads see the new value.
390func TestSetConfigField_AutoReloads(t *testing.T) {
391 t.Parallel()
392
393 dir := t.TempDir()
394 configPath := filepath.Join(dir, "crush.json")
395
396 // Create initial config file with debug = false
397 initialConfig := `{"options": {"debug": false}}`
398 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
399
400 // Load initial config
401 store, err := Load(dir, dir, false)
402 require.NoError(t, err)
403
404 // Verify initial state
405 require.False(t, store.config.Options.Debug)
406
407 // Set globalDataPath and capture snapshot for staleness tracking
408 store.globalDataPath = configPath
409 store.CaptureStalenessSnapshot([]string{configPath})
410
411 // Use SetConfigField to change debug to true
412 err = store.SetConfigField(ScopeGlobal, "options.debug", true)
413 require.NoError(t, err)
414
415 // Verify in-memory state was automatically reloaded and reflects the change
416 require.True(t, store.config.Options.Debug, "Expected config to auto-reload and show debug = true")
417
418 // Verify staleness is clean after the reload
419 staleness := store.ConfigStaleness()
420 require.False(t, staleness.Dirty, "Expected staleness to be clean after auto-reload")
421}
422
423// TestRemoveConfigField_AutoReloads verifies that RemoveConfigField automatically
424// reloads config into memory after writing.
425func TestRemoveConfigField_AutoReloads(t *testing.T) {
426 t.Parallel()
427
428 dir := t.TempDir()
429 configPath := filepath.Join(dir, "crush.json")
430
431 // Create initial config file with a custom option
432 initialConfig := `{"options": {"debug": true, "custom_field": "value"}}`
433 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
434
435 // Load initial config
436 store, err := Load(dir, dir, false)
437 require.NoError(t, err)
438
439 // Set globalDataPath and capture snapshot
440 store.globalDataPath = configPath
441 store.CaptureStalenessSnapshot([]string{configPath})
442
443 // Verify the field exists initially (indirectly - store loaded successfully)
444 require.True(t, store.config.Options.Debug)
445
446 // Remove the debug field
447 err = store.RemoveConfigField(ScopeGlobal, "options.debug")
448 require.NoError(t, err)
449
450 // Verify auto-reload occurred and stale state is clean
451 staleness := store.ConfigStaleness()
452 require.False(t, staleness.Dirty, "Expected staleness to be clean after auto-reload from RemoveConfigField")
453}
454
455// TestSetConfigField_AutoReloadSkipsWhenNoWorkingDir verifies that auto-reload
456// gracefully skips when working directory is not set (e.g., during testing).
457func TestSetConfigField_AutoReloadSkipsWhenNoWorkingDir(t *testing.T) {
458 t.Parallel()
459
460 dir := t.TempDir()
461 configPath := filepath.Join(dir, "crush.json")
462
463 // Create a store without working directory (like some test setups)
464 store := &ConfigStore{
465 config: &Config{},
466 globalDataPath: configPath,
467 // workingDir is empty
468 }
469
470 // SetConfigField should succeed even without workingDir (auto-reload skips)
471 err := store.SetConfigField(ScopeGlobal, "foo", "bar")
472 require.NoError(t, err)
473
474 // Verify file was still written
475 data, err := os.ReadFile(configPath)
476 require.NoError(t, err)
477 require.Contains(t, string(data), "foo")
478}
479
480// TestAutoReloadDisabledDuringReload verifies that auto-reload is suppressed
481// during ReloadFromDisk to prevent re-entrant/nested reload calls.
482func TestAutoReloadDisabledDuringReload(t *testing.T) {
483 t.Parallel()
484
485 dir := t.TempDir()
486 configPath := filepath.Join(dir, "crush.json")
487
488 // Create initial config with a provider that will trigger config modification during reload
489 // (simulating the anthropic OAuth token removal case)
490 initialConfig := `{
491 "providers": {
492 "anthropic": {
493 "api_key": "test-key",
494 "oauth": {"access_token": "token", "refresh_token": "refresh"}
495 }
496 }
497 }`
498 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
499
500 // Load will trigger configureProviders which removes anthropic OAuth config
501 // This should NOT cause infinite recursion thanks to autoReloadDisabled guard
502 store, err := Load(dir, dir, false)
503 require.NoError(t, err)
504
505 // Verify the store loaded successfully and autoReloadDisabled was unset
506 require.False(t, store.autoReloadDisabled)
507
508 // Capture snapshot and verify reload also works without recursion
509 store.globalDataPath = configPath
510 store.CaptureStalenessSnapshot([]string{configPath})
511
512 // Modify file and reload - this should work without re-entrancy issues
513 time.Sleep(10 * time.Millisecond)
514 require.NoError(t, os.WriteFile(configPath, []byte(`{"options": {"debug": true}}`), 0o600))
515
516 err = store.ReloadFromDisk(context.Background())
517 require.NoError(t, err)
518
519 // Verify reload completed successfully
520 require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
521}
522
523// TestSetConfigFields_AutoReloadsAtomically verifies that SetConfigFields writes
524// multiple fields in a single disk write and triggers only one auto-reload,
525// avoiding intermediate states where only some fields are persisted.
526func TestSetConfigFields_AutoReloadsAtomically(t *testing.T) {
527 t.Parallel()
528
529 dir := t.TempDir()
530 configPath := filepath.Join(dir, "crush.json")
531
532 // Create initial config file.
533 initialConfig := `{"options": {"debug": false}}`
534 require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
535
536 // Load initial config.
537 store, err := Load(dir, dir, false)
538 require.NoError(t, err)
539
540 // Set globalDataPath and capture snapshot.
541 store.globalDataPath = configPath
542 store.CaptureStalenessSnapshot([]string{configPath})
543
544 // Write multiple fields atomically.
545 err = store.SetConfigFields(ScopeGlobal, map[string]any{
546 "options.debug": true,
547 "options.custom": "hello",
548 })
549 require.NoError(t, err)
550
551 // Verify both fields are reflected in memory.
552 require.True(t, store.config.Options.Debug)
553}
554
555func TestLoadTokenFromDisk_ReturnsNewerToken(t *testing.T) {
556 t.Parallel()
557
558 dir := t.TempDir()
559 configPath := filepath.Join(dir, "crush.json")
560
561 // Create config file with a newer token on disk
562 configContent := `{
563 "providers": {
564 "hyper": {
565 "oauth": {
566 "access_token": "newer-token-from-disk",
567 "refresh_token": "refresh-abc",
568 "expires_in": 3600,
569 "expires_at": 9999999999
570 }
571 }
572 }
573 }`
574 require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
575
576 store := &ConfigStore{
577 config: &Config{},
578 globalDataPath: configPath,
579 }
580
581 token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
582 require.NoError(t, err)
583 require.NotNil(t, token)
584 require.Equal(t, "newer-token-from-disk", token.AccessToken)
585 require.Equal(t, "refresh-abc", token.RefreshToken)
586 require.Equal(t, 3600, token.ExpiresIn)
587 require.Equal(t, int64(9999999999), token.ExpiresAt)
588}
589
590func TestLoadTokenFromDisk_ReturnsNilWhenSameToken(t *testing.T) {
591 t.Parallel()
592
593 dir := t.TempDir()
594 configPath := filepath.Join(dir, "crush.json")
595
596 // Create config file with the same token
597 configContent := `{
598 "providers": {
599 "hyper": {
600 "oauth": {
601 "access_token": "same-token",
602 "refresh_token": "refresh-abc",
603 "expires_in": 3600,
604 "expires_at": 9999999999
605 }
606 }
607 }
608 }`
609 require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
610
611 store := &ConfigStore{
612 config: &Config{},
613 globalDataPath: configPath,
614 }
615
616 token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
617 require.NoError(t, err)
618 require.NotNil(t, token)
619 require.Equal(t, "same-token", token.AccessToken)
620}
621
622func TestLoadTokenFromDisk_ReturnsNilWhenFileMissing(t *testing.T) {
623 t.Parallel()
624
625 dir := t.TempDir()
626 configPath := filepath.Join(dir, "nonexistent.json")
627
628 store := &ConfigStore{
629 config: &Config{},
630 globalDataPath: configPath,
631 }
632
633 token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
634 require.NoError(t, err)
635 require.Nil(t, token)
636}
637
638func TestLoadTokenFromDisk_ReturnsNilWhenProviderMissing(t *testing.T) {
639 t.Parallel()
640
641 dir := t.TempDir()
642 configPath := filepath.Join(dir, "crush.json")
643
644 // Create config file without the hyper provider
645 configContent := `{"providers": {"openai": {"api_key": "test-key"}}}`
646 require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
647
648 store := &ConfigStore{
649 config: &Config{},
650 globalDataPath: configPath,
651 }
652
653 token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
654 require.NoError(t, err)
655 require.Nil(t, token)
656}
657
658func TestLoadTokenFromDisk_ReturnsNilWhenOAuthMissing(t *testing.T) {
659 t.Parallel()
660
661 dir := t.TempDir()
662 configPath := filepath.Join(dir, "crush.json")
663
664 // Create config file with provider but no OAuth token
665 configContent := `{"providers": {"hyper": {"api_key": "test-key"}}}`
666 require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
667
668 store := &ConfigStore{
669 config: &Config{},
670 globalDataPath: configPath,
671 }
672
673 token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
674 require.NoError(t, err)
675 require.Nil(t, token)
676}
677
678func TestRefreshOAuthToken_UsesDiskTokenWhenDifferent(t *testing.T) {
679 t.Parallel()
680
681 dir := t.TempDir()
682 configPath := filepath.Join(dir, "crush.json")
683
684 // Create config file with a newer token on disk
685 configContent := `{
686 "providers": {
687 "hyper": {
688 "api_key": "newer-access-token",
689 "oauth": {
690 "access_token": "newer-access-token",
691 "refresh_token": "refresh-abc",
692 "expires_in": 3600,
693 "expires_at": 9999999999
694 }
695 }
696 }
697 }`
698 require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
699
700 // Set up store with an older in-memory token
701 oldToken := &oauth.Token{
702 AccessToken: "older-access-token",
703 RefreshToken: "refresh-abc",
704 ExpiresIn: 3600,
705 ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Expired
706 }
707
708 providers := csync.NewMap[string, ProviderConfig]()
709 providers.Set("hyper", ProviderConfig{
710 ID: "hyper",
711 Name: "Hyper",
712 APIKey: oldToken.AccessToken,
713 OAuthToken: oldToken,
714 })
715
716 store := &ConfigStore{
717 config: &Config{
718 Providers: providers,
719 },
720 globalDataPath: configPath,
721 }
722
723 // Refresh should use the disk token without making an external call
724 err := store.RefreshOAuthToken(context.Background(), ScopeGlobal, "hyper")
725 require.NoError(t, err)
726
727 // Verify the in-memory token was updated to the disk token
728 updatedConfig, ok := store.config.Providers.Get("hyper")
729 require.True(t, ok)
730 require.Equal(t, "newer-access-token", updatedConfig.APIKey)
731 require.Equal(t, "newer-access-token", updatedConfig.OAuthToken.AccessToken)
732 require.Equal(t, "refresh-abc", updatedConfig.OAuthToken.RefreshToken)
733}
734
735// TestConfigStore_SetConfigFields_concurrentInProcess verifies that
736// concurrent in-process writes do not lose data when serialized by the
737// s.mu mutex. This does not exercise the cross-process flock; testing
738// that would require spawning a separate OS process.
739func TestConfigStore_SetConfigFields_concurrentInProcess(t *testing.T) {
740 t.Parallel()
741
742 dir := t.TempDir()
743 configPath := filepath.Join(dir, "crush.json")
744 require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
745 require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0o600))
746
747 store := &ConfigStore{
748 config: &Config{
749 Providers: csync.NewMap[string, ProviderConfig](),
750 Models: make(map[SelectedModelType]SelectedModel),
751 },
752 globalDataPath: configPath,
753 workingDir: dir,
754 }
755
756 const (
757 numGoroutines = 20
758 fieldsPerRoutine = 5
759 )
760
761 errs := make(chan error, numGoroutines)
762 for i := 0; i < numGoroutines; i++ {
763 go func(id int) {
764 kv := make(map[string]any, fieldsPerRoutine)
765 for j := 0; j < fieldsPerRoutine; j++ {
766 key := fmt.Sprintf("goroutine_%d_field_%d", id, j)
767 kv[key] = fmt.Sprintf("value_%d_%d", id, j)
768 }
769 errs <- store.SetConfigFields(ScopeGlobal, kv)
770 }(i)
771 }
772
773 for i := 0; i < numGoroutines; i++ {
774 require.NoError(t, <-errs)
775 }
776
777 // Verify all fields are present in the config file.
778 data, err := os.ReadFile(configPath)
779 require.NoError(t, err)
780
781 for i := 0; i < numGoroutines; i++ {
782 for j := 0; j < fieldsPerRoutine; j++ {
783 key := fmt.Sprintf("goroutine_%d_field_%d", i, j)
784 expectedValue := fmt.Sprintf("value_%d_%d", i, j)
785 result := gjson.Get(string(data), key)
786 require.True(t, result.Exists(), "key %s should exist", key)
787 require.Equal(t, expectedValue, result.String(), "key %s should have the correct value", key)
788 }
789 }
790}