// credential-manager depends on keychain-manager and crypto-utils (both singletons). // crypto-utils is already initialized via jest.setup.js env var. // keychain-manager may not have OS keychain available in test env. const fs = require('fs'); const os = require('os'); const path = require('path'); const credentialManager = require('../credential-manager'); // Use a temp file for credentials in tests const TEMP_CREDS_FILE = path.join(os.tmpdir(), 'dashcaddy-test-creds.json'); beforeEach(() => { // Reset singleton state credentialManager.cache.clear(); // Clean up temp file if (fs.existsSync(TEMP_CREDS_FILE)) { fs.unlinkSync(TEMP_CREDS_FILE); } }); afterAll(() => { if (fs.existsSync(TEMP_CREDS_FILE)) { fs.unlinkSync(TEMP_CREDS_FILE); } }); describe('store', () => { test('rejects invalid key (null)', async () => { const result = await credentialManager.store(null, 'value'); expect(result).toBe(false); }); test('rejects invalid key (non-string)', async () => { const result = await credentialManager.store(123, 'value'); expect(result).toBe(false); }); test('rejects invalid value (null)', async () => { const result = await credentialManager.store('key', null); expect(result).toBe(false); }); test('rejects invalid value (non-string)', async () => { const result = await credentialManager.store('key', 123); expect(result).toBe(false); }); test('stores credential and caches it', async () => { const result = await credentialManager.store('test.key', 'secret123'); expect(result).toBe(true); expect(credentialManager.cache.get('test.key')).toBe('secret123'); }); test('handles very long credential values', async () => { const longValue = 'x'.repeat(100000); // 100KB value const result = await credentialManager.store('long.value', longValue); expect(result).toBe(true); const retrieved = await credentialManager.retrieve('long.value'); expect(retrieved).toBe(longValue); }); test('handles special characters in keys', async () => { const specialKeys = [ 'key.with.dots', 'key-with-dashes', 'key_with_underscores', 'key:with:colons', 'key/with/slashes', ]; for (const key of specialKeys) { const result = await credentialManager.store(key, 'value'); expect(result).toBe(true); expect(await credentialManager.retrieve(key)).toBe('value'); } }); test('handles special characters in values', async () => { const specialValues = [ 'password!@#$%^&*()', 'token\nwith\nnewlines', 'json{"key":"value"}', 'unicode=���=���G��', 'quotes"and\'apostrophes', ]; for (let i = 0; i < specialValues.length; i++) { const key = `special.${i}`; const result = await credentialManager.store(key, specialValues[i]); expect(result).toBe(true); expect(await credentialManager.retrieve(key)).toBe(specialValues[i]); } }); test('overwrites existing credential', async () => { await credentialManager.store('overwrite.key', 'original'); expect(await credentialManager.retrieve('overwrite.key')).toBe('original'); await credentialManager.store('overwrite.key', 'updated'); expect(await credentialManager.retrieve('overwrite.key')).toBe('updated'); }); }); describe('retrieve', () => { test('returns cached value when available', async () => { credentialManager.cache.set('cached.key', 'cached-value'); const result = await credentialManager.retrieve('cached.key'); expect(result).toBe('cached-value'); }); test('returns null for non-existent key', async () => { const result = await credentialManager.retrieve('nonexistent'); expect(result).toBeNull(); }); test('handles empty string key', async () => { const result = await credentialManager.retrieve(''); expect(result).toBeNull(); }); }); describe('store + retrieve round-trip', () => { test('retrieves what was stored', async () => { await credentialManager.store('roundtrip.key', 'my-secret'); // Clear cache to force file read credentialManager.cache.clear(); const result = await credentialManager.retrieve('roundtrip.key'); expect(result).toBe('my-secret'); }); test('handles binary-like data (base64)', async () => { const binaryData = Buffer.from('binary content').toString('base64'); await credentialManager.store('binary.key', binaryData); credentialManager.cache.clear(); const result = await credentialManager.retrieve('binary.key'); expect(result).toBe(binaryData); }); }); describe('delete', () => { test('removes from cache', async () => { await credentialManager.store('delete.key', 'value'); expect(credentialManager.cache.has('delete.key')).toBe(true); await credentialManager.delete('delete.key'); expect(credentialManager.cache.has('delete.key')).toBe(false); }); test('deleted credential cannot be retrieved', async () => { await credentialManager.store('delete2.key', 'value'); await credentialManager.delete('delete2.key'); credentialManager.cache.clear(); const result = await credentialManager.retrieve('delete2.key'); expect(result).toBeNull(); }); test('deleting non-existent key does not throw', async () => { await expect(credentialManager.delete('nonexistent')).resolves.not.toThrow(); }); test('multiple deletes are idempotent', async () => { await credentialManager.store('idempotent.key', 'value'); await credentialManager.delete('idempotent.key'); await credentialManager.delete('idempotent.key'); await credentialManager.delete('idempotent.key'); expect(await credentialManager.retrieve('idempotent.key')).toBeNull(); }); }); describe('list', () => { test('returns array of credential keys', async () => { await credentialManager.store('list.a', 'val1'); await credentialManager.store('list.b', 'val2'); const keys = await credentialManager.list(); expect(keys).toContain('list.a'); expect(keys).toContain('list.b'); }); test('returns empty array when no credentials', async () => { const keys = await credentialManager.list(); expect(Array.isArray(keys)).toBe(true); }); test('does not include deleted keys', async () => { await credentialManager.store('list.deleted', 'value'); await credentialManager.delete('list.deleted'); const keys = await credentialManager.list(); expect(keys).not.toContain('list.deleted'); }); }); describe('getMetadata', () => { test('returns metadata for existing key', async () => { await credentialManager.store('meta.key', 'val', { description: 'Test credential' }); const meta = await credentialManager.getMetadata('meta.key'); expect(meta).toEqual({ description: 'Test credential' }); }); test('returns null for non-existent key', async () => { const meta = await credentialManager.getMetadata('nonexistent'); expect(meta).toBeNull(); }); test('handles metadata with multiple fields', async () => { const metadata = { description: 'API Key', service: 'GitHub', expiresAt: '2026-12-31', createdBy: 'admin', }; await credentialManager.store('meta.complex', 'value', metadata); const retrieved = await credentialManager.getMetadata('meta.complex'); expect(retrieved).toEqual(metadata); }); }); describe('exportBackup / importBackup', () => { test('export returns encrypted string', async () => { await credentialManager.store('backup.key', 'backup-value'); const backup = await credentialManager.exportBackup(); expect(typeof backup).toBe('string'); expect(backup.split(':').length).toBe(3); // iv:authTag:ciphertext }); test('import restores credentials from backup', async () => { await credentialManager.store('backup.key', 'backup-value'); const backup = await credentialManager.exportBackup(); // Clear everything await credentialManager.delete('backup.key'); credentialManager.cache.clear(); // Import backup const result = await credentialManager.importBackup(backup); expect(result).toBe(true); // Verify restored const keys = await credentialManager.list(); expect(keys).toContain('backup.key'); }); test('importBackup rejects unsupported version', async () => { const cryptoUtils = require('../crypto-utils'); const badBackup = cryptoUtils.encrypt(JSON.stringify({ version: '99.0', credentials: {} })); const result = await credentialManager.importBackup(badBackup); expect(result).toBe(false); }); test('export includes metadata', async () => { await credentialManager.store('backup.meta', 'value', { description: 'Metadata test' }); const backup = await credentialManager.exportBackup(); await credentialManager.delete('backup.meta'); await credentialManager.importBackup(backup); const meta = await credentialManager.getMetadata('backup.meta'); expect(meta).toHaveProperty('description', 'Metadata test'); }); test('import does not corrupt existing credentials', async () => { await credentialManager.store('existing.key', 'existing-value'); // Create backup with different credential await credentialManager.store('backup.key', 'backup-value'); const backup = await credentialManager.exportBackup(); await credentialManager.delete('backup.key'); // Import should add backup.key without affecting existing.key await credentialManager.importBackup(backup); expect(await credentialManager.retrieve('existing.key')).toBe('existing-value'); expect(await credentialManager.retrieve('backup.key')).toBe('backup-value'); }); test('handles empty backup', async () => { const cryptoUtils = require('../crypto-utils'); const emptyBackup = cryptoUtils.encrypt(JSON.stringify({ version: '1.0', credentials: {} })); const result = await credentialManager.importBackup(emptyBackup); expect(result).toBe(true); }); test('handles large backup (stress test)', async () => { // Create 100 credentials for (let i = 0; i < 100; i++) { await credentialManager.store(`stress.${i}`, `value${i}`); } const backup = await credentialManager.exportBackup(); expect(backup.length).toBeGreaterThan(1000); // Clear and restore for (let i = 0; i < 100; i++) { await credentialManager.delete(`stress.${i}`); } const result = await credentialManager.importBackup(backup); expect(result).toBe(true); const keys = await credentialManager.list(); expect(keys.filter(k => k.startsWith('stress.')).length).toBe(100); }); }); describe('migrateToEncrypted', () => { test('returns migration count', async () => { const result = await credentialManager.migrateToEncrypted(); expect(result).toHaveProperty('migrated'); expect(result).toHaveProperty('skipped'); expect(result).toHaveProperty('total'); }); test('migration is idempotent', async () => { const result1 = await credentialManager.migrateToEncrypted(); const result2 = await credentialManager.migrateToEncrypted(); expect(result2.migrated).toBe(0); // Nothing left to migrate }); }); describe('Concurrent Access', () => { test('handles concurrent writes to same key', async () => { const promises = [ credentialManager.store('concurrent.key', 'value1'), credentialManager.store('concurrent.key', 'value2'), credentialManager.store('concurrent.key', 'value3'), ]; await Promise.all(promises); // One of them should have won const final = await credentialManager.retrieve('concurrent.key'); expect(['value1', 'value2', 'value3']).toContain(final); }); test('handles concurrent writes to different keys', async () => { const promises = []; for (let i = 0; i < 10; i++) { promises.push(credentialManager.store(`concurrent.${i}`, `value${i}`)); } await Promise.all(promises); // All should be stored for (let i = 0; i < 10; i++) { const value = await credentialManager.retrieve(`concurrent.${i}`); expect(value).toBe(`value${i}`); } }); test('handles concurrent read/write', async () => { await credentialManager.store('readwrite.key', 'initial'); const promises = [ credentialManager.retrieve('readwrite.key'), credentialManager.store('readwrite.key', 'updated'), credentialManager.retrieve('readwrite.key'), ]; const results = await Promise.all(promises); // Should not throw or corrupt expect(results[0]).toBeTruthy(); expect(results[1]).toBe(true); expect(results[2]).toBeTruthy(); }); }); describe('Edge Cases', () => { test('handles credential with empty string value', async () => { const result = await credentialManager.store('empty.value', ''); expect(result).toBe(true); expect(await credentialManager.retrieve('empty.value')).toBe(''); }); test('does not leak credentials in error messages', async () => { // This is a security test - errors should not contain credential values try { // Try to trigger an error condition await credentialManager.store('error.test', 'secret-password-123'); // Force an error by corrupting internal state } catch (error) { expect(error.message).not.toContain('secret-password-123'); } }); test('cache size does not grow indefinitely', async () => { // Store many credentials for (let i = 0; i < 1000; i++) { await credentialManager.store(`cache.${i}`, `value${i}`); } // Cache should still work const result = await credentialManager.retrieve('cache.999'); expect(result).toBe('value999'); }); }); describe('Cache Behavior', () => { test('cache speeds up repeated retrievals', async () => { await credentialManager.store('cache.perf', 'value'); // First retrieval (from disk) const start1 = Date.now(); await credentialManager.retrieve('cache.perf'); const time1 = Date.now() - start1; // Second retrieval (from cache) const start2 = Date.now(); await credentialManager.retrieve('cache.perf'); const time2 = Date.now() - start2; // Cached should be faster (though this is not a guarantee in all test envs) expect(time2).toBeLessThanOrEqual(time1 + 5); }); test('cache invalidation on delete', async () => { await credentialManager.store('cache.delete', 'value'); expect(credentialManager.cache.has('cache.delete')).toBe(true); await credentialManager.delete('cache.delete'); expect(credentialManager.cache.has('cache.delete')).toBe(false); }); test('cache invalidation on store', async () => { await credentialManager.store('cache.update', 'original'); expect(credentialManager.cache.get('cache.update')).toBe('original'); await credentialManager.store('cache.update', 'updated'); expect(credentialManager.cache.get('cache.update')).toBe('updated'); }); }); // ========== EXTENDED COVERAGE TESTS ========== // Additional tests for concurrency, edge cases, encryption, and recovery describe('Credential Manager - Extended Coverage', () => { describe('Concurrent Access', () => { test('should handle concurrent store operations', async () => { // Store multiple credentials concurrently const promises = []; for (let i = 0; i < 10; i++) { promises.push(credentialManager.store(`concurrent.${i}`, `value${i}`)); } const results = await Promise.all(promises); // All should succeed results.forEach(result => expect(result).toBe(true)); // All should be retrievable for (let i = 0; i < 10; i++) { const value = await credentialManager.retrieve(`concurrent.${i}`); expect(value).toBe(`value${i}`); } }); test('should handle concurrent retrieve operations', async () => { // Store a credential await credentialManager.store('shared.key', 'shared-value'); // Retrieve it concurrently multiple times const promises = []; for (let i = 0; i < 20; i++) { promises.push(credentialManager.retrieve('shared.key')); } const results = await Promise.all(promises); // All should return the same value results.forEach(result => expect(result).toBe('shared-value')); }); test('should handle concurrent store/retrieve mix', async () => { const operations = []; // Mix of stores and retrieves for (let i = 0; i < 5; i++) { operations.push(credentialManager.store(`mix.${i}`, `value${i}`)); operations.push(credentialManager.retrieve(`mix.${i}`)); } // Should not throw await expect(Promise.all(operations)).resolves.toBeDefined(); }); test('should handle concurrent delete operations safely', async () => { await credentialManager.store('delete.concurrent', 'value'); // Try to delete the same key concurrently const promises = [ credentialManager.delete('delete.concurrent'), credentialManager.delete('delete.concurrent'), credentialManager.delete('delete.concurrent'), ]; // Should not throw await expect(Promise.all(promises)).resolves.toBeDefined(); // Key should be gone const value = await credentialManager.retrieve('delete.concurrent'); expect(value).toBeNull(); }); }); describe('Special Characters & Edge Cases', () => { test('should handle very long credential values', async () => { const longValue = 'x'.repeat(10000); const stored = await credentialManager.store('long.value', longValue); expect(stored).toBe(true); credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('long.value'); expect(retrieved).toBe(longValue); }); test('should handle credential values with special characters', async () => { const specialChars = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`\n\r\t\\'; const stored = await credentialManager.store('special.chars', specialChars); expect(stored).toBe(true); credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('special.chars'); expect(retrieved).toBe(specialChars); }); test('should handle unicode characters', async () => { const unicode = 'S+�s�+S+�t�� =��� +�+�+�+�+� +�+�+�+�+�+�'; const stored = await credentialManager.store('unicode.key', unicode); expect(stored).toBe(true); credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('unicode.key'); expect(retrieved).toBe(unicode); }); test('should handle JSON-like strings', async () => { const jsonString = '{"nested": {"key": "value"}, "array": [1,2,3]}'; const stored = await credentialManager.store('json.string', jsonString); expect(stored).toBe(true); credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('json.string'); expect(retrieved).toBe(jsonString); }); test('should handle empty string values', async () => { const stored = await credentialManager.store('empty.string', ''); expect(stored).toBe(true); credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('empty.string'); expect(retrieved).toBe(''); }); test('should handle whitespace-only values', async () => { const whitespace = ' \n\t '; const stored = await credentialManager.store('whitespace.key', whitespace); expect(stored).toBe(true); credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('whitespace.key'); expect(retrieved).toBe(whitespace); }); test('should handle keys with dots and dashes', async () => { const complexKey = 'my-app.production.database.password'; const stored = await credentialManager.store(complexKey, 'secret123'); expect(stored).toBe(true); const retrieved = await credentialManager.retrieve(complexKey); expect(retrieved).toBe('secret123'); }); }); describe('Encryption & Security', () => { test('should encrypt credentials before storage', async () => { await credentialManager.store('encrypt.test', 'plaintext-secret'); // Try to read the file directly // If properly encrypted, the plaintext should not appear in the file // (This is a basic check - actual encryption is tested in crypto-utils.test.js) const keys = await credentialManager.list(); expect(keys).toContain('encrypt.test'); }); test('should not leak credentials in error messages', async () => { // Store a credential await credentialManager.store('sensitive.key', 'super-secret-password'); // The cache should contain the value, but stringifying shouldn't expose it const cacheString = JSON.stringify(credentialManager.cache); // This is implementation-dependent, but generally caches are Map objects // which stringify to empty objects expect(cacheString).not.toContain('super-secret-password'); }); test('should handle corrupted credential data gracefully', async () => { // This would require mocking file I/O or crypto-utils // For now, test that invalid keys return null const result = await credentialManager.retrieve('definitely.not.real'); expect(result).toBeNull(); }); }); describe('Metadata Operations', () => { test('should store and retrieve metadata', async () => { const metadata = { description: 'Production database password', createdAt: new Date().toISOString(), owner: 'admin', tags: ['production', 'database'], }; await credentialManager.store('meta.full', 'value', metadata); const retrieved = await credentialManager.getMetadata('meta.full'); expect(retrieved).toEqual(metadata); }); test('should allow updating metadata without changing value', async () => { await credentialManager.store('meta.update', 'original-value', { version: 1 }); // Update metadata await credentialManager.store('meta.update', 'original-value', { version: 2, updated: true }); const meta = await credentialManager.getMetadata('meta.update'); expect(meta.version).toBe(2); expect(meta.updated).toBe(true); // Value should be unchanged const value = await credentialManager.retrieve('meta.update'); expect(value).toBe('original-value'); }); test('should handle metadata with special characters', async () => { const metadata = { description: 'Test with "quotes" and \'apostrophes\'', notes: 'Line 1\nLine 2\tTabbed', }; await credentialManager.store('meta.special', 'value', metadata); const retrieved = await credentialManager.getMetadata('meta.special'); expect(retrieved.description).toBe(metadata.description); expect(retrieved.notes).toBe(metadata.notes); }); }); describe('Backup & Restore', () => { test('should preserve metadata in backup', async () => { const metadata = { description: 'Important credential', priority: 'high' }; await credentialManager.store('backup.meta', 'value123', metadata); const backup = await credentialManager.exportBackup(); // Clear everything await credentialManager.delete('backup.meta'); credentialManager.cache.clear(); // Restore await credentialManager.importBackup(backup); // Check metadata preserved const restoredMeta = await credentialManager.getMetadata('backup.meta'); expect(restoredMeta).toEqual(metadata); }); test('should handle backup of empty credential store', async () => { const backup = await credentialManager.exportBackup(); expect(typeof backup).toBe('string'); expect(backup.length).toBeGreaterThan(0); }); test('should handle importing same backup multiple times', async () => { await credentialManager.store('backup.repeat', 'value1'); const backup = await credentialManager.exportBackup(); // Import once await credentialManager.importBackup(backup); // Import again const result = await credentialManager.importBackup(backup); expect(result).toBe(true); // Should not cause duplicates or errors const keys = await credentialManager.list(); const count = keys.filter(k => k === 'backup.repeat').length; expect(count).toBe(1); }); test('should handle corrupted backup data gracefully', async () => { const result = await credentialManager.importBackup('corrupted:data:here'); expect(result).toBe(false); }); test('should handle empty backup string', async () => { const result = await credentialManager.importBackup(''); expect(result).toBe(false); }); test('should handle backup with invalid JSON', async () => { const cryptoUtils = require('../crypto-utils'); const invalidBackup = cryptoUtils.encrypt('{ invalid json }'); const result = await credentialManager.importBackup(invalidBackup); expect(result).toBe(false); }); }); describe('Cache Behavior', () => { test('should cache retrieved values', async () => { await credentialManager.store('cache.test', 'cached-value'); // First retrieval await credentialManager.retrieve('cache.test'); expect(credentialManager.cache.has('cache.test')).toBe(true); // Second retrieval should use cache const cached = await credentialManager.retrieve('cache.test'); expect(cached).toBe('cached-value'); }); test('should invalidate cache on delete', async () => { await credentialManager.store('cache.delete', 'value'); await credentialManager.retrieve('cache.delete'); expect(credentialManager.cache.has('cache.delete')).toBe(true); await credentialManager.delete('cache.delete'); expect(credentialManager.cache.has('cache.delete')).toBe(false); }); test('should invalidate cache on store update', async () => { await credentialManager.store('cache.update', 'original'); await credentialManager.retrieve('cache.update'); // Update the credential await credentialManager.store('cache.update', 'updated'); // Cache should have new value expect(credentialManager.cache.get('cache.update')).toBe('updated'); // Retrieval should return updated value credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('cache.update'); expect(retrieved).toBe('updated'); }); test('should handle cache clearing during operations', async () => { await credentialManager.store('cache.clear', 'value1'); // Clear cache manually credentialManager.cache.clear(); // Should still be able to retrieve from storage const retrieved = await credentialManager.retrieve('cache.clear'); expect(retrieved).toBe('value1'); }); }); describe('List Operations', () => { test('should list credentials in sorted order', async () => { await credentialManager.store('zebra', 'val1'); await credentialManager.store('alpha', 'val2'); await credentialManager.store('beta', 'val3'); const keys = await credentialManager.list(); // Should be sorted const sortedKeys = [...keys].sort(); expect(keys).toEqual(sortedKeys); }); test('should not include deleted credentials in list', async () => { await credentialManager.store('list.keep', 'val1'); await credentialManager.store('list.delete', 'val2'); await credentialManager.delete('list.delete'); const keys = await credentialManager.list(); expect(keys).toContain('list.keep'); expect(keys).not.toContain('list.delete'); }); test('should return unique keys only', async () => { await credentialManager.store('unique.key', 'val1'); await credentialManager.store('unique.key', 'val2'); // Update const keys = await credentialManager.list(); const uniqueCount = keys.filter(k => k === 'unique.key').length; expect(uniqueCount).toBe(1); }); }); describe('Error Handling & Recovery', () => { test('should handle retrieve errors gracefully', async () => { // Try to retrieve with invalid key types const result1 = await credentialManager.retrieve(null); const result2 = await credentialManager.retrieve(undefined); const result3 = await credentialManager.retrieve(''); expect(result1).toBeNull(); expect(result2).toBeNull(); expect(result3).toBeNull(); }); test('should handle delete of non-existent credential', async () => { // Should not throw await expect(credentialManager.delete('nonexistent.key')).resolves.toBeDefined(); }); test('should recover from partial operations', async () => { // Store a credential await credentialManager.store('recover.test', 'original'); // Try to store invalid data await credentialManager.store('recover.test', null); // Original should still be intact credentialManager.cache.clear(); const retrieved = await credentialManager.retrieve('recover.test'); expect(retrieved).toBe('original'); }); }); });