/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'node:path'; import * as os from 'node:os'; import { getEnvContents, maybePromptForSettings, promptForSetting, type ExtensionSetting, } from './extensionSettings.js'; import type { ExtensionConfig } from '../extension.js'; import { ExtensionStorage } from './storage.js'; import prompts from 'prompts'; import * as fsPromises from 'node:fs/promises'; import * as fs from 'node:fs'; import { KeychainTokenStorage } from '@google/gemini-cli-core'; vi.mock('prompts'); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, homedir: vi.fn(), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, KeychainTokenStorage: vi.fn().mockImplementation(() => ({ getSecret: vi.fn(), setSecret: vi.fn(), deleteSecret: vi.fn(), listSecrets: vi.fn(), isAvailable: vi.fn().mockResolvedValue(true), })), }; }); interface MockKeychainStorage { getSecret: ReturnType; setSecret: ReturnType; deleteSecret: ReturnType; listSecrets: ReturnType; isAvailable: ReturnType; } describe('extensionSettings', () => { let tempHomeDir: string; let extensionDir: string; let mockKeychainStorage: MockKeychainStorage; let keychainData: Record; beforeEach(() => { vi.clearAllMocks(); keychainData = {}; mockKeychainStorage = { getSecret: vi .fn() .mockImplementation(async (key: string) => keychainData[key] || null), setSecret: vi .fn() .mockImplementation(async (key: string, value: string) => { keychainData[key] = value; }), deleteSecret: vi.fn().mockImplementation(async (key: string) => { delete keychainData[key]; }), listSecrets: vi .fn() .mockImplementation(async () => Object.keys(keychainData)), isAvailable: vi.fn().mockResolvedValue(true), }; ( KeychainTokenStorage as unknown as ReturnType ).mockImplementation(() => mockKeychainStorage); tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); // Spy and mock the method, but also create the directory so we can write to it. vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( extensionDir, ); fs.mkdirSync(extensionDir, { recursive: true }); vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.mocked(prompts).mockClear(); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); describe('maybePromptForSettings', () => { const mockRequestSetting = vi.fn( async (setting: ExtensionSetting) => `mock-${setting.envVar}`, ); beforeEach(() => { mockRequestSetting.mockClear(); }); it('should do nothing if settings are undefined', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); expect(mockRequestSetting).not.toHaveBeenCalled(); }); it('should do nothing if settings are empty', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); expect(mockRequestSetting).not.toHaveBeenCalled(); }); it('should prompt for all settings if there is no previous config', async () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; await maybePromptForSettings( config, '12345', mockRequestSetting, undefined, undefined, ); expect(mockRequestSetting).toHaveBeenCalledTimes(2); expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); }); it('should only prompt for new settings', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const previousSettings = { VAR1: 'previous-VAR1' }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).toHaveBeenCalledTimes(1); expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n'; expect(actualContent).toBe(expectedContent); }); it('should clear settings if new config has no settings', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; const previousSettings = { VAR1: 'previous-VAR1', SENSITIVE_VAR: 'secret', }; keychainData['SENSITIVE_VAR'] = 'secret'; const envPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1'); await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).not.toHaveBeenCalled(); const actualContent = await fsPromises.readFile(envPath, 'utf-8'); expect(actualContent).toBe(''); expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith( 'SENSITIVE_VAR', ); }); it('should remove sensitive settings from keychain', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [], }; const previousSettings = { SENSITIVE_VAR: 'secret' }; keychainData['SENSITIVE_VAR'] = 'secret'; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith( 'SENSITIVE_VAR', ); }); it('should remove settings that are no longer in the config', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], }; const previousSettings = { VAR1: 'previous-VAR1', VAR2: 'previous-VAR2', }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).not.toHaveBeenCalled(); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\n'; expect(actualContent).toBe(expectedContent); }); it('should reprompt if a setting changes sensitivity', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: false }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: true }, ], }; const previousSettings = { VAR1: 'previous-VAR1' }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).toHaveBeenCalledTimes(1); expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![0]); // The value should now be in keychain, not the .env file. const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toBe(''); }); it('should not prompt if settings are identical', async () => { const previousConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const newConfig: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'VAR2' }, ], }; const previousSettings = { VAR1: 'previous-VAR1', VAR2: 'previous-VAR2', }; await maybePromptForSettings( newConfig, '12345', mockRequestSetting, previousConfig, previousSettings, ); expect(mockRequestSetting).not.toHaveBeenCalled(); const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n'; expect(actualContent).toBe(expectedContent); }); }); describe('promptForSetting', () => { it.each([ { description: 'should use prompts with type "password" for sensitive settings', setting: { name: 'API Key', description: 'Your secret key', envVar: 'API_KEY', sensitive: true, }, expectedType: 'password', promptValue: 'secret-key', }, { description: 'should use prompts with type "text" for non-sensitive settings', setting: { name: 'Username', description: 'Your public username', envVar: 'USERNAME', sensitive: false, }, expectedType: 'text', promptValue: 'test-user', }, { description: 'should default to "text" if sensitive is undefined', setting: { name: 'Username', description: 'Your public username', envVar: 'USERNAME', }, expectedType: 'text', promptValue: 'test-user', }, ])('$description', async ({ setting, expectedType, promptValue }) => { vi.mocked(prompts).mockResolvedValue({ value: promptValue }); const result = await promptForSetting(setting as ExtensionSetting); expect(prompts).toHaveBeenCalledWith({ type: expectedType, name: 'value', message: `${setting.name}\n${setting.description}`, }); expect(result).toBe(promptValue); }); it('should return undefined if the user cancels the prompt', async () => { vi.mocked(prompts).mockResolvedValue({ value: undefined }); const result = await promptForSetting({ name: 'Test', description: 'Test desc', envVar: 'TEST_VAR', }); expect(result).toBeUndefined(); }); }); describe('getEnvContents', () => { const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0', settings: [ { name: 's1', description: 'd1', envVar: 'VAR1' }, { name: 's2', description: 'd2', envVar: 'SENSITIVE_VAR', sensitive: true, }, ], }; it('should return combined contents from .env and keychain', async () => { const envPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(envPath, 'VAR1=value1'); keychainData['SENSITIVE_VAR'] = 'secret'; const contents = await getEnvContents(config, '12345'); expect(contents).toEqual({ VAR1: 'value1', SENSITIVE_VAR: 'secret', }); }); it('should return an empty object if no settings are defined', async () => { const contents = await getEnvContents( { name: 'test-ext', version: '1.0.0' }, '12345', ); expect(contents).toEqual({}); }); it('should return only keychain contents if .env file does not exist', async () => { keychainData['SENSITIVE_VAR'] = 'secret'; const contents = await getEnvContents(config, '12345'); expect(contents).toEqual({ SENSITIVE_VAR: 'secret' }); }); it('should return only .env contents if keychain is empty', async () => { const envPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(envPath, 'VAR1=value1'); const contents = await getEnvContents(config, '12345'); expect(contents).toEqual({ VAR1: 'value1' }); }); }); });