@@ -23,9 +23,12 @@ describe('AuthManager', () => {
2323
2424 // Setup mock OAuth2 client
2525 mockOAuth2Client = {
26- setCredentials : jest . fn ( ) ,
26+ setCredentials : jest . fn ( ) . mockImplementation ( ( creds ) => {
27+ mockOAuth2Client . credentials = creds ;
28+ } ) ,
2729 generateAuthUrl : jest . fn ( ) ,
2830 on : jest . fn ( ) ,
31+ refreshAccessToken : jest . fn ( ) ,
2932 credentials : { }
3033 } ;
3134
@@ -69,11 +72,112 @@ describe('AuthManager', () => {
6972 await tokensCallback ( newTokens ) ;
7073
7174 // Verify saveCredentials was called with merged tokens
75+ // New tokens take precedence, but refresh_token is preserved from old credentials
7276 expect ( OAuthCredentialStorage . saveCredentials ) . toHaveBeenCalledWith ( {
7377 access_token : 'new_token' ,
74- refresh_token : 'old_refresh' , // Should be preserved
75- expiry_date : 123456789 ,
78+ refresh_token : 'old_refresh' , // Preserved from old credentials
79+ expiry_date : 123456789
80+ // Note: scope is NOT preserved because newTokens didn't include it
81+ } ) ;
82+ } ) ;
83+
84+ it ( 'should preserve refresh token during manual refresh if not returned' , async ( ) => {
85+ // Setup initial state with a refresh token
86+ ( OAuthCredentialStorage . loadCredentials as jest . Mock ) . mockResolvedValue ( {
87+ access_token : 'old_token' ,
88+ refresh_token : 'old_refresh_token' ,
89+ scope : 'scope1'
90+ } ) ;
91+
92+ // Initialize client to populate this.client
93+ await authManager . getAuthenticatedClient ( ) ;
94+
95+ // Mock refresh to return ONLY access token (no refresh token)
96+ // We need to update the mock to actually update credentials, similar to real OAuth2Client
97+ mockOAuth2Client . refreshAccessToken . mockImplementation ( async ( ) => {
98+ const newCreds = {
99+ access_token : 'new_access_token' ,
100+ expiry_date : 999999999
101+ } ;
102+ mockOAuth2Client . credentials = newCreds ;
103+ return { credentials : newCreds } ;
104+ } ) ;
105+
106+ await authManager . refreshToken ( ) ;
107+
108+ // Verify saveCredentials was called with BOTH new access token AND old refresh token
109+ expect ( OAuthCredentialStorage . saveCredentials ) . toHaveBeenCalledWith ( expect . objectContaining ( {
110+ access_token : 'new_access_token' ,
111+ refresh_token : 'old_refresh_token'
112+ } ) ) ;
113+ } ) ;
114+
115+ it ( 'should preserve refresh token when refreshAccessToken mutates credentials in-place' , async ( ) => {
116+ // Setup initial state with a refresh token
117+ ( OAuthCredentialStorage . loadCredentials as jest . Mock ) . mockResolvedValue ( {
118+ access_token : 'old_token' ,
119+ refresh_token : 'old_refresh_token' ,
76120 scope : 'scope1'
77121 } ) ;
122+
123+ // Initialize client to populate this.client
124+ await authManager . getAuthenticatedClient ( ) ;
125+
126+ // This test simulates the REAL OAuth2Client behavior where refreshAccessToken
127+ // mutates the credentials object IN-PLACE before returning
128+ mockOAuth2Client . refreshAccessToken . mockImplementation ( async ( ) => {
129+ // CRITICAL: Mutate the existing credentials object in-place
130+ // This is what the real OAuth2Client does!
131+ mockOAuth2Client . credentials . access_token = 'new_access_token' ;
132+ mockOAuth2Client . credentials . expiry_date = 999999999 ;
133+ // Note: refresh_token is NOT included in the refresh response
134+ delete mockOAuth2Client . credentials . refresh_token ;
135+ delete mockOAuth2Client . credentials . scope ;
136+
137+ // Return the new credentials (which are the SAME object reference)
138+ return { credentials : mockOAuth2Client . credentials } ;
139+ } ) ;
140+
141+ await authManager . refreshToken ( ) ;
142+
143+ // This test will FAIL if the bug exists, because:
144+ // 1. Line 146 captures a reference to mockOAuth2Client.credentials
145+ // 2. Line 148 calls refreshAccessToken which mutates that same object
146+ // 3. The merge logic sees currentCredentials.refresh_token is undefined (it was deleted)
147+ // 4. The refresh_token is lost
148+ expect ( OAuthCredentialStorage . saveCredentials ) . toHaveBeenCalledWith ( expect . objectContaining ( {
149+ access_token : 'new_access_token' ,
150+ refresh_token : 'old_refresh_token'
151+ } ) ) ;
152+ } ) ;
153+
154+ it ( 'should preserve refresh token in tokens event handler' , async ( ) => {
155+ // Setup initial state with a refresh token in storage
156+ ( OAuthCredentialStorage . loadCredentials as jest . Mock ) . mockResolvedValue ( {
157+ access_token : 'old_token' ,
158+ refresh_token : 'stored_refresh_token' ,
159+ scope : 'scope1'
160+ } ) ;
161+
162+ await authManager . getAuthenticatedClient ( ) ;
163+
164+ // Get the registered callback
165+ const tokensCallback = mockOAuth2Client . on . mock . calls . find ( ( call : any [ ] ) => call [ 0 ] === 'tokens' ) [ 1 ] ;
166+
167+ // Simulate automatic refresh that doesn't include refresh_token
168+ const newTokens = {
169+ access_token : 'auto_refreshed_token' ,
170+ expiry_date : 999999999
171+ // Note: no refresh_token
172+ } ;
173+
174+ await tokensCallback ( newTokens ) ;
175+
176+ // Verify saveCredentials was called with BOTH new access token AND stored refresh token
177+ expect ( OAuthCredentialStorage . saveCredentials ) . toHaveBeenCalledWith ( {
178+ access_token : 'auto_refreshed_token' ,
179+ expiry_date : 999999999 ,
180+ refresh_token : 'stored_refresh_token'
181+ } ) ;
78182 } ) ;
79183} ) ;
0 commit comments