2025-08-20 10:55:47 +09:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2026-02-06 08:10:17 -08:00
import { beforeEach , describe , it , expect , vi , afterEach } from 'vitest' ;
vi . unmock ( './storage.js' ) ;
vi . unmock ( './projectRegistry.js' ) ;
vi . unmock ( './storageMigration.js' ) ;
2025-08-25 22:11:27 +02:00
import * as os from 'node:os' ;
2025-08-20 10:55:47 +09:00
import * as path from 'node:path' ;
2026-02-19 17:47:08 -05:00
import * as fs from 'node:fs' ;
2025-08-20 10:55:47 +09:00
vi . mock ( 'fs' , async ( importOriginal ) = > {
const actual = await importOriginal < typeof import ( 'fs' ) > ( ) ;
return {
. . . actual ,
mkdirSync : vi.fn ( ) ,
2026-02-19 17:47:08 -05:00
realpathSync : vi.fn ( actual . realpathSync ) ,
2025-08-20 10:55:47 +09:00
} ;
} ) ;
import { Storage } from './storage.js' ;
2026-03-09 12:02:13 -04:00
import { GEMINI_DIR , homedir , resolveToRealPath } from '../utils/paths.js' ;
2026-02-06 08:10:17 -08:00
import { ProjectRegistry } from './projectRegistry.js' ;
import { StorageMigration } from './storageMigration.js' ;
const PROJECT_SLUG = 'project-slug' ;
vi . mock ( './projectRegistry.js' ) ;
vi . mock ( './storageMigration.js' ) ;
describe ( 'Storage – initialize' , ( ) = > {
const projectRoot = '/tmp/project' ;
let storage : Storage ;
beforeEach ( ( ) = > {
ProjectRegistry . prototype . initialize = vi . fn ( ) . mockResolvedValue ( undefined ) ;
ProjectRegistry . prototype . getShortId = vi
. fn ( )
. mockReturnValue ( PROJECT_SLUG ) ;
storage = new Storage ( projectRoot ) ;
vi . clearAllMocks ( ) ;
// Mock StorageMigration.migrateDirectory
vi . mocked ( StorageMigration . migrateDirectory ) . mockResolvedValue ( undefined ) ;
} ) ;
it ( 'sets up the registry and performs migration if `getProjectTempDir` is called' , async ( ) = > {
await storage . initialize ( ) ;
expect ( storage . getProjectTempDir ( ) ) . toBe (
path . join ( os . homedir ( ) , GEMINI_DIR , 'tmp' , PROJECT_SLUG ) ,
) ;
// Verify registry initialization
expect ( ProjectRegistry ) . toHaveBeenCalled ( ) ;
expect ( vi . mocked ( ProjectRegistry ) . prototype . initialize ) . toHaveBeenCalled ( ) ;
expect (
vi . mocked ( ProjectRegistry ) . prototype . getShortId ,
) . toHaveBeenCalledWith ( projectRoot ) ;
// Verify migration calls
// We can't easily get the hash here without repeating logic, but we can verify it's called twice
expect ( StorageMigration . migrateDirectory ) . toHaveBeenCalledTimes ( 2 ) ;
// Verify identifier is set by checking a path
2026-02-19 17:47:08 -05:00
expect ( storage . getProjectTempDir ( ) ) . toContain ( PROJECT_SLUG ) ;
2026-02-06 08:10:17 -08:00
} ) ;
} ) ;
2026-02-02 22:07:36 -08:00
vi . mock ( '../utils/paths.js' , async ( importOriginal ) = > {
const actual = await importOriginal < typeof import ( '../utils/paths.js' ) > ( ) ;
return {
. . . actual ,
homedir : vi.fn ( actual . homedir ) ,
} ;
} ) ;
2025-08-20 10:55:47 +09:00
describe ( 'Storage – getGlobalSettingsPath' , ( ) = > {
it ( 'returns path to ~/.gemini/settings.json' , ( ) = > {
2025-10-14 02:31:39 +09:00
const expected = path . join ( os . homedir ( ) , GEMINI_DIR , 'settings.json' ) ;
2025-08-20 10:55:47 +09:00
expect ( Storage . getGlobalSettingsPath ( ) ) . toBe ( expected ) ;
} ) ;
} ) ;
2026-02-02 22:07:36 -08:00
describe ( 'Storage - Security' , ( ) = > {
it ( 'falls back to tmp for gemini but returns empty for agents if the home directory cannot be determined' , ( ) = > {
vi . mocked ( homedir ) . mockReturnValue ( '' ) ;
// .gemini falls back for backward compatibility
expect ( Storage . getGlobalGeminiDir ( ) ) . toBe (
path . join ( os . tmpdir ( ) , GEMINI_DIR ) ,
) ;
// .agents returns empty to avoid insecure fallback WITHOUT throwing error
expect ( Storage . getGlobalAgentsDir ( ) ) . toBe ( '' ) ;
vi . mocked ( homedir ) . mockReturnValue ( os . homedir ( ) ) ;
} ) ;
} ) ;
2025-08-20 10:55:47 +09:00
describe ( 'Storage – additional helpers' , ( ) = > {
const projectRoot = '/tmp/project' ;
const storage = new Storage ( projectRoot ) ;
2026-02-19 17:47:08 -05:00
beforeEach ( ( ) = > {
ProjectRegistry . prototype . getShortId = vi
. fn ( )
. mockReturnValue ( PROJECT_SLUG ) ;
} ) ;
2025-08-20 10:55:47 +09:00
it ( 'getWorkspaceSettingsPath returns project/.gemini/settings.json' , ( ) = > {
2025-10-14 02:31:39 +09:00
const expected = path . join ( projectRoot , GEMINI_DIR , 'settings.json' ) ;
2025-08-20 10:55:47 +09:00
expect ( storage . getWorkspaceSettingsPath ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'getUserCommandsDir returns ~/.gemini/commands' , ( ) = > {
2025-10-14 02:31:39 +09:00
const expected = path . join ( os . homedir ( ) , GEMINI_DIR , 'commands' ) ;
2025-08-20 10:55:47 +09:00
expect ( Storage . getUserCommandsDir ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'getProjectCommandsDir returns project/.gemini/commands' , ( ) = > {
2025-10-14 02:31:39 +09:00
const expected = path . join ( projectRoot , GEMINI_DIR , 'commands' ) ;
2025-08-20 10:55:47 +09:00
expect ( storage . getProjectCommandsDir ( ) ) . toBe ( expected ) ;
} ) ;
2025-12-30 13:35:52 -08:00
it ( 'getUserSkillsDir returns ~/.gemini/skills' , ( ) = > {
const expected = path . join ( os . homedir ( ) , GEMINI_DIR , 'skills' ) ;
expect ( Storage . getUserSkillsDir ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'getProjectSkillsDir returns project/.gemini/skills' , ( ) = > {
const expected = path . join ( projectRoot , GEMINI_DIR , 'skills' ) ;
expect ( storage . getProjectSkillsDir ( ) ) . toBe ( expected ) ;
} ) ;
2025-12-17 22:46:55 -05:00
it ( 'getUserAgentsDir returns ~/.gemini/agents' , ( ) = > {
const expected = path . join ( os . homedir ( ) , GEMINI_DIR , 'agents' ) ;
expect ( Storage . getUserAgentsDir ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'getProjectAgentsDir returns project/.gemini/agents' , ( ) = > {
const expected = path . join ( projectRoot , GEMINI_DIR , 'agents' ) ;
expect ( storage . getProjectAgentsDir ( ) ) . toBe ( expected ) ;
} ) ;
2025-08-20 10:55:47 +09:00
it ( 'getMcpOAuthTokensPath returns ~/.gemini/mcp-oauth-tokens.json' , ( ) = > {
const expected = path . join (
os . homedir ( ) ,
2025-10-14 02:31:39 +09:00
GEMINI_DIR ,
2025-08-20 10:55:47 +09:00
'mcp-oauth-tokens.json' ,
) ;
expect ( Storage . getMcpOAuthTokensPath ( ) ) . toBe ( expected ) ;
} ) ;
2025-09-08 14:44:56 -07:00
it ( 'getGlobalBinDir returns ~/.gemini/tmp/bin' , ( ) = > {
2025-10-14 02:31:39 +09:00
const expected = path . join ( os . homedir ( ) , GEMINI_DIR , 'tmp' , 'bin' ) ;
2025-09-08 14:44:56 -07:00
expect ( Storage . getGlobalBinDir ( ) ) . toBe ( expected ) ;
} ) ;
2026-01-26 16:57:27 -05:00
2026-02-12 14:02:59 -05:00
it ( 'getProjectTempPlansDir returns ~/.gemini/tmp/<identifier>/plans when no sessionId is provided' , async ( ) = > {
2026-02-06 08:10:17 -08:00
await storage . initialize ( ) ;
2026-01-26 16:57:27 -05:00
const tempDir = storage . getProjectTempDir ( ) ;
const expected = path . join ( tempDir , 'plans' ) ;
expect ( storage . getProjectTempPlansDir ( ) ) . toBe ( expected ) ;
} ) ;
2026-02-12 14:02:59 -05:00
it ( 'getProjectTempPlansDir returns ~/.gemini/tmp/<identifier>/<sessionId>/plans when sessionId is provided' , async ( ) = > {
const sessionId = 'test-session-id' ;
const storageWithSession = new Storage ( projectRoot , sessionId ) ;
ProjectRegistry . prototype . getShortId = vi
. fn ( )
. mockReturnValue ( PROJECT_SLUG ) ;
await storageWithSession . initialize ( ) ;
const tempDir = storageWithSession . getProjectTempDir ( ) ;
const expected = path . join ( tempDir , sessionId , 'plans' ) ;
expect ( storageWithSession . getProjectTempPlansDir ( ) ) . toBe ( expected ) ;
} ) ;
2026-02-19 17:47:08 -05:00
2026-03-13 16:35:26 -07:00
it ( 'getProjectTempTrackerDir returns ~/.gemini/tmp/<identifier>/tracker when no sessionId is provided' , async ( ) = > {
await storage . initialize ( ) ;
const tempDir = storage . getProjectTempDir ( ) ;
const expected = path . join ( tempDir , 'tracker' ) ;
expect ( storage . getProjectTempTrackerDir ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'getProjectTempTrackerDir returns ~/.gemini/tmp/<identifier>/<sessionId>/tracker when sessionId is provided' , async ( ) = > {
const sessionId = 'test-session-id' ;
const storageWithSession = new Storage ( projectRoot , sessionId ) ;
ProjectRegistry . prototype . getShortId = vi
. fn ( )
. mockReturnValue ( PROJECT_SLUG ) ;
await storageWithSession . initialize ( ) ;
const tempDir = storageWithSession . getProjectTempDir ( ) ;
const expected = path . join ( tempDir , sessionId , 'tracker' ) ;
expect ( storageWithSession . getProjectTempTrackerDir ( ) ) . toBe ( expected ) ;
} ) ;
2026-02-19 16:47:35 -08:00
describe ( 'Session and JSON Loading' , ( ) = > {
beforeEach ( async ( ) = > {
await storage . initialize ( ) ;
} ) ;
it ( 'listProjectChatFiles returns sorted sessions from chats directory' , async ( ) = > {
const readdirSpy = vi
. spyOn ( fs . promises , 'readdir' )
/* eslint-disable @typescript-eslint/no-explicit-any */
. mockResolvedValue ( [
'session-1.json' ,
'session-2.json' ,
'not-a-session.txt' ,
] as any ) ;
const statSpy = vi
. spyOn ( fs . promises , 'stat' )
. mockImplementation ( async ( p : any ) = > {
if ( p . toString ( ) . endsWith ( 'session-1.json' ) ) {
return {
mtime : new Date ( '2026-02-01' ) ,
mtimeMs : 1000 ,
} as any ;
}
return {
mtime : new Date ( '2026-02-02' ) ,
mtimeMs : 2000 ,
} as any ;
} ) ;
/* eslint-enable @typescript-eslint/no-explicit-any */
const sessions = await storage . listProjectChatFiles ( ) ;
expect ( readdirSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'chats' ) ) ;
expect ( sessions ) . toHaveLength ( 2 ) ;
// Sorted by mtime desc
expect ( sessions [ 0 ] . filePath ) . toBe ( path . join ( 'chats' , 'session-2.json' ) ) ;
expect ( sessions [ 1 ] . filePath ) . toBe ( path . join ( 'chats' , 'session-1.json' ) ) ;
expect ( sessions [ 0 ] . lastUpdated ) . toBe (
new Date ( '2026-02-02' ) . toISOString ( ) ,
) ;
readdirSpy . mockRestore ( ) ;
statSpy . mockRestore ( ) ;
} ) ;
it ( 'loadProjectTempFile loads and parses JSON from relative path' , async ( ) = > {
const readFileSpy = vi
. spyOn ( fs . promises , 'readFile' )
. mockResolvedValue ( JSON . stringify ( { hello : 'world' } ) ) ;
const result = await storage . loadProjectTempFile < { hello : string } > (
'some/file.json' ,
) ;
expect ( readFileSpy ) . toHaveBeenCalledWith (
expect . stringContaining ( path . join ( PROJECT_SLUG , 'some/file.json' ) ) ,
'utf8' ,
) ;
expect ( result ) . toEqual ( { hello : 'world' } ) ;
readFileSpy . mockRestore ( ) ;
} ) ;
it ( 'loadProjectTempFile returns null if file does not exist' , async ( ) = > {
const error = new Error ( 'File not found' ) ;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
( error as any ) . code = 'ENOENT' ;
const readFileSpy = vi
. spyOn ( fs . promises , 'readFile' )
. mockRejectedValue ( error ) ;
const result = await storage . loadProjectTempFile ( 'missing.json' ) ;
expect ( result ) . toBeNull ( ) ;
readFileSpy . mockRestore ( ) ;
} ) ;
} ) ;
2026-02-19 17:47:08 -05:00
describe ( 'getPlansDir' , ( ) = > {
interface TestCase {
name : string ;
customDir : string | undefined ;
expected : string | ( ( ) = > string ) ;
expectedError? : string ;
setup ? : ( ) = > ( ) = > void ;
}
const testCases : TestCase [ ] = [
{
name : 'custom relative path' ,
customDir : '.my-plans' ,
expected : path.resolve ( projectRoot , '.my-plans' ) ,
} ,
{
name : 'custom absolute path outside throws' ,
customDir : '/absolute/path/to/plans' ,
expected : '' ,
2026-03-09 12:02:13 -04:00
expectedError : ` Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root ' ${ resolveToRealPath ( projectRoot ) } '. ` ,
2026-02-19 17:47:08 -05:00
} ,
{
name : 'absolute path that happens to be inside project root' ,
customDir : path.join ( projectRoot , 'internal-plans' ) ,
expected : path.join ( projectRoot , 'internal-plans' ) ,
} ,
{
name : 'relative path that stays within project root' ,
customDir : 'subdir/../plans' ,
expected : path.resolve ( projectRoot , 'plans' ) ,
} ,
{
name : 'dot path' ,
customDir : '.' ,
expected : projectRoot ,
} ,
{
name : 'default behavior when customDir is undefined' ,
customDir : undefined ,
expected : ( ) = > storage . getProjectTempPlansDir ( ) ,
} ,
{
name : 'escaping relative path throws' ,
customDir : '../escaped-plans' ,
expected : '' ,
2026-03-09 12:02:13 -04:00
expectedError : ` Custom plans directory '../escaped-plans' resolves to ' ${ resolveToRealPath ( path . resolve ( projectRoot , '../escaped-plans' ) ) } ', which is outside the project root ' ${ resolveToRealPath ( projectRoot ) } '. ` ,
2026-02-19 17:47:08 -05:00
} ,
{
name : 'hidden directory starting with ..' ,
customDir : '..plans' ,
expected : path.resolve ( projectRoot , '..plans' ) ,
} ,
{
name : 'security escape via symbolic link throws' ,
customDir : 'symlink-to-outside' ,
setup : ( ) = > {
vi . mocked ( fs . realpathSync ) . mockImplementation ( ( p : fs.PathLike ) = > {
if ( p . toString ( ) . includes ( 'symlink-to-outside' ) ) {
return '/outside/project/root' ;
}
return p . toString ( ) ;
} ) ;
return ( ) = > vi . mocked ( fs . realpathSync ) . mockRestore ( ) ;
} ,
expected : '' ,
expectedError :
"Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'." ,
} ,
] ;
testCases . forEach ( ( { name , customDir , expected , expectedError , setup } ) = > {
it ( ` should handle ${ name } ` , async ( ) = > {
const cleanup = setup ? . ( ) ;
try {
if ( name . includes ( 'default behavior' ) ) {
await storage . initialize ( ) ;
}
storage . setCustomPlansDir ( customDir ) ;
if ( expectedError ) {
expect ( ( ) = > storage . getPlansDir ( ) ) . toThrow ( expectedError ) ;
} else {
const expectedValue =
typeof expected === 'function' ? expected ( ) : expected ;
expect ( storage . getPlansDir ( ) ) . toBe ( expectedValue ) ;
}
} finally {
cleanup ? . ( ) ;
}
} ) ;
} ) ;
} ) ;
2025-08-20 10:55:47 +09:00
} ) ;
2026-01-26 19:27:49 -05:00
describe ( 'Storage - System Paths' , ( ) = > {
const originalEnv = process . env [ 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' ] ;
afterEach ( ( ) = > {
if ( originalEnv !== undefined ) {
process . env [ 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' ] = originalEnv ;
} else {
delete process . env [ 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' ] ;
}
} ) ;
it ( 'getSystemSettingsPath returns correct path based on platform (default)' , ( ) = > {
delete process . env [ 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' ] ;
const platform = os . platform ( ) ;
const result = Storage . getSystemSettingsPath ( ) ;
if ( platform === 'darwin' ) {
expect ( result ) . toBe (
'/Library/Application Support/GeminiCli/settings.json' ,
) ;
} else if ( platform === 'win32' ) {
expect ( result ) . toBe ( 'C:\\ProgramData\\gemini-cli\\settings.json' ) ;
} else {
expect ( result ) . toBe ( '/etc/gemini-cli/settings.json' ) ;
}
} ) ;
it ( 'getSystemSettingsPath follows GEMINI_CLI_SYSTEM_SETTINGS_PATH if set' , ( ) = > {
const customPath = '/custom/path/settings.json' ;
process . env [ 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' ] = customPath ;
expect ( Storage . getSystemSettingsPath ( ) ) . toBe ( customPath ) ;
} ) ;
it ( 'getSystemPoliciesDir returns correct path based on platform and ignores env var' , ( ) = > {
process . env [ 'GEMINI_CLI_SYSTEM_SETTINGS_PATH' ] =
'/custom/path/settings.json' ;
const platform = os . platform ( ) ;
const result = Storage . getSystemPoliciesDir ( ) ;
expect ( result ) . not . toContain ( '/custom/path' ) ;
if ( platform === 'darwin' ) {
expect ( result ) . toBe ( '/Library/Application Support/GeminiCli/policies' ) ;
} else if ( platform === 'win32' ) {
expect ( result ) . toBe ( 'C:\\ProgramData\\gemini-cli\\policies' ) ;
} else {
expect ( result ) . toBe ( '/etc/gemini-cli/policies' ) ;
}
} ) ;
} ) ;