Files
dashcaddy/dashcaddy-api/__tests__/crypto-utils.test.js
Sami ea5acfa9a2 test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core
infrastructure, API routes, and error handling. Final coverage: 86.73%
statements / 80.57% branches / 85.57% functions / 87.42% lines, all above
the 80% threshold enforced by jest.config.js.

Highlights:
- Unit tests for crypto-utils, credential-manager, auth-manager, csrf,
  input-validator, state-manager, health-checker, backup-manager,
  update-manager, resource-monitor, app-templates, platform-paths,
  port-lock-manager, errors, error-handler, pagination, url-resolver
- Route tests for health, services, and containers (supertest + mocked deps)
- Shared test-utils helper for mock factories and Express app builder
- npm scripts for CI: test:ci, test:unit, test:routes, test:security,
  test:changed, test:debug
- jest.config.js: expand coverage targets, add 80% threshold gate
- routes/services.js: import ValidationError and NotFoundError from errors
- .gitignore: exclude coverage/, *.bak, *.log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:36:46 -07:00

341 lines
13 KiB
JavaScript

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'
);
});
});
});