test: add regression test for settings path collision guard
Verify that when CWD equals the Claude config home directory (/home/spark), projectSettings and localSettings do not override userSettings. This prevents settings.local.json from silently clobbering the user's explicit configuration. 4 tests: - Guard active: local does NOT override user when CWD=/home/spark - Guard inactive: local DOES override user when CWD≠/home/spark - Edge: no settings files → empty result - Edge: empty settings.local.json → no errors Uses existing tests/mocks/state.ts mock infrastructure.
This commit is contained in:
parent
95f0c99584
commit
9c5df5d1a6
342
src/utils/__tests__/settings.test.ts
Normal file
342
src/utils/__tests__/settings.test.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
mock,
|
||||||
|
test,
|
||||||
|
} from 'bun:test'
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { stateMock } from '../../../tests/mocks/state'
|
||||||
|
|
||||||
|
const ALL_SETTING_SOURCES = [
|
||||||
|
'userSettings',
|
||||||
|
'projectSettings',
|
||||||
|
'localSettings',
|
||||||
|
'flagSettings',
|
||||||
|
'policySettings',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
let dynamicCwd = process.cwd()
|
||||||
|
let managedRoot = join(tmpdir(), 'settings-test-managed-initial')
|
||||||
|
|
||||||
|
mock.module('src/bootstrap/state.js', () => ({
|
||||||
|
...stateMock(),
|
||||||
|
getOriginalCwd: () => dynamicCwd,
|
||||||
|
getCwdState: () => dynamicCwd,
|
||||||
|
getProjectRoot: () => dynamicCwd,
|
||||||
|
setOriginalCwd: (cwd: string) => {
|
||||||
|
dynamicCwd = cwd
|
||||||
|
},
|
||||||
|
setCwdState: (cwd: string) => {
|
||||||
|
dynamicCwd = cwd
|
||||||
|
},
|
||||||
|
setProjectRoot: () => {},
|
||||||
|
getAllowedSettingSources: () => [...ALL_SETTING_SOURCES],
|
||||||
|
getFlagSettingsPath: () => undefined,
|
||||||
|
getFlagSettingsInline: () => null,
|
||||||
|
setFlagSettingsPath: () => {},
|
||||||
|
setFlagSettingsInline: () => {},
|
||||||
|
getUseCoworkPlugins: () => false,
|
||||||
|
setUseCoworkPlugins: () => {},
|
||||||
|
waitForScrollIdle: () => Promise.resolve(),
|
||||||
|
markScrollActivity: () => {},
|
||||||
|
getIsScrollDraining: () => false,
|
||||||
|
getSessionTrustAccepted: () => false,
|
||||||
|
setSessionTrustAccepted: () => {},
|
||||||
|
getModelStrings: () => null,
|
||||||
|
setModelStrings: () => {},
|
||||||
|
resetModelStringsForTestingOnly: () => {},
|
||||||
|
getMainLoopModelOverride: () => undefined,
|
||||||
|
setMainLoopModelOverride: () => {},
|
||||||
|
getInitialMainLoopModel: () => 'sonnet',
|
||||||
|
setInitialMainLoopModel: () => {},
|
||||||
|
getSdkBetas: () => undefined,
|
||||||
|
setSdkBetas: () => {},
|
||||||
|
getSessionIngressToken: () => undefined,
|
||||||
|
setSessionIngressToken: () => {},
|
||||||
|
getOauthTokenFromFd: () => undefined,
|
||||||
|
setOauthTokenFromFd: () => {},
|
||||||
|
getApiKeyFromFd: () => undefined,
|
||||||
|
setApiKeyFromFd: () => {},
|
||||||
|
getLastClassifierRequests: () => null,
|
||||||
|
setLastClassifierRequests: () => {},
|
||||||
|
getCachedClaudeMdContent: () => null,
|
||||||
|
setCachedClaudeMdContent: () => {},
|
||||||
|
getInlinePlugins: () => [],
|
||||||
|
setInlinePlugins: () => {},
|
||||||
|
getChromeFlagOverride: () => undefined,
|
||||||
|
setChromeFlagOverride: () => {},
|
||||||
|
getSessionBypassPermissionsMode: () => false,
|
||||||
|
setSessionBypassPermissionsMode: () => {},
|
||||||
|
getScheduledTasksEnabled: () => true,
|
||||||
|
setScheduledTasksEnabled: () => {},
|
||||||
|
getSessionCronTasks: () => [],
|
||||||
|
addSessionCronTask: () => {},
|
||||||
|
removeSessionCronTasks: () => 0,
|
||||||
|
setSessionPersistenceDisabled: () => {},
|
||||||
|
isSessionPersistenceDisabled: () => false,
|
||||||
|
hasExitedPlanModeInSession: () => false,
|
||||||
|
setHasExitedPlanMode: () => {},
|
||||||
|
needsPlanModeExitAttachment: () => false,
|
||||||
|
setNeedsPlanModeExitAttachment: () => {},
|
||||||
|
handlePlanModeTransition: () => {},
|
||||||
|
needsAutoModeExitAttachment: () => false,
|
||||||
|
setNeedsAutoModeExitAttachment: () => {},
|
||||||
|
handleAutoModeTransition: () => {},
|
||||||
|
hasShownLspRecommendationThisSession: () => false,
|
||||||
|
setLspRecommendationShownThisSession: () => {},
|
||||||
|
getInitJsonSchema: () => null,
|
||||||
|
setInitJsonSchema: () => {},
|
||||||
|
getRegisteredHooks: () => ({}),
|
||||||
|
registerHookCallbacks: () => {},
|
||||||
|
clearRegisteredHooks: () => {},
|
||||||
|
clearRegisteredPluginHooks: () => {},
|
||||||
|
resetSdkInitState: () => {},
|
||||||
|
getPlanSlugCache: () => new Map(),
|
||||||
|
getSessionCreatedTeams: () => new Set(),
|
||||||
|
getTeleportedSessionInfo: () => null,
|
||||||
|
setTeleportedSessionInfo: () => {},
|
||||||
|
markFirstTeleportMessageLogged: () => {},
|
||||||
|
addInvokedSkill: () => {},
|
||||||
|
getInvokedSkills: () => new Map(),
|
||||||
|
getInvokedSkillsForAgent: () => new Map(),
|
||||||
|
clearInvokedSkills: () => {},
|
||||||
|
clearInvokedSkillsForAgent: () => {},
|
||||||
|
getMainThreadAgentType: () => undefined,
|
||||||
|
setMainThreadAgentType: () => {},
|
||||||
|
getIsRemoteMode: () => false,
|
||||||
|
setIsRemoteMode: () => {},
|
||||||
|
getSystemPromptSectionCache: () => new Map(),
|
||||||
|
setSystemPromptSectionCacheEntry: () => {},
|
||||||
|
clearSystemPromptSectionState: () => {},
|
||||||
|
getLastEmittedDate: () => null,
|
||||||
|
setLastEmittedDate: () => {},
|
||||||
|
getAdditionalDirectoriesForClaudeMd: () => [],
|
||||||
|
setAdditionalDirectoriesForClaudeMd: () => {},
|
||||||
|
getAllowedChannels: () => [],
|
||||||
|
setAllowedChannels: () => {},
|
||||||
|
getHasDevChannels: () => false,
|
||||||
|
setHasDevChannels: () => {},
|
||||||
|
getPromptCache1hAllowlist: () => null,
|
||||||
|
setPromptCache1hAllowlist: () => {},
|
||||||
|
getPromptCache1hEligible: () => null,
|
||||||
|
setPromptCache1hEligible: () => {},
|
||||||
|
getAfkModeHeaderLatched: () => null,
|
||||||
|
setAfkModeHeaderLatched: () => {},
|
||||||
|
getFastModeHeaderLatched: () => null,
|
||||||
|
setFastModeHeaderLatched: () => {},
|
||||||
|
getCacheEditingHeaderLatched: () => null,
|
||||||
|
setCacheEditingHeaderLatched: () => {},
|
||||||
|
clearBetaHeaderLatches: () => {},
|
||||||
|
getPromptId: () => null,
|
||||||
|
setPromptId: () => {},
|
||||||
|
isReplBridgeActive: () => false,
|
||||||
|
getModelUsage: () => ({}),
|
||||||
|
getUsageForModel: () => undefined,
|
||||||
|
resetCostState: () => {},
|
||||||
|
setCostStateForRestore: () => {},
|
||||||
|
setMeter: () => {},
|
||||||
|
getMeter: () => null,
|
||||||
|
getSessionCounter: () => null,
|
||||||
|
getLocCounter: () => null,
|
||||||
|
getPrCounter: () => null,
|
||||||
|
getCommitCounter: () => null,
|
||||||
|
getCostCounter: () => null,
|
||||||
|
getTokenCounter: () => null,
|
||||||
|
getCodeEditToolDecisionCounter: () => null,
|
||||||
|
getActiveTimeCounter: () => null,
|
||||||
|
getLoggerProvider: () => null,
|
||||||
|
setLoggerProvider: () => {},
|
||||||
|
getEventLogger: () => null,
|
||||||
|
setEventLogger: () => {},
|
||||||
|
getMeterProvider: () => null,
|
||||||
|
setMeterProvider: () => {},
|
||||||
|
getTracerProvider: () => null,
|
||||||
|
setTracerProvider: () => {},
|
||||||
|
getIsInteractive: () => true,
|
||||||
|
setIsInteractive: () => {},
|
||||||
|
getClientType: () => 'cli',
|
||||||
|
setClientType: () => {},
|
||||||
|
getKairosActive: () => false,
|
||||||
|
setKairosActive: () => {},
|
||||||
|
getStrictToolResultPairing: () => false,
|
||||||
|
setStrictToolResultPairing: () => {},
|
||||||
|
getUserMsgOptIn: () => false,
|
||||||
|
setUserMsgOptIn: () => {},
|
||||||
|
getSessionSource: () => undefined,
|
||||||
|
setSessionSource: () => {},
|
||||||
|
getQuestionPreviewFormat: () => undefined,
|
||||||
|
setQuestionPreviewFormat: () => {},
|
||||||
|
snapshotOutputTokensForTurn: () => {},
|
||||||
|
getBudgetContinuationCount: () => 0,
|
||||||
|
incrementBudgetContinuationCount: () => {},
|
||||||
|
hasUnknownModelCost: () => false,
|
||||||
|
setHasUnknownModelCost: () => {},
|
||||||
|
getLastMainRequestId: () => undefined,
|
||||||
|
setLastMainRequestId: () => {},
|
||||||
|
getLastApiCompletionTimestamp: () => null,
|
||||||
|
setLastApiCompletionTimestamp: () => {},
|
||||||
|
markPostCompaction: () => {},
|
||||||
|
consumePostCompaction: () => false,
|
||||||
|
addToInMemoryErrorLog: () => {},
|
||||||
|
preferThirdPartyAuthentication: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/settings/managedPath.js', () => ({
|
||||||
|
getManagedFilePath: () => managedRoot,
|
||||||
|
getManagedSettingsDropInDir: () => join(managedRoot, 'managed-settings.d'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/services/remoteManagedSettings/syncCacheState.js', () => ({
|
||||||
|
getRemoteManagedSettingsSyncFromCache: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('src/utils/settings/mdm/settings.js', () => ({
|
||||||
|
getHkcuSettings: () => ({ settings: {}, errors: [] }),
|
||||||
|
getMdmSettings: () => ({ settings: {}, errors: [] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let getSettingsWithErrors: typeof import('../settings/settings.js').getSettingsWithErrors
|
||||||
|
let getSettingsFilePathForSource: typeof import('../settings/settings.js').getSettingsFilePathForSource
|
||||||
|
let resetSettingsCache: typeof import('../settings/settingsCache.js').resetSettingsCache
|
||||||
|
let setOriginalCwd: (cwd: string) => void
|
||||||
|
|
||||||
|
const originalProcessCwd = process.cwd()
|
||||||
|
const originalHome = process.env.HOME
|
||||||
|
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
|
let tempDirs: string[] = []
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const settingsModule = await import('../settings/settings.js')
|
||||||
|
const settingsCacheModule = await import('../settings/settingsCache.js')
|
||||||
|
const stateModule = await import('src/bootstrap/state.js')
|
||||||
|
|
||||||
|
getSettingsWithErrors = settingsModule.getSettingsWithErrors
|
||||||
|
getSettingsFilePathForSource = settingsModule.getSettingsFilePathForSource
|
||||||
|
resetSettingsCache = settingsCacheModule.resetSettingsCache
|
||||||
|
setOriginalCwd = stateModule.setOriginalCwd
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDirs = []
|
||||||
|
managedRoot = makeTempDir('settings-managed-')
|
||||||
|
resetSettingsCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetSettingsCache()
|
||||||
|
process.chdir(originalProcessCwd)
|
||||||
|
restoreEnv('HOME', originalHome)
|
||||||
|
restoreEnv('CLAUDE_CONFIG_DIR', originalClaudeConfigDir)
|
||||||
|
setOriginalCwd(originalProcessCwd)
|
||||||
|
|
||||||
|
for (const dir of tempDirs) {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
tempDirs = []
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('settings source path collision guard', () => {
|
||||||
|
test('does not let localSettings override userSettings when CWD is HOME', () => {
|
||||||
|
const homeDir = makeTempDir('settings-home-')
|
||||||
|
const configDir = configureSession({ homeDir, cwdDir: homeDir })
|
||||||
|
|
||||||
|
writeSettings(getSettingsFilePathForSource('userSettings'), {
|
||||||
|
model: 'good-model',
|
||||||
|
})
|
||||||
|
writeSettings(getSettingsFilePathForSource('localSettings'), {
|
||||||
|
model: 'evil-override',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { settings, errors } = getSettingsWithErrors()
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(configDir).toBe(join(homeDir, '.claude'))
|
||||||
|
expect(settings.model).toBe('good-model')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lets localSettings override userSettings when CWD is not HOME', () => {
|
||||||
|
const homeDir = makeTempDir('settings-home-')
|
||||||
|
const projectDir = makeTempDir('settings-project-')
|
||||||
|
configureSession({ homeDir, cwdDir: projectDir })
|
||||||
|
|
||||||
|
writeSettings(getSettingsFilePathForSource('userSettings'), {
|
||||||
|
model: 'global-model',
|
||||||
|
})
|
||||||
|
writeSettings(getSettingsFilePathForSource('localSettings'), {
|
||||||
|
model: 'local-model',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { settings, errors } = getSettingsWithErrors()
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(settings.model).toBe('local-model')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty settings when no settings files exist', () => {
|
||||||
|
const homeDir = makeTempDir('settings-home-')
|
||||||
|
const projectDir = makeTempDir('settings-project-')
|
||||||
|
configureSession({ homeDir, cwdDir: projectDir })
|
||||||
|
|
||||||
|
const { settings, errors } = getSettingsWithErrors()
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(settings).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not report an error for an empty settings.local.json', () => {
|
||||||
|
const homeDir = makeTempDir('settings-home-')
|
||||||
|
const projectDir = makeTempDir('settings-project-')
|
||||||
|
configureSession({ homeDir, cwdDir: projectDir })
|
||||||
|
|
||||||
|
writeSettings(getSettingsFilePathForSource('localSettings'), {})
|
||||||
|
|
||||||
|
const { settings, errors } = getSettingsWithErrors()
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(settings).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeTempDir(prefix: string): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||||
|
tempDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureSession({
|
||||||
|
homeDir,
|
||||||
|
cwdDir,
|
||||||
|
}: {
|
||||||
|
homeDir: string
|
||||||
|
cwdDir: string
|
||||||
|
}): string {
|
||||||
|
const configDir = join(homeDir, '.claude')
|
||||||
|
mkdirSync(configDir, { recursive: true })
|
||||||
|
mkdirSync(join(cwdDir, '.claude'), { recursive: true })
|
||||||
|
|
||||||
|
process.env.HOME = homeDir
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||||
|
process.chdir(cwdDir)
|
||||||
|
setOriginalCwd(cwdDir)
|
||||||
|
resetSettingsCache()
|
||||||
|
|
||||||
|
return configDir
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSettings(path: string | undefined, settings: object): void {
|
||||||
|
expect(path).toBeDefined()
|
||||||
|
writeFileSync(path!, `${JSON.stringify(settings, null, 2)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user