/** * Persist Command Tests * * Tests for the `ccs persist` CLI command including: * - Argument parsing * - API key masking * - Settings merge logic * - Backup management * - Error handling */ const assert = require('assert'); const fs = require('fs'); const path = require('path'); describe('Persist Command', () => { // ========================================================================= // Argument Parsing Tests // ========================================================================= describe('parseArgs', () => { /** * Simulates the argument parsing logic from persist-command.ts */ function parseArgs(args) { const validPermissionModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions']; const knownFlags = [ '--yes', '-y', '--list-backups', '--restore', '--permission-mode', '--dangerously-skip-permissions', '--auto-approve', '--help', '-h', ]; const result = { profile: undefined, yes: false, listBackups: false, restore: undefined, permissionMode: undefined, dangerouslySkipPermissions: false, parseError: undefined, }; const unknownFlags = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--yes' || arg === '-y') { result.yes = true; } else if (arg === '--help' || arg === '-h') { // Will be handled in main function } else if (arg === '--list-backups') { result.listBackups = true; } else if (arg === '--restore') { // Check if next arg is a timestamp (not a flag) const nextArg = args[i + 1]; if (nextArg && !nextArg.startsWith('-')) { result.restore = nextArg; i++; // Skip next arg } else { result.restore = true; // Use latest } } else if (arg === '--permission-mode') { const nextArg = args[i + 1]; if (!nextArg || nextArg.startsWith('-')) { result.parseError = 'Missing value for --permission-mode'; } else if (!validPermissionModes.includes(nextArg)) { result.parseError = `Invalid --permission-mode "${nextArg}". Valid modes: ${validPermissionModes.join(', ')}`; } else { result.permissionMode = nextArg; i++; // Skip next arg } } else if (arg.startsWith('--permission-mode=')) { const mode = arg.split('=').slice(1).join('='); if (!mode.trim()) { result.parseError = 'Missing value for --permission-mode'; } else if (!validPermissionModes.includes(mode)) { result.parseError = `Invalid --permission-mode "${mode}". Valid modes: ${validPermissionModes.join(', ')}`; } else { result.permissionMode = mode; } } else if (arg === '--dangerously-skip-permissions' || arg === '--auto-approve') { result.dangerouslySkipPermissions = true; } else if (!arg.startsWith('-') && !result.profile) { result.profile = arg; } else if (arg.startsWith('-')) { const known = knownFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)); if (!known) { unknownFlags.push(arg); } } } if (!result.parseError && unknownFlags.length > 0) { const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`; } if (!result.parseError && result.listBackups && result.restore) { result.parseError = '--list-backups cannot be used with --restore'; } if ( !result.parseError && (result.listBackups || result.restore) && (result.permissionMode || result.dangerouslySkipPermissions) ) { result.parseError = 'Permission flags are not valid with backup operations. Use them only with ccs persist .'; } return result; } function resolvePermissionMode(parsedArgs) { if (!parsedArgs.dangerouslySkipPermissions) { return parsedArgs.permissionMode; } if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') { throw new Error( '--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.' ); } return 'bypassPermissions'; } it('parses profile name as first positional argument', () => { const result = parseArgs(['glm']); assert.strictEqual(result.profile, 'glm'); }); it('parses --yes flag', () => { const result = parseArgs(['glm', '--yes']); assert.strictEqual(result.yes, true); assert.strictEqual(result.profile, 'glm'); }); it('parses -y short flag', () => { const result = parseArgs(['gemini', '-y']); assert.strictEqual(result.yes, true); assert.strictEqual(result.profile, 'gemini'); }); it('parses flags before profile name', () => { const result = parseArgs(['--yes', 'kimi']); assert.strictEqual(result.yes, true); assert.strictEqual(result.profile, 'kimi'); }); it('handles no arguments', () => { const result = parseArgs([]); assert.strictEqual(result.profile, undefined); assert.strictEqual(result.yes, false); }); it('rejects unknown flags with a clear parseError', () => { const result = parseArgs(['glm', '--unknown', '--yes']); assert.match(result.parseError, /Unknown option\(s\)/); }); it('takes only first positional as profile', () => { const result = parseArgs(['first', 'second', 'third']); assert.strictEqual(result.profile, 'first'); }); it('parses --list-backups flag', () => { const result = parseArgs(['--list-backups']); assert.strictEqual(result.listBackups, true); assert.strictEqual(result.profile, undefined); }); it('parses --restore flag without timestamp (use latest)', () => { const result = parseArgs(['--restore']); assert.strictEqual(result.restore, true); }); it('parses --restore flag with timestamp', () => { const result = parseArgs(['--restore', '20260110_205324']); assert.strictEqual(result.restore, '20260110_205324'); }); it('parses --restore with --yes flag', () => { const result = parseArgs(['--restore', '--yes']); assert.strictEqual(result.restore, true); assert.strictEqual(result.yes, true); }); it('parses --restore with timestamp and --yes flag', () => { const result = parseArgs(['--restore', '20260110_205324', '--yes']); assert.strictEqual(result.restore, '20260110_205324'); assert.strictEqual(result.yes, true); }); it('parses --permission-mode with valid mode', () => { const result = parseArgs(['glm', '--permission-mode', 'acceptEdits']); assert.strictEqual(result.profile, 'glm'); assert.strictEqual(result.permissionMode, 'acceptEdits'); assert.strictEqual(result.parseError, undefined); }); it('parses --permission-mode=value syntax', () => { const result = parseArgs(['glm', '--permission-mode=bypassPermissions']); assert.strictEqual(result.permissionMode, 'bypassPermissions'); assert.strictEqual(result.parseError, undefined); }); it('sets parseError for invalid --permission-mode', () => { const result = parseArgs(['glm', '--permission-mode', 'invalid']); assert.match(result.parseError, /Invalid --permission-mode/); }); it('sets parseError for missing --permission-mode value', () => { const result = parseArgs(['glm', '--permission-mode']); assert.strictEqual(result.parseError, 'Missing value for --permission-mode'); }); it('sets parseError for empty inline --permission-mode=', () => { const result = parseArgs(['glm', '--permission-mode=']); assert.strictEqual(result.parseError, 'Missing value for --permission-mode'); }); it('parses --dangerously-skip-permissions flag', () => { const result = parseArgs(['glm', '--dangerously-skip-permissions']); assert.strictEqual(result.dangerouslySkipPermissions, true); }); it('parses --auto-approve as alias flag', () => { const result = parseArgs(['glm', '--auto-approve']); assert.strictEqual(result.dangerouslySkipPermissions, true); }); it('handles both dangerous alias flags together', () => { const result = parseArgs(['glm', '--dangerously-skip-permissions', '--auto-approve']); assert.strictEqual(result.dangerouslySkipPermissions, true); assert.strictEqual(resolvePermissionMode(result), 'bypassPermissions'); }); ['acceptEdits', 'plan', 'default'].forEach((mode) => { it(`throws on conflict between dangerous mode and --permission-mode ${mode}`, () => { const parsed = parseArgs(['glm', '--permission-mode', mode, '--auto-approve']); assert.throws( () => resolvePermissionMode(parsed), /--dangerously-skip-permissions conflicts with --permission-mode/ ); }); }); it('allows dangerous mode with --permission-mode bypassPermissions', () => { const parsed = parseArgs([ 'glm', '--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', ]); assert.strictEqual(resolvePermissionMode(parsed), 'bypassPermissions'); }); it('sets parseError when --list-backups and --restore are both provided', () => { const parsed = parseArgs(['--list-backups', '--restore']); assert.strictEqual(parsed.parseError, '--list-backups cannot be used with --restore'); }); it('sets parseError when permission flags are used with --restore', () => { const parsed = parseArgs(['--restore', '--auto-approve']); assert.match(parsed.parseError, /Permission flags are not valid with backup operations/); }); it('sets parseError when permission flags are used with --list-backups', () => { const parsed = parseArgs(['--list-backups', '--permission-mode', 'plan']); assert.match(parsed.parseError, /Permission flags are not valid with backup operations/); }); it('keeps test permission mode list exactly aligned with source constant', () => { const sourcePath = path.join(__dirname, '../../../src/commands/persist-command.ts'); const sourceContent = fs.readFileSync(sourcePath, 'utf8'); const match = sourceContent.match(/const VALID_PERMISSION_MODES = \[(.*?)\] as const/s); assert(match, 'Expected VALID_PERMISSION_MODES constant to exist'); const sourceModes = [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]).sort(); const testModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions'].sort(); assert.deepStrictEqual(sourceModes, testModes); }); it('accepts common option ordering permutations', () => { const cases = [ ['glm', '--yes', '--permission-mode', 'plan'], ['--yes', 'glm', '--permission-mode=plan'], ['--permission-mode', 'plan', 'glm', '--yes'], ['--yes', '--permission-mode', 'plan', 'glm'], ]; for (const input of cases) { const parsed = parseArgs(input); assert.strictEqual(parsed.parseError, undefined, `Expected no parseError for: ${input.join(' ')}`); assert.strictEqual(parsed.profile, 'glm'); assert.strictEqual(parsed.yes, true); assert.strictEqual(parsed.permissionMode, 'plan'); } }); }); // ========================================================================= // API Key Masking Tests // ========================================================================= describe('maskApiKey', () => { /** * Simulates the maskApiKey function from persist-command.ts */ function maskApiKey(key) { if (key.length <= 12) { return '****'; } return `${key.slice(0, 4)}...${key.slice(-4)}`; } it('masks keys showing first 4 and last 4 characters', () => { const result = maskApiKey('sk-1234567890abcdef'); assert.strictEqual(result, 'sk-1...cdef'); }); it('returns **** for keys <= 12 characters', () => { assert.strictEqual(maskApiKey('123456789012'), '****'); assert.strictEqual(maskApiKey('12345678901'), '****'); assert.strictEqual(maskApiKey('short'), '****'); assert.strictEqual(maskApiKey(''), '****'); }); it('handles exactly 13 character keys', () => { const result = maskApiKey('1234567890abc'); assert.strictEqual(result, '1234...0abc'); }); it('handles long API keys', () => { const longKey = 'api_key_1234567890abcdefghijklmnopqrstuvwxyz'; const result = maskApiKey(longKey); assert.strictEqual(result, 'api_...wxyz'); }); it('preserves special characters', () => { const key = '!@#$%^&*()_+-=[]{}'; const result = maskApiKey(key); assert.strictEqual(result, '!@#$...[]{}'); }); }); // ========================================================================= // Settings Merge Tests // ========================================================================= describe('Settings Merge Logic', () => { /** * Simulates the merge logic from persist-command.ts */ function mergeSettings(existing, newEnv, permissionMode) { const existingEnv = existing.env || {}; const merged = { ...existing, env: { ...existingEnv, ...newEnv, }, }; if (permissionMode) { const existingPermissions = existing.permissions && typeof existing.permissions === 'object' ? existing.permissions : {}; merged.permissions = { ...existingPermissions, defaultMode: permissionMode, }; } return merged; } it('merges env vars into empty settings', () => { const existing = {}; const newEnv = { ANTHROPIC_BASE_URL: 'http://example.com' }; const result = mergeSettings(existing, newEnv); assert.deepStrictEqual(result.env, newEnv); }); it('preserves existing hooks', () => { const existing = { hooks: { PreToolUse: [{ matcher: 'WebSearch' }] }, }; const newEnv = { ANTHROPIC_MODEL: 'test' }; const result = mergeSettings(existing, newEnv); assert.deepStrictEqual(result.hooks, existing.hooks); }); it('preserves existing presets', () => { const existing = { presets: [{ name: 'test', default: 'model' }], }; const newEnv = { ANTHROPIC_MODEL: 'test' }; const result = mergeSettings(existing, newEnv); assert.deepStrictEqual(result.presets, existing.presets); }); it('preserves other settings like model and alwaysThinkingEnabled', () => { const existing = { model: 'opus', alwaysThinkingEnabled: true, }; const newEnv = { ANTHROPIC_MODEL: 'test' }; const result = mergeSettings(existing, newEnv); assert.strictEqual(result.model, 'opus'); assert.strictEqual(result.alwaysThinkingEnabled, true); }); it('merges new env vars with existing env vars', () => { const existing = { env: { EXISTING_VAR: 'value' }, }; const newEnv = { NEW_VAR: 'new_value' }; const result = mergeSettings(existing, newEnv); assert.strictEqual(result.env.EXISTING_VAR, 'value'); assert.strictEqual(result.env.NEW_VAR, 'new_value'); }); it('overwrites existing env vars with new values', () => { const existing = { env: { ANTHROPIC_MODEL: 'old_model' }, }; const newEnv = { ANTHROPIC_MODEL: 'new_model' }; const result = mergeSettings(existing, newEnv); assert.strictEqual(result.env.ANTHROPIC_MODEL, 'new_model'); }); it('handles complex settings with multiple fields', () => { const existing = { hooks: { PreToolUse: [] }, presets: [], model: 'sonnet', env: { OLD_VAR: 'old' }, customField: 'custom', }; const newEnv = { ANTHROPIC_BASE_URL: 'http://test.com', ANTHROPIC_MODEL: 'test', }; const result = mergeSettings(existing, newEnv); assert.deepStrictEqual(result.hooks, existing.hooks); assert.deepStrictEqual(result.presets, existing.presets); assert.strictEqual(result.model, 'sonnet'); assert.strictEqual(result.customField, 'custom'); assert.strictEqual(result.env.OLD_VAR, 'old'); assert.strictEqual(result.env.ANTHROPIC_BASE_URL, 'http://test.com'); assert.strictEqual(result.env.ANTHROPIC_MODEL, 'test'); }); it('sets permissions.defaultMode when permission mode is provided', () => { const existing = { hooks: { PreToolUse: [] }, }; const result = mergeSettings(existing, { ANTHROPIC_MODEL: 'test' }, 'acceptEdits'); assert.strictEqual(result.permissions.defaultMode, 'acceptEdits'); assert.deepStrictEqual(result.hooks, existing.hooks); }); it('preserves existing permissions allow/deny when setting defaultMode', () => { const existing = { permissions: { allow: ['Bash(ls:*)'], deny: ['Bash(rm:*)'], }, }; const result = mergeSettings(existing, { ANTHROPIC_MODEL: 'test' }, 'bypassPermissions'); assert.deepStrictEqual(result.permissions.allow, ['Bash(ls:*)']); assert.deepStrictEqual(result.permissions.deny, ['Bash(rm:*)']); assert.strictEqual(result.permissions.defaultMode, 'bypassPermissions'); }); }); // ========================================================================= // Backup Timestamp Tests // ========================================================================= describe('Backup Timestamp Format', () => { /** * Simulates the timestamp generation from persist-command.ts */ function generateTimestamp(date) { return ( date.getFullYear().toString() + (date.getMonth() + 1).toString().padStart(2, '0') + date.getDate().toString().padStart(2, '0') + '_' + date.getHours().toString().padStart(2, '0') + date.getMinutes().toString().padStart(2, '0') + date.getSeconds().toString().padStart(2, '0') ); } it('generates correct format YYYYMMDD_HHMMSS', () => { const date = new Date(2025, 0, 15, 10, 30, 45); // Jan 15, 2025 10:30:45 const result = generateTimestamp(date); assert.strictEqual(result, '20250115_103045'); }); it('pads single digit months', () => { const date = new Date(2025, 0, 1, 0, 0, 0); // Jan 1 const result = generateTimestamp(date); assert.match(result, /^202501/); }); it('pads single digit days', () => { const date = new Date(2025, 11, 5, 0, 0, 0); // Dec 5 const result = generateTimestamp(date); assert.match(result, /^20251205/); }); it('pads single digit hours, minutes, seconds', () => { const date = new Date(2025, 5, 15, 1, 2, 3); const result = generateTimestamp(date); assert.strictEqual(result, '20250615_010203'); }); }); // ========================================================================= // Profile Type Detection Tests // ========================================================================= describe('Profile Type Messages', () => { const profileTypes = { settings: 'API', cliproxy: 'CLIProxy', copilot: 'Copilot', account: 'Account (not supported)', default: 'Default (not supported)', }; it('maps settings type to API', () => { assert.strictEqual(profileTypes.settings, 'API'); }); it('maps cliproxy type to CLIProxy', () => { assert.strictEqual(profileTypes.cliproxy, 'CLIProxy'); }); it('maps copilot type to Copilot', () => { assert.strictEqual(profileTypes.copilot, 'Copilot'); }); }); // ========================================================================= // Sensitive Key Detection Tests // ========================================================================= describe('Sensitive Key Detection', () => { /** * Simulates the logic to detect sensitive keys for masking */ function isSensitiveKey(key) { const sensitiveParts = new Set([ 'TOKEN', 'KEY', 'SECRET', 'PASSWORD', 'PASS', 'AUTH', 'CREDENTIAL', 'PRIVATE', 'ACCESS', 'REFRESH', 'APIKEY', ]); const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2'); const parts = withCamelCaseBoundaries.toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean); if (parts.some((part) => sensitiveParts.has(part))) { return true; } const compact = parts.join(''); return ( compact.includes('TOKEN') || compact.includes('APIKEY') || compact.includes('ACCESSKEY') || compact.includes('AUTHKEY') || compact.includes('SECRET') || compact.includes('PASSWORD') || compact.includes('CREDENTIAL') ); } it('detects TOKEN in key name', () => { assert.strictEqual(isSensitiveKey('ANTHROPIC_AUTH_TOKEN'), true); assert.strictEqual(isSensitiveKey('ACCESS_TOKEN'), true); }); it('detects KEY in key name', () => { assert.strictEqual(isSensitiveKey('API_KEY'), true); assert.strictEqual(isSensitiveKey('ANTHROPIC_API_KEY'), true); }); it('detects SECRET in key name', () => { assert.strictEqual(isSensitiveKey('CLIENT_SECRET'), true); assert.strictEqual(isSensitiveKey('SECRET_KEY'), true); }); it('does not flag non-sensitive keys', () => { assert.strictEqual(isSensitiveKey('ANTHROPIC_BASE_URL'), false); assert.strictEqual(isSensitiveKey('ANTHROPIC_MODEL'), false); assert.strictEqual(isSensitiveKey('DISABLE_TELEMETRY'), false); }); it('detects lowercase and mixed-case sensitive keys', () => { assert.strictEqual(isSensitiveKey('anthropic_auth_token'), true); assert.strictEqual(isSensitiveKey('Api_Key'), true); assert.strictEqual(isSensitiveKey('clientSecret'), true); }); it('detects additional secret families', () => { assert.strictEqual(isSensitiveKey('DB_PASSWORD'), true); assert.strictEqual(isSensitiveKey('refresh_token_value'), true); assert.strictEqual(isSensitiveKey('private_credential_blob'), true); }); it('detects camelCase sensitive key variants', () => { assert.strictEqual(isSensitiveKey('accessKeyId'), true); assert.strictEqual(isSensitiveKey('authTokenValue'), true); assert.strictEqual(isSensitiveKey('apiKeyValue'), true); }); }); // ========================================================================= // Help Text Coverage Tests // ========================================================================= describe('Help Text Coverage', () => { const expectedOptions = ['--yes', '-y', '--help', '-h']; const expectedProfileTypes = ['API profiles', 'CLIProxy', 'Copilot', 'Account-based']; it('documents all CLI options', () => { expectedOptions.forEach((option) => { assert(typeof option === 'string', `Option ${option} should be a string`); assert(option.startsWith('-'), `Option ${option} should start with -`); }); }); it('documents all profile types', () => { expectedProfileTypes.forEach((type) => { assert(typeof type === 'string', `Profile type ${type} should be documented`); }); }); }); // ========================================================================= // Error Message Tests // ========================================================================= describe('Error Messages', () => { it('account profile error message mentions CLAUDE_CONFIG_DIR', () => { const errorMessage = 'Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n' + "Use 'ccs profileName' to run with this profile instead."; assert(errorMessage.includes('CLAUDE_CONFIG_DIR')); assert(errorMessage.includes('ccs profileName')); }); it('no env vars error message includes profile name placeholder', () => { const profileName = 'test'; const errorMessage = `Profile '${profileName}' has no env vars configured`; assert(errorMessage.includes(profileName)); }); }); // ========================================================================= // Backup File Parsing Tests // ========================================================================= describe('Backup File Parsing', () => { const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/; /** * Simulates parsing a backup filename into a BackupFile object */ function parseBackupFile(filename, dir) { const match = filename.match(backupPattern); if (!match) return null; const timestamp = match[1]; // Parse YYYYMMDD_HHMMSS const year = parseInt(timestamp.slice(0, 4)); const month = parseInt(timestamp.slice(4, 6)) - 1; const day = parseInt(timestamp.slice(6, 8)); const hour = parseInt(timestamp.slice(9, 11)); const min = parseInt(timestamp.slice(11, 13)); const sec = parseInt(timestamp.slice(13, 15)); return { path: dir + '/' + filename, timestamp, date: new Date(year, month, day, hour, min, sec), }; } it('matches valid backup filename pattern', () => { assert(backupPattern.test('settings.json.backup.20260110_205324')); assert(backupPattern.test('settings.json.backup.20250101_000000')); }); it('rejects invalid backup filenames', () => { assert(!backupPattern.test('settings.json')); assert(!backupPattern.test('settings.json.backup')); assert(!backupPattern.test('settings.json.backup.invalid')); assert(!backupPattern.test('settings.json.backup.20260110')); assert(!backupPattern.test('other.json.backup.20260110_205324')); }); it('parses backup file correctly', () => { const result = parseBackupFile('settings.json.backup.20260110_205324', '/home/user/.claude'); assert.strictEqual(result.timestamp, '20260110_205324'); assert.strictEqual(result.path, '/home/user/.claude/settings.json.backup.20260110_205324'); assert.strictEqual(result.date.getFullYear(), 2026); assert.strictEqual(result.date.getMonth(), 0); // January assert.strictEqual(result.date.getDate(), 10); assert.strictEqual(result.date.getHours(), 20); assert.strictEqual(result.date.getMinutes(), 53); assert.strictEqual(result.date.getSeconds(), 24); }); it('returns null for non-matching files', () => { const result = parseBackupFile('settings.json', '/home/user/.claude'); assert.strictEqual(result, null); }); it('sorts backup files by date descending (newest first)', () => { const files = [ { timestamp: '20260109_100000', date: new Date(2026, 0, 9, 10, 0, 0) }, { timestamp: '20260110_205324', date: new Date(2026, 0, 10, 20, 53, 24) }, { timestamp: '20260110_100000', date: new Date(2026, 0, 10, 10, 0, 0) }, ]; const sorted = files.sort((a, b) => b.date.getTime() - a.date.getTime()); assert.strictEqual(sorted[0].timestamp, '20260110_205324'); assert.strictEqual(sorted[1].timestamp, '20260110_100000'); assert.strictEqual(sorted[2].timestamp, '20260109_100000'); }); }); // ========================================================================= // Backup Restore Logic Tests // ========================================================================= describe('Backup Restore Logic', () => { it('selects first backup when restore=true (latest)', () => { const backups = [{ timestamp: '20260110_205324' }, { timestamp: '20260110_100000' }]; const restore = true; const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); assert.strictEqual(selected.timestamp, '20260110_205324'); }); it('selects specific backup when restore is a timestamp', () => { const backups = [{ timestamp: '20260110_205324' }, { timestamp: '20260110_100000' }]; const restore = '20260110_100000'; const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); assert.strictEqual(selected.timestamp, '20260110_100000'); }); it('returns undefined when timestamp not found', () => { const backups = [{ timestamp: '20260110_205324' }, { timestamp: '20260110_100000' }]; const restore = '20260101_000000'; const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); assert.strictEqual(selected, undefined); }); }); });