const crypto = require('crypto'); const path = require('path'); // Mock fs BEFORE requiring crypto-utils jest.mock('fs'); const fs = require('fs'); const TEST_KEY = crypto.randomBytes(32); const TEST_KEY_HEX = TEST_KEY.toString('hex'); // Load the module once — no jest.resetModules() needed // We control key state via clearCachedKey() + env vars process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX; const cryptoUtils = require('../crypto-utils'); describe('Crypto Utils', () => { beforeEach(() => { // Reset key state and env vars before each test cryptoUtils.clearCachedKey(); delete process.env.DASHCADDY_ENCRYPTION_KEY; delete process.env.ENCRYPTION_KEY_FILE; // Reset fs mock implementations fs.existsSync.mockReturnValue(false); fs.writeFileSync.mockImplementation(() => {}); fs.readFileSync.mockReturnValue(''); }); // Helper: ensure module has a known key loaded (via env var) function ensureKey() { process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX; cryptoUtils.clearCachedKey(); return cryptoUtils.loadOrCreateKey(); } describe('loadOrCreateKey', () => { it('loads key from DASHCADDY_ENCRYPTION_KEY env var', () => { process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX; const key = cryptoUtils.loadOrCreateKey(); expect(Buffer.isBuffer(key)).toBe(true); expect(key.length).toBe(32); expect(key.toString('hex')).toBe(TEST_KEY_HEX); }); it('loads key from file when env var absent', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(TEST_KEY_HEX); const key = cryptoUtils.loadOrCreateKey(); expect(key.toString('hex')).toBe(TEST_KEY_HEX); expect(fs.readFileSync).toHaveBeenCalled(); }); it('generates new key when no file and no env var', () => { const key = cryptoUtils.loadOrCreateKey(); expect(Buffer.isBuffer(key)).toBe(true); expect(key.length).toBe(32); }); it('saves generated key to file with 0o600 permissions', () => { cryptoUtils.loadOrCreateKey(); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.any(String), expect.any(String), { mode: 0o600 } ); }); it('returns cached key on subsequent calls', () => { process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX; const key1 = cryptoUtils.loadOrCreateKey(); const key2 = cryptoUtils.loadOrCreateKey(); expect(key1).toBe(key2); // Same reference }); it('handles invalid key file (too short) by generating new key', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue('abcd'); // Too short const key = cryptoUtils.loadOrCreateKey(); expect(key.length).toBe(32); expect(fs.writeFileSync).toHaveBeenCalled(); }); it('handles unreadable key file gracefully', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockImplementation(() => { throw new Error('EACCES'); }); const key = cryptoUtils.loadOrCreateKey(); expect(key.length).toBe(32); }); it('handles write failure gracefully', () => { fs.writeFileSync.mockImplementation(() => { throw new Error('EROFS'); }); const key = cryptoUtils.loadOrCreateKey(); expect(key.length).toBe(32); }); it('clearCachedKey forces reload on next call', () => { process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX; const key1 = cryptoUtils.loadOrCreateKey(); cryptoUtils.clearCachedKey(); const key2 = cryptoUtils.loadOrCreateKey(); expect(key1).not.toBe(key2); expect(key1.toString('hex')).toBe(key2.toString('hex')); }); }); describe('encrypt / decrypt', () => { beforeEach(() => ensureKey()); it('roundtrip: encrypt then decrypt returns original string', () => { const plaintext = 'hello world'; const encrypted = cryptoUtils.encrypt(plaintext); const decrypted = cryptoUtils.decrypt(encrypted); expect(decrypted).toBe(plaintext); }); it('roundtrip: encrypt then decrypt returns original JSON object', () => { const obj = { user: 'admin', pass: 'secret123' }; const encrypted = cryptoUtils.encrypt(obj); const decrypted = cryptoUtils.decrypt(encrypted); expect(JSON.parse(decrypted)).toEqual(obj); }); it('output format is iv:authTag:ciphertext (3 colon-separated base64 parts)', () => { const encrypted = cryptoUtils.encrypt('test'); const parts = encrypted.split(':'); expect(parts).toHaveLength(3); for (const part of parts) { expect(() => Buffer.from(part, 'base64')).not.toThrow(); } }); it('each encryption produces different ciphertext (random IV)', () => { const encrypted1 = cryptoUtils.encrypt('same data'); const encrypted2 = cryptoUtils.encrypt('same data'); expect(encrypted1).not.toBe(encrypted2); }); it('decrypt with tampered authTag throws', () => { const encrypted = cryptoUtils.encrypt('sensitive'); const parts = encrypted.split(':'); const tamperedTag = Buffer.from('aaaaaaaaaaaaaaaa').toString('base64'); const tampered = `${parts[0]}:${tamperedTag}:${parts[2]}`; expect(() => cryptoUtils.decrypt(tampered)).toThrow(); }); it('decrypt with tampered ciphertext throws', () => { const encrypted = cryptoUtils.encrypt('sensitive'); const parts = encrypted.split(':'); const tampered = `${parts[0]}:${parts[1]}:${Buffer.from('garbage').toString('base64')}`; expect(() => cryptoUtils.decrypt(tampered)).toThrow(); }); it('decrypt with invalid format (2 parts) throws', () => { expect(() => cryptoUtils.decrypt('part1:part2')).toThrow('Invalid encrypted data format'); }); it('decrypt with invalid format (4 parts) throws', () => { expect(() => cryptoUtils.decrypt('a:b:c:d')).toThrow('Invalid encrypted data format'); }); }); describe('isEncrypted', () => { beforeEach(() => ensureKey()); it('returns true for properly formatted encrypted string', () => { const encrypted = cryptoUtils.encrypt('test'); expect(cryptoUtils.isEncrypted(encrypted)).toBe(true); }); it('returns false for plain text', () => { expect(cryptoUtils.isEncrypted('just a normal string')).toBe(false); }); it('returns false for non-string input', () => { expect(cryptoUtils.isEncrypted(123)).toBe(false); expect(cryptoUtils.isEncrypted(null)).toBe(false); expect(cryptoUtils.isEncrypted(undefined)).toBe(false); expect(cryptoUtils.isEncrypted({ key: 'val' })).toBe(false); }); it('returns false for string with fewer than 3 colon-separated parts', () => { expect(cryptoUtils.isEncrypted('only:two')).toBe(false); }); }); describe('encryptFields / decryptFields', () => { beforeEach(() => ensureKey()); it('encrypts specified fields, leaves others untouched', () => { const obj = { username: 'admin', password: 'secret', role: 'admin' }; const result = cryptoUtils.encryptFields(obj, ['password']); expect(result.username).toBe('admin'); expect(result.role).toBe('admin'); expect(result.password).not.toBe('secret'); expect(cryptoUtils.isEncrypted(result.password)).toBe(true); }); it('sets _encrypted: true and _encryptedFields array', () => { const result = cryptoUtils.encryptFields({ a: '1' }, ['a']); expect(result._encrypted).toBe(true); expect(result._encryptedFields).toEqual(['a']); }); it('skips null/undefined field values', () => { const obj = { password: null, token: undefined, name: 'test' }; const result = cryptoUtils.encryptFields(obj, ['password', 'token']); expect(result.password).toBeNull(); expect(result.token).toBeUndefined(); }); it('does not double-encrypt already-encrypted fields', () => { const obj = { password: 'secret' }; const first = cryptoUtils.encryptFields(obj, ['password']); const encryptedValue = first.password; const second = cryptoUtils.encryptFields({ password: encryptedValue }, ['password']); expect(second.password).toBe(encryptedValue); }); it('decryptFields restores original values and removes markers', () => { const original = { username: 'admin', password: 'secret' }; const encrypted = cryptoUtils.encryptFields(original, ['password']); const decrypted = cryptoUtils.decryptFields(encrypted); expect(decrypted.password).toBe('secret'); expect(decrypted.username).toBe('admin'); expect(decrypted._encrypted).toBeUndefined(); expect(decrypted._encryptedFields).toBeUndefined(); }); it('decryptFields with no _encrypted flag returns object unchanged', () => { const obj = { name: 'test' }; const result = cryptoUtils.decryptFields(obj); expect(result).toEqual(obj); }); }); describe('readEncryptedFile / writeEncryptedFile', () => { beforeEach(() => ensureKey()); it('writeEncryptedFile encrypts and writes JSON', () => { cryptoUtils.writeEncryptedFile('/tmp/creds.json', { password: 'secret' }, ['password']); expect(fs.writeFileSync).toHaveBeenCalledWith( '/tmp/creds.json', expect.any(String), 'utf8' ); const writtenData = JSON.parse(fs.writeFileSync.mock.calls[0][1]); expect(writtenData._encrypted).toBe(true); }); it('readEncryptedFile reads and decrypts', () => { const encrypted = cryptoUtils.encryptFields({ password: 'secret' }, ['password']); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(JSON.stringify(encrypted)); const result = cryptoUtils.readEncryptedFile('/tmp/creds.json', ['password']); expect(result.password).toBe('secret'); expect(result._encrypted).toBeUndefined(); }); it('readEncryptedFile returns null when file missing', () => { fs.existsSync.mockReturnValue(false); const result = cryptoUtils.readEncryptedFile('/tmp/nope.json'); expect(result).toBeNull(); }); it('readEncryptedFile returns null on corrupt JSON', () => { fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue('{broken json'); const result = cryptoUtils.readEncryptedFile('/tmp/bad.json'); expect(result).toBeNull(); }); it('readEncryptedFile returns plaintext data when not encrypted', () => { const plainData = { username: 'admin', password: 'plain' }; fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(JSON.stringify(plainData)); const result = cryptoUtils.readEncryptedFile('/tmp/plain.json'); expect(result.password).toBe('plain'); }); }); describe('deriveKey', () => { it('returns 32-byte buffer', async () => { const key = await cryptoUtils.deriveKey('password', crypto.randomBytes(32)); expect(Buffer.isBuffer(key)).toBe(true); expect(key.length).toBe(32); }); it('same password + salt yields same key', async () => { const salt = crypto.randomBytes(32); const key1 = await cryptoUtils.deriveKey('mypass', salt); const key2 = await cryptoUtils.deriveKey('mypass', salt); expect(key1.equals(key2)).toBe(true); }); it('different salt yields different key', async () => { const key1 = await cryptoUtils.deriveKey('mypass', crypto.randomBytes(32)); const key2 = await cryptoUtils.deriveKey('mypass', crypto.randomBytes(32)); expect(key1.equals(key2)).toBe(false); }); }); describe('rotateKey / decryptWithKey', () => { beforeEach(() => ensureKey()); it('rotateKey generates new key and returns oldKey + newKey', () => { const { oldKey, newKey } = cryptoUtils.rotateKey(); expect(Buffer.isBuffer(oldKey)).toBe(true); expect(Buffer.isBuffer(newKey)).toBe(true); expect(oldKey.length).toBe(32); expect(newKey.length).toBe(32); expect(oldKey.equals(newKey)).toBe(false); }); it('old data is decryptable with decryptWithKey using oldKey', () => { const plaintext = 'my secret'; const encrypted = cryptoUtils.encrypt(plaintext); const { oldKey } = cryptoUtils.rotateKey(); const decrypted = cryptoUtils.decryptWithKey(encrypted, oldKey); expect(decrypted).toBe(plaintext); }); it('new encrypt uses the new key after rotation', () => { const { newKey } = cryptoUtils.rotateKey(); const encrypted = cryptoUtils.encrypt('after rotation'); const decrypted = cryptoUtils.decryptWithKey(encrypted, newKey); expect(decrypted).toBe('after rotation'); }); it('rotateKey throws if file write fails', () => { fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); expect(() => cryptoUtils.rotateKey()).toThrow('Failed to save new encryption key'); }); it('decryptWithKey with invalid format throws', () => { expect(() => cryptoUtils.decryptWithKey('bad:format', TEST_KEY)).toThrow( 'Invalid encrypted data format' ); }); }); });