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>
341 lines
13 KiB
JavaScript
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'
|
|
);
|
|
});
|
|
});
|
|
});
|