Files
dashcaddy/dashcaddy-api/__tests__/crypto-utils.test.js

291 lines
10 KiB
JavaScript

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