Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
CONFIG_PERSISTENCE_TEST_IMPLEMENTATION.md19.1 kB
# Config Persistence Test Implementation Plan ## Overview This document contains the complete test implementation for the config persistence fix and related features. The critical bug was that ConfigManager was using SecureYamlParser (designed for markdown with frontmatter) instead of js-yaml for pure YAML config files, causing all values to reset on every load. ## Test Files to Create/Update ### 1. Fix ConfigManager.test.ts - Update for YAML Format **Current Issues:** - Tests expect JSON format but ConfigManager uses YAML (config.yml) - No regression test for the SecureYamlParser bug - Missing persistence validation between instances **Key Changes Needed:** ```typescript // Update line 40 - config path should be config.yml not config.json const configPath = path.join(configDir, 'config.yml'); // Update line 118 - should write YAML not JSON expect(mockWriteFile).toHaveBeenCalledWith( expect.stringContaining('.tmp'), expect.stringMatching(/^version: ['"]?1\.0\.0/), // YAML format { mode: 384 } ); // Update line 132 - mock should return YAML not JSON mockReadFile.mockResolvedValue(` version: '1.0.0' github: auth: client_id: 'Ov23liTestClientId123' `); // Update line 147 - handle corrupted YAML not JSON mockReadFile.mockResolvedValue('invalid: yaml: content: :::'); // Update line 159 - expect YAML format in write expect(mockWriteFile).toHaveBeenCalledWith( expect.stringContaining('.tmp'), expect.stringMatching(/^version:/), // YAML format { mode: 384 } ); ``` ### 2. Add Critical Regression Test ```typescript describe('YAML Parser Selection (Regression Test for Bug)', () => { it('should use js-yaml for config files, NOT SecureYamlParser', async () => { // This test ensures we don't regress to using SecureYamlParser // which expects markdown with frontmatter and returns empty {} for pure YAML const yamlConfig = ` version: '1.0.0' user: username: mickdarling email: mick@mickdarling.com sync: enabled: true bulk: download_enabled: true `; const mockReadFile = fs.readFile as jest.MockedFunction<typeof fs.readFile>; mockReadFile.mockResolvedValue(yamlConfig); const configManager = ConfigManager.getInstance(); await configManager.initialize(); // These should NOT be null - the bug was they were returning null const config = configManager.getConfig(); expect(config.user.username).toBe('mickdarling'); expect(config.user.email).toBe('mick@mickdarling.com'); expect(config.sync.enabled).toBe(true); expect(config.sync.bulk.download_enabled).toBe(true); }); it('should persist config values between ConfigManager instances', async () => { const yamlConfig = ` version: '1.0.0' user: username: testuser email: test@example.com sync: enabled: true `; const mockReadFile = fs.readFile as jest.MockedFunction<typeof fs.readFile>; const mockWriteFile = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>; // First instance saves config mockReadFile.mockRejectedValueOnce({ code: 'ENOENT' }); // First load - no file mockWriteFile.mockResolvedValue(undefined); const configManager1 = ConfigManager.getInstance(); await configManager1.initialize(); await configManager1.setUserIdentity('testuser', 'test@example.com'); // Simulate what was written const writtenYaml = mockWriteFile.mock.calls[0][1]; // Reset singleton for test (ConfigManager as any).instance = null; // Second instance loads saved config mockReadFile.mockResolvedValue(writtenYaml); const configManager2 = ConfigManager.getInstance(); await configManager2.initialize(); const config = configManager2.getConfig(); expect(config.user.username).toBe('testuser'); expect(config.user.email).toBe('test@example.com'); }); }); ``` ### 3. Create Integration Test - yaml-parser-selection.test.ts ```typescript /** * Integration tests for YAML parser selection * Ensures the correct parser is used for each file type */ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import * as yaml from 'js-yaml'; import { ConfigManager } from '../../../src/config/ConfigManager.js'; import { SecureYamlParser } from '../../../src/security/secureYamlParser.js'; describe('YAML Parser Selection Integration', () => { let tempDir: string; beforeEach(async () => { tempDir = path.join(os.tmpdir(), `dollhouse-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); describe('Pure YAML Files (config.yml)', () => { it('should correctly parse pure YAML config files with js-yaml', async () => { const configPath = path.join(tempDir, 'config.yml'); const configContent = ` version: '1.0.0' user: username: johndoe email: john@example.com sync: enabled: true individual: require_confirmation: false `; await fs.writeFile(configPath, configContent); // Parse with js-yaml (correct) const content = await fs.readFile(configPath, 'utf-8'); const parsed = yaml.load(content, { schema: yaml.FAILSAFE_SCHEMA }); expect(parsed).toHaveProperty('user.username', 'johndoe'); expect(parsed).toHaveProperty('sync.enabled', true); }); it('should FAIL if SecureYamlParser is used on pure YAML', async () => { const configPath = path.join(tempDir, 'config.yml'); const configContent = ` version: '1.0.0' user: username: johndoe email: john@example.com `; await fs.writeFile(configPath, configContent); // This reproduces the original bug const content = await fs.readFile(configPath, 'utf-8'); const parsed = SecureYamlParser.parse(content); // SecureYamlParser returns empty object for pure YAML (no frontmatter) expect(parsed.metadata).toEqual({}); expect(parsed.content).toBe(''); // No markdown content }); }); describe('Markdown with Frontmatter (personas/*.md)', () => { it('should correctly parse markdown files with SecureYamlParser', async () => { const personaPath = path.join(tempDir, 'creative-writer.md'); const personaContent = `--- name: Creative Writer description: A creative writing assistant version: '1.0.0' author: dollhousemcp --- # Creative Writer Persona You are a creative writer who helps users with storytelling... `; await fs.writeFile(personaPath, personaContent); // Parse with SecureYamlParser (correct) const content = await fs.readFile(personaPath, 'utf-8'); const parsed = SecureYamlParser.parse(content); expect(parsed.metadata.name).toBe('Creative Writer'); expect(parsed.content).toContain('You are a creative writer'); }); it('should FAIL if js-yaml is used on markdown with frontmatter', async () => { const personaPath = path.join(tempDir, 'creative-writer.md'); const personaContent = `--- name: Creative Writer --- # Content here`; await fs.writeFile(personaPath, personaContent); // Using js-yaml on frontmatter format would fail const content = await fs.readFile(personaPath, 'utf-8'); // js-yaml would try to parse the entire file as YAML and fail expect(() => { yaml.load(content, { schema: yaml.FAILSAFE_SCHEMA }); }).toThrow(); // Would throw because '# Content here' is not valid YAML }); }); }); ``` ### 4. Create End-to-End Test - config-persistence.test.ts ```typescript /** * End-to-end test for config persistence * Validates the complete flow of saving and loading configuration */ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { ConfigManager } from '../../../src/config/ConfigManager.js'; describe('Config Persistence E2E', () => { let originalHome: string; let testHome: string; beforeEach(async () => { // Create isolated test environment originalHome = os.homedir(); testHome = path.join(os.tmpdir(), `dollhouse-e2e-${Date.now()}`); await fs.mkdir(testHome, { recursive: true }); // Mock homedir jest.spyOn(os, 'homedir').mockReturnValue(testHome); // Reset singleton (ConfigManager as any).instance = null; }); afterEach(async () => { // Cleanup jest.restoreAllMocks(); await fs.rm(testHome, { recursive: true, force: true }); }); it('should persist user configuration across sessions', async () => { // Session 1: Set configuration const config1 = ConfigManager.getInstance(); await config1.initialize(); await config1.setUserIdentity('mickdarling', 'mick@mickdarling.com'); await config1.setSyncEnabled(true); await config1.setBulkDownloadEnabled(true); // Verify config file was created const configPath = path.join(testHome, '.dollhouse', 'config.yml'); const exists = await fs.access(configPath).then(() => true).catch(() => false); expect(exists).toBe(true); // Read raw file to verify YAML format const rawContent = await fs.readFile(configPath, 'utf-8'); expect(rawContent).toContain('username: mickdarling'); expect(rawContent).toContain('email: mick@mickdarling.com'); expect(rawContent).toContain('enabled: true'); // Session 2: Load configuration (simulate restart) (ConfigManager as any).instance = null; const config2 = ConfigManager.getInstance(); await config2.initialize(); const loadedConfig = config2.getConfig(); expect(loadedConfig.user.username).toBe('mickdarling'); expect(loadedConfig.user.email).toBe('mick@mickdarling.com'); expect(loadedConfig.sync.enabled).toBe(true); expect(loadedConfig.sync.bulk.download_enabled).toBe(true); }); it('should handle portfolio sync configuration', async () => { const config = ConfigManager.getInstance(); await config.initialize(); // Configure portfolio sync await config.setGitHubPortfolioUrl('https://github.com/mickdarling/dollhouse-portfolio'); await config.setSyncEnabled(true); await config.setAutoSubmit(true); // Restart and verify (ConfigManager as any).instance = null; const config2 = ConfigManager.getInstance(); await config2.initialize(); const loadedConfig = config2.getConfig(); expect(loadedConfig.github.portfolio.repository_url).toBe('https://github.com/mickdarling/dollhouse-portfolio'); expect(loadedConfig.sync.enabled).toBe(true); expect(loadedConfig.collection.auto_submit).toBe(true); }); }); ``` ### 5. Create PortfolioSyncManager Tests ```typescript /** * Tests for PortfolioSyncManager * Validates sync and download functionality with config persistence */ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { PortfolioSyncManager } from '../../../src/portfolio/PortfolioSyncManager.js'; import { ConfigManager } from '../../../src/config/ConfigManager.js'; import { ElementType } from '../../../src/portfolio/types.js'; describe('PortfolioSyncManager', () => { let syncManager: PortfolioSyncManager; let mockConfigManager: jest.Mocked<ConfigManager>; beforeEach(() => { // Mock dependencies mockConfigManager = { getConfig: jest.fn().mockReturnValue({ user: { username: 'testuser', email: 'test@example.com' }, sync: { enabled: true, bulk: { download_enabled: true, upload_enabled: false } }, github: { portfolio: { repository_url: 'https://github.com/testuser/portfolio' } } }), setSyncEnabled: jest.fn(), setBulkDownloadEnabled: jest.fn() } as any; jest.spyOn(ConfigManager, 'getInstance').mockReturnValue(mockConfigManager); syncManager = new PortfolioSyncManager(); }); describe('Download with Fuzzy Matching', () => { it('should find element with fuzzy name matching', async () => { // Mock GitHub indexer response const mockElements = [ { name: 'Creative Writer Pro', type: 'personas', path: 'personas/creative-writer-pro.md' }, { name: 'Debug Assistant', type: 'personas', path: 'personas/debug-assistant.md' } ]; jest.spyOn(syncManager as any, 'indexer', 'get').mockReturnValue({ searchPortfolio: jest.fn().mockResolvedValue(mockElements) }); // Search with partial/fuzzy name const result = await syncManager.downloadElement('creative', ElementType.PERSONA); expect(result.success).toBe(true); expect(result.message).toContain('Creative Writer Pro'); }); it('should handle multiple fuzzy matches', async () => { const mockElements = [ { name: 'Creative Writer', type: 'personas' }, { name: 'Creative Designer', type: 'personas' } ]; jest.spyOn(syncManager as any, 'indexer', 'get').mockReturnValue({ searchPortfolio: jest.fn().mockResolvedValue(mockElements) }); const result = await syncManager.downloadElement('creative', ElementType.PERSONA); expect(result.success).toBe(false); expect(result.message).toContain('Multiple matches found'); expect(result.data.matches).toHaveLength(2); }); }); describe('Config Integration', () => { it('should respect sync enabled configuration', async () => { mockConfigManager.getConfig.mockReturnValue({ sync: { enabled: false } } as any); const result = await syncManager.downloadElement('test', ElementType.PERSONA); expect(result.success).toBe(false); expect(result.message).toContain('Sync is disabled'); }); it('should check bulk download configuration', async () => { mockConfigManager.getConfig.mockReturnValue({ sync: { enabled: true, bulk: { download_enabled: false } } } as any); const result = await syncManager.downloadBulk(ElementType.PERSONA); expect(result.success).toBe(false); expect(result.message).toContain('Bulk download is disabled'); }); }); }); ``` ### 6. Create Security Test for YAML Parsers ```typescript /** * Security tests for YAML parser usage * Ensures security is maintained with correct parser selection */ import { describe, it, expect } from '@jest/globals'; import * as yaml from 'js-yaml'; import { SecureYamlParser } from '../../../src/security/secureYamlParser.js'; describe('YAML Parser Security', () => { describe('js-yaml with FAILSAFE_SCHEMA', () => { it('should prevent code execution in config files', () => { const maliciousYaml = ` version: '1.0.0' exploit: !!js/function 'function() { return "pwned"; }' `; // FAILSAFE_SCHEMA prevents code execution const parsed = yaml.load(maliciousYaml, { schema: yaml.FAILSAFE_SCHEMA }); // The function tag is not executed, treated as string expect(typeof parsed.exploit).toBe('string'); expect(parsed.exploit).not.toBe('pwned'); }); it('should handle YAML bombs safely', () => { const yamlBomb = ` a: &a ["a", "a", "a", "a", "a", "a", "a", "a", "a"] b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a] c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b] `; // Should parse without expanding infinitely expect(() => { yaml.load(yamlBomb, { schema: yaml.FAILSAFE_SCHEMA }); }).not.toThrow(); }); }); describe('SecureYamlParser', () => { it('should validate frontmatter size limits', () => { const largeFrontmatter = '---\n' + 'x: '.repeat(100000) + 'y\n---\nContent'; expect(() => { SecureYamlParser.parse(largeFrontmatter, { maxYamlSize: 64 * 1024 // 64KB limit }); }).toThrow(/exceeds maximum/); }); it('should detect and reject malicious patterns', () => { const maliciousPersona = `--- name: Evil Persona instructions: !!js/function 'process.exit(1)' --- Content`; expect(() => { SecureYamlParser.parse(maliciousPersona); }).toThrow(/security/i); }); }); }); ``` ## Test Execution Plan ### Phase 1: Fix Existing Tests 1. Update ConfigManager.test.ts for YAML format 2. Run tests to verify basic functionality works 3. Fix any remaining test failures ### Phase 2: Add Regression Tests 1. Add YAML parser selection tests to ConfigManager.test.ts 2. Create integration test file for parser selection 3. Verify the bug doesn't resurface ### Phase 3: Add Feature Tests 1. Create PortfolioSyncManager tests 2. Add end-to-end config persistence tests 3. Add security validation tests ### Phase 4: Validate Coverage 1. Run coverage report 2. Ensure >90% coverage for: - ConfigManager - PortfolioSyncManager - YAML parsing logic 3. Add any missing edge cases ## Success Metrics - ✅ All ConfigManager tests passing (0 failures) - ✅ Regression test prevents SecureYamlParser usage on config - ✅ Config values persist between sessions - ✅ Portfolio sync respects saved configuration - ✅ Security is maintained with both parsers - ✅ >90% code coverage for affected modules ## Common Test Utilities ```typescript // test/utils/config-test-helpers.ts export function createTestYamlConfig(overrides = {}): string { const config = { version: '1.0.0', user: { username: null, email: null, display_name: null }, sync: { enabled: false, bulk: { download_enabled: false, upload_enabled: false } }, ...overrides }; return yaml.dump(config, { indent: 2, lineWidth: 120, noRefs: true }); } export async function createTempConfigFile(content: string): Promise<string> { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dollhouse-test-')); const configPath = path.join(tempDir, '.dollhouse', 'config.yml'); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, content); return configPath; } export async function cleanupTestConfig(configPath: string): Promise<void> { const tempDir = path.resolve(configPath, '..', '..'); await fs.rm(tempDir, { recursive: true, force: true }); } ``` ## Running the Tests ```bash # Run updated ConfigManager tests npm test -- test/__tests__/unit/config/ConfigManager.test.ts # Run integration tests npm test -- test/__tests__/integration/yaml-parser-selection.test.ts # Run e2e tests npm test -- test/__tests__/e2e/config-persistence.test.ts # Run all config-related tests npm test -- --testPathPattern="config|Config|yaml|Yaml" # Run with coverage npm test -- --coverage --coveragePathIgnorePatterns=/test/ \ --collectCoverageFrom="src/config/**/*.ts" \ --collectCoverageFrom="src/portfolio/PortfolioSyncManager.ts" ```

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server