Download packages/core/src/code_assist/oauth2.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 64.5 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/code_assist/oauth2.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/code_assist/oauth2.test.ts
-
curl -L -o oauth2.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/code_assist/oauth2.test.ts
64.5 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| OAuth2Client, | |
| Compute, | |
| GoogleAuth, | |
| type Credentials, | |
| } from 'google-auth-library'; | |
| import { | |
| describe, | |
| it, | |
| expect, | |
| vi, | |
| beforeEach, | |
| afterEach, | |
| type Mock, | |
| } from 'vitest'; | |
| import { | |
| getOauthClient, | |
| resetOauthClientForTesting, | |
| clearCachedCredentialFile, | |
| clearOauthClientCache, | |
| authEvents, | |
| } from './oauth2.js'; | |
| import { UserAccountManager } from '../utils/userAccountManager.js'; | |
| import * as fs from 'node:fs'; | |
| import * as path from 'node:path'; | |
| import http from 'node:http'; | |
| import open from 'open'; | |
| import crypto from 'node:crypto'; | |
| import * as os from 'node:os'; | |
| import { AuthType } from '../core/contentGenerator.js'; | |
| import type { Config } from '../config/config.js'; | |
| import readline from 'node:readline'; | |
| import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js'; | |
| import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js'; | |
| import { debugLogger } from '../utils/debugLogger.js'; | |
| import { writeToStdout } from '../utils/stdio.js'; | |
| import { | |
| FatalCancellationError, | |
| FatalAuthenticationError, | |
| } from '../utils/errors.js'; | |
| import process from 'node:process'; | |
| import { coreEvents } from '../utils/events.js'; | |
| import { isHeadlessMode } from '../utils/headless.js'; | |
| vi.mock('node:os', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('node:os')>(); | |
| return { | |
| ...actual, | |
| homedir: vi.fn(), | |
| }; | |
| }); | |
| vi.mock('../utils/paths.js', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('../utils/paths.js')>(); | |
| return { | |
| ...actual, | |
| homedir: vi.fn(), | |
| }; | |
| }); | |
| vi.mock('google-auth-library'); | |
| vi.mock('http'); | |
| vi.mock('open'); | |
| vi.mock('crypto'); | |
| vi.mock('node:readline'); | |
| vi.mock('../utils/headless.js', () => ({ | |
| isHeadlessMode: vi.fn(), | |
| })); | |
| vi.mock('../utils/browser.js', () => ({ | |
| shouldAttemptBrowserLaunch: () => true, | |
| })); | |
| vi.mock('../utils/stdio.js', () => ({ | |
| writeToStdout: vi.fn(), | |
| writeToStderr: vi.fn(), | |
| createWorkingStdio: vi.fn(() => ({ | |
| stdout: process.stdout, | |
| stderr: process.stderr, | |
| })), | |
| enterAlternateScreen: vi.fn(), | |
| exitAlternateScreen: vi.fn(), | |
| enableLineWrapping: vi.fn(), | |
| disableMouseEvents: vi.fn(), | |
| disableKittyKeyboardProtocol: vi.fn(), | |
| })); | |
| vi.mock('./oauth-credential-storage.js', () => ({ | |
| OAuthCredentialStorage: { | |
| saveCredentials: vi.fn(), | |
| loadCredentials: vi.fn(), | |
| clearCredentials: vi.fn(), | |
| }, | |
| })); | |
| vi.mock('../mcp/token-storage/hybrid-token-storage.js', () => ({ | |
| HybridTokenStorage: vi.fn(() => ({ | |
| getCredentials: vi.fn(), | |
| setCredentials: vi.fn(), | |
| deleteCredentials: vi.fn(), | |
| })), | |
| })); | |
| const mockConfig = { | |
| getNoBrowser: () => false, | |
| getProxy: () => 'http://test.proxy.com:8080', | |
| isBrowserLaunchSuppressed: () => false, | |
| getAcpMode: () => false, | |
| isInteractive: () => true, | |
| } as unknown as Config; | |
| // Mock fetch globally | |
| global.fetch = vi.fn(); | |
| describe('oauth2', () => { | |
| beforeEach(() => { | |
| vi.mocked(isHeadlessMode).mockReturnValue(false); | |
| (readline.createInterface as Mock).mockReturnValue({ | |
| question: vi.fn((_query, callback) => callback('')), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| }); | |
| vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); | |
| vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { | |
| payload.onConfirm(true); | |
| }); | |
| }); | |
| describe('with encrypted flag false', () => { | |
| let tempHomeDir: string; | |
| beforeEach(() => { | |
| process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] = 'false'; | |
| tempHomeDir = fs.mkdtempSync( | |
| path.join(os.tmpdir(), 'gemini-cli-test-home-'), | |
| ); | |
| vi.mocked(os.homedir).mockReturnValue(tempHomeDir); | |
| vi.mocked(pathsHomedir).mockReturnValue(tempHomeDir); | |
| }); | |
| afterEach(() => { | |
| fs.rmSync(tempHomeDir, { recursive: true, force: true }); | |
| vi.clearAllMocks(); | |
| resetOauthClientForTesting(); | |
| vi.unstubAllEnvs(); | |
| }); | |
| it('should perform a web login', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockCode = 'test-code'; | |
| const mockState = 'test-state'; | |
| const mockTokens = { | |
| access_token: 'test-access-token', | |
| refresh_token: 'test-refresh-token', | |
| }; | |
| const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl); | |
| const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens }); | |
| const mockSetCredentials = vi.fn(); | |
| const mockGetAccessToken = vi | |
| .fn() | |
| .mockResolvedValue({ token: 'mock-access-token' }); | |
| let tokensListener: ((tokens: Credentials) => void) | undefined; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: mockGenerateAuthUrl, | |
| getToken: mockGetToken, | |
| setCredentials: mockSetCredentials, | |
| getAccessToken: mockGetAccessToken, | |
| credentials: mockTokens, | |
| on: vi.fn((event, listener) => { | |
| if (event === 'tokens') { | |
| tokensListener = listener; | |
| } | |
| }), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| // Mock the UserInfo API response | |
| vi.mocked(global.fetch).mockResolvedValue({ | |
| ok: true, | |
| json: vi | |
| .fn() | |
| .mockResolvedValue({ email: 'test-google-account@gmail.com' }), | |
| } as unknown as Response); | |
| let requestCallback!: http.RequestListener< | |
| typeof http.IncomingMessage, | |
| typeof http.ServerResponse | |
| >; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| let capturedPort = 0; | |
| const mockHttpServer = { | |
| listen: vi.fn((port: number, _host: string, callback?: () => void) => { | |
| capturedPort = port; | |
| if (callback) { | |
| callback(); | |
| } | |
| serverListeningCallback(undefined); | |
| }), | |
| close: vi.fn((callback?: () => void) => { | |
| if (callback) { | |
| callback(); | |
| } | |
| }), | |
| on: vi.fn(), | |
| address: () => ({ port: capturedPort }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb as http.RequestListener< | |
| typeof http.IncomingMessage, | |
| typeof http.ServerResponse | |
| >; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| // wait for server to start listening. | |
| await serverListeningPromise; | |
| const mockReq = { | |
| url: `/oauth2callback?code=${mockCode}&state=${mockState}`, | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| requestCallback(mockReq, mockRes); | |
| const client = await clientPromise; | |
| expect(client).toBe(mockOAuth2Client); | |
| expect(open).toHaveBeenCalledWith(mockAuthUrl); | |
| expect(mockGetToken).toHaveBeenCalledWith({ | |
| code: mockCode, | |
| redirect_uri: `http://127.0.0.1:${capturedPort}/oauth2callback`, | |
| }); | |
| expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); | |
| // Manually trigger the 'tokens' event listener | |
| if (tokensListener) { | |
| await ( | |
| tokensListener as unknown as (tokens: Credentials) => Promise<void> | |
| )(mockTokens); | |
| } | |
| // Verify Google Account was cached | |
| const googleAccountPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'google_accounts.json', | |
| ); | |
| expect(fs.existsSync(googleAccountPath)).toBe(true); | |
| const cachedGoogleAccount = fs.readFileSync(googleAccountPath, 'utf-8'); | |
| expect(JSON.parse(cachedGoogleAccount)).toEqual({ | |
| active: 'test-google-account@gmail.com', | |
| old: [], | |
| }); | |
| // Verify the getCachedGoogleAccount function works | |
| const userAccountManager = new UserAccountManager(); | |
| expect(userAccountManager.getCachedGoogleAccount()).toBe( | |
| 'test-google-account@gmail.com', | |
| ); | |
| }); | |
| it('should clear credentials file', async () => { | |
| // Setup initial state with files | |
| const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, '{}'); | |
| await clearCachedCredentialFile(); | |
| expect(fs.existsSync(credsPath)).toBe(false); | |
| }); | |
| it('should emit post_auth event when loading cached credentials', async () => { | |
| const cachedCreds = { refresh_token: 'cached-token' }; | |
| const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); | |
| const mockClient = { | |
| setCredentials: vi.fn(), | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| getTokenInfo: vi.fn().mockResolvedValue({}), | |
| on: vi.fn(), | |
| }; | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => mockClient as unknown as OAuth2Client, | |
| ); | |
| const eventPromise = new Promise<void>((resolve) => { | |
| authEvents.once('post_auth', (creds) => { | |
| expect(creds.refresh_token).toBe('cached-token'); | |
| resolve(); | |
| }); | |
| }); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| await eventPromise; | |
| }); | |
| it('should throw FatalAuthenticationError in non-interactive session when manual auth is required', async () => { | |
| const mockConfigNonInteractive = { | |
| getNoBrowser: () => true, | |
| getProxy: () => 'http://test.proxy.com:8080', | |
| isBrowserLaunchSuppressed: () => true, | |
| isInteractive: () => false, | |
| } as unknown as Config; | |
| await expect( | |
| getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigNonInteractive), | |
| ).rejects.toThrow(FatalAuthenticationError); | |
| await expect( | |
| getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigNonInteractive), | |
| ).rejects.toThrow( | |
| 'Manual authorization is required but the current session is non-interactive.', | |
| ); | |
| }); | |
| it('should perform login with user code', async () => { | |
| const mockConfigWithNoBrowser = { | |
| getNoBrowser: () => true, | |
| getProxy: () => 'http://test.proxy.com:8080', | |
| isBrowserLaunchSuppressed: () => true, | |
| isInteractive: () => true, | |
| } as unknown as Config; | |
| const mockCodeVerifier = { | |
| codeChallenge: 'test-challenge', | |
| codeVerifier: 'test-verifier', | |
| }; | |
| const mockAuthUrl = 'https://example.com/auth-user-code'; | |
| const mockCode = 'test-user-code'; | |
| const mockTokens = { | |
| access_token: 'test-access-token-user-code', | |
| refresh_token: 'test-refresh-token-user-code', | |
| }; | |
| const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl); | |
| const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens }); | |
| const mockGenerateCodeVerifierAsync = vi | |
| .fn() | |
| .mockResolvedValue(mockCodeVerifier); | |
| const mockOAuth2Client = { | |
| generateAuthUrl: mockGenerateAuthUrl, | |
| getToken: mockGetToken, | |
| generateCodeVerifierAsync: mockGenerateCodeVerifierAsync, | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| on: vi.fn(), | |
| credentials: {}, | |
| } as unknown as OAuth2Client; | |
| mockOAuth2Client.setCredentials = vi.fn().mockImplementation((creds) => { | |
| mockOAuth2Client.credentials = creds; | |
| }); | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| const mockReadline = { | |
| question: vi.fn((_query, callback) => callback(mockCode)), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| }; | |
| (readline.createInterface as Mock).mockReturnValue(mockReadline); | |
| const client = await getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfigWithNoBrowser, | |
| ); | |
| expect(client).toBe(mockOAuth2Client); | |
| // Verify the auth flow | |
| expect(mockGenerateCodeVerifierAsync).toHaveBeenCalled(); | |
| expect(mockGenerateAuthUrl).toHaveBeenCalled(); | |
| expect(vi.mocked(writeToStdout)).toHaveBeenCalledWith( | |
| expect.stringContaining(mockAuthUrl), | |
| ); | |
| expect(mockReadline.question).toHaveBeenCalledWith( | |
| 'Enter the authorization code: ', | |
| expect.any(Function), | |
| ); | |
| expect(mockGetToken).toHaveBeenCalledWith({ | |
| code: mockCode, | |
| codeVerifier: mockCodeVerifier.codeVerifier, | |
| redirect_uri: 'https://codeassist.google.com/authcode', | |
| }); | |
| expect(mockOAuth2Client.setCredentials).toHaveBeenCalledWith(mockTokens); | |
| }); | |
| it('should cache Google Account when logging in with user code', async () => { | |
| const mockConfigWithNoBrowser = { | |
| getNoBrowser: () => true, | |
| getProxy: () => 'http://test.proxy.com:8080', | |
| isBrowserLaunchSuppressed: () => true, | |
| isInteractive: () => true, | |
| } as unknown as Config; | |
| const mockCodeVerifier = { | |
| codeChallenge: 'test-challenge', | |
| codeVerifier: 'test-verifier', | |
| }; | |
| const mockAuthUrl = 'https://example.com/auth-user-code'; | |
| const mockCode = 'test-user-code'; | |
| const mockTokens = { | |
| access_token: 'test-access-token-user-code', | |
| refresh_token: 'test-refresh-token-user-code', | |
| }; | |
| const mockGenerateAuthUrl = vi.fn().mockReturnValue(mockAuthUrl); | |
| const mockGetToken = vi.fn().mockResolvedValue({ tokens: mockTokens }); | |
| const mockGenerateCodeVerifierAsync = vi | |
| .fn() | |
| .mockResolvedValue(mockCodeVerifier); | |
| const mockGetAccessToken = vi | |
| .fn() | |
| .mockResolvedValue({ token: 'test-access-token-user-code' }); | |
| const mockOAuth2Client = { | |
| generateAuthUrl: mockGenerateAuthUrl, | |
| getToken: mockGetToken, | |
| generateCodeVerifierAsync: mockGenerateCodeVerifierAsync, | |
| getAccessToken: mockGetAccessToken, | |
| on: vi.fn(), | |
| credentials: {}, | |
| } as unknown as OAuth2Client; | |
| mockOAuth2Client.setCredentials = vi.fn().mockImplementation((creds) => { | |
| mockOAuth2Client.credentials = creds; | |
| }); | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue('test-state' as never); | |
| const mockReadline = { | |
| question: vi.fn((_query, callback) => callback(mockCode)), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| }; | |
| (readline.createInterface as Mock).mockReturnValue(mockReadline); | |
| // Mock User Info API | |
| vi.mocked(global.fetch).mockResolvedValue({ | |
| ok: true, | |
| json: vi | |
| .fn() | |
| .mockResolvedValue({ email: 'test-user-code-account@gmail.com' }), | |
| } as unknown as Response); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigWithNoBrowser); | |
| // Verify Google Account was cached | |
| const googleAccountPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'google_accounts.json', | |
| ); | |
| expect(fs.existsSync(googleAccountPath)).toBe(true); | |
| if (fs.existsSync(googleAccountPath)) { | |
| const cachedGoogleAccount = fs.readFileSync(googleAccountPath, 'utf-8'); | |
| expect(JSON.parse(cachedGoogleAccount)).toEqual({ | |
| active: 'test-user-code-account@gmail.com', | |
| old: [], | |
| }); | |
| } | |
| }); | |
| describe('in Cloud Shell', () => { | |
| const mockGetAccessToken = vi.fn(); | |
| let mockComputeClient: Compute; | |
| beforeEach(() => { | |
| mockGetAccessToken.mockResolvedValue({ token: 'test-access-token' }); | |
| mockComputeClient = { | |
| credentials: { refresh_token: 'test-refresh-token' }, | |
| getAccessToken: mockGetAccessToken, | |
| } as unknown as Compute; | |
| (Compute as unknown as Mock).mockImplementation( | |
| () => mockComputeClient, | |
| ); | |
| }); | |
| it('should attempt to load cached credentials first', async () => { | |
| const cachedCreds = { refresh_token: 'cached-token' }; | |
| const credsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); | |
| const mockClient = { | |
| setCredentials: vi.fn(), | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| getTokenInfo: vi.fn().mockResolvedValue({}), | |
| on: vi.fn(), | |
| }; | |
| // To mock the new OAuth2Client() inside the function | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => mockClient as unknown as OAuth2Client, | |
| ); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| expect(mockClient.setCredentials).toHaveBeenCalledWith(cachedCreds); | |
| expect(mockClient.getAccessToken).toHaveBeenCalled(); | |
| expect(mockClient.getTokenInfo).toHaveBeenCalled(); | |
| expect(Compute).not.toHaveBeenCalled(); // Should not fetch new client if cache is valid | |
| }); | |
| it('should use Compute to get a client if no cached credentials exist', async () => { | |
| await getOauthClient(AuthType.COMPUTE_ADC, mockConfig); | |
| expect(Compute).toHaveBeenCalledWith({}); | |
| expect(mockGetAccessToken).toHaveBeenCalled(); | |
| }); | |
| it('should not cache the credentials after fetching them via ADC', async () => { | |
| const newCredentials = { refresh_token: 'new-adc-token' }; | |
| mockComputeClient.credentials = newCredentials; | |
| mockGetAccessToken.mockResolvedValue({ token: 'new-adc-token' }); | |
| await getOauthClient(AuthType.COMPUTE_ADC, mockConfig); | |
| const credsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| expect(fs.existsSync(credsPath)).toBe(false); | |
| }); | |
| it('should return the Compute client on successful ADC authentication', async () => { | |
| const client = await getOauthClient(AuthType.COMPUTE_ADC, mockConfig); | |
| expect(client).toBe(mockComputeClient); | |
| }); | |
| it('should throw an error if ADC fails', async () => { | |
| const testError = new Error('ADC Failed'); | |
| mockGetAccessToken.mockRejectedValue(testError); | |
| await expect( | |
| getOauthClient(AuthType.COMPUTE_ADC, mockConfig), | |
| ).rejects.toThrow( | |
| 'Could not authenticate using metadata server application default credentials. Please select a different authentication method or ensure you are in a properly configured environment. Error: ADC Failed', | |
| ); | |
| }); | |
| }); | |
| describe('credential loading order', () => { | |
| it('should prioritize default cached credentials over GOOGLE_APPLICATION_CREDENTIALS', async () => { | |
| // Setup default cached credentials | |
| const defaultCreds = { refresh_token: 'default-cached-token' }; | |
| const defaultCredsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(defaultCredsPath), { | |
| recursive: true, | |
| }); | |
| await fs.promises.writeFile( | |
| defaultCredsPath, | |
| JSON.stringify(defaultCreds), | |
| ); | |
| // Setup credentials via environment variable | |
| const envCreds = { refresh_token: 'env-var-token' }; | |
| const envCredsPath = path.join(tempHomeDir, 'env_creds.json'); | |
| await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds)); | |
| vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath); | |
| const mockClient = { | |
| setCredentials: vi.fn(), | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| getTokenInfo: vi.fn().mockResolvedValue({}), | |
| on: vi.fn(), | |
| }; | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => mockClient as unknown as OAuth2Client, | |
| ); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| // Assert the correct credentials were used | |
| expect(mockClient.setCredentials).toHaveBeenCalledWith(defaultCreds); | |
| expect(mockClient.setCredentials).not.toHaveBeenCalledWith(envCreds); | |
| }); | |
| it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cache is missing', async () => { | |
| // Setup credentials via environment variable | |
| const envCreds = { refresh_token: 'env-var-token' }; | |
| const envCredsPath = path.join(tempHomeDir, 'env_creds.json'); | |
| await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds)); | |
| vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath); | |
| const mockClient = { | |
| setCredentials: vi.fn(), | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| getTokenInfo: vi.fn().mockResolvedValue({}), | |
| on: vi.fn(), | |
| }; | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => mockClient as unknown as OAuth2Client, | |
| ); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| // Assert the correct credentials were used | |
| expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds); | |
| }); | |
| it('should use GoogleAuth for BYOID credentials from GOOGLE_APPLICATION_CREDENTIALS', async () => { | |
| // Setup BYOID credentials via environment variable | |
| const byoidCredentials = { | |
| type: 'external_account_authorized_user', | |
| client_id: 'mock-client-id', | |
| }; | |
| const envCredsPath = path.join(tempHomeDir, 'byoid_creds.json'); | |
| await fs.promises.writeFile( | |
| envCredsPath, | |
| JSON.stringify(byoidCredentials), | |
| ); | |
| vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath); | |
| // Mock GoogleAuth and its chain of calls | |
| const mockExternalAccountClient = { | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'byoid-token' }), | |
| }; | |
| const mockFromJSON = vi.fn().mockReturnValue(mockExternalAccountClient); | |
| const mockGoogleAuthInstance = { | |
| fromJSON: mockFromJSON, | |
| }; | |
| (GoogleAuth as unknown as Mock).mockImplementation( | |
| () => mockGoogleAuthInstance, | |
| ); | |
| const mockOAuth2Client = { | |
| on: vi.fn(), | |
| }; | |
| (OAuth2Client as unknown as Mock).mockImplementation( | |
| () => mockOAuth2Client, | |
| ); | |
| const client = await getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| // Assert that GoogleAuth was used and the correct client was returned | |
| expect(GoogleAuth).toHaveBeenCalledWith({ | |
| scopes: expect.any(Array), | |
| }); | |
| expect(mockFromJSON).toHaveBeenCalledWith(byoidCredentials); | |
| expect(client).toBe(mockExternalAccountClient); | |
| }); | |
| it('should fall back to GOOGLE_APPLICATION_CREDENTIALS if default cached credentials are invalid or expired', async () => { | |
| // Setup default cached credentials that are expired/invalid | |
| const defaultCreds = { refresh_token: 'expired-token' }; | |
| const defaultCredsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(defaultCredsPath), { | |
| recursive: true, | |
| }); | |
| await fs.promises.writeFile( | |
| defaultCredsPath, | |
| JSON.stringify(defaultCreds), | |
| ); | |
| // Setup valid fallback credentials via environment variable | |
| const envCreds = { refresh_token: 'valid-env-token' }; | |
| const envCredsPath = path.join(tempHomeDir, 'env_creds.json'); | |
| await fs.promises.writeFile(envCredsPath, JSON.stringify(envCreds)); | |
| vi.stubEnv('GOOGLE_APPLICATION_CREDENTIALS', envCredsPath); | |
| let currentCredentials: Credentials | null = null; | |
| const mockClient = { | |
| setCredentials: vi.fn((creds) => { | |
| currentCredentials = creds as Credentials; | |
| }), | |
| getAccessToken: vi.fn(async () => { | |
| if ( | |
| currentCredentials && | |
| currentCredentials.refresh_token === 'expired-token' | |
| ) { | |
| throw new Error('Token is expired or revoked'); | |
| } | |
| return { token: 'valid-token' }; | |
| }), | |
| getTokenInfo: vi.fn(async (_token) => { | |
| if ( | |
| currentCredentials && | |
| currentCredentials.refresh_token === 'expired-token' | |
| ) { | |
| throw new Error('Token is expired or revoked'); | |
| } | |
| return {}; | |
| }), | |
| on: vi.fn(), | |
| }; | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => mockClient as unknown as OAuth2Client, | |
| ); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| // Assert that fallback envCreds were eventually loaded and used | |
| expect(mockClient.setCredentials).toHaveBeenCalledWith(envCreds); | |
| }); | |
| }); | |
| describe('with GCP environment variables', () => { | |
| it('should use GOOGLE_CLOUD_ACCESS_TOKEN when GOOGLE_GENAI_USE_GCA is true', async () => { | |
| vi.stubEnv('GOOGLE_GENAI_USE_GCA', 'true'); | |
| vi.stubEnv('GOOGLE_CLOUD_ACCESS_TOKEN', 'gcp-access-token'); | |
| const mockSetCredentials = vi.fn(); | |
| const mockGetAccessToken = vi | |
| .fn() | |
| .mockResolvedValue({ token: 'gcp-access-token' }); | |
| const mockOAuth2Client = { | |
| setCredentials: mockSetCredentials, | |
| getAccessToken: mockGetAccessToken, | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| // Mock the UserInfo API response for fetchAndCacheUserInfo | |
| (global.fetch as Mock).mockResolvedValue({ | |
| ok: true, | |
| json: vi | |
| .fn() | |
| .mockResolvedValue({ email: 'test-gcp-account@gmail.com' }), | |
| } as unknown as Response); | |
| const client = await getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| expect(client).toBe(mockOAuth2Client); | |
| expect(mockSetCredentials).toHaveBeenCalledWith({ | |
| access_token: 'gcp-access-token', | |
| }); | |
| // Verify fetchAndCacheUserInfo was effectively called | |
| expect(mockGetAccessToken).toHaveBeenCalled(); | |
| expect(global.fetch).toHaveBeenCalledWith( | |
| 'https://www.googleapis.com/oauth2/v2/userinfo', | |
| { | |
| headers: { | |
| Authorization: 'Bearer gcp-access-token', | |
| }, | |
| }, | |
| ); | |
| // Verify Google Account was cached | |
| const googleAccountPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'google_accounts.json', | |
| ); | |
| const cachedContent = fs.readFileSync(googleAccountPath, 'utf-8'); | |
| expect(JSON.parse(cachedContent)).toEqual({ | |
| active: 'test-gcp-account@gmail.com', | |
| old: [], | |
| }); | |
| }); | |
| it('should not use GCP token if GOOGLE_CLOUD_ACCESS_TOKEN is not set', async () => { | |
| vi.stubEnv('GOOGLE_GENAI_USE_GCA', 'true'); | |
| const mockSetCredentials = vi.fn(); | |
| const mockGetAccessToken = vi | |
| .fn() | |
| .mockResolvedValue({ token: 'cached-access-token' }); | |
| const mockGetTokenInfo = vi.fn().mockResolvedValue({}); | |
| const mockOAuth2Client = { | |
| setCredentials: mockSetCredentials, | |
| getAccessToken: mockGetAccessToken, | |
| getTokenInfo: mockGetTokenInfo, | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| // Make it fall through to cached credentials path | |
| const cachedCreds = { refresh_token: 'cached-token' }; | |
| const credsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| // It should be called with the cached credentials, not the GCP access token. | |
| expect(mockSetCredentials).toHaveBeenCalledTimes(1); | |
| expect(mockSetCredentials).toHaveBeenCalledWith(cachedCreds); | |
| }); | |
| it('should not use GCP token if GOOGLE_GENAI_USE_GCA is not set', async () => { | |
| vi.stubEnv('GOOGLE_CLOUD_ACCESS_TOKEN', 'gcp-access-token'); | |
| const mockSetCredentials = vi.fn(); | |
| const mockGetAccessToken = vi | |
| .fn() | |
| .mockResolvedValue({ token: 'cached-access-token' }); | |
| const mockGetTokenInfo = vi.fn().mockResolvedValue({}); | |
| const mockOAuth2Client = { | |
| setCredentials: mockSetCredentials, | |
| getAccessToken: mockGetAccessToken, | |
| getTokenInfo: mockGetTokenInfo, | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| // Make it fall through to cached credentials path | |
| const cachedCreds = { refresh_token: 'cached-token' }; | |
| const credsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| // It should be called with the cached credentials, not the GCP access token. | |
| expect(mockSetCredentials).toHaveBeenCalledTimes(1); | |
| expect(mockSetCredentials).toHaveBeenCalledWith(cachedCreds); | |
| }); | |
| }); | |
| describe('error handling', () => { | |
| it('should handle browser launch failure with FatalAuthenticationError', async () => { | |
| const mockError = new Error('Browser launch failed'); | |
| (open as Mock).mockRejectedValue(mockError); | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue('https://example.com/auth'), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| await expect( | |
| getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig), | |
| ).rejects.toThrow('Failed to open browser: Browser launch failed'); | |
| }); | |
| it('should handle authentication timeout with proper error message', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn(), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation( | |
| () => mockHttpServer as unknown as http.Server, | |
| ); | |
| // Mock setTimeout to trigger timeout immediately | |
| const originalSetTimeout = global.setTimeout; | |
| global.setTimeout = vi.fn( | |
| (callback) => (callback(), {} as unknown as NodeJS.Timeout), | |
| ) as unknown as typeof setTimeout; | |
| await expect( | |
| getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig), | |
| ).rejects.toThrow( | |
| 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. Please try again or use NO_BROWSER=true for manual authentication.', | |
| ); | |
| global.setTimeout = originalSetTimeout; | |
| }); | |
| it('should clear the authorization timeout immediately upon successful web login to prevent memory leaks', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockCode = 'test-code'; | |
| const mockState = 'test-state'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| getToken: vi.fn().mockResolvedValue({ | |
| tokens: { | |
| access_token: 'test-token', | |
| refresh_token: 'test-refresh', | |
| }, | |
| }), | |
| setCredentials: vi.fn().mockImplementation(function ( | |
| this: { credentials?: unknown }, | |
| creds: unknown, | |
| ) { | |
| this.credentials = creds; | |
| }), | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| on: vi.fn(), | |
| credentials: {}, | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn( | |
| (_port: number, _host: string, callback?: () => void) => { | |
| if (callback) callback(); | |
| serverListeningCallback(undefined); | |
| }, | |
| ), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| const mockReq = { | |
| url: `/oauth2callback?code=${mockCode}&state=${mockState}`, | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| on: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| // Trigger the successful server response | |
| requestCallback(mockReq, mockRes); | |
| await clientPromise; | |
| // Verify that the watchdog timer was cleared correctly | |
| expect(clearTimeoutSpy).toHaveBeenCalled(); | |
| clearTimeoutSpy.mockRestore(); | |
| }); | |
| it('should handle OAuth callback errors with descriptive messages', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn( | |
| (_port: number, _host: string, callback?: () => void) => { | |
| if (callback) callback(); | |
| serverListeningCallback(undefined); | |
| }, | |
| ), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| // Test OAuth error with description | |
| const mockReq = { | |
| url: '/oauth2callback?error=access_denied&error_description=User+denied+access', | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| await expect(async () => { | |
| requestCallback(mockReq, mockRes); | |
| await clientPromise; | |
| }).rejects.toThrow( | |
| 'Google OAuth error: access_denied. User denied access', | |
| ); | |
| }); | |
| it('should handle OAuth error without description', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn( | |
| (_port: number, _host: string, callback?: () => void) => { | |
| if (callback) callback(); | |
| serverListeningCallback(undefined); | |
| }, | |
| ), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| // Test OAuth error without description | |
| const mockReq = { | |
| url: '/oauth2callback?error=server_error', | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| await expect(async () => { | |
| requestCallback(mockReq, mockRes); | |
| await clientPromise; | |
| }).rejects.toThrow( | |
| 'Google OAuth error: server_error. No additional details provided', | |
| ); | |
| }); | |
| it('should handle unexpected requests (like /favicon.ico) without crashing', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn( | |
| (_port: number, _host: string, callback?: () => void) => { | |
| if (callback) callback(); | |
| serverListeningCallback(undefined); | |
| }, | |
| ), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| // Simulate an unexpected request, like a browser requesting a favicon | |
| const mockReq = { | |
| url: '/favicon.ico', | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| await expect(async () => { | |
| requestCallback(mockReq, mockRes); | |
| await clientPromise; | |
| }).rejects.toThrow( | |
| 'OAuth callback not received. Unexpected request: /favicon.ico', | |
| ); | |
| // Assert that we correctly redirected to the failure page | |
| expect(mockRes.writeHead).toHaveBeenCalledWith(301, { | |
| Location: | |
| 'https://developers.google.com/gemini-code-assist/auth_failure_gemini', | |
| }); | |
| expect(mockRes.end).toHaveBeenCalled(); | |
| }); | |
| it('should handle token exchange failure with descriptive error', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockCode = 'test-code'; | |
| const mockState = 'test-state'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| getToken: vi | |
| .fn() | |
| .mockRejectedValue(new Error('Token exchange failed')), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn( | |
| (_port: number, _host: string, callback?: () => void) => { | |
| if (callback) callback(); | |
| serverListeningCallback(undefined); | |
| }, | |
| ), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| const mockReq = { | |
| url: `/oauth2callback?code=${mockCode}&state=${mockState}`, | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| await expect(async () => { | |
| requestCallback(mockReq, mockRes); | |
| await clientPromise; | |
| }).rejects.toThrow( | |
| 'Failed to exchange authorization code for tokens: Token exchange failed', | |
| ); | |
| }); | |
| it('should handle fetchAndCacheUserInfo failure gracefully', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockCode = 'test-code'; | |
| const mockState = 'test-state'; | |
| const mockTokens = { | |
| access_token: 'test-access-token', | |
| refresh_token: 'test-refresh-token', | |
| }; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| getToken: vi.fn().mockResolvedValue({ tokens: mockTokens }), | |
| getAccessToken: vi | |
| .fn() | |
| .mockResolvedValue({ token: 'test-access-token' }), | |
| on: vi.fn(), | |
| credentials: {}, | |
| } as unknown as OAuth2Client; | |
| mockOAuth2Client.setCredentials = vi | |
| .fn() | |
| .mockImplementation((creds) => { | |
| mockOAuth2Client.credentials = creds; | |
| }); | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| // Mock fetch to fail | |
| vi.mocked(global.fetch).mockResolvedValue({ | |
| ok: false, | |
| status: 500, | |
| statusText: 'Internal Server Error', | |
| } as unknown as Response); | |
| const consoleLogSpy = vi | |
| .spyOn(debugLogger, 'log') | |
| .mockImplementation(() => {}); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn( | |
| (_port: number, _host: string, callback?: () => void) => { | |
| if (callback) callback(); | |
| serverListeningCallback(undefined); | |
| }, | |
| ), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| } as unknown as http.Server; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb; | |
| return mockHttpServer; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| const mockReq = { | |
| url: `/oauth2callback?code=${mockCode}&state=${mockState}`, | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| requestCallback(mockReq, mockRes); | |
| const client = await clientPromise; | |
| // Authentication should succeed even if fetchAndCacheUserInfo fails | |
| expect(client).toBe(mockOAuth2Client); | |
| expect(consoleLogSpy).toHaveBeenCalledWith( | |
| 'Failed to fetch user info:', | |
| 500, | |
| 'Internal Server Error', | |
| ); | |
| consoleLogSpy.mockRestore(); | |
| }); | |
| it('should handle user code authentication failure with descriptive error', async () => { | |
| const mockConfigWithNoBrowser = { | |
| getNoBrowser: () => true, | |
| getProxy: () => 'http://test.proxy.com:8080', | |
| isBrowserLaunchSuppressed: () => true, | |
| isInteractive: () => true, | |
| } as unknown as Config; | |
| const mockOAuth2Client = { | |
| generateCodeVerifierAsync: vi.fn().mockResolvedValue({ | |
| codeChallenge: 'test-challenge', | |
| codeVerifier: 'test-verifier', | |
| }), | |
| generateAuthUrl: vi.fn().mockReturnValue('https://example.com/auth'), | |
| getToken: vi | |
| .fn() | |
| .mockRejectedValue(new Error('Invalid authorization code')), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| const mockReadline = { | |
| question: vi.fn((_query, callback) => callback('invalid-code')), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| }; | |
| (readline.createInterface as Mock).mockReturnValue(mockReadline); | |
| const consoleLogSpy = vi | |
| .spyOn(debugLogger, 'log') | |
| .mockImplementation(() => {}); | |
| const consoleErrorSpy = vi | |
| .spyOn(debugLogger, 'error') | |
| .mockImplementation(() => {}); | |
| await expect( | |
| getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigWithNoBrowser), | |
| ).rejects.toThrow('Failed to authenticate with user code.'); | |
| expect(consoleErrorSpy).toHaveBeenCalledWith( | |
| 'Failed to authenticate with authorization code:', | |
| 'Invalid authorization code', | |
| ); | |
| consoleLogSpy.mockRestore(); | |
| consoleErrorSpy.mockRestore(); | |
| }); | |
| }); | |
| describe('cancellation', () => { | |
| it('should cancel when SIGINT is received', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockState = 'test-state'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| // Mock createServer to return a server that doesn't do anything (keeps promise pending) | |
| const mockHttpServer = { | |
| listen: vi.fn(), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation( | |
| () => mockHttpServer as unknown as http.Server, | |
| ); | |
| // Mock process.on to capture SIGINT handler | |
| const processOnSpy = vi | |
| .spyOn(process, 'on') | |
| .mockImplementation(() => process); | |
| const processRemoveListenerSpy = vi.spyOn(process, 'removeListener'); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| // Wait for the SIGINT handler to be registered | |
| let sigIntHandler: (() => void) | undefined; | |
| await vi.waitFor(() => { | |
| const sigintCall = processOnSpy.mock.calls.find( | |
| (call) => call[0] === 'SIGINT', | |
| ); | |
| sigIntHandler = sigintCall?.[1] as (() => void) | undefined; | |
| if (!sigIntHandler) | |
| throw new Error('SIGINT handler not registered yet'); | |
| }); | |
| expect(sigIntHandler).toBeDefined(); | |
| // Trigger SIGINT | |
| if (sigIntHandler) { | |
| sigIntHandler(); | |
| } | |
| await expect(clientPromise).rejects.toThrow(FatalCancellationError); | |
| expect(processRemoveListenerSpy).toHaveBeenCalledWith( | |
| 'SIGINT', | |
| expect.any(Function), | |
| ); | |
| processOnSpy.mockRestore(); | |
| processRemoveListenerSpy.mockRestore(); | |
| }); | |
| it('should cancel when Ctrl+C (0x03) is received on stdin', async () => { | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockState = 'test-state'; | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn(), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation( | |
| () => mockHttpServer as unknown as http.Server, | |
| ); | |
| // Spy on process.stdin.on to capture data handler | |
| const stdinOnSpy = vi | |
| .spyOn(process.stdin, 'on') | |
| .mockImplementation(() => process.stdin); | |
| const stdinRemoveListenerSpy = vi.spyOn( | |
| process.stdin, | |
| 'removeListener', | |
| ); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| // Wait for the stdin handler to be registered | |
| let dataHandler: ((data: Buffer) => void) | undefined; | |
| await vi.waitFor(() => { | |
| const dataCall = stdinOnSpy.mock.calls.find( | |
| (call: [string | symbol, ...unknown[]]) => call[0] === 'data', | |
| ); | |
| dataHandler = dataCall?.[1] as ((data: Buffer) => void) | undefined; | |
| if (!dataHandler) throw new Error('stdin handler not registered yet'); | |
| }); | |
| expect(dataHandler).toBeDefined(); | |
| // Trigger Ctrl+C | |
| if (dataHandler) { | |
| dataHandler(Buffer.from([0x03])); | |
| } | |
| await expect(clientPromise).rejects.toThrow(FatalCancellationError); | |
| expect(stdinRemoveListenerSpy).toHaveBeenCalledWith( | |
| 'data', | |
| expect.any(Function), | |
| ); | |
| stdinOnSpy.mockRestore(); | |
| stdinRemoveListenerSpy.mockRestore(); | |
| }); | |
| it('should NOT cancel when 0x03 is embedded in a multi-byte escape sequence (Ghostty/VS Code WSL false-positive)', async () => { | |
| // Only a lone 0x03 byte is Ctrl+C; a multi-byte escape sequence that | |
| // merely contains 0x03 (e.g. from Ghostty on init/resize) must not cancel. | |
| const stdinOnSpy = vi | |
| .spyOn(process.stdin, 'on') | |
| .mockImplementation(() => process.stdin); | |
| vi.spyOn(process.stdin, 'removeListener').mockImplementation( | |
| () => process.stdin, | |
| ); | |
| const mockHttpServer = { | |
| listen: vi.fn(), | |
| close: vi.fn(), | |
| on: vi.fn(), | |
| address: () => ({ port: 3000 }), | |
| }; | |
| (http.createServer as Mock).mockImplementation( | |
| () => mockHttpServer as unknown as http.Server, | |
| ); | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => | |
| ({ | |
| generateAuthUrl: vi.fn().mockReturnValue('https://example.com'), | |
| on: vi.fn(), | |
| }) as unknown as OAuth2Client, | |
| ); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| // Grab the registered stdin data handler | |
| let dataHandler: ((data: Buffer) => void) | undefined; | |
| await vi.waitFor(() => { | |
| dataHandler = stdinOnSpy.mock.calls.find( | |
| (c: [string | symbol, ...unknown[]]) => c[0] === 'data', | |
| )?.[1] as (data: Buffer) => void; | |
| if (!dataHandler) throw new Error('handler not registered'); | |
| }); | |
| // Fire an escape sequence embedding 0x03 — must NOT cancel. | |
| dataHandler!(Buffer.from([0x1b, 0x5b, 0x03, 0x4d])); // ESC [ 0x03 M | |
| // Promise must still be pending (not rejected). | |
| const result = await Promise.race([ | |
| clientPromise.then( | |
| () => 'resolved', | |
| () => 'rejected', | |
| ), | |
| new Promise<string>((r) => setTimeout(() => r('pending'), 50)), | |
| ]); | |
| expect(result).toBe('pending'); | |
| stdinOnSpy.mockRestore(); | |
| vi.spyOn(process.stdin, 'removeListener').mockRestore(); | |
| }); | |
| it('should throw FatalCancellationError when consent is denied', async () => { | |
| vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation( | |
| (payload) => { | |
| payload.onConfirm(false); | |
| }, | |
| ); | |
| await expect( | |
| getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig), | |
| ).rejects.toThrow(FatalCancellationError); | |
| }); | |
| }); | |
| describe('clearCachedCredentialFile', () => { | |
| it('should clear cached credentials and Google account', async () => { | |
| const cachedCreds = { refresh_token: 'test-token' }; | |
| const credsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); | |
| const googleAccountPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'google_accounts.json', | |
| ); | |
| const accountData = { active: 'test@example.com', old: [] }; | |
| await fs.promises.writeFile( | |
| googleAccountPath, | |
| JSON.stringify(accountData), | |
| ); | |
| const userAccountManager = new UserAccountManager(); | |
| expect(fs.existsSync(credsPath)).toBe(true); | |
| expect(fs.existsSync(googleAccountPath)).toBe(true); | |
| expect(userAccountManager.getCachedGoogleAccount()).toBe( | |
| 'test@example.com', | |
| ); | |
| await clearCachedCredentialFile(); | |
| expect(fs.existsSync(credsPath)).toBe(false); | |
| expect(userAccountManager.getCachedGoogleAccount()).toBeNull(); | |
| const updatedAccountData = JSON.parse( | |
| fs.readFileSync(googleAccountPath, 'utf-8'), | |
| ); | |
| expect(updatedAccountData.active).toBeNull(); | |
| expect(updatedAccountData.old).toContain('test@example.com'); | |
| }); | |
| it('should clear the in-memory OAuth client cache', async () => { | |
| const mockSetCredentials = vi.fn(); | |
| const mockGetAccessToken = vi | |
| .fn() | |
| .mockResolvedValue({ token: 'test-token' }); | |
| const mockGetTokenInfo = vi.fn().mockResolvedValue({}); | |
| const mockOAuth2Client = { | |
| setCredentials: mockSetCredentials, | |
| getAccessToken: mockGetAccessToken, | |
| getTokenInfo: mockGetTokenInfo, | |
| on: vi.fn(), | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| // Pre-populate credentials to make getOauthClient resolve quickly | |
| const credsPath = path.join( | |
| tempHomeDir, | |
| GEMINI_DIR, | |
| 'oauth_creds.json', | |
| ); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile( | |
| credsPath, | |
| JSON.stringify({ refresh_token: 'token' }), | |
| ); | |
| // First call, should create a client | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| expect(OAuth2Client).toHaveBeenCalledTimes(1); | |
| // Second call, should use cached client | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| expect(OAuth2Client).toHaveBeenCalledTimes(1); | |
| clearOauthClientCache(); | |
| // Third call, after clearing cache, should create a new client | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| expect(OAuth2Client).toHaveBeenCalledTimes(2); | |
| }); | |
| }); | |
| }); | |
| describe('with encrypted flag true', () => { | |
| let tempHomeDir: string; | |
| beforeEach(() => { | |
| process.env[FORCE_ENCRYPTED_FILE_ENV_VAR] = 'true'; | |
| tempHomeDir = fs.mkdtempSync( | |
| path.join(os.tmpdir(), 'gemini-cli-test-home-'), | |
| ); | |
| vi.mocked(os.homedir).mockReturnValue(tempHomeDir); | |
| vi.mocked(pathsHomedir).mockReturnValue(tempHomeDir); | |
| }); | |
| afterEach(() => { | |
| fs.rmSync(tempHomeDir, { recursive: true, force: true }); | |
| vi.clearAllMocks(); | |
| resetOauthClientForTesting(); | |
| vi.unstubAllEnvs(); | |
| }); | |
| it('should save credentials using OAuthCredentialStorage during web login', async () => { | |
| const { OAuthCredentialStorage } = await import( | |
| './oauth-credential-storage.js' | |
| ); | |
| const mockAuthUrl = 'https://example.com/auth'; | |
| const mockCode = 'test-code'; | |
| const mockState = 'test-state'; | |
| const mockTokens = { | |
| access_token: 'test-access-token', | |
| refresh_token: 'test-refresh-token', | |
| }; | |
| let onTokensCallback: (tokens: Credentials) => void = () => {}; | |
| const mockOn = vi.fn((event, callback) => { | |
| if (event === 'tokens') { | |
| onTokensCallback = callback; | |
| } | |
| }); | |
| const mockGetToken = vi.fn().mockImplementation(async () => { | |
| onTokensCallback(mockTokens); | |
| return { tokens: mockTokens }; | |
| }); | |
| const mockOAuth2Client = { | |
| generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), | |
| getToken: mockGetToken, | |
| setCredentials: vi.fn(), | |
| getAccessToken: vi | |
| .fn() | |
| .mockResolvedValue({ token: 'mock-access-token' }), | |
| on: mockOn, | |
| credentials: mockTokens, | |
| } as unknown as OAuth2Client; | |
| vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); | |
| vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); | |
| vi.mocked(open).mockImplementation( | |
| async () => ({ on: vi.fn() }) as never, | |
| ); | |
| (global.fetch as Mock).mockResolvedValue({ | |
| ok: true, | |
| json: vi | |
| .fn() | |
| .mockResolvedValue({ email: 'test-google-account@gmail.com' }), | |
| } as unknown as Response); | |
| let requestCallback!: http.RequestListener; | |
| let serverListeningCallback: (value: unknown) => void; | |
| const serverListeningPromise = new Promise( | |
| (resolve) => (serverListeningCallback = resolve), | |
| ); | |
| let capturedPort = 0; | |
| const mockHttpServer = { | |
| listen: vi.fn((port: number, _host: string, callback?: () => void) => { | |
| capturedPort = port; | |
| if (callback) { | |
| callback(); | |
| } | |
| serverListeningCallback(undefined); | |
| }), | |
| close: vi.fn((callback?: () => void) => { | |
| if (callback) { | |
| callback(); | |
| } | |
| }), | |
| on: vi.fn(), | |
| address: () => ({ port: capturedPort }), | |
| }; | |
| (http.createServer as Mock).mockImplementation((cb) => { | |
| requestCallback = cb as http.RequestListener; | |
| return mockHttpServer as unknown as http.Server; | |
| }); | |
| const clientPromise = getOauthClient( | |
| AuthType.LOGIN_WITH_GOOGLE, | |
| mockConfig, | |
| ); | |
| await serverListeningPromise; | |
| const mockReq = { | |
| url: `/oauth2callback?code=${mockCode}&state=${mockState}`, | |
| } as http.IncomingMessage; | |
| const mockRes = { | |
| writeHead: vi.fn(), | |
| end: vi.fn(), | |
| } as unknown as http.ServerResponse; | |
| requestCallback(mockReq, mockRes); | |
| await clientPromise; | |
| expect( | |
| vi.mocked(OAuthCredentialStorage.saveCredentials), | |
| ).toHaveBeenCalledWith(mockTokens); | |
| const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); | |
| expect(fs.existsSync(credsPath)).toBe(false); | |
| }); | |
| it('should load credentials using OAuthCredentialStorage and not from file', async () => { | |
| const { OAuthCredentialStorage } = await import( | |
| './oauth-credential-storage.js' | |
| ); | |
| const cachedCreds = { refresh_token: 'cached-encrypted-token' }; | |
| vi.mocked(OAuthCredentialStorage.loadCredentials).mockResolvedValue( | |
| cachedCreds, | |
| ); | |
| // Create a dummy unencrypted credential file. | |
| // If the logic is correct, this file should be ignored. | |
| const unencryptedCreds = { refresh_token: 'unencrypted-token' }; | |
| const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, JSON.stringify(unencryptedCreds)); | |
| const mockClient = { | |
| setCredentials: vi.fn(), | |
| getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), | |
| getTokenInfo: vi.fn().mockResolvedValue({}), | |
| on: vi.fn(), | |
| }; | |
| vi.mocked(OAuth2Client).mockImplementation( | |
| () => mockClient as unknown as OAuth2Client, | |
| ); | |
| await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); | |
| expect( | |
| vi.mocked(OAuthCredentialStorage.loadCredentials), | |
| ).toHaveBeenCalled(); | |
| expect(mockClient.setCredentials).toHaveBeenCalledWith(cachedCreds); | |
| expect(mockClient.setCredentials).not.toHaveBeenCalledWith( | |
| unencryptedCreds, | |
| ); | |
| }); | |
| it('should clear credentials using OAuthCredentialStorage', async () => { | |
| const { OAuthCredentialStorage } = await import( | |
| './oauth-credential-storage.js' | |
| ); | |
| // Create a dummy unencrypted credential file. It should not be deleted. | |
| const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); | |
| await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); | |
| await fs.promises.writeFile(credsPath, '{}'); | |
| await clearCachedCredentialFile(); | |
| expect( | |
| OAuthCredentialStorage.clearCredentials as Mock, | |
| ).toHaveBeenCalled(); | |
| expect(fs.existsSync(credsPath)).toBe(true); // The unencrypted file should remain | |
| }); | |
| }); | |
| }); | |