Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
290
dashcaddy-api/__tests__/crypto-utils.test.js
Normal file
290
dashcaddy-api/__tests__/crypto-utils.test.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user