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>
This commit is contained in:
347
dashcaddy-api/__tests__/credential-manager.test.js
Normal file
347
dashcaddy-api/__tests__/credential-manager.test.js
Normal file
@@ -0,0 +1,347 @@
|
||||
// Mock dependencies before requiring the module
|
||||
jest.mock('../keychain-manager', () => ({
|
||||
available: false,
|
||||
store: jest.fn().mockResolvedValue(false),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../crypto-utils', () => ({
|
||||
encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`),
|
||||
decrypt: jest.fn(data => {
|
||||
const parts = data.split(':');
|
||||
return Buffer.from(parts[2], 'base64').toString('utf8');
|
||||
}),
|
||||
isEncrypted: jest.fn(data => typeof data === 'string' && data.startsWith('enc:')),
|
||||
loadOrCreateKey: jest.fn(() => Buffer.alloc(32, 'k')),
|
||||
rotateKey: jest.fn(() => ({ oldKey: Buffer.alloc(32, 'k'), newKey: Buffer.alloc(32, 'n') })),
|
||||
}));
|
||||
|
||||
jest.mock('proper-lockfile', () => ({
|
||||
lock: jest.fn().mockResolvedValue(jest.fn().mockResolvedValue()),
|
||||
unlock: jest.fn().mockResolvedValue(),
|
||||
check: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||
writeFileSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('CredentialManager', () => {
|
||||
let credentialManager;
|
||||
let fs, lockfile, keychainManager, cryptoUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
|
||||
// Re-get mocked modules
|
||||
fs = require('fs');
|
||||
lockfile = require('proper-lockfile');
|
||||
keychainManager = require('../keychain-manager');
|
||||
cryptoUtils = require('../crypto-utils');
|
||||
|
||||
// Reset mock implementations
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager.available = false;
|
||||
|
||||
credentialManager = require('../credential-manager');
|
||||
credentialManager.cache.clear();
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
it('stores value in encrypted file when keychain unavailable', async () => {
|
||||
const result = await credentialManager.store('test.key', 'secret-value');
|
||||
expect(result).toBe(true);
|
||||
expect(cryptoUtils.encrypt).toHaveBeenCalledWith('secret-value');
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores value in keychain when available', async () => {
|
||||
keychainManager.available = true;
|
||||
// Need to get a fresh instance that sees available=true
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile = require('proper-lockfile');
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager = require('../keychain-manager');
|
||||
keychainManager.available = true;
|
||||
keychainManager.store.mockResolvedValue(true);
|
||||
credentialManager = require('../credential-manager');
|
||||
|
||||
const result = await credentialManager.store('test.key', 'value');
|
||||
expect(result).toBe(true);
|
||||
expect(keychainManager.store).toHaveBeenCalledWith('test.key', 'value');
|
||||
});
|
||||
|
||||
it('falls back to file if keychain store fails', async () => {
|
||||
keychainManager.available = true;
|
||||
jest.resetModules();
|
||||
fs = require('fs');
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile = require('proper-lockfile');
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager = require('../keychain-manager');
|
||||
keychainManager.available = true;
|
||||
keychainManager.store.mockResolvedValue(false);
|
||||
cryptoUtils = require('../crypto-utils');
|
||||
credentialManager = require('../credential-manager');
|
||||
|
||||
const result = await credentialManager.store('test.key', 'value');
|
||||
expect(result).toBe(true);
|
||||
expect(cryptoUtils.encrypt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty key', async () => {
|
||||
const result = await credentialManager.store('', 'value');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty value', async () => {
|
||||
const result = await credentialManager.store('key', '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('updates cache after storing', async () => {
|
||||
await credentialManager.store('test.key', 'cached-value');
|
||||
expect(credentialManager.cache.has('test.key')).toBe(true);
|
||||
expect(credentialManager.cache.get('test.key').value).toBe('cached-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('returns cached value within TTL', async () => {
|
||||
credentialManager.cache.set('cached.key', {
|
||||
value: 'cached-val',
|
||||
exp: Date.now() + 60000
|
||||
});
|
||||
const result = await credentialManager.retrieve('cached.key');
|
||||
expect(result).toBe('cached-val');
|
||||
});
|
||||
|
||||
it('does not return expired cache entry', async () => {
|
||||
credentialManager.cache.set('expired.key', {
|
||||
value: 'old-val',
|
||||
exp: Date.now() - 1000
|
||||
});
|
||||
// Set up file to return data
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'expired.key': { value: 'enc:tag:' + Buffer.from('file-val').toString('base64') }
|
||||
}));
|
||||
const result = await credentialManager.retrieve('expired.key');
|
||||
expect(result).toBe('file-val');
|
||||
});
|
||||
|
||||
it('retrieves from encrypted file as fallback', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'file.key': { value: 'enc:tag:' + Buffer.from('secret').toString('base64') }
|
||||
}));
|
||||
const result = await credentialManager.retrieve('file.key');
|
||||
expect(result).toBe('secret');
|
||||
});
|
||||
|
||||
it('returns null when key not found', async () => {
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
const result = await credentialManager.retrieve('missing.key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on error', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockImplementation(() => { throw new Error('fail'); });
|
||||
const result = await credentialManager.retrieve('broken.key');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes from cache, keychain, and file', async () => {
|
||||
credentialManager.cache.set('del.key', { value: 'x', exp: Date.now() + 60000 });
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ 'del.key': { value: 'x' } }));
|
||||
|
||||
const result = await credentialManager.delete('del.key');
|
||||
expect(result).toBe(true);
|
||||
expect(credentialManager.cache.has('del.key')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
lockfile.lock.mockRejectedValue(new Error('lock fail'));
|
||||
const result = await credentialManager.delete('fail.key');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all keys from credentials file', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'key1': { value: 'a' },
|
||||
'key2': { value: 'b' }
|
||||
}));
|
||||
const keys = await credentialManager.list();
|
||||
expect(keys).toEqual(['key1', 'key2']);
|
||||
});
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
const keys = await credentialManager.list();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata', () => {
|
||||
it('returns metadata for a credential', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'test.key': { value: 'x', metadata: { provider: 'cloudflare' } }
|
||||
}));
|
||||
const meta = await credentialManager.getMetadata('test.key');
|
||||
expect(meta).toEqual({ provider: 'cloudflare' });
|
||||
});
|
||||
|
||||
it('returns null when key not found', async () => {
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
const meta = await credentialManager.getMetadata('missing');
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_lockedUpdate', () => {
|
||||
it('acquires lock, reads, applies update, writes, releases', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ a: 1 }));
|
||||
|
||||
await credentialManager._lockedUpdate(creds => {
|
||||
creds.b = 2;
|
||||
return creds;
|
||||
});
|
||||
|
||||
expect(lockfile.lock).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
const writtenData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
|
||||
expect(writtenData).toEqual({ a: 1, b: 2 });
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on ELOCKED error', async () => {
|
||||
const error = new Error('locked');
|
||||
error.code = 'ELOCKED';
|
||||
lockfile.lock.mockRejectedValue(error);
|
||||
|
||||
await expect(credentialManager._lockedUpdate(() => ({}))).rejects.toThrow('locked by another process');
|
||||
});
|
||||
|
||||
it('releases lock even on error', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
|
||||
await expect(
|
||||
credentialManager._lockedUpdate(() => { throw new Error('update error'); })
|
||||
).rejects.toThrow('update error');
|
||||
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateEncryptionKey', () => {
|
||||
it('decrypts all credentials then re-encrypts with new key', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'key1': { value: 'enc:tag:' + Buffer.from('secret1').toString('base64'), metadata: {} }
|
||||
}));
|
||||
|
||||
const result = await credentialManager.rotateEncryptionKey();
|
||||
expect(result).toBe(true);
|
||||
expect(cryptoUtils.rotateKey).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears cache after rotation', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
credentialManager.cache.set('x', { value: 'y', exp: Date.now() + 60000 });
|
||||
// Must have non-empty credentials so code path reaches cache.clear()
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||
'key1': { value: 'enc:tag:' + Buffer.from('val').toString('base64'), metadata: {} }
|
||||
}));
|
||||
|
||||
await credentialManager.rotateEncryptionKey();
|
||||
expect(credentialManager.cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
lockfile.lock.mockRejectedValue(new Error('nope'));
|
||||
const result = await credentialManager.rotateEncryptionKey();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportBackup / importBackup', () => {
|
||||
it('exportBackup returns encrypted JSON string', async () => {
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ key1: { value: 'x' } }));
|
||||
const backup = await credentialManager.exportBackup();
|
||||
expect(cryptoUtils.encrypt).toHaveBeenCalled();
|
||||
expect(typeof backup).toBe('string');
|
||||
});
|
||||
|
||||
it('importBackup decrypts and replaces credentials', async () => {
|
||||
const backupData = JSON.stringify({
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials: { imported: { value: 'y' } }
|
||||
});
|
||||
const encrypted = `enc:tag:${Buffer.from(backupData).toString('base64')}`;
|
||||
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
|
||||
const result = await credentialManager.importBackup(encrypted);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('importBackup rejects unsupported backup version', async () => {
|
||||
const backupData = JSON.stringify({ version: '2.0', credentials: {} });
|
||||
const encrypted = `enc:tag:${Buffer.from(backupData).toString('base64')}`;
|
||||
|
||||
const result = await credentialManager.importBackup(encrypted);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('importBackup returns false on error', async () => {
|
||||
cryptoUtils.decrypt.mockImplementationOnce(() => { throw new Error('bad'); });
|
||||
const result = await credentialManager.importBackup('bad-data');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache TTL', () => {
|
||||
it('cache entries expire after TTL', async () => {
|
||||
credentialManager.cache.set('ttl.key', {
|
||||
value: 'val',
|
||||
exp: Date.now() - 1 // Already expired
|
||||
});
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
const result = await credentialManager.retrieve('ttl.key');
|
||||
expect(result).toBeNull();
|
||||
expect(credentialManager.cache.has('ttl.key')).toBe(false);
|
||||
});
|
||||
|
||||
it('new store refreshes cache TTL', async () => {
|
||||
await credentialManager.store('fresh.key', 'val');
|
||||
const cached = credentialManager.cache.get('fresh.key');
|
||||
expect(cached.exp).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user