/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as fs from 'node:fs'; import { TrustedHooksManager } from './trustedHooks.js'; import { Storage } from '../config/storage.js'; import { HookEventName, HookType, type HookDefinition } from './types.js'; vi.mock('node:fs'); vi.mock('../config/storage.js'); vi.mock('../utils/debugLogger.js', () => ({ debugLogger: { warn: vi.fn(), error: vi.fn(), log: vi.fn(), debug: vi.fn(), }, })); describe('TrustedHooksManager', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue('/mock/home/.gemini'); }); describe('initialization', () => { it('should load existing trusted hooks', () => { const existingData = { '/project/a': ['hook1:cmd1'], }; vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingData)); const manager = new TrustedHooksManager(); const untrusted = manager.getUntrustedHooks('/project/a', { [HookEventName.BeforeTool]: [ { hooks: [{ type: HookType.Command, command: 'cmd1', name: 'hook1' }], }, ], }); expect(untrusted).toHaveLength(0); }); it('should handle missing config file', () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new TrustedHooksManager(); const untrusted = manager.getUntrustedHooks('/project/a', { [HookEventName.BeforeTool]: [ { hooks: [{ type: HookType.Command, command: 'cmd1', name: 'hook1' }], }, ], }); expect(untrusted).toEqual(['hook1']); }); }); describe('getUntrustedHooks', () => { it('should return names of untrusted hooks', () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new TrustedHooksManager(); const projectHooks = { [HookEventName.BeforeTool]: [ { hooks: [ { name: 'trusted-hook', type: HookType.Command, command: 'cmd1', } as const, { name: 'new-hook', type: HookType.Command, command: 'cmd2', } as const, ], }, ], }; // Initially both are untrusted expect(manager.getUntrustedHooks('/project', projectHooks)).toEqual([ 'trusted-hook', 'new-hook', ]); // Trust one manager.trustHooks('/project', { [HookEventName.BeforeTool]: [ { hooks: [ { name: 'trusted-hook', type: HookType.Command, command: 'cmd1', } as const, ], }, ], }); // Only the other one is untrusted expect(manager.getUntrustedHooks('/project', projectHooks)).toEqual([ 'new-hook', ]); }); it('should use command if name is missing', () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new TrustedHooksManager(); const projectHooks = { [HookEventName.BeforeTool]: [ { hooks: [{ type: HookType.Command, command: './script.sh' }], }, ], }; expect( manager.getUntrustedHooks( '/project', projectHooks as Partial>, ), ).toEqual(['./script.sh']); }); it('should detect change in command as untrusted', () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new TrustedHooksManager(); const originalHook = { [HookEventName.BeforeTool]: [ { hooks: [ { name: 'my-hook', type: HookType.Command, command: 'old-cmd' }, ], }, ], }; const updatedHook = { [HookEventName.BeforeTool]: [ { hooks: [ { name: 'my-hook', type: HookType.Command, command: 'new-cmd' }, ], }, ], }; manager.trustHooks( '/project', originalHook as Partial>, ); expect( manager.getUntrustedHooks( '/project', updatedHook as Partial>, ), ).toEqual(['my-hook']); }); }); describe('persistence', () => { it('should save to file when trusting hooks', () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new TrustedHooksManager(); manager.trustHooks('/project', { [HookEventName.BeforeTool]: [ { hooks: [{ name: 'hook1', type: HookType.Command, command: 'cmd1' }], }, ], }); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.stringContaining('trusted_hooks.json'), expect.stringContaining('hook1:cmd1'), ); }); it('should create directory if missing on save', () => { vi.mocked(fs.existsSync).mockReturnValue(false); const manager = new TrustedHooksManager(); manager.trustHooks('/project', {}); expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true, }); }); }); });