/** * Config migration tests * * These tests verify that a config file from any older version of DashCaddy * gets correctly migrated to the current version. Migration MUST be: * - Deterministic (same input always produces same output) * - Idempotent (running migration on already-migrated config is a no-op) * - Safe (no data loss; only adds fields, never removes user values) * - Silent (no exceptions thrown for any version from 0 to CURRENT) */ const fs = require('fs'); const os = require('os'); const path = require('path'); const { CURRENT_VERSION, migrations, migrate, loadAndMigrate } = require('../src/config/migrations'); describe('config/migrations', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); describe('migrate()', () => { test('null/empty config returns fresh v_current', () => { const result = migrate(null); expect(result._version).toBe(CURRENT_VERSION); }); test('undefined config returns fresh v_current', () => { const result = migrate(undefined); expect(result._version).toBe(CURRENT_VERSION); }); test('v0 (no _version) migrates all the way to current', () => { const v0 = { tld: '.home', customValue: 'preserved' }; const result = migrate(v0); expect(result._version).toBe(CURRENT_VERSION); // User data must be preserved expect(result.tld).toBe('.home'); expect(result.customValue).toBe('preserved'); }); test('each intermediate version migrates forward to current', () => { for (let v = 0; v < CURRENT_VERSION; v++) { const config = { _version: v, tld: '.test' }; const result = migrate(config); // Final version is always CURRENT_VERSION after running all migrations expect(result._version).toBe(CURRENT_VERSION); // User data preserved expect(result.tld).toBe('.test'); } }); test('config at current version passes through unchanged', () => { const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' }; const result = migrate(current); expect(result).toEqual(current); }); test('config from FUTURE version is left alone (forward compat)', () => { const future = { _version: 999, tld: '.home', newField: 'unknown' }; const result = migrate(future); // We don't touch future configs — let validation catch issues expect(result._version).toBe(999); expect(result.newField).toBe('unknown'); }); }); describe('v0 → v1 migration: dns normalization', () => { test('string dns gets converted to object', () => { const result = migrations[1]({ dns: '192.168.1.1' }); expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 }); }); test('missing dns gets default object', () => { const result = migrations[1]({ tld: '.home' }); expect(result.dns).toEqual({ ip: '', port: 5380 }); }); test('object dns passes through unchanged', () => { const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } }); expect(result.dns.ip).toBe('10.0.0.1'); expect(result.dns.custom).toBe('kept'); }); test('_version is set to 1', () => { const result = migrations[1]({ tld: '.home' }); expect(result._version).toBe(1); }); }); describe('v1 → v2 migration: dns.provider field', () => { test('adds provider: technitium default', () => { const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 }); expect(result.dns.provider).toBe('technitium'); expect(result.dns.ip).toBe('10.0.0.1'); expect(result.dns.port).toBe(5380); }); test('respects existing provider if set', () => { const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 }); expect(result.dns.provider).toBe('cloudflare'); }); test('_version is set to 2', () => { const result = migrations[2]({ _version: 1 }); expect(result._version).toBe(2); }); }); describe('loadAndMigrate()', () => { test('creates fresh config when file does not exist', () => { const configFile = path.join(tmpDir, 'config.json'); const result = loadAndMigrate(configFile, null); expect(result._version).toBe(CURRENT_VERSION); // Should NOT write a file when there was nothing to migrate expect(fs.existsSync(configFile)).toBe(false); }); test('migrates old config and writes back to disk', () => { const configFile = path.join(tmpDir, 'config.json'); // Write an unversioned config (v0) fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' })); const result = loadAndMigrate(configFile, null); // Returned value is migrated expect(result._version).toBe(CURRENT_VERSION); expect(result.tld).toBe('.sami'); expect(result.customField).toBe('preserve-me'); // File on disk is updated const written = JSON.parse(fs.readFileSync(configFile, 'utf8')); expect(written._version).toBe(CURRENT_VERSION); expect(written.tld).toBe('.sami'); }); test('does not rewrite file when already at current version', () => { const configFile = path.join(tmpDir, 'config.json'); const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2); fs.writeFileSync(configFile, original); // Record mtime before const mtimeBefore = fs.statSync(configFile).mtimeMs; // Wait a tick const start = Date.now(); let spin = start; while (Date.now() - spin < 50) { spin = Date.now(); } // 50ms busy-wait loadAndMigrate(configFile, null); // File should not have been rewritten (mtime unchanged) const mtimeAfter = fs.statSync(configFile).mtimeMs; expect(mtimeAfter).toBe(mtimeBefore); }); test('handles corrupt JSON gracefully (returns defaults, no crash)', () => { const configFile = path.join(tmpDir, 'config.json'); fs.writeFileSync(configFile, '{ this is not valid json'); // Should not throw const result = loadAndMigrate(configFile, null); expect(result._version).toBe(CURRENT_VERSION); }); test('creates parent directory if missing', () => { const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json'); // Pre-create parent dirs (test setup) fs.mkdirSync(path.dirname(nested), { recursive: true }); fs.writeFileSync(nested, JSON.stringify({ tld: '.home' })); const result = loadAndMigrate(nested, null); expect(result._version).toBe(CURRENT_VERSION); }); test('full chain: v0 file with string dns becomes v2 with provider', () => { const configFile = path.join(tmpDir, 'config.json'); fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', dns: '10.0.0.1' })); const result = loadAndMigrate(configFile, null); expect(result._version).toBe(CURRENT_VERSION); // After full chain, dns is normalized to object AND has provider expect(result.dns.ip).toBe('10.0.0.1'); expect(result.dns.port).toBe(5380); expect(result.dns.provider).toBe('technitium'); }); }); describe('idempotency', () => { test('running migration twice produces same result', () => { const v0 = { tld: '.home', customField: 'x' }; const first = migrate(v0); const second = migrate(first); expect(second).toEqual(first); }); test('loadAndMigrate is idempotent across reloads', () => { const configFile = path.join(tmpDir, 'config.json'); fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' })); const first = loadAndMigrate(configFile, null); const second = loadAndMigrate(configFile, null); expect(second).toEqual(first); }); }); });