const fc = require('fast-check'); const ConfigManager = require('./config-manager'); const fs = require('fs').promises; const path = require('path'); const os = require('os'); /** * Property-based tests for ConfigManager * These tests validate universal properties across all valid inputs */ describe('ConfigManager Property Tests', () => { let manager; let testDir; beforeEach(async () => { manager = new ConfigManager(); testDir = path.join(os.tmpdir(), `dashcaddy-prop-test-${Date.now()}`); }); afterEach(async () => { try { await fs.rm(testDir, { recursive: true, force: true }); } catch (error) { // Ignore cleanup errors } }); /** * Feature: dashcaddy-installer, Property 5: Configuration Persistence * For any installation configuration, saving and then loading the configuration * should produce an equivalent configuration object. * Validates: Requirements 2.6, 10.1, 10.2 */ describe('Property 5: Configuration Persistence', () => { // Generator for valid configuration objects const configArbitrary = () => fc.record({ installPath: fc.constant(testDir), tier: fc.constantFrom('basic', 'intermediate', 'advanced'), dashboardName: fc.string({ minLength: 1, maxLength: 50 }), customTLD: fc.option(fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s)), caddyAdminUrl: fc.constant('http://localhost:2021'), apiPort: fc.integer({ min: 1024, max: 65535 }), dnsEnabled: fc.boolean(), tailscaleEnabled: fc.boolean() }); test('configuration round-trips correctly', async () => { await fc.assert( fc.asyncProperty( configArbitrary(), async (config) => { // Save configuration const saveResult = await manager.saveConfig(config, testDir); if (!saveResult.success) return true; // Skip if save failed // Load configuration const loadResult = await manager.loadConfig(testDir); if (!loadResult.exists) return false; // Verify all original fields are preserved const loaded = loadResult.config; return ( loaded.installPath === config.installPath && loaded.tier === config.tier && loaded.dashboardName === config.dashboardName && loaded.apiPort === config.apiPort && loaded.dnsEnabled === config.dnsEnabled && loaded.tailscaleEnabled === config.tailscaleEnabled ); } ), { numRuns: 100 } ); }); test('saved config always includes metadata', async () => { await fc.assert( fc.asyncProperty( configArbitrary(), async (config) => { await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; // Skip if load failed return ( typeof result.config.version === 'string' && typeof result.config.lastModified === 'string' ); } ), { numRuns: 100 } ); }); test('loading non-existent config always returns exists=false', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }), async (randomPath) => { const nonExistentPath = path.join(testDir, randomPath); const result = await manager.loadConfig(nonExistentPath); return result.exists === false && result.config === null; } ), { numRuns: 50 } ); }); }); /** * Feature: dashcaddy-installer, Property 3: Installation Path Validation * For any selected folder path, the installer should correctly determine * if the path is writable before proceeding with installation. * Validates: Requirements 2.3, 2.4 */ describe('Property 3: Installation Path Validation', () => { test('validatePath returns consistent structure', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 50 }), async (pathSegment) => { const testPath = path.join(testDir, pathSegment); const result = await manager.validatePath(testPath); return ( typeof result === 'object' && typeof result.valid === 'boolean' && typeof result.message === 'string' ); } ), { numRuns: 50 } ); }); test('valid paths are consistently validated', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }), async (pathSegment) => { const testPath = path.join(testDir, pathSegment); // Validate twice const result1 = await manager.validatePath(testPath); const result2 = await manager.validatePath(testPath); // Results should be consistent return result1.valid === result2.valid; } ), { numRuns: 30 } ); }); }); /** * Feature: dashcaddy-installer, Property 4: Directory Structure Creation * For any valid installation path, the installer should create all required * subdirectories (config, data, logs, caddyfile) and verify their existence. * Validates: Requirements 2.5 */ describe('Property 4: Directory Structure Creation', () => { test('createDirectories creates all required directories', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }), async (pathSegment) => { const installPath = path.join(testDir, pathSegment); const result = await manager.createDirectories(installPath); if (!result.success) return true; // Skip if creation failed // Verify all required directories exist const requiredDirs = ['config', 'data', 'logs', 'caddyfile']; const checks = await Promise.all( requiredDirs.map(async (dir) => { const dirPath = path.join(installPath, dir); try { await fs.access(dirPath); return true; } catch { return false; } }) ); return checks.every(check => check === true); } ), { numRuns: 50 } ); }); test('createDirectories is idempotent', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')), async (pathSegment) => { const installPath = path.join(testDir, pathSegment); // Create directories twice const result1 = await manager.createDirectories(installPath); const result2 = await manager.createDirectories(installPath); // Both should succeed (or both fail for invalid paths) return result1.success === result2.success; } ), { numRuns: 30 } ); }); }); /** * Feature: dashcaddy-installer, Property 8: DNS Credential Security * For any DNS credentials, saving and then loading the credentials should * preserve all fields, and the password should be encrypted in storage. * Validates: Requirements 4.8, 10.3 */ describe('Property 8: DNS Credential Security', () => { const credentialsArbitrary = () => fc.record({ server: fc.ipV4(), username: fc.string({ minLength: 1, maxLength: 20 }), password: fc.string({ minLength: 8, maxLength: 50 }), tld: fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s) }); test('DNS credentials round-trip correctly', async () => { await fc.assert( fc.asyncProperty( credentialsArbitrary(), async (credentials) => { // Save credentials const saveResult = await manager.saveDNSCredentials(credentials, testDir); if (!saveResult.success) return true; // Skip if save failed // Load credentials const loadResult = await manager.loadDNSCredentials(testDir); if (!loadResult.exists) return false; // Verify all fields are preserved const loaded = loadResult.credentials; return ( loaded.server === credentials.server && loaded.username === credentials.username && loaded.password === credentials.password && loaded.tld === credentials.tld ); } ), { numRuns: 100 } ); }); test('saved credentials always include timestamp', async () => { await fc.assert( fc.asyncProperty( credentialsArbitrary(), async (credentials) => { await manager.saveDNSCredentials(credentials, testDir); const result = await manager.loadDNSCredentials(testDir); if (!result.exists) return true; // Skip if load failed return typeof result.credentials.savedAt === 'string'; } ), { numRuns: 100 } ); }); }); /** * Feature: dashcaddy-installer, Property 6: Disk Space Verification * For any valid path, the installer should correctly determine available disk space * and report consistent results across multiple checks. * Validates: Requirements 2.4 */ describe('Property 6: Disk Space Verification', () => { test('getDiskSpace returns consistent structure', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')), async (pathSegment) => { const testPath = path.join(testDir, pathSegment); const result = await manager.getDiskSpace(testPath); return ( typeof result === 'object' && typeof result.available === 'boolean' && (result.available ? typeof result.path === 'string' : typeof result.error === 'string') ); } ), { numRuns: 50 } ); }); test('getDiskSpace is idempotent for same path', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')), async (pathSegment) => { const testPath = path.join(testDir, pathSegment); // Check twice const result1 = await manager.getDiskSpace(testPath); const result2 = await manager.getDiskSpace(testPath); // Results should be consistent return result1.available === result2.available; } ), { numRuns: 30 } ); }); test('getDiskSpace returns available=true for existing writable paths', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 15 }).filter(s => /^[a-zA-Z0-9_-]+$/.test(s)), async (pathSegment) => { const testPath = path.join(testDir, pathSegment); // Create the directory first await fs.mkdir(testPath, { recursive: true }); const result = await manager.getDiskSpace(testPath); return result.available === true; } ), { numRuns: 30 } ); }); }); /** * Feature: dashcaddy-installer, Property 7: Tier Validation * For any configuration, only valid tier values (basic, intermediate, advanced) * should be accepted and persisted correctly. * Validates: Requirements 3.1, 3.2, 3.3 */ describe('Property 7: Tier Validation', () => { const validTiers = ['basic', 'intermediate', 'advanced']; test('valid tiers are preserved in config round-trip', async () => { await fc.assert( fc.asyncProperty( fc.constantFrom(...validTiers), fc.string({ minLength: 1, maxLength: 20 }), async (tier, dashboardName) => { const config = { installPath: testDir, tier: tier, dashboardName: dashboardName, apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; // Skip if save/load failed return result.config.tier === tier; } ), { numRuns: 50 } ); }); test('tier value is always one of valid options after load', async () => { await fc.assert( fc.asyncProperty( fc.constantFrom(...validTiers), async (tier) => { const config = { installPath: testDir, tier: tier, dashboardName: 'Test', apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; return validTiers.includes(result.config.tier); } ), { numRuns: 30 } ); }); test('tier determines expected feature availability', async () => { await fc.assert( fc.asyncProperty( fc.constantFrom(...validTiers), async (tier) => { const config = { installPath: testDir, tier: tier, dashboardName: 'Test', apiPort: 3001, dnsEnabled: tier === 'advanced', tailscaleEnabled: tier === 'advanced' }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; // Advanced tier should support DNS and Tailscale if (tier === 'advanced') { return result.config.dnsEnabled === true && result.config.tailscaleEnabled === true; } // Non-advanced tiers should have these disabled return result.config.dnsEnabled === false && result.config.tailscaleEnabled === false; } ), { numRuns: 30 } ); }); }); /** * Additional property tests for installation management */ describe('Installation Management Properties', () => { // Generator for valid configuration objects (reused from above) const configArbitrary = () => fc.record({ installPath: fc.constant(testDir), tier: fc.constantFrom('basic', 'intermediate', 'advanced'), dashboardName: fc.string({ minLength: 1, maxLength: 50 }), apiPort: fc.integer({ min: 1024, max: 65535 }), dnsEnabled: fc.boolean(), tailscaleEnabled: fc.boolean() }); test('installationExists is consistent with config file presence', async () => { await fc.assert( fc.asyncProperty( configArbitrary(), async (config) => { // Use a unique directory for this test const uniqueDir = path.join(testDir, `test-${Date.now()}-${Math.random()}`); config.installPath = uniqueDir; // Before saving, installation should not exist const existsBefore = await manager.installationExists(uniqueDir); // Save config await manager.saveConfig(config, uniqueDir); // After saving, installation should exist const existsAfter = await manager.installationExists(uniqueDir); return !existsBefore && existsAfter; } ), { numRuns: 50 } ); }); test('removeInstallation removes all files', async () => { await fc.assert( fc.asyncProperty( configArbitrary(), async (config) => { // Create installation await manager.createDirectories(testDir); await manager.saveConfig(config, testDir); // Verify it exists const existsBefore = await manager.installationExists(testDir); if (!existsBefore) return true; // Skip if creation failed // Remove installation const removeResult = await manager.removeInstallation(testDir); if (!removeResult.success) return true; // Skip if removal failed // Verify it no longer exists const existsAfter = await manager.installationExists(testDir); return !existsAfter; } ), { numRuns: 30 } ); }); test('listInstallationFiles returns array', async () => { await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 20 }), async (pathSegment) => { const installPath = path.join(testDir, pathSegment); const files = await manager.listInstallationFiles(installPath); return Array.isArray(files); } ), { numRuns: 50 } ); }); }); /** * Configuration Edge Cases * Tests for special characters, boundary conditions, and data integrity */ describe('Configuration Edge Cases', () => { test('special characters in dashboardName are preserved', async () => { // Generator for strings with special characters const specialCharArbitrary = fc.string({ unit: fc.constantFrom( 'a', 'Z', '0', ' ', '-', '_', '.', '!', '@', '#', 'é', 'ñ', '中', '日', '한', 'α', 'β' ), minLength: 1, maxLength: 30 }); await fc.assert( fc.asyncProperty( specialCharArbitrary, async (dashboardName) => { const config = { installPath: testDir, tier: 'basic', dashboardName: dashboardName, apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; return result.config.dashboardName === dashboardName; } ), { numRuns: 100 } ); }); test('port values at boundaries are handled correctly', async () => { const boundaryPorts = fc.constantFrom(1024, 1025, 3000, 3001, 8080, 49151, 65534, 65535); await fc.assert( fc.asyncProperty( boundaryPorts, async (port) => { const config = { installPath: testDir, tier: 'basic', dashboardName: 'Test', apiPort: port, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; return result.config.apiPort === port; } ), { numRuns: 20 } ); }); test('customTLD with various formats preserved correctly', async () => { const tldArbitrary = fc.oneof( fc.constant(undefined), fc.constant(null), fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s.replace(/[^a-zA-Z]/g, 'x')), fc.constantFrom('.local', '.home', '.lan', '.sami', '.test') ); await fc.assert( fc.asyncProperty( tldArbitrary, async (customTLD) => { const config = { installPath: testDir, tier: 'basic', dashboardName: 'Test', customTLD: customTLD, apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; // Both null/undefined should be equivalent after round-trip if (customTLD === null || customTLD === undefined) { return result.config.customTLD === null || result.config.customTLD === undefined || result.config.customTLD === customTLD; } return result.config.customTLD === customTLD; } ), { numRuns: 50 } ); }); test('boolean fields remain booleans after round-trip', async () => { await fc.assert( fc.asyncProperty( fc.boolean(), fc.boolean(), async (dnsEnabled, tailscaleEnabled) => { const config = { installPath: testDir, tier: 'basic', dashboardName: 'Test', apiPort: 3001, dnsEnabled: dnsEnabled, tailscaleEnabled: tailscaleEnabled }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; return ( typeof result.config.dnsEnabled === 'boolean' && typeof result.config.tailscaleEnabled === 'boolean' && result.config.dnsEnabled === dnsEnabled && result.config.tailscaleEnabled === tailscaleEnabled ); } ), { numRuns: 50 } ); }); test('config version is valid semver format', async () => { const semverRegex = /^\d+\.\d+\.\d+$/; await fc.assert( fc.asyncProperty( fc.constantFrom('basic', 'intermediate', 'advanced'), async (tier) => { const config = { installPath: testDir, tier: tier, dashboardName: 'Test', apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; return semverRegex.test(result.config.version); } ), { numRuns: 30 } ); }); test('lastModified is valid ISO date string', async () => { await fc.assert( fc.asyncProperty( fc.constantFrom('basic', 'intermediate', 'advanced'), async (tier) => { const config = { installPath: testDir, tier: tier, dashboardName: 'Test', apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }; await manager.saveConfig(config, testDir); const result = await manager.loadConfig(testDir); if (!result.exists) return true; // Should be parseable as a date and not NaN const date = new Date(result.config.lastModified); return !isNaN(date.getTime()); } ), { numRuns: 30 } ); }); test('multiple rapid saves preserve final state', async () => { await fc.assert( fc.asyncProperty( fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 2, maxLength: 5 }), async (dashboardNames) => { // Rapidly save multiple configs for (const name of dashboardNames) { await manager.saveConfig({ installPath: testDir, tier: 'basic', dashboardName: name, apiPort: 3001, dnsEnabled: false, tailscaleEnabled: false }, testDir); } const result = await manager.loadConfig(testDir); if (!result.exists) return true; // Final saved name should be the last one return result.config.dashboardName === dashboardNames[dashboardNames.length - 1]; } ), { numRuns: 30 } ); }); }); /** * DNS Credential Edge Cases */ describe('DNS Credential Edge Cases', () => { test('passwords with special characters are preserved', async () => { const specialPasswordArbitrary = fc.string({ unit: fc.constantFrom( 'a', 'Z', '0', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+', '[', ']', '{', '}', '|', ';', ':', "'", '"', '<', '>', ',', '.', '/', '?', '`', '~' ), minLength: 8, maxLength: 50 }); await fc.assert( fc.asyncProperty( specialPasswordArbitrary, async (password) => { const credentials = { server: '192.168.1.1', username: 'admin', password: password, tld: '.local' }; await manager.saveDNSCredentials(credentials, testDir); const result = await manager.loadDNSCredentials(testDir); if (!result.exists) return true; return result.credentials.password === password; } ), { numRuns: 100 } ); }); test('various IP address formats in server field', async () => { await fc.assert( fc.asyncProperty( fc.ipV4(), async (server) => { const credentials = { server: server, username: 'admin', password: 'password123', tld: '.local' }; await manager.saveDNSCredentials(credentials, testDir); const result = await manager.loadDNSCredentials(testDir); if (!result.exists) return true; return result.credentials.server === server; } ), { numRuns: 50 } ); }); test('username with edge case lengths', async () => { const usernameArbitrary = fc.string({ unit: fc.constantFrom('a', 'b', 'c', '1', '2', '_', '-'), minLength: 1, maxLength: 100 }); await fc.assert( fc.asyncProperty( usernameArbitrary, async (username) => { const credentials = { server: '192.168.1.1', username: username, password: 'password123', tld: '.local' }; await manager.saveDNSCredentials(credentials, testDir); const result = await manager.loadDNSCredentials(testDir); if (!result.exists) return true; return result.credentials.username === username; } ), { numRuns: 50 } ); }); }); });