store_test.go

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