@@ -8,6 +8,8 @@ import (
88 "testing"
99 "time"
1010
11+ "github.com/charmbracelet/crush/internal/csync"
12+ "github.com/charmbracelet/crush/internal/oauth"
1113 "github.com/stretchr/testify/require"
1214)
1315
@@ -510,3 +512,183 @@ func TestAutoReloadDisabledDuringReload(t *testing.T) {
510512 // Verify reload completed successfully
511513 require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
512514}
515+
516+func TestLoadTokenFromDisk_ReturnsNewerToken(t *testing.T) {
517+ t.Parallel()
518+
519+ dir := t.TempDir()
520+ configPath := filepath.Join(dir, "crush.json")
521+
522+ // Create config file with a newer token on disk
523+ configContent := `{
524+ "providers": {
525+ "hyper": {
526+ "oauth": {
527+ "access_token": "newer-token-from-disk",
528+ "refresh_token": "refresh-abc",
529+ "expires_in": 3600,
530+ "expires_at": 9999999999
531+ }
532+ }
533+ }
534+ }`
535+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
536+
537+ store := &ConfigStore{
538+ config: &Config{},
539+ globalDataPath: configPath,
540+ }
541+
542+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
543+ require.NoError(t, err)
544+ require.NotNil(t, token)
545+ require.Equal(t, "newer-token-from-disk", token.AccessToken)
546+ require.Equal(t, "refresh-abc", token.RefreshToken)
547+ require.Equal(t, 3600, token.ExpiresIn)
548+ require.Equal(t, int64(9999999999), token.ExpiresAt)
549+}
550+
551+func TestLoadTokenFromDisk_ReturnsNilWhenSameToken(t *testing.T) {
552+ t.Parallel()
553+
554+ dir := t.TempDir()
555+ configPath := filepath.Join(dir, "crush.json")
556+
557+ // Create config file with the same token
558+ configContent := `{
559+ "providers": {
560+ "hyper": {
561+ "oauth": {
562+ "access_token": "same-token",
563+ "refresh_token": "refresh-abc",
564+ "expires_in": 3600,
565+ "expires_at": 9999999999
566+ }
567+ }
568+ }
569+ }`
570+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
571+
572+ store := &ConfigStore{
573+ config: &Config{},
574+ globalDataPath: configPath,
575+ }
576+
577+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
578+ require.NoError(t, err)
579+ require.NotNil(t, token)
580+ require.Equal(t, "same-token", token.AccessToken)
581+}
582+
583+func TestLoadTokenFromDisk_ReturnsNilWhenFileMissing(t *testing.T) {
584+ t.Parallel()
585+
586+ dir := t.TempDir()
587+ configPath := filepath.Join(dir, "nonexistent.json")
588+
589+ store := &ConfigStore{
590+ config: &Config{},
591+ globalDataPath: configPath,
592+ }
593+
594+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
595+ require.NoError(t, err)
596+ require.Nil(t, token)
597+}
598+
599+func TestLoadTokenFromDisk_ReturnsNilWhenProviderMissing(t *testing.T) {
600+ t.Parallel()
601+
602+ dir := t.TempDir()
603+ configPath := filepath.Join(dir, "crush.json")
604+
605+ // Create config file without the hyper provider
606+ configContent := `{"providers": {"openai": {"api_key": "test-key"}}}`
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.Nil(t, token)
617+}
618+
619+func TestLoadTokenFromDisk_ReturnsNilWhenOAuthMissing(t *testing.T) {
620+ t.Parallel()
621+
622+ dir := t.TempDir()
623+ configPath := filepath.Join(dir, "crush.json")
624+
625+ // Create config file with provider but no OAuth token
626+ configContent := `{"providers": {"hyper": {"api_key": "test-key"}}}`
627+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
628+
629+ store := &ConfigStore{
630+ config: &Config{},
631+ globalDataPath: configPath,
632+ }
633+
634+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
635+ require.NoError(t, err)
636+ require.Nil(t, token)
637+}
638+
639+func TestRefreshOAuthToken_UsesDiskTokenWhenDifferent(t *testing.T) {
640+ t.Parallel()
641+
642+ dir := t.TempDir()
643+ configPath := filepath.Join(dir, "crush.json")
644+
645+ // Create config file with a newer token on disk
646+ configContent := `{
647+ "providers": {
648+ "hyper": {
649+ "api_key": "newer-access-token",
650+ "oauth": {
651+ "access_token": "newer-access-token",
652+ "refresh_token": "refresh-abc",
653+ "expires_in": 3600,
654+ "expires_at": 9999999999
655+ }
656+ }
657+ }
658+ }`
659+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
660+
661+ // Set up store with an older in-memory token
662+ oldToken := &oauth.Token{
663+ AccessToken: "older-access-token",
664+ RefreshToken: "refresh-abc",
665+ ExpiresIn: 3600,
666+ ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Expired
667+ }
668+
669+ providers := csync.NewMap[string, ProviderConfig]()
670+ providers.Set("hyper", ProviderConfig{
671+ ID: "hyper",
672+ Name: "Hyper",
673+ APIKey: oldToken.AccessToken,
674+ OAuthToken: oldToken,
675+ })
676+
677+ store := &ConfigStore{
678+ config: &Config{
679+ Providers: providers,
680+ },
681+ globalDataPath: configPath,
682+ }
683+
684+ // Refresh should use the disk token without making an external call
685+ err := store.RefreshOAuthToken(context.Background(), ScopeGlobal, "hyper")
686+ require.NoError(t, err)
687+
688+ // Verify the in-memory token was updated to the disk token
689+ updatedConfig, ok := store.config.Providers.Get("hyper")
690+ require.True(t, ok)
691+ require.Equal(t, "newer-access-token", updatedConfig.APIKey)
692+ require.Equal(t, "newer-access-token", updatedConfig.OAuthToken.AccessToken)
693+ require.Equal(t, "refresh-abc", updatedConfig.OAuthToken.RefreshToken)
694+}