@@ -16,6 +16,7 @@ import (
1616 "github.com/stacklok/toolhive/pkg/auth/remote"
1717 "github.com/stacklok/toolhive/pkg/config"
1818 configmocks "github.com/stacklok/toolhive/pkg/config/mocks"
19+ "github.com/stacklok/toolhive/pkg/secrets"
1920 secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks"
2021)
2122
@@ -647,24 +648,43 @@ func TestLogout(t *testing.T) {
647648 })
648649}
649650
650- // TestLogout_DeletesCachedToken cannot be parallel because it uses t.Setenv.
651- func TestLogout_DeletesCachedToken (t * testing.T ) {
651+ // fullCaps is the set of capabilities a real keyring/encrypted provider exposes:
652+ // every operation is supported, so Logout takes the list-and-delete fast path.
653+ var fullCaps = secrets.ProviderCapabilities {CanRead : true , CanWrite : true , CanDelete : true , CanList : true }
654+
655+ // TestLogout_DeletesAllScopedSecrets cannot be parallel because it uses t.Setenv.
656+ //
657+ // Verifies that Logout wipes every secret the (already scope-restricted)
658+ // provider can see — both the refresh token and the access-token cache entry
659+ // stored under "<key>_AT" by the token source. The original bug (#5373) was
660+ // that the "_AT" entry survived logout and let the next login short-circuit
661+ // through the access-token cache without a browser flow.
662+ func TestLogout_DeletesAllScopedSecrets (t * testing.T ) {
652663 tmpDir := resolvedTempDir (t )
653664 t .Setenv ("XDG_CACHE_HOME" , tmpDir )
654665
655666 cfg := configWithOAuth ()
656- cfg .RegistryAuth .OAuth .CachedRefreshTokenRef = "my-token-ref "
667+ cfg .RegistryAuth .OAuth .CachedRefreshTokenRef = "REGISTRY_OAUTH_abc "
657668 cfg .RegistryAuth .OAuth .CachedTokenExpiry = time .Now ().Add (time .Hour )
658669
659670 ctrl := gomock .NewController (t )
660671 mockCfg := configmocks .NewMockProvider (ctrl )
661672 mockCfg .EXPECT ().LoadOrCreateConfig ().Return (cfg , nil )
662673
663674 mockSecrets := secretsmocks .NewMockProvider (ctrl )
664- mockSecrets .EXPECT ().DeleteSecret (gomock .Any (), "my-token-ref" ).Return (nil )
665- // Derived key fallback: DeriveSecretKey(registryURL, issuer) differs from "my-token-ref".
666- derivedKey := DeriveSecretKey (cfg .RegistryApiUrl , cfg .RegistryAuth .OAuth .Issuer )
667- mockSecrets .EXPECT ().DeleteSecret (gomock .Any (), derivedKey ).Return (nil )
675+ mockSecrets .EXPECT ().Capabilities ().Return (fullCaps )
676+ // A scoped provider strips the "__thv_registry_" prefix from listed keys;
677+ // the bug surfaces when the "_AT" entry is present alongside the refresh token.
678+ mockSecrets .EXPECT ().ListSecrets (gomock .Any ()).Return ([]secrets.SecretDescription {
679+ {Key : "REGISTRY_OAUTH_abc" },
680+ {Key : "REGISTRY_OAUTH_abc_AT" },
681+ {Key : "REGISTRY_OAUTH_stale" }, // left over from an earlier issuer/registry config
682+ }, nil )
683+ mockSecrets .EXPECT ().DeleteSecrets (gomock .Any (), gomock .InAnyOrder ([]string {
684+ "REGISTRY_OAUTH_abc" ,
685+ "REGISTRY_OAUTH_abc_AT" ,
686+ "REGISTRY_OAUTH_stale" ,
687+ })).Return (nil )
668688
669689 mockCfg .EXPECT ().UpdateConfig (gomock .Any ()).DoAndReturn (func (fn func (* config.Config ) error ) error {
670690 require .NoError (t , fn (cfg ))
@@ -676,46 +696,91 @@ func TestLogout_DeletesCachedToken(t *testing.T) {
676696 require .NoError (t , Logout (context .Background (), mockCfg , mockSecrets ))
677697}
678698
679- // TestLogout_NoCachedRefSkipsDelete cannot be parallel because it uses t.Setenv.
680- func TestLogout_NoCachedRefSkipsDelete (t * testing.T ) {
699+ // TestLogout_NoSecretsIsNoop cannot be parallel because it uses t.Setenv.
700+ func TestLogout_NoSecretsIsNoop (t * testing.T ) {
701+ tmpDir := resolvedTempDir (t )
702+ t .Setenv ("XDG_CACHE_HOME" , tmpDir )
703+
704+ cfg := configWithOAuth ()
705+
706+ ctrl := gomock .NewController (t )
707+ mockCfg := configmocks .NewMockProvider (ctrl )
708+ mockCfg .EXPECT ().LoadOrCreateConfig ().Return (cfg , nil )
709+
710+ mockSecrets := secretsmocks .NewMockProvider (ctrl )
711+ mockSecrets .EXPECT ().Capabilities ().Return (fullCaps )
712+ mockSecrets .EXPECT ().ListSecrets (gomock .Any ()).Return (nil , nil )
713+ // DeleteSecrets is not expected — the list is empty, nothing to delete.
714+
715+ mockCfg .EXPECT ().UpdateConfig (gomock .Any ()).Return (nil )
716+
717+ require .NoError (t , Logout (context .Background (), mockCfg , mockSecrets ))
718+ }
719+
720+ // TestLogout_ReadOnlyProviderSkipsCleanup cannot be parallel because it uses t.Setenv.
721+ //
722+ // Providers like EnvironmentProvider cannot list or delete; they also cannot
723+ // hold cached tokens, so cleanup is a no-op rather than an error.
724+ func TestLogout_ReadOnlyProviderSkipsCleanup (t * testing.T ) {
681725 tmpDir := resolvedTempDir (t )
682726 t .Setenv ("XDG_CACHE_HOME" , tmpDir )
683727
684728 cfg := configWithOAuth ()
685- cfg .RegistryAuth .OAuth .CachedRefreshTokenRef = ""
686729
687730 ctrl := gomock .NewController (t )
688731 mockCfg := configmocks .NewMockProvider (ctrl )
689732 mockCfg .EXPECT ().LoadOrCreateConfig ().Return (cfg , nil )
690733
691734 mockSecrets := secretsmocks .NewMockProvider (ctrl )
692- // No CachedRefreshTokenRef, but derived key fallback fires.
693- derivedKey := DeriveSecretKey (cfg .RegistryApiUrl , cfg .RegistryAuth .OAuth .Issuer )
694- mockSecrets .EXPECT ().DeleteSecret (gomock .Any (), derivedKey ).Return (nil )
735+ mockSecrets .EXPECT ().Capabilities ().Return (secrets.ProviderCapabilities {CanRead : true })
736+ // ListSecrets / DeleteSecrets are not expected.
695737
696738 mockCfg .EXPECT ().UpdateConfig (gomock .Any ()).Return (nil )
697739
698740 require .NoError (t , Logout (context .Background (), mockCfg , mockSecrets ))
699741}
700742
701- // TestLogout_DeleteSecretError cannot be parallel because it uses t.Setenv.
702- func TestLogout_DeleteSecretError (t * testing.T ) {
743+ // TestLogout_ListSecretsError cannot be parallel because it uses t.Setenv.
744+ func TestLogout_ListSecretsError (t * testing.T ) {
703745 tmpDir := resolvedTempDir (t )
704746 t .Setenv ("XDG_CACHE_HOME" , tmpDir )
705747
706748 cfg := configWithOAuth ()
707- cfg .RegistryAuth .OAuth .CachedRefreshTokenRef = "token-ref"
708749
709750 ctrl := gomock .NewController (t )
710751 mockCfg := configmocks .NewMockProvider (ctrl )
711752 mockCfg .EXPECT ().LoadOrCreateConfig ().Return (cfg , nil )
712753
713754 mockSecrets := secretsmocks .NewMockProvider (ctrl )
714- mockSecrets .EXPECT ().DeleteSecret (gomock .Any (), "token-ref" ).Return (errors .New ("vault locked" ))
755+ mockSecrets .EXPECT ().Capabilities ().Return (fullCaps )
756+ mockSecrets .EXPECT ().ListSecrets (gomock .Any ()).Return (nil , errors .New ("vault locked" ))
757+
758+ err := Logout (context .Background (), mockCfg , mockSecrets )
759+ require .Error (t , err )
760+ require .Contains (t , err .Error (), "deleting cached tokens" )
761+ }
762+
763+ // TestLogout_DeleteSecretsError cannot be parallel because it uses t.Setenv.
764+ func TestLogout_DeleteSecretsError (t * testing.T ) {
765+ tmpDir := resolvedTempDir (t )
766+ t .Setenv ("XDG_CACHE_HOME" , tmpDir )
767+
768+ cfg := configWithOAuth ()
769+
770+ ctrl := gomock .NewController (t )
771+ mockCfg := configmocks .NewMockProvider (ctrl )
772+ mockCfg .EXPECT ().LoadOrCreateConfig ().Return (cfg , nil )
773+
774+ mockSecrets := secretsmocks .NewMockProvider (ctrl )
775+ mockSecrets .EXPECT ().Capabilities ().Return (fullCaps )
776+ mockSecrets .EXPECT ().ListSecrets (gomock .Any ()).Return ([]secrets.SecretDescription {
777+ {Key : "REGISTRY_OAUTH_abc" },
778+ }, nil )
779+ mockSecrets .EXPECT ().DeleteSecrets (gomock .Any (), gomock .Any ()).Return (errors .New ("vault locked" ))
715780
716781 err := Logout (context .Background (), mockCfg , mockSecrets )
717782 require .Error (t , err )
718- require .Contains (t , err .Error (), "deleting cached token " )
783+ require .Contains (t , err .Error (), "deleting cached tokens " )
719784}
720785
721786// TestLogout_UpdateConfigError cannot be parallel because it uses t.Setenv.
@@ -730,9 +795,8 @@ func TestLogout_UpdateConfigError(t *testing.T) {
730795 mockCfg .EXPECT ().LoadOrCreateConfig ().Return (cfg , nil )
731796
732797 mockSecrets := secretsmocks .NewMockProvider (ctrl )
733- // Derived key fallback fires since CachedRefreshTokenRef is empty.
734- derivedKey := DeriveSecretKey (cfg .RegistryApiUrl , cfg .RegistryAuth .OAuth .Issuer )
735- mockSecrets .EXPECT ().DeleteSecret (gomock .Any (), derivedKey ).Return (nil )
798+ mockSecrets .EXPECT ().Capabilities ().Return (fullCaps )
799+ mockSecrets .EXPECT ().ListSecrets (gomock .Any ()).Return (nil , nil )
736800
737801 mockCfg .EXPECT ().UpdateConfig (gomock .Any ()).Return (errors .New ("write failed" ))
738802
0 commit comments