/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, type MockInstance, } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { useFolderTrust } from './useFolderTrust.js'; import type { LoadedSettings } from '../../config/settings.js'; import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import * as trustedFolders from '../../config/trustedFolders.js'; import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; const mockedCwd = vi.hoisted(() => vi.fn()); const mockedExit = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual< typeof import('@google/gemini-cli-core') >('@google/gemini-cli-core'); return { ...actual, isHeadlessMode: vi.fn().mockReturnValue(false), }; }); vi.mock('node:process', async () => { const actual = await vi.importActual('node:process'); return { ...actual, cwd: mockedCwd, exit: mockedExit, platform: 'linux', }; }); describe('useFolderTrust', () => { let mockSettings: LoadedSettings; let mockTrustedFolders: LoadedTrustedFolders; let isWorkspaceTrustedSpy: MockInstance; let onTrustChange: (isTrusted: boolean | undefined) => void; let addItem: Mock; const originalStdoutIsTTY = process.stdout.isTTY; const originalStdinIsTTY = process.stdin.isTTY; beforeEach(() => { vi.useFakeTimers(); // Default to interactive mode for tests Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, writable: true, }); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true, writable: true, }); mockSettings = { merged: { security: { folderTrust: { enabled: true, }, }, }, setValue: vi.fn(), } as unknown as LoadedSettings; mockTrustedFolders = { setValue: vi.fn(), } as unknown as LoadedTrustedFolders; vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue( mockTrustedFolders, ); isWorkspaceTrustedSpy = vi.spyOn(trustedFolders, 'isWorkspaceTrusted'); mockedCwd.mockReturnValue('/test/path'); onTrustChange = vi.fn(); addItem = vi.fn(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true, writable: true, }); Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true, writable: true, }); }); it('should not open dialog when folder is already trusted', () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: true, source: 'file' }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); expect(result.current.isFolderTrustDialogOpen).toBe(false); expect(onTrustChange).toHaveBeenCalledWith(true); }); it('should not open dialog when folder is already untrusted', () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file' }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); expect(result.current.isFolderTrustDialogOpen).toBe(false); expect(onTrustChange).toHaveBeenCalledWith(false); }); it('should open dialog when folder trust is undefined', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await waitFor(() => { expect(result.current.isFolderTrustDialogOpen).toBe(true); }); expect(onTrustChange).toHaveBeenCalledWith(undefined); }); it('should send a message if the folder is untrusted', () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file' }); renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem)); expect(addItem).toHaveBeenCalledWith( { text: 'This folder is untrusted, project settings, hooks, MCPs, and GEMINI.md files will not be applied for this folder.\nUse the `/permissions` command to change the trust level.', type: 'info', }, expect.any(Number), ); }); it('should not send a message if the folder is trusted', () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: true, source: 'file' }); renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem)); expect(addItem).not.toHaveBeenCalled(); }); it('should handle TRUST_FOLDER choice and trigger restart', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); (mockTrustedFolders.setValue as Mock).mockImplementation(() => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: true, source: 'file', }); }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await waitFor(() => { expect(result.current.isTrusted).toBeUndefined(); }); await act(async () => { await result.current.handleFolderTrustSelect( FolderTrustChoice.TRUST_FOLDER, ); }); await waitFor(() => { expect(mockTrustedFolders.setValue).toHaveBeenCalledWith( '/test/path', TrustLevel.TRUST_FOLDER, ); expect(result.current.isRestarting).toBe(true); expect(result.current.isFolderTrustDialogOpen).toBe(true); expect(onTrustChange).toHaveBeenLastCalledWith(true); }); }); it('should handle TRUST_PARENT choice and trigger restart', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await act(async () => { await result.current.handleFolderTrustSelect( FolderTrustChoice.TRUST_PARENT, ); }); await waitFor(() => { expect(mockTrustedFolders.setValue).toHaveBeenCalledWith( '/test/path', TrustLevel.TRUST_PARENT, ); expect(result.current.isRestarting).toBe(true); expect(result.current.isFolderTrustDialogOpen).toBe(true); expect(onTrustChange).toHaveBeenLastCalledWith(true); }); }); it('should handle DO_NOT_TRUST choice and NOT trigger restart (implicit -> explicit)', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await act(async () => { await result.current.handleFolderTrustSelect( FolderTrustChoice.DO_NOT_TRUST, ); }); await waitFor(() => { expect(mockTrustedFolders.setValue).toHaveBeenCalledWith( '/test/path', TrustLevel.DO_NOT_TRUST, ); expect(onTrustChange).toHaveBeenLastCalledWith(false); expect(result.current.isRestarting).toBe(false); expect(result.current.isFolderTrustDialogOpen).toBe(false); }); }); it('should do nothing for default choice', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await act(async () => { await result.current.handleFolderTrustSelect( 'invalid_choice' as FolderTrustChoice, ); }); await waitFor(() => { expect(mockTrustedFolders.setValue).not.toHaveBeenCalled(); expect(mockSettings.setValue).not.toHaveBeenCalled(); expect(result.current.isFolderTrustDialogOpen).toBe(true); expect(onTrustChange).toHaveBeenCalledWith(undefined); }); }); it('should set isRestarting to true when trust status changes from false to true', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file' }); // Initially untrusted (mockTrustedFolders.setValue as Mock).mockImplementation(() => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: true, source: 'file', }); }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await waitFor(() => { expect(result.current.isTrusted).toBe(false); }); await act(async () => { await result.current.handleFolderTrustSelect( FolderTrustChoice.TRUST_FOLDER, ); }); await waitFor(() => { expect(result.current.isRestarting).toBe(true); expect(result.current.isFolderTrustDialogOpen).toBe(true); // Dialog should stay open }); }); it('should not set isRestarting to true when trust status does not change (true -> true)', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: true, source: 'file', }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await act(async () => { await result.current.handleFolderTrustSelect( FolderTrustChoice.TRUST_FOLDER, ); }); await waitFor(() => { expect(result.current.isRestarting).toBe(false); expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close }); }); it('should emit feedback on failure to set value', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); (mockTrustedFolders.setValue as Mock).mockImplementation(() => { throw new Error('test error'); }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); await act(async () => { await result.current.handleFolderTrustSelect( FolderTrustChoice.TRUST_FOLDER, ); }); await vi.runAllTimersAsync(); expect(emitFeedbackSpy).toHaveBeenCalledWith( 'error', 'Failed to save trust settings. Exiting Gemini CLI.', ); expect(mockedExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); }); describe('headless mode', () => { it('should force trust and hide dialog in headless mode', () => { vi.mocked(isHeadlessMode).mockReturnValue(true); isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file', }); const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); expect(result.current.isFolderTrustDialogOpen).toBe(false); expect(onTrustChange).toHaveBeenCalledWith(true); expect(addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, text: expect.stringContaining('This folder is untrusted'), }), expect.any(Number), ); }); }); });