// crypto-utils exports a module that calls loadOrCreateKey() at load time (line 263). // The jest.setup.js sets DASHCADDY_ENCRYPTION_KEY env var so it uses a deterministic key. const cryptoUtils = require('../crypto-utils'); describe('encrypt / decrypt', () => { test('round-trips a string', () => { const plaintext = 'hello world'; const encrypted = cryptoUtils.encrypt(plaintext); const decrypted = cryptoUtils.decrypt(encrypted); expect(decrypted).toBe(plaintext); }); test('round-trips an object via JSON', () => { const obj = { user: 'admin', pass: 'secret123' }; const encrypted = cryptoUtils.encrypt(obj); const decrypted = JSON.parse(cryptoUtils.decrypt(encrypted)); expect(decrypted).toEqual(obj); }); test('encrypted output differs from plaintext', () => { const plaintext = 'sensitive data'; const encrypted = cryptoUtils.encrypt(plaintext); expect(encrypted).not.toBe(plaintext); }); test('encrypted format is iv:authTag:ciphertext (3 colon-separated parts)', () => { const encrypted = cryptoUtils.encrypt('test'); const parts = encrypted.split(':'); expect(parts.length).toBe(3); }); test('each encryption produces different output (random IV)', () => { const plaintext = 'same input'; const enc1 = cryptoUtils.encrypt(plaintext); const enc2 = cryptoUtils.encrypt(plaintext); expect(enc1).not.toBe(enc2); // But both decrypt to same value expect(cryptoUtils.decrypt(enc1)).toBe(plaintext); expect(cryptoUtils.decrypt(enc2)).toBe(plaintext); }); test('throws on tampered ciphertext', () => { const encrypted = cryptoUtils.encrypt('test'); const parts = encrypted.split(':'); parts[2] = 'AAAA' + parts[2].slice(4); // tamper with ciphertext expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow(); }); test('throws on tampered authTag', () => { const encrypted = cryptoUtils.encrypt('test'); const parts = encrypted.split(':'); parts[1] = 'AAAA' + parts[1].slice(4); // tamper with auth tag expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow(); }); test('throws on invalid encrypted format (wrong number of parts)', () => { expect(() => cryptoUtils.decrypt('only:two')).toThrow('Invalid encrypted data format'); expect(() => cryptoUtils.decrypt('just-one')).toThrow('Invalid encrypted data format'); }); test('handles empty string', () => { const encrypted = cryptoUtils.encrypt(''); expect(cryptoUtils.decrypt(encrypted)).toBe(''); }); test('handles special characters', () => { const special = 'p@$$w0rd!<>&"\';DROP TABLE--'; expect(cryptoUtils.decrypt(cryptoUtils.encrypt(special))).toBe(special); }); }); describe('isEncrypted', () => { test('returns true for encrypted strings', () => { const encrypted = cryptoUtils.encrypt('test'); expect(cryptoUtils.isEncrypted(encrypted)).toBe(true); }); test('returns false for plain strings', () => { expect(cryptoUtils.isEncrypted('hello world')).toBe(false); }); test('returns false for non-string input', () => { expect(cryptoUtils.isEncrypted(123)).toBe(false); expect(cryptoUtils.isEncrypted(null)).toBe(false); expect(cryptoUtils.isEncrypted(undefined)).toBe(false); }); test('returns false for string with wrong number of colons', () => { expect(cryptoUtils.isEncrypted('one:two')).toBe(false); expect(cryptoUtils.isEncrypted('one:two:three:four')).toBe(false); }); }); describe('encryptFields', () => { test('encrypts only specified fields', () => { const obj = { username: 'admin', password: 'secret', role: 'user' }; const result = cryptoUtils.encryptFields(obj, ['password']); expect(result.username).toBe('admin'); expect(result.role).toBe('user'); expect(result.password).not.toBe('secret'); expect(cryptoUtils.isEncrypted(result.password)).toBe(true); }); test('leaves non-specified fields unchanged', () => { const obj = { a: '1', b: '2', c: '3' }; const result = cryptoUtils.encryptFields(obj, ['a']); expect(result.b).toBe('2'); expect(result.c).toBe('3'); }); test('adds _encrypted marker', () => { const result = cryptoUtils.encryptFields({ x: 'y' }, ['x']); expect(result._encrypted).toBe(true); }); test('adds _encryptedFields list', () => { const result = cryptoUtils.encryptFields({ x: 'y' }, ['x']); expect(result._encryptedFields).toEqual(['x']); }); test('does not double-encrypt already-encrypted fields', () => { const obj = { password: 'secret' }; const first = cryptoUtils.encryptFields(obj, ['password']); const second = cryptoUtils.encryptFields(first, ['password']); // Should still be decryptable to original expect(cryptoUtils.decrypt(second.password)).toBe('secret'); }); test('skips null/undefined fields', () => { const obj = { a: null, b: undefined, c: 'val' }; const result = cryptoUtils.encryptFields(obj, ['a', 'b', 'c']); expect(result.a).toBeNull(); expect(result.b).toBeUndefined(); expect(cryptoUtils.isEncrypted(result.c)).toBe(true); }); }); describe('decryptFields', () => { test('decrypts specified fields', () => { const encrypted = cryptoUtils.encryptFields({ password: 'secret', name: 'test' }, ['password']); const decrypted = cryptoUtils.decryptFields(encrypted, ['password']); expect(decrypted.password).toBe('secret'); expect(decrypted.name).toBe('test'); }); test('returns object without encryption markers', () => { const encrypted = cryptoUtils.encryptFields({ x: 'y' }, ['x']); const decrypted = cryptoUtils.decryptFields(encrypted); expect(decrypted._encrypted).toBeUndefined(); expect(decrypted._encryptedFields).toBeUndefined(); }); test('returns object as-is when _encrypted is false/absent', () => { const obj = { a: '1', b: '2' }; const result = cryptoUtils.decryptFields(obj); expect(result).toEqual(obj); }); test('uses _encryptedFields when fields param is null', () => { const encrypted = cryptoUtils.encryptFields({ password: 'secret', token: 'abc' }, ['password', 'token']); const decrypted = cryptoUtils.decryptFields(encrypted); expect(decrypted.password).toBe('secret'); expect(decrypted.token).toBe('abc'); }); }); describe('encryptFields + decryptFields round-trip', () => { test('full round-trip preserves all field values', () => { const original = { user: 'admin', pass: 'p@ss', apiKey: 'key123', role: 'editor' }; const fields = ['pass', 'apiKey']; const encrypted = cryptoUtils.encryptFields(original, fields); const decrypted = cryptoUtils.decryptFields(encrypted, fields); expect(decrypted.user).toBe(original.user); expect(decrypted.pass).toBe(original.pass); expect(decrypted.apiKey).toBe(original.apiKey); expect(decrypted.role).toBe(original.role); }); }); describe('migrateToEncrypted', () => { test('encrypts plaintext credentials', () => { const plain = { password: 'secret', token: 'abc123' }; const result = cryptoUtils.migrateToEncrypted(plain, ['password', 'token']); expect(result._encrypted).toBe(true); expect(cryptoUtils.isEncrypted(result.password)).toBe(true); }); test('returns already-encrypted credentials unchanged', () => { const encrypted = cryptoUtils.encryptFields({ password: 'secret' }, ['password']); const result = cryptoUtils.migrateToEncrypted(encrypted, ['password']); expect(result).toEqual(encrypted); }); }); describe('loadOrCreateKey', () => { test('returns a buffer', () => { const key = cryptoUtils.loadOrCreateKey(); expect(Buffer.isBuffer(key)).toBe(true); }); test('returns 32-byte key', () => { const key = cryptoUtils.loadOrCreateKey(); expect(key.length).toBe(32); }); test('returns cached key on subsequent calls', () => { const key1 = cryptoUtils.loadOrCreateKey(); const key2 = cryptoUtils.loadOrCreateKey(); expect(key1).toBe(key2); // same reference (cached) }); }); describe('readEncryptedFile', () => { const fs = require('fs'); const os = require('os'); const path = require('path'); test('returns null when file does not exist', () => { const result = cryptoUtils.readEncryptedFile('/nonexistent/file.json'); expect(result).toBeNull(); }); test('reads and returns plaintext JSON file', () => { const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-plain.json'); fs.writeFileSync(tmpFile, JSON.stringify({ username: 'admin', password: 'plain' })); try { const result = cryptoUtils.readEncryptedFile(tmpFile); expect(result.username).toBe('admin'); expect(result.password).toBe('plain'); } finally { fs.unlinkSync(tmpFile); } }); test('reads and decrypts encrypted JSON file', () => { const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-encrypted.json'); const data = { username: 'admin', password: 'secret' }; cryptoUtils.writeEncryptedFile(tmpFile, data, ['password']); try { const result = cryptoUtils.readEncryptedFile(tmpFile, ['password']); expect(result.username).toBe('admin'); expect(result.password).toBe('secret'); } finally { fs.unlinkSync(tmpFile); } }); test('returns null on JSON parse error', () => { const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-bad.json'); fs.writeFileSync(tmpFile, 'not json at all {{{'); try { const result = cryptoUtils.readEncryptedFile(tmpFile); expect(result).toBeNull(); } finally { fs.unlinkSync(tmpFile); } }); }); describe('writeEncryptedFile', () => { const fs = require('fs'); const os = require('os'); const path = require('path'); test('writes valid JSON to disk', () => { const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-write.json'); cryptoUtils.writeEncryptedFile(tmpFile, { user: 'test', token: 'abc' }, ['token']); try { const content = JSON.parse(fs.readFileSync(tmpFile, 'utf8')); expect(content._encrypted).toBe(true); expect(content.user).toBe('test'); expect(cryptoUtils.isEncrypted(content.token)).toBe(true); } finally { fs.unlinkSync(tmpFile); } }); test('encrypts specified fields', () => { const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-write2.json'); cryptoUtils.writeEncryptedFile(tmpFile, { a: 'plain', b: 'secret' }, ['b']); try { const content = JSON.parse(fs.readFileSync(tmpFile, 'utf8')); expect(content.a).toBe('plain'); expect(content.b).not.toBe('secret'); } finally { fs.unlinkSync(tmpFile); } }); });