diff --git a/dashcaddy-api/.gitignore b/dashcaddy-api/.gitignore index 45b6b52..a8ad317 100644 --- a/dashcaddy-api/.gitignore +++ b/dashcaddy-api/.gitignore @@ -2,7 +2,15 @@ # Backups .backup/ server-old.js +*.bak +*.bak2 +*.bak3 +*.bak4 + +# Logs +error.log +*.log # Test artifacts -__tests__/jest.setup.js +coverage/ audit-routes.js diff --git a/dashcaddy-api/__tests__/app-templates.test.js b/dashcaddy-api/__tests__/app-templates.test.js new file mode 100644 index 0000000..1f83a91 --- /dev/null +++ b/dashcaddy-api/__tests__/app-templates.test.js @@ -0,0 +1,182 @@ +const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); + +describe('App Templates', () => { + const templates = Object.values(APP_TEMPLATES); + const templateIds = Object.keys(APP_TEMPLATES); + const categoryNames = Object.keys(TEMPLATE_CATEGORIES); + + describe('Template Structure', () => { + it('has at least 40 templates', () => { + expect(templates.length).toBeGreaterThanOrEqual(40); + }); + + it('every template has required fields: name, description, icon, category', () => { + for (const tmpl of templates) { + expect(tmpl).toHaveProperty('name'); + expect(tmpl).toHaveProperty('description'); + expect(tmpl).toHaveProperty('icon'); + expect(tmpl).toHaveProperty('category'); + expect(typeof tmpl.name).toBe('string'); + expect(tmpl.name.length).toBeGreaterThan(0); + expect(typeof tmpl.description).toBe('string'); + } + }); + + it('every Docker-based template has docker config with image', () => { + for (const id of templateIds) { + const tmpl = APP_TEMPLATES[id]; + if (!tmpl.docker) continue; // Skip static sites and dashboard widgets + expect(tmpl.docker).toHaveProperty('image'); + expect(typeof tmpl.docker.image).toBe('string'); + expect(tmpl.docker.image.length).toBeGreaterThan(0); + } + }); + + it('every template has subdomain property', () => { + for (const id of templateIds) { + const tmpl = APP_TEMPLATES[id]; + expect(tmpl).toHaveProperty('subdomain'); + // subdomain can be null for widgets + if (tmpl.subdomain !== null) { + expect(typeof tmpl.subdomain).toBe('string'); + } + } + }); + + it('all Docker-based templates have valid defaultPorts (1-65535)', () => { + for (const id of templateIds) { + const tmpl = APP_TEMPLATES[id]; + if (!tmpl.docker) continue; // Skip non-Docker templates + const port = tmpl.defaultPort; + expect(port).toBeGreaterThanOrEqual(1); + expect(port).toBeLessThanOrEqual(65535); + } + }); + + it('all category values are in TEMPLATE_CATEGORIES', () => { + for (const tmpl of templates) { + expect(categoryNames).toContain(tmpl.category); + } + }); + + it('Docker images have no shell injection characters', () => { + const dangerous = [';', '&', '|', '`', '$', '\n']; + for (const id of templateIds) { + const tmpl = APP_TEMPLATES[id]; + if (!tmpl.docker) continue; + const image = tmpl.docker.image; + for (const char of dangerous) { + expect(image).not.toContain(char); + } + } + }); + }); + + describe('TEMPLATE_CATEGORIES', () => { + it('is a non-empty object with category entries', () => { + expect(typeof TEMPLATE_CATEGORIES).toBe('object'); + expect(TEMPLATE_CATEGORIES).not.toBeNull(); + expect(categoryNames.length).toBeGreaterThan(0); + }); + + it('each category has icon and color', () => { + for (const name of categoryNames) { + const cat = TEMPLATE_CATEGORIES[name]; + expect(cat).toHaveProperty('icon'); + expect(cat).toHaveProperty('color'); + expect(typeof cat.color).toBe('string'); + } + }); + }); + + describe('DIFFICULTY_LEVELS', () => { + it('is a non-empty object with difficulty entries', () => { + const levels = Object.keys(DIFFICULTY_LEVELS); + expect(levels.length).toBeGreaterThan(0); + }); + + it('each level has color and description', () => { + for (const [name, level] of Object.entries(DIFFICULTY_LEVELS)) { + expect(level).toHaveProperty('color'); + expect(level).toHaveProperty('description'); + expect(typeof level.color).toBe('string'); + expect(typeof level.description).toBe('string'); + } + }); + + it('includes Easy, Intermediate, and Advanced levels', () => { + expect(DIFFICULTY_LEVELS).toHaveProperty('Easy'); + expect(DIFFICULTY_LEVELS).toHaveProperty('Intermediate'); + expect(DIFFICULTY_LEVELS).toHaveProperty('Advanced'); + }); + }); + + describe('Specific Templates', () => { + it('plex template has PLEX_CLAIM as empty string', () => { + const plex = APP_TEMPLATES.plex; + expect(plex).toBeDefined(); + expect(plex.docker.environment).toHaveProperty('PLEX_CLAIM'); + expect(plex.docker.environment.PLEX_CLAIM).toBe(''); + }); + + it('jellyfin template exists with correct default port', () => { + const jf = APP_TEMPLATES.jellyfin; + expect(jf).toBeDefined(); + expect(jf.defaultPort).toBe(8096); + }); + + it('radarr template exists with correct default port', () => { + const radarr = APP_TEMPLATES.radarr; + expect(radarr).toBeDefined(); + expect(radarr.defaultPort).toBe(7878); + }); + + it('sonarr template exists with correct default port', () => { + const sonarr = APP_TEMPLATES.sonarr; + expect(sonarr).toBeDefined(); + expect(sonarr.defaultPort).toBe(8989); + }); + + it('prowlarr template exists with correct default port', () => { + const prowlarr = APP_TEMPLATES.prowlarr; + expect(prowlarr).toBeDefined(); + expect(prowlarr.defaultPort).toBe(9696); + }); + + it('DashCA is a static site without docker config', () => { + const dashca = APP_TEMPLATES.dashca; + if (dashca) { + expect(dashca.isStaticSite).toBe(true); + expect(dashca.docker).toBeUndefined(); + } + }); + }); + + describe('Template Ports', () => { + it('all templates with docker.ports have valid port mappings', () => { + // Ports use template syntax like "{{PORT}}:32400" or "{{PORT}}:32400/tcp" + const portPattern = /^(\{\{PORT\}\}|\d+):(\d+)(\/[a-z]+)?$/; + for (const id of templateIds) { + const tmpl = APP_TEMPLATES[id]; + if (!tmpl.docker || !tmpl.docker.ports) continue; + expect(Array.isArray(tmpl.docker.ports)).toBe(true); + for (const port of tmpl.docker.ports) { + expect(typeof port).toBe('string'); + expect(port).toMatch(portPattern); + } + } + }); + + it('no two templates share the same default port (prevent conflicts)', () => { + const portMap = new Map(); + for (const id of templateIds) { + const port = APP_TEMPLATES[id].defaultPort; + if (port !== null) { + portMap.set(port, id); + } + } + // At minimum, we should have more unique ports than 30% of templates + expect(portMap.size).toBeGreaterThan(templateIds.length * 0.3); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/auth-manager.test.js b/dashcaddy-api/__tests__/auth-manager.test.js new file mode 100644 index 0000000..577fe39 --- /dev/null +++ b/dashcaddy-api/__tests__/auth-manager.test.js @@ -0,0 +1,291 @@ +// Must mock crypto-utils BEFORE auth-manager is required, +// because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey() +const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad'); +jest.mock('../crypto-utils', () => ({ + loadOrCreateKey: jest.fn(() => mockFixedKey), +})); + +jest.mock('../credential-manager', () => ({ + store: jest.fn().mockResolvedValue(true), + retrieve: jest.fn().mockResolvedValue(null), + delete: jest.fn().mockResolvedValue(true), + list: jest.fn().mockResolvedValue([]), +})); + +const crypto = require('crypto'); +const authManager = require('../auth-manager'); +const credentialManager = require('../credential-manager'); + +describe('AuthManager', () => { + beforeEach(() => { + authManager.clearCache(); + jest.clearAllMocks(); + }); + + describe('JWT Generation and Verification', () => { + it('generateJWT returns a valid JWT string', async () => { + const token = await authManager.generateJWT({ sub: 'user1' }); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // header.payload.signature + }); + + it('generateJWT defaults scope to [read, write]', async () => { + const token = await authManager.generateJWT({ sub: 'user1' }); + const result = await authManager.verifyJWT(token); + expect(result.scope).toEqual(['read', 'write']); + }); + + it('generateJWT respects custom scope', async () => { + const token = await authManager.generateJWT({ sub: 'user1', scope: ['admin'] }); + const result = await authManager.verifyJWT(token); + expect(result.scope).toEqual(['admin']); + }); + + it('generateJWT throws if payload.sub missing', async () => { + await expect(authManager.generateJWT({ name: 'test' })) + .rejects.toThrow('must include "sub"'); + }); + + it('generateJWT respects custom expiresIn', async () => { + const token = await authManager.generateJWT({ sub: 'user1' }, '1s'); + // Token should be valid immediately + const result = await authManager.verifyJWT(token); + expect(result).not.toBeNull(); + }); + + it('verifyJWT returns decoded payload for valid token', async () => { + const token = await authManager.generateJWT({ sub: 'user1' }); + const result = await authManager.verifyJWT(token); + expect(result).not.toBeNull(); + expect(result.userId).toBe('user1'); + expect(result.scope).toEqual(['read', 'write']); + expect(result.iat).toBeDefined(); + expect(result.exp).toBeDefined(); + }); + + it('verifyJWT returns null for expired token', async () => { + const token = await authManager.generateJWT({ sub: 'user1' }, '0s'); + // Wait a tick for expiration + await new Promise(r => setTimeout(r, 50)); + const result = await authManager.verifyJWT(token); + expect(result).toBeNull(); + }); + + it('verifyJWT returns null for invalid token', async () => { + const result = await authManager.verifyJWT('garbage.not.ajwt'); + expect(result).toBeNull(); + }); + + it('verifyJWT returns null for token signed with different secret', async () => { + const jwt = require('jsonwebtoken'); + const fakeToken = jwt.sign({ sub: 'user1' }, 'wrong-secret'); + const result = await authManager.verifyJWT(fakeToken); + expect(result).toBeNull(); + }); + }); + + describe('API Key Generation', () => { + it('generateAPIKey returns key in dk__ format', async () => { + const result = await authManager.generateAPIKey('My Key'); + expect(result.key).toMatch(/^dk_[a-f0-9]+_[a-f0-9]+$/); + }); + + it('generateAPIKey stores SHA-256 hash via credentialManager', async () => { + const result = await authManager.generateAPIKey('Test Key'); + expect(credentialManager.store).toHaveBeenCalledWith( + expect.stringContaining('auth.apikey.'), + expect.any(String) // SHA-256 hash + ); + }); + + it('generateAPIKey stores metadata separately', async () => { + await authManager.generateAPIKey('Named Key', ['read']); + // Second call should be metadata + const metaCalls = credentialManager.store.mock.calls.filter( + call => call[0].startsWith('auth.metadata.') + ); + expect(metaCalls.length).toBe(1); + const metadata = JSON.parse(metaCalls[0][1]); + expect(metadata.name).toBe('Named Key'); + expect(metadata.scopes).toEqual(['read']); + }); + + it('generateAPIKey returns id, name, scopes, createdAt', async () => { + const result = await authManager.generateAPIKey('Full Key', ['read', 'write']); + expect(result).toHaveProperty('key'); + expect(result).toHaveProperty('id'); + expect(result.name).toBe('Full Key'); + expect(result.scopes).toEqual(['read', 'write']); + expect(result.createdAt).toBeDefined(); + }); + + it('generateAPIKey throws if name missing', async () => { + await expect(authManager.generateAPIKey('')).rejects.toThrow('name is required'); + }); + + it('generateAPIKey caches metadata', async () => { + const result = await authManager.generateAPIKey('Cached Key'); + expect(authManager.keyMetadataCache.has(result.id)).toBe(true); + }); + }); + + describe('API Key Verification', () => { + let testKey; + let testKeyId; + let testHash; + + beforeEach(async () => { + // Generate a key for verification tests + const generated = await authManager.generateAPIKey('Verify Test'); + testKey = generated.key; + testKeyId = generated.id; + testHash = crypto.createHash('sha256').update(testKey).digest('hex'); + + // Set up credentialManager to return the hash and metadata + credentialManager.retrieve.mockImplementation(async (key) => { + if (key === `auth.apikey.${testKeyId}`) return testHash; + if (key === `auth.metadata.${testKeyId}`) { + return JSON.stringify({ id: testKeyId, name: 'Verify Test', scopes: ['read', 'write'] }); + } + return null; + }); + }); + + it('verifyAPIKey returns keyId, scopes, name for valid key', async () => { + // Clear cache to force credential lookup + authManager.clearCache(); + const result = await authManager.verifyAPIKey(testKey); + expect(result).not.toBeNull(); + expect(result.keyId).toBe(testKeyId); + expect(result.scopes).toEqual(['read', 'write']); + expect(result.name).toBe('Verify Test'); + }); + + it('verifyAPIKey returns null for key not starting with dk_', async () => { + const result = await authManager.verifyAPIKey('invalid_prefix_key'); + expect(result).toBeNull(); + }); + + it('verifyAPIKey returns null for key with wrong part count', async () => { + const result = await authManager.verifyAPIKey('dk_only_two'); + expect(result).toBeNull(); + }); + + it('verifyAPIKey returns null when stored hash not found', async () => { + credentialManager.retrieve.mockResolvedValue(null); + authManager.clearCache(); + const result = await authManager.verifyAPIKey(`dk_${testKeyId}_wrongsecret`); + expect(result).toBeNull(); + }); + + it('verifyAPIKey returns null on hash mismatch', async () => { + credentialManager.retrieve.mockImplementation(async (key) => { + if (key.startsWith('auth.apikey.')) return 'wrong-hash-value-that-does-not-match'; + return null; + }); + authManager.clearCache(); + // The hash comparison will fail because hashes have different lengths + const result = await authManager.verifyAPIKey(testKey); + expect(result).toBeNull(); + }); + + it('verifyAPIKey returns null when metadata not found', async () => { + credentialManager.retrieve.mockImplementation(async (key) => { + if (key.startsWith('auth.apikey.')) return testHash; + return null; // No metadata + }); + authManager.clearCache(); + const result = await authManager.verifyAPIKey(testKey); + expect(result).toBeNull(); + }); + }); + + describe('API Key Revocation', () => { + it('revokeAPIKey deletes hash and metadata', async () => { + await authManager.revokeAPIKey('abc123'); + expect(credentialManager.delete).toHaveBeenCalledWith('auth.apikey.abc123'); + expect(credentialManager.delete).toHaveBeenCalledWith('auth.metadata.abc123'); + }); + + it('revokeAPIKey removes from cache', async () => { + authManager.keyMetadataCache.set('abc123', { name: 'test' }); + await authManager.revokeAPIKey('abc123'); + expect(authManager.keyMetadataCache.has('abc123')).toBe(false); + }); + + it('revokeAPIKey returns true on success', async () => { + const result = await authManager.revokeAPIKey('test'); + expect(result).toBe(true); + }); + + it('revokeAPIKey returns false on error', async () => { + credentialManager.delete.mockRejectedValueOnce(new Error('fail')); + const result = await authManager.revokeAPIKey('fail-key'); + expect(result).toBe(false); + }); + }); + + describe('API Key Listing', () => { + it('listAPIKeys returns metadata for all keys', async () => { + credentialManager.list.mockResolvedValue([ + 'auth.metadata.key1', + 'auth.metadata.key2', + 'auth.apikey.key1', + 'auth.apikey.key2' + ]); + credentialManager.retrieve.mockImplementation(async (key) => { + if (key === 'auth.metadata.key1') return JSON.stringify({ id: 'key1', name: 'Key 1' }); + if (key === 'auth.metadata.key2') return JSON.stringify({ id: 'key2', name: 'Key 2' }); + return null; + }); + + const keys = await authManager.listAPIKeys(); + expect(keys).toHaveLength(2); + expect(keys[0].name).toBe('Key 1'); + expect(keys[1].name).toBe('Key 2'); + }); + + it('listAPIKeys returns empty array on error', async () => { + credentialManager.list.mockRejectedValue(new Error('fail')); + const keys = await authManager.listAPIKeys(); + expect(keys).toEqual([]); + }); + }); + + describe('Key Metadata', () => { + it('getKeyMetadata returns from cache when available', async () => { + authManager.keyMetadataCache.set('cached', { name: 'Cached' }); + const result = await authManager.getKeyMetadata('cached'); + expect(result.name).toBe('Cached'); + expect(credentialManager.retrieve).not.toHaveBeenCalled(); + }); + + it('getKeyMetadata fetches from credentialManager when not cached', async () => { + credentialManager.retrieve.mockResolvedValue(JSON.stringify({ id: 'x', name: 'Fetched' })); + const result = await authManager.getKeyMetadata('x'); + expect(result.name).toBe('Fetched'); + expect(credentialManager.retrieve).toHaveBeenCalledWith('auth.metadata.x'); + }); + + it('getKeyMetadata caches fetched result', async () => { + credentialManager.retrieve.mockResolvedValue(JSON.stringify({ id: 'y', name: 'Cached Now' })); + await authManager.getKeyMetadata('y'); + expect(authManager.keyMetadataCache.has('y')).toBe(true); + }); + + it('getKeyMetadata returns null when not found', async () => { + credentialManager.retrieve.mockResolvedValue(null); + const result = await authManager.getKeyMetadata('missing'); + expect(result).toBeNull(); + }); + }); + + describe('Cache', () => { + it('clearCache empties keyMetadataCache', () => { + authManager.keyMetadataCache.set('a', { name: 'A' }); + authManager.keyMetadataCache.set('b', { name: 'B' }); + authManager.clearCache(); + expect(authManager.keyMetadataCache.size).toBe(0); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/backup-manager.test.js b/dashcaddy-api/__tests__/backup-manager.test.js new file mode 100644 index 0000000..3ebe08e --- /dev/null +++ b/dashcaddy-api/__tests__/backup-manager.test.js @@ -0,0 +1,784 @@ +// Backup Manager Tests +// Validates backup/restore lifecycle for DashCaddy configurations + +jest.mock('fs'); +jest.mock('child_process'); +jest.mock('../credential-manager', () => ({ + exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }), + importBackup: jest.fn() +})); +jest.mock('../resource-monitor', () => ({ + exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }), + importStats: jest.fn() +})); + +const fs = require('fs'); +const crypto = require('crypto'); +const credentialManager = require('../credential-manager'); +const resourceMonitor = require('../resource-monitor'); + +// Setup defaults BEFORE requiring singleton (constructor calls loadConfig/loadHistory) +fs.existsSync.mockReturnValue(false); +fs.readFileSync.mockReturnValue('{}'); +fs.writeFileSync.mockReturnValue(undefined); +fs.mkdirSync.mockReturnValue(undefined); +fs.unlinkSync.mockReturnValue(undefined); + +const backupManager = require('../backup-manager'); + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // Restore defaults + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue('{}'); + fs.writeFileSync.mockReturnValue(undefined); + fs.mkdirSync.mockReturnValue(undefined); + fs.unlinkSync.mockReturnValue(undefined); + + // Reset internal state + backupManager.history = []; + backupManager.config = { backups: {}, defaultRetention: { keep: 7 } }; + backupManager.running = false; + // Clear all scheduled jobs directly (stop() only clears when running=true) + for (const [, job] of backupManager.scheduledJobs.entries()) { + clearInterval(job); + } + backupManager.scheduledJobs.clear(); +}); + +afterEach(() => { + backupManager.stop(); + jest.useRealTimers(); +}); + +describe('BackupManager — backup/restore lifecycle', () => { + + describe('constructor and config', () => { + it('starts with empty config when no config file exists', () => { + const config = backupManager.getConfig(); + expect(config.backups).toEqual({}); + expect(config.defaultRetention).toEqual({ keep: 7 }); + }); + + it('loadConfig returns saved config when file exists', () => { + const savedConfig = { + backups: { daily: { enabled: true, schedule: 'daily' } }, + defaultRetention: { keep: 14 } + }; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(savedConfig)); + const config = backupManager.loadConfig(); + expect(config.backups.daily).toBeDefined(); + expect(config.defaultRetention.keep).toBe(14); + }); + + it('loadConfig returns defaults on error', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('read error'); }); + const config = backupManager.loadConfig(); + expect(config.backups).toEqual({}); + }); + + it('loadHistory returns empty array when no file', () => { + fs.existsSync.mockReturnValue(false); + expect(backupManager.loadHistory()).toEqual([]); + }); + + it('loadHistory loads saved entries', () => { + const history = [{ id: 'test-1', status: 'success' }]; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(history)); + expect(backupManager.loadHistory()).toEqual(history); + }); + }); + + describe('start/stop scheduler', () => { + it('does nothing on double start', () => { + backupManager.start(); + backupManager.start(); // should not throw + expect(backupManager.running).toBe(true); + }); + + it('does nothing on stop when not running', () => { + backupManager.stop(); // should not throw + expect(backupManager.running).toBe(false); + }); + + it('clears scheduled jobs on stop', () => { + backupManager.scheduledJobs.set('test', setInterval(() => {}, 10000)); + backupManager.running = true; + backupManager.stop(); + expect(backupManager.scheduledJobs.size).toBe(0); + expect(backupManager.running).toBe(false); + }); + }); + + describe('scheduleBackup intervals', () => { + it('schedules hourly backup', () => { + backupManager.scheduleBackup('test', { schedule: 'hourly' }); + expect(backupManager.scheduledJobs.has('test')).toBe(true); + }); + + it('schedules daily backup', () => { + backupManager.scheduleBackup('test', { schedule: 'daily' }); + expect(backupManager.scheduledJobs.has('test')).toBe(true); + }); + + it('schedules weekly backup', () => { + backupManager.scheduleBackup('test', { schedule: 'weekly' }); + expect(backupManager.scheduledJobs.has('test')).toBe(true); + }); + + it('schedules monthly backup', () => { + backupManager.scheduleBackup('test', { schedule: 'monthly' }); + expect(backupManager.scheduledJobs.has('test')).toBe(true); + }); + + it('accepts custom interval in minutes', () => { + backupManager.scheduleBackup('test', { schedule: '30' }); + expect(backupManager.scheduledJobs.has('test')).toBe(true); + }); + + it('rejects invalid schedule', () => { + backupManager.scheduleBackup('test', { schedule: 'bogus' }); + expect(backupManager.scheduledJobs.has('test')).toBe(false); + }); + }); + + describe('compress/decompress', () => { + it('round-trips data through gzip', async () => { + const original = { version: '1.0', data: { services: [{ id: 'plex' }] } }; + const compressed = await backupManager.compressBackup(original); + expect(Buffer.isBuffer(compressed)).toBe(true); + + const decompressed = await backupManager.decompressBackup(compressed); + expect(decompressed).toEqual(original); + }); + + it('compressed output is smaller than JSON', async () => { + const data = { bigArray: Array(100).fill({ id: 'test', name: 'test-service' }) }; + const compressed = await backupManager.compressBackup(data); + expect(compressed.length).toBeLessThan(JSON.stringify(data).length); + }); + }); + + describe('encrypt/decrypt (AES-256-GCM)', () => { + const testKey = crypto.randomBytes(32).toString('hex'); + + it('round-trips data through encryption', async () => { + const original = Buffer.from('DashCaddy backup data'); + const encrypted = await backupManager.encryptBackup(original, testKey); + const decrypted = await backupManager.decryptBackup(encrypted, testKey); + expect(decrypted.toString()).toBe('DashCaddy backup data'); + }); + + it('encrypted format is iv:authTag:ciphertext (base64)', async () => { + const data = Buffer.from('test'); + const encrypted = await backupManager.encryptBackup(data, testKey); + const parts = encrypted.toString().split(':'); + expect(parts.length).toBeGreaterThanOrEqual(3); + }); + + it('rejects tampered data (auth tag mismatch)', async () => { + const data = Buffer.from('test'); + const encrypted = await backupManager.encryptBackup(data, testKey); + // Corrupt the first character of the IV + const str = encrypted.toString(); + const tampered = Buffer.from('X' + str.substring(1)); + await expect(backupManager.decryptBackup(tampered, testKey)) + .rejects.toThrow(); + }); + + it('rejects wrong key', async () => { + const data = Buffer.from('test'); + const encrypted = await backupManager.encryptBackup(data, testKey); + const wrongKey = crypto.randomBytes(32).toString('hex'); + await expect(backupManager.decryptBackup(encrypted, wrongKey)) + .rejects.toThrow(); + }); + + it('rejects invalid format (fewer than 3 parts)', async () => { + await expect(backupManager.decryptBackup(Buffer.from('onlyonepart'), testKey)) + .rejects.toThrow('Invalid encrypted backup format'); + }); + }); + + describe('calculateChecksum', () => { + it('returns SHA-256 hex digest', () => { + const data = Buffer.from('test data'); + const checksum = backupManager.calculateChecksum(data); + expect(checksum).toMatch(/^[a-f0-9]{64}$/); + }); + + it('same data produces same checksum', () => { + const data = Buffer.from('DashCaddy'); + expect(backupManager.calculateChecksum(data)) + .toBe(backupManager.calculateChecksum(data)); + }); + + it('different data produces different checksum', () => { + expect(backupManager.calculateChecksum(Buffer.from('A'))) + .not.toBe(backupManager.calculateChecksum(Buffer.from('B'))); + }); + }); + + describe('saveToLocal', () => { + it('creates backup directory if missing', async () => { + fs.existsSync.mockReturnValue(false); + await backupManager.saveToLocal(Buffer.from('data'), { path: '/custom/backups' }, 'test-123'); + expect(fs.mkdirSync).toHaveBeenCalledWith('/custom/backups', { recursive: true }); + }); + + it('writes backup file with correct name', async () => { + fs.existsSync.mockReturnValue(true); + const result = await backupManager.saveToLocal(Buffer.from('data'), {}, 'daily-1234'); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('daily-1234.backup'), + expect.any(Buffer) + ); + expect(result.type).toBe('local'); + expect(result.size).toBe(4); + }); + }); + + describe('verifyBackup', () => { + it('passes when checksum matches', async () => { + const data = Buffer.from('verified'); + const checksum = crypto.createHash('sha256').update(data).digest('hex'); + fs.readFileSync.mockReturnValue(data); + + const result = await backupManager.verifyBackup({ type: 'local', path: '/backup.dat' }, checksum); + expect(result).toBe(true); + }); + + it('throws on checksum mismatch', async () => { + fs.readFileSync.mockReturnValue(Buffer.from('tampered')); + await expect(backupManager.verifyBackup( + { type: 'local', path: '/backup.dat' }, + 'wrong-checksum' + )).rejects.toThrow('checksum mismatch'); + }); + }); + + describe('history management', () => { + it('addToHistory appends and saves', () => { + backupManager.addToHistory({ id: 'test-1', status: 'success' }); + expect(backupManager.getHistory()).toHaveLength(1); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('caps history at 100 entries', () => { + for (let i = 0; i < 110; i++) { + backupManager.addToHistory({ id: `test-${i}`, status: 'success' }); + } + expect(backupManager.history.length).toBe(100); + }); + + it('getHistory returns newest first', () => { + backupManager.addToHistory({ id: 'old', status: 'success' }); + backupManager.addToHistory({ id: 'new', status: 'success' }); + const history = backupManager.getHistory(); + expect(history[0].id).toBe('new'); + expect(history[1].id).toBe('old'); + }); + + it('getHistory respects limit', () => { + for (let i = 0; i < 10; i++) { + backupManager.addToHistory({ id: `test-${i}`, status: 'success' }); + } + expect(backupManager.getHistory(3)).toHaveLength(3); + }); + }); + + describe('updateConfig', () => { + it('merges new config and saves', () => { + backupManager.updateConfig({ customSetting: true }); + expect(backupManager.getConfig().customSetting).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('restarts scheduler on config update', () => { + backupManager.start(); + expect(backupManager.running).toBe(true); + backupManager.updateConfig({ backups: {} }); + // Should still be running after restart + expect(backupManager.running).toBe(true); + }); + }); + + describe('backupServices / backupConfig', () => { + it('reads services.json when it exists', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }])); + const result = backupManager.backupServices(); + expect(result).toEqual([{ id: 'plex' }]); + }); + + it('returns null when services.json missing', () => { + fs.existsSync.mockReturnValue(false); + expect(backupManager.backupServices()).toBeNull(); + }); + + it('returns null on read error', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('read error'); }); + expect(backupManager.backupServices()).toBeNull(); + }); + + it('reads config.json when it exists', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify({ tld: '.sami' })); + const result = backupManager.backupConfig(); + expect(result).toEqual({ tld: '.sami' }); + }); + }); + + describe('cleanupOldBackups', () => { + it('deletes backups beyond retention limit', async () => { + // Add 5 successful backups + for (let i = 0; i < 5; i++) { + backupManager.history.push({ + id: `daily-${i}`, + name: 'daily', + status: 'success', + timestamp: new Date(2026, 0, i + 1).toISOString(), + locations: [{ type: 'local', path: `/backups/daily-${i}.backup` }] + }); + } + fs.existsSync.mockReturnValue(true); + + await backupManager.cleanupOldBackups('daily', { keep: 2 }); + + // Should delete 3 oldest + expect(fs.unlinkSync).toHaveBeenCalledTimes(3); + // History should have 2 remaining for 'daily' + const remaining = backupManager.history.filter(b => b.name === 'daily'); + expect(remaining).toHaveLength(2); + }); + + it('keeps all when under retention limit', async () => { + backupManager.history.push({ + id: 'daily-1', name: 'daily', status: 'success', + timestamp: new Date().toISOString(), + locations: [{ type: 'local', path: '/backups/daily-1.backup' }] + }); + + await backupManager.cleanupOldBackups('daily', { keep: 7 }); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + }); + + describe('backupCredentials / backupStats', () => { + it('returns credential export data', () => { + const result = backupManager.backupCredentials(); + expect(result).toEqual({ encrypted: 'cred-data' }); + expect(credentialManager.exportBackup).toHaveBeenCalled(); + }); + + it('returns null on credential export error', () => { + credentialManager.exportBackup.mockImplementationOnce(() => { throw new Error('no key'); }); + expect(backupManager.backupCredentials()).toBeNull(); + }); + + it('returns stats export data', () => { + const result = backupManager.backupStats(); + expect(result).toEqual({ stats: [{ cpu: 10 }] }); + expect(resourceMonitor.exportStats).toHaveBeenCalled(); + }); + + it('returns null on stats export error', () => { + resourceMonitor.exportStats.mockImplementationOnce(() => { throw new Error('no stats'); }); + expect(backupManager.backupStats()).toBeNull(); + }); + }); + + describe('createBackupData', () => { + it('includes all sources when "all" specified', async () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((filePath) => { + if (typeof filePath === 'string') { + if (filePath.includes('services')) return JSON.stringify([{ id: 'plex' }]); + if (filePath.includes('config')) return JSON.stringify({ tld: '.sami' }); + } + return '{}'; + }); + + const data = await backupManager.createBackupData(['all']); + expect(data.version).toBe('1.0'); + expect(data.data.services).toEqual([{ id: 'plex' }]); + expect(data.data.config).toEqual({ tld: '.sami' }); + expect(data.data.credentials).toEqual({ encrypted: 'cred-data' }); + expect(data.data.stats).toEqual({ stats: [{ cpu: 10 }] }); + }); + + it('includes only credentials when specified', async () => { + const data = await backupManager.createBackupData(['credentials']); + expect(data.data.credentials).toEqual({ encrypted: 'cred-data' }); + expect(data.data.services).toBeUndefined(); + }); + + it('includes only stats when specified', async () => { + const data = await backupManager.createBackupData(['stats']); + expect(data.data.stats).toEqual({ stats: [{ cpu: 10 }] }); + expect(data.data.services).toBeUndefined(); + }); + }); + + describe('saveToDestination', () => { + it('routes to saveToLocal for local type', async () => { + fs.existsSync.mockReturnValue(true); + const result = await backupManager.saveToDestination(Buffer.from('data'), { type: 'local' }, 'bk-1'); + expect(result.type).toBe('local'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('throws for unsupported destination type', async () => { + await expect(backupManager.saveToDestination(Buffer.from('data'), { type: 's3' }, 'bk-1')) + .rejects.toThrow('Unsupported destination type: s3'); + }); + }); + + describe('executeBackup', () => { + it('runs full backup pipeline and records success in history', async () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((filePath) => { + if (typeof filePath === 'string') { + if (filePath.includes('services')) return JSON.stringify([{ id: 'plex' }]); + if (filePath.includes('config')) return JSON.stringify({ tld: '.sami' }); + } + return '{}'; + }); + + const events = []; + backupManager.on('backup-start', e => events.push({ type: 'start', ...e })); + backupManager.on('backup-complete', e => events.push({ type: 'complete', ...e })); + + const result = await backupManager.executeBackup('daily', { + include: ['services', 'config'], + destinations: [{ type: 'local' }], + verify: false + }); + + expect(result.status).toBe('success'); + expect(result.name).toBe('daily'); + expect(result.compressed).toBe(true); + expect(result.size).toBeGreaterThan(0); + expect(backupManager.history).toHaveLength(1); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('start'); + expect(events[1].type).toBe('complete'); + + backupManager.removeAllListeners(); + }); + + it('runs encrypted backup pipeline', async () => { + const key = crypto.randomBytes(32).toString('hex'); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }])); + + const result = await backupManager.executeBackup('encrypted', { + include: ['services'], + destinations: [{ type: 'local' }], + encrypt: true, + encryptionKey: key, + verify: false + }); + + expect(result.status).toBe('success'); + expect(result.encrypted).toBe(true); + }); + + it('records failure in history when all destinations fail', async () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }])); + fs.writeFileSync.mockImplementation((path) => { + if (typeof path === 'string' && path.includes('.backup')) throw new Error('disk full'); + }); + + const events = []; + backupManager.on('backup-failed', e => events.push(e)); + + await expect(backupManager.executeBackup('daily', { + include: ['services'], + destinations: [{ type: 'local' }], + verify: false + })).rejects.toThrow('Failed to save backup to any destination'); + + expect(backupManager.history).toHaveLength(1); + expect(backupManager.history[0].status).toBe('failed'); + expect(events).toHaveLength(1); + + backupManager.removeAllListeners(); + }); + + it('runs cleanup after successful backup with retention', async () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify([{ id: 'plex' }])); + + // Pre-fill history with old backups + for (let i = 0; i < 5; i++) { + backupManager.history.push({ + id: `daily-old-${i}`, name: 'daily', status: 'success', + timestamp: new Date(2026, 0, i + 1).toISOString(), + locations: [{ type: 'local', path: `/backups/daily-old-${i}.backup` }] + }); + } + + await backupManager.executeBackup('daily', { + include: ['services'], + destinations: [{ type: 'local' }], + verify: false, + retention: { keep: 2 } + }); + + // Old backups should be cleaned up (5 old + 1 new = 6 total, keep 2 → delete 4) + expect(fs.unlinkSync).toHaveBeenCalled(); + }); + }); + + describe('restoreBackup', () => { + it('throws when backup not found in history', async () => { + await expect(backupManager.restoreBackup('nonexistent')) + .rejects.toThrow('Backup not found: nonexistent'); + }); + + it('throws on unsupported backup version', async () => { + // Create backup data with wrong version + const wrongVersionData = { version: '2.0', data: {} }; + const compressed = await backupManager.compressBackup(wrongVersionData); + + backupManager.history.push({ + id: 'test-restore', + status: 'success', + encrypted: false, + locations: [{ type: 'local', path: '/backups/test-restore.backup' }] + }); + + fs.readFileSync.mockReturnValue(compressed); + + await expect(backupManager.restoreBackup('test-restore')) + .rejects.toThrow('Unsupported backup version: 2.0'); + }); + + it('restores services and config from backup', async () => { + const backupData = { + version: '1.0', + data: { + services: [{ id: 'plex' }, { id: 'radarr' }], + config: { tld: '.sami' } + } + }; + const compressed = await backupManager.compressBackup(backupData); + + backupManager.history.push({ + id: 'test-restore', + status: 'success', + encrypted: false, + locations: [{ type: 'local', path: '/backups/test-restore.backup' }] + }); + + fs.readFileSync.mockReturnValue(compressed); + + const events = []; + backupManager.on('restore-start', e => events.push({ type: 'start', ...e })); + backupManager.on('restore-complete', e => events.push({ type: 'complete', ...e })); + + const result = await backupManager.restoreBackup('test-restore'); + + expect(result.success).toBe(true); + expect(result.restored.services).toBe(true); + expect(result.restored.config).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('services'), + expect.stringContaining('plex') + ); + expect(events).toHaveLength(2); + + backupManager.removeAllListeners(); + }); + + it('restores credentials and stats from backup', async () => { + const backupData = { + version: '1.0', + data: { + credentials: { encrypted: 'cred-data' }, + stats: { stats: [{ cpu: 10 }] } + } + }; + const compressed = await backupManager.compressBackup(backupData); + + backupManager.history.push({ + id: 'full-restore', + status: 'success', + encrypted: false, + locations: [{ type: 'local', path: '/backups/full-restore.backup' }] + }); + + fs.readFileSync.mockReturnValue(compressed); + + const result = await backupManager.restoreBackup('full-restore'); + + expect(result.restored.credentials).toBe(true); + expect(result.restored.stats).toBe(true); + expect(credentialManager.importBackup).toHaveBeenCalledWith({ encrypted: 'cred-data' }); + expect(resourceMonitor.importStats).toHaveBeenCalledWith({ stats: [{ cpu: 10 }] }); + }); + + it('restores encrypted backup', async () => { + const key = crypto.randomBytes(32).toString('hex'); + const backupData = { version: '1.0', data: { services: [{ id: 'plex' }] } }; + const compressed = await backupManager.compressBackup(backupData); + const encrypted = await backupManager.encryptBackup(compressed, key); + + backupManager.history.push({ + id: 'enc-restore', + status: 'success', + encrypted: true, + locations: [{ type: 'local', path: '/backups/enc-restore.backup' }] + }); + + fs.readFileSync.mockReturnValue(encrypted); + + const result = await backupManager.restoreBackup('enc-restore', { encryptionKey: key }); + + expect(result.success).toBe(true); + expect(result.restored.services).toBe(true); + }); + + it('emits restore-failed on error', async () => { + backupManager.history.push({ + id: 'fail-restore', + status: 'success', + encrypted: false, + locations: [{ type: 'local', path: '/backups/fail-restore.backup' }] + }); + + fs.readFileSync.mockImplementation(() => { throw new Error('read error'); }); + + const events = []; + backupManager.on('restore-failed', e => events.push(e)); + + await expect(backupManager.restoreBackup('fail-restore')) + .rejects.toThrow(); + + expect(events).toHaveLength(1); + expect(events[0].error).toBeDefined(); + + backupManager.removeAllListeners(); + }); + + it('skips restore of specific sections when options disable them', async () => { + const backupData = { + version: '1.0', + data: { + services: [{ id: 'plex' }], + config: { tld: '.sami' }, + credentials: { encrypted: 'data' }, + stats: { stats: [] } + } + }; + const compressed = await backupManager.compressBackup(backupData); + + backupManager.history.push({ + id: 'partial-restore', + status: 'success', + encrypted: false, + locations: [{ type: 'local', path: '/backups/partial.backup' }] + }); + + fs.readFileSync.mockReturnValue(compressed); + + const result = await backupManager.restoreBackup('partial-restore', { + restoreServices: false, + restoreConfig: false, + restoreCredentials: false, + restoreStats: false + }); + + expect(result.success).toBe(true); + expect(result.restored.services).toBeUndefined(); + expect(result.restored.config).toBeUndefined(); + expect(result.restored.credentials).toBeUndefined(); + expect(result.restored.stats).toBeUndefined(); + }); + }); + + describe('start with configured backups', () => { + it('schedules enabled backups on start', () => { + backupManager.config = { + backups: { + daily: { enabled: true, schedule: 'daily' }, + disabled: { enabled: false, schedule: 'hourly' } + }, + defaultRetention: { keep: 7 } + }; + + backupManager.start(); + + expect(backupManager.scheduledJobs.has('daily')).toBe(true); + expect(backupManager.scheduledJobs.has('disabled')).toBe(false); + }); + }); + + describe('persistence error handling', () => { + it('saveConfig handles write error gracefully', () => { + fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); + expect(() => backupManager.saveConfig()).not.toThrow(); + }); + + it('saveHistory handles write error gracefully', () => { + fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); + expect(() => backupManager.saveHistory()).not.toThrow(); + }); + + it('backupConfig returns null on read error', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('corrupt'); }); + expect(backupManager.backupConfig()).toBeNull(); + }); + }); + + describe('verifyBackup edge cases', () => { + it('returns true for non-local backup type', async () => { + const result = await backupManager.verifyBackup({ type: 'remote', path: 'na' }, 'checksum'); + expect(result).toBe(true); + }); + }); + + describe('DashCaddy scenarios', () => { + it('full backup pipeline: services + config → compress → verify', async () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((filePath) => { + if (typeof filePath === 'string') { + if (filePath.includes('services')) return JSON.stringify([{ id: 'plex' }, { id: 'radarr' }]); + if (filePath.includes('config')) return JSON.stringify({ tld: '.sami', mode: 'homelab' }); + } + return '{}'; + }); + + const data = await backupManager.createBackupData(['services', 'config']); + expect(data.version).toBe('1.0'); + expect(data.data.services).toEqual([{ id: 'plex' }, { id: 'radarr' }]); + expect(data.data.config).toEqual({ tld: '.sami', mode: 'homelab' }); + + // Compress and verify round-trip + const compressed = await backupManager.compressBackup(data); + const decompressed = await backupManager.decompressBackup(compressed); + expect(decompressed.data.services).toEqual(data.data.services); + }); + + it('encrypted backup round-trip with real AES-256-GCM', async () => { + const key = crypto.randomBytes(32).toString('hex'); + const payload = { version: '1.0', data: { services: [{ id: 'jellyfin' }] } }; + + const compressed = await backupManager.compressBackup(payload); + const encrypted = await backupManager.encryptBackup(compressed, key); + const decrypted = await backupManager.decryptBackup(encrypted, key); + const restored = await backupManager.decompressBackup(decrypted); + + expect(restored.data.services[0].id).toBe('jellyfin'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/credential-manager.test.js b/dashcaddy-api/__tests__/credential-manager.test.js new file mode 100644 index 0000000..6c80430 --- /dev/null +++ b/dashcaddy-api/__tests__/credential-manager.test.js @@ -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()); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/crypto-utils.test.js b/dashcaddy-api/__tests__/crypto-utils.test.js new file mode 100644 index 0000000..2a4c9cb --- /dev/null +++ b/dashcaddy-api/__tests__/crypto-utils.test.js @@ -0,0 +1,340 @@ +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' + ); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/csrf-protection.test.js b/dashcaddy-api/__tests__/csrf-protection.test.js new file mode 100644 index 0000000..f36b002 --- /dev/null +++ b/dashcaddy-api/__tests__/csrf-protection.test.js @@ -0,0 +1,340 @@ +const crypto = require('crypto'); + +// Mock crypto-utils to provide a predictable signing key +const mockFixedKey = Buffer.alloc(32, 'test-key-material'); +jest.mock('../crypto-utils', () => ({ + loadOrCreateKey: jest.fn(() => mockFixedKey), +})); + +const { + CSRF_TOKEN_LENGTH, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + generateToken, + signToken, + parseCookie, + csrfCookieMiddleware, + csrfValidationMiddleware, + renewCSRFToken +} = require('../csrf-protection'); +const { createMockReqRes } = require('./helpers/test-utils'); + +describe('CSRF Protection', () => { + + describe('generateToken', () => { + it('returns a base64url-encoded string', () => { + const token = generateToken(); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + // base64url chars only + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('returns different values on each call', () => { + const t1 = generateToken(); + const t2 = generateToken(); + expect(t1).not.toBe(t2); + }); + + it('has appropriate length for 32 bytes of randomness', () => { + const token = generateToken(); + // 32 bytes = 43 base64url chars (no padding) + expect(token.length).toBe(43); + }); + }); + + describe('signToken', () => { + it('returns a base64url-encoded HMAC signature', () => { + const sig = signToken('test-nonce'); + expect(typeof sig).toBe('string'); + expect(sig).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('same nonce produces same signature (deterministic)', () => { + const sig1 = signToken('my-nonce'); + const sig2 = signToken('my-nonce'); + expect(sig1).toBe(sig2); + }); + + it('different nonces produce different signatures', () => { + const sig1 = signToken('nonce-a'); + const sig2 = signToken('nonce-b'); + expect(sig1).not.toBe(sig2); + }); + }); + + describe('parseCookie', () => { + it('parses single cookie', () => { + expect(parseCookie('name=value')).toEqual({ name: 'value' }); + }); + + it('parses multiple cookies', () => { + const result = parseCookie('a=1; b=2; c=3'); + expect(result).toEqual({ a: '1', b: '2', c: '3' }); + }); + + it('handles cookies with = in value', () => { + const result = parseCookie('token=abc=def=ghi'); + expect(result.token).toBe('abc=def=ghi'); + }); + + it('returns empty object for null/undefined/empty input', () => { + expect(parseCookie(null)).toEqual({}); + expect(parseCookie(undefined)).toEqual({}); + expect(parseCookie('')).toEqual({}); + }); + + it('trims outer whitespace of each cookie pair', () => { + const result = parseCookie(' name=value '); + expect(result['name']).toBe('value'); + }); + }); + + describe('csrfCookieMiddleware', () => { + it('generates new nonce and sets cookie when no existing cookie', () => { + const { req, res, next } = createMockReqRes(); + req.headers.cookie = ''; + + csrfCookieMiddleware(req, res, next); + + expect(req.csrfNonce).toBeDefined(); + expect(req.csrfToken).toBeDefined(); + expect(res.cookie).toHaveBeenCalledWith( + CSRF_COOKIE_NAME, + req.csrfNonce, + expect.objectContaining({ + httpOnly: false, + sameSite: 'strict', + path: '/', + }) + ); + expect(next).toHaveBeenCalled(); + }); + + it('reuses existing nonce from cookie (no new Set-Cookie)', () => { + const { req, res, next } = createMockReqRes(); + const existingNonce = 'existing-nonce-value'; + req.headers.cookie = `${CSRF_COOKIE_NAME}=${existingNonce}`; + + csrfCookieMiddleware(req, res, next); + + expect(req.csrfNonce).toBe(existingNonce); + expect(res.cookie).not.toHaveBeenCalled(); // No new cookie set + expect(next).toHaveBeenCalled(); + }); + + it('sets req.csrfToken as HMAC signature of nonce', () => { + const { req, res, next } = createMockReqRes(); + req.headers.cookie = `${CSRF_COOKIE_NAME}=my-nonce`; + + csrfCookieMiddleware(req, res, next); + + const expectedSig = signToken('my-nonce'); + expect(req.csrfToken).toBe(expectedSig); + }); + }); + + describe('csrfValidationMiddleware', () => { + it('skips validation for GET requests', () => { + const { req, res, next } = createMockReqRes({ method: 'GET' }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('skips validation for HEAD requests', () => { + const { req, res, next } = createMockReqRes({ method: 'HEAD' }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('skips validation for OPTIONS requests', () => { + const { req, res, next } = createMockReqRes({ method: 'OPTIONS' }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('skips validation in test environment', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services' }); + + csrfValidationMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + process.env.NODE_ENV = origEnv; + }); + + it('skips validation for excluded paths', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const excludedPaths = ['/api/totp/verify', '/api/totp/setup', '/health', '/api/health']; + for (const excludedPath of excludedPaths) { + const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + } + + process.env.NODE_ENV = origEnv; + }); + + it('skips validation for auth gate paths', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/auth/gate/plex' + }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + + process.env.NODE_ENV = origEnv; + }); + + it('skips validation when x-api-key header present', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/services', + headers: { 'x-api-key': 'dk_abc_123' } + }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + + process.env.NODE_ENV = origEnv; + }); + + it('skips validation when Authorization Bearer header present', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/services', + headers: { authorization: 'Bearer some-jwt-token' } + }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + + process.env.NODE_ENV = origEnv; + }); + + it('returns 403 when CSRF cookie missing', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/services', + headers: { cookie: '' } + }); + csrfValidationMiddleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('DC-100') }) + ); + + process.env.NODE_ENV = origEnv; + }); + + it('returns 403 when CSRF header missing', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const nonce = generateToken(); + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/services', + headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` } + }); + csrfValidationMiddleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('DC-100') }) + ); + + process.env.NODE_ENV = origEnv; + }); + + it('returns 403 when signature is invalid', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const nonce = generateToken(); + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/services', + headers: { + cookie: `${CSRF_COOKIE_NAME}=${nonce}`, + 'x-csrf-token': 'totally-wrong-signature' + } + }); + csrfValidationMiddleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('DC-101') }) + ); + + process.env.NODE_ENV = origEnv; + }); + + it('passes when cookie nonce and header signature match', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const nonce = generateToken(); + const signature = signToken(nonce); + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/services', + headers: { + cookie: `${CSRF_COOKIE_NAME}=${nonce}`, + 'x-csrf-token': signature + } + }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + + process.env.NODE_ENV = origEnv; + }); + + it('normalizes /api/v1/ prefix for exclusion matching', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const { req, res, next } = createMockReqRes({ + method: 'POST', path: '/api/v1/totp/verify' + }); + csrfValidationMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + + process.env.NODE_ENV = origEnv; + }); + }); + + describe('renewCSRFToken', () => { + it('generates new nonce and sets cookie', () => { + const { res } = createMockReqRes(); + const token = renewCSRFToken(res, true); + + expect(typeof token).toBe('string'); + expect(res.cookie).toHaveBeenCalledWith( + CSRF_COOKIE_NAME, + expect.any(String), + expect.objectContaining({ + httpOnly: false, + secure: true, + sameSite: 'strict', + path: '/', + }) + ); + }); + + it('returns signed token', () => { + const { res } = createMockReqRes(); + const token = renewCSRFToken(res, false); + // Get the nonce that was set in the cookie + const setCookieNonce = res.cookie.mock.calls[0][1]; + const expectedSig = signToken(setCookieNonce); + expect(token).toBe(expectedSig); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/error-handler.test.js b/dashcaddy-api/__tests__/error-handler.test.js new file mode 100644 index 0000000..1179c3b --- /dev/null +++ b/dashcaddy-api/__tests__/error-handler.test.js @@ -0,0 +1,190 @@ +jest.mock('../error-logger', () => ({ + logError: jest.fn(), +})); + +const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler'); +const { + AppError, + ValidationError, + AuthenticationError, + NotFoundError, + RateLimitError, + DockerError, +} = require('../errors'); + +describe('Error Handler', () => { + let req, res, next; + + beforeEach(() => { + req = { + method: 'GET', + path: '/api/test', + ip: '127.0.0.1', + user: { id: 'user1' }, + body: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + next = jest.fn(); + }); + + describe('asyncHandler', () => { + it('calls the wrapped function', async () => { + const fn = jest.fn().mockResolvedValue(); + const wrapped = asyncHandler(fn); + await wrapped(req, res, next); + expect(fn).toHaveBeenCalledWith(req, res, next); + }); + + it('calls next(err) on rejected promise', async () => { + const error = new Error('async fail'); + const fn = jest.fn().mockRejectedValue(error); + const wrapped = asyncHandler(fn); + await wrapped(req, res, next); + expect(next).toHaveBeenCalledWith(error); + }); + }); + + describe('errorMiddleware', () => { + it('returns 400 for ValidationError', () => { + const err = new ValidationError('bad input', 'email'); + errorMiddleware(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'bad input', + code: 'DC-400', + field: 'email', + }) + ); + }); + + it('returns 401 for AuthenticationError with requiresTotp', () => { + const err = new AuthenticationError('auth needed', true); + errorMiddleware(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'auth needed', + requiresTotp: true, + }) + ); + }); + + it('returns 404 for NotFoundError with resource', () => { + const err = new NotFoundError('Service'); + errorMiddleware(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Service not found', + resource: 'Service', + }) + ); + }); + + it('returns 429 for RateLimitError with retryAfter', () => { + const err = new RateLimitError(30); + errorMiddleware(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Rate limit exceeded', + retryAfter: 30, + }) + ); + }); + + it('returns 500 with "Internal server error" for generic Error', () => { + const err = new Error('db connection lost'); + errorMiddleware(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Internal server error', // NOT the real message + }) + ); + }); + + it('includes error code in DC-XXX format', () => { + const err = new AppError('test', 418, 'DC-TEAPOT'); + errorMiddleware(err, req, res, next); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'DC-TEAPOT' }) + ); + }); + + it('includes details for DockerError', () => { + const err = new DockerError('container fail', 'create', { id: '123' }); + errorMiddleware(err, req, res, next); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + details: { id: '123' }, + }) + ); + }); + + it('includes stack trace in development mode', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const err = new AppError('test'); + errorMiddleware(err, req, res, next); + + const response = res.json.mock.calls[0][0]; + expect(response.stack).toBeDefined(); + + process.env.NODE_ENV = origEnv; + }); + + it('excludes stack trace in production mode', () => { + const origEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + const err = new AppError('test'); + errorMiddleware(err, req, res, next); + + const response = res.json.mock.calls[0][0]; + expect(response.stack).toBeUndefined(); + + process.env.NODE_ENV = origEnv; + }); + + it('logs non-operational errors as FATAL', () => { + const origError = console.error; + console.error = jest.fn(); + + const err = new Error('programming bug'); + errorMiddleware(err, req, res, next); + + expect(console.error).toHaveBeenCalledWith( + 'FATAL: Non-operational error detected', + expect.any(Object) + ); + + console.error = origError; + }); + }); + + describe('notFoundHandler', () => { + it('passes NotFoundError to next()', () => { + notFoundHandler(req, res, next); + expect(next).toHaveBeenCalledWith(expect.any(NotFoundError)); + const passedError = next.mock.calls[0][0]; + expect(passedError.message).toContain('GET'); + expect(passedError.message).toContain('/api/test'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/errors.test.js b/dashcaddy-api/__tests__/errors.test.js new file mode 100644 index 0000000..51b861f --- /dev/null +++ b/dashcaddy-api/__tests__/errors.test.js @@ -0,0 +1,157 @@ +const { + AppError, + ValidationError, + AuthenticationError, + ForbiddenError, + NotFoundError, + ConflictError, + RateLimitError, + DockerError, + CaddyError, + DNSError, + ServiceUnavailableError +} = require('../errors'); + +describe('Error Classes', () => { + describe('AppError', () => { + it('has default statusCode 500 and auto-generated code', () => { + const err = new AppError('something broke'); + expect(err.message).toBe('something broke'); + expect(err.statusCode).toBe(500); + expect(err.code).toBe('APP_ERROR'); + expect(err.isOperational).toBe(true); + expect(err).toBeInstanceOf(Error); + }); + + it('accepts custom statusCode and code', () => { + const err = new AppError('custom', 418, 'DC-TEAPOT'); + expect(err.statusCode).toBe(418); + expect(err.code).toBe('DC-TEAPOT'); + }); + }); + + describe('ValidationError', () => { + it('has statusCode 400, code DC-400, and optional field', () => { + const err = new ValidationError('bad input', 'email'); + expect(err.statusCode).toBe(400); + expect(err.code).toBe('DC-400'); + expect(err.field).toBe('email'); + expect(err).toBeInstanceOf(AppError); + }); + + it('field defaults to null', () => { + const err = new ValidationError('bad'); + expect(err.field).toBeNull(); + }); + }); + + describe('AuthenticationError', () => { + it('has statusCode 401 and requiresTotp flag', () => { + const err = new AuthenticationError('need auth', true); + expect(err.statusCode).toBe(401); + expect(err.code).toBe('DC-401'); + expect(err.requiresTotp).toBe(true); + expect(err).toBeInstanceOf(AppError); + }); + + it('has sensible defaults', () => { + const err = new AuthenticationError(); + expect(err.message).toBe('Authentication required'); + expect(err.requiresTotp).toBe(false); + }); + }); + + describe('ForbiddenError', () => { + it('has statusCode 403', () => { + const err = new ForbiddenError(); + expect(err.statusCode).toBe(403); + expect(err.code).toBe('DC-403'); + expect(err.message).toBe('Forbidden'); + expect(err).toBeInstanceOf(AppError); + }); + }); + + describe('NotFoundError', () => { + it('has statusCode 404 and resource in message', () => { + const err = new NotFoundError('Service'); + expect(err.statusCode).toBe(404); + expect(err.code).toBe('DC-404'); + expect(err.message).toBe('Service not found'); + expect(err.resource).toBe('Service'); + expect(err).toBeInstanceOf(AppError); + }); + + it('defaults to "Resource"', () => { + const err = new NotFoundError(); + expect(err.message).toBe('Resource not found'); + }); + }); + + describe('ConflictError', () => { + it('has statusCode 409 and optional conflictingResource', () => { + const err = new ConflictError('already exists', 'service-x'); + expect(err.statusCode).toBe(409); + expect(err.code).toBe('DC-409'); + expect(err.conflictingResource).toBe('service-x'); + expect(err).toBeInstanceOf(AppError); + }); + }); + + describe('RateLimitError', () => { + it('has statusCode 429 and retryAfter', () => { + const err = new RateLimitError(30); + expect(err.statusCode).toBe(429); + expect(err.code).toBe('DC-429'); + expect(err.retryAfter).toBe(30); + expect(err.message).toBe('Rate limit exceeded'); + expect(err).toBeInstanceOf(AppError); + }); + + it('defaults retryAfter to 60', () => { + const err = new RateLimitError(); + expect(err.retryAfter).toBe(60); + }); + }); + + describe('DockerError', () => { + it('has statusCode 500, operation, and details', () => { + const err = new DockerError('container failed', 'create', { containerId: '123' }); + expect(err.statusCode).toBe(500); + expect(err.code).toBe('DC-500-DOCKER'); + expect(err.operation).toBe('create'); + expect(err.details).toEqual({ containerId: '123' }); + expect(err).toBeInstanceOf(AppError); + }); + }); + + describe('CaddyError', () => { + it('has statusCode 502', () => { + const err = new CaddyError('reload failed', 'reload'); + expect(err.statusCode).toBe(502); + expect(err.code).toBe('DC-502-CADDY'); + expect(err.operation).toBe('reload'); + expect(err).toBeInstanceOf(AppError); + }); + }); + + describe('DNSError', () => { + it('has statusCode 502', () => { + const err = new DNSError('zone create failed', 'create-zone'); + expect(err.statusCode).toBe(502); + expect(err.code).toBe('DC-502-DNS'); + expect(err).toBeInstanceOf(AppError); + }); + }); + + describe('ServiceUnavailableError', () => { + it('has statusCode 503, service name, and optional retryAfter', () => { + const err = new ServiceUnavailableError('plex', 120); + expect(err.statusCode).toBe(503); + expect(err.code).toBe('DC-503'); + expect(err.message).toBe('Service unavailable: plex'); + expect(err.service).toBe('plex'); + expect(err.retryAfter).toBe(120); + expect(err).toBeInstanceOf(AppError); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/health-checker.test.js b/dashcaddy-api/__tests__/health-checker.test.js new file mode 100644 index 0000000..60cfa71 --- /dev/null +++ b/dashcaddy-api/__tests__/health-checker.test.js @@ -0,0 +1,513 @@ +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockReturnValue('{"services":{}}'), + writeFileSync: jest.fn(), +})); + +jest.useFakeTimers(); + +describe('HealthChecker', () => { + let HealthChecker, healthChecker, fs; + + beforeEach(() => { + jest.resetModules(); + fs = require('fs'); + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue('{"services":{}}'); + fs.writeFileSync.mockImplementation(() => {}); + + // Fresh instance each test + HealthChecker = require('../health-checker').constructor; + healthChecker = new HealthChecker(); + }); + + afterEach(() => { + healthChecker.stop(); + jest.clearAllTimers(); + }); + + describe('constructor', () => { + it('initializes with empty state', () => { + expect(healthChecker.currentStatus).toBeInstanceOf(Map); + expect(healthChecker.incidents).toEqual([]); + expect(healthChecker.checking).toBe(false); + }); + + it('loads config from file when it exists', () => { + jest.resetModules(); + fs = require('fs'); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify({ + services: { svc1: { url: 'http://test.local', enabled: true } } + })); + + HealthChecker = require('../health-checker').constructor; + const hc = new HealthChecker(); + expect(hc.config.services.svc1).toBeDefined(); + }); + + it('returns default config on parse error', () => { + jest.resetModules(); + fs = require('fs'); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('invalid json'); + + HealthChecker = require('../health-checker').constructor; + const hc = new HealthChecker(); + expect(hc.config).toEqual({ services: {} }); + }); + }); + + describe('start / stop', () => { + it('start sets checking to true and schedules interval', () => { + // Mock checkAll to prevent real HTTP calls + healthChecker.checkAll = jest.fn(); + healthChecker.start(); + expect(healthChecker.checking).toBe(true); + expect(healthChecker.checkAll).toHaveBeenCalled(); + }); + + it('start is idempotent (no-op if already checking)', () => { + healthChecker.checkAll = jest.fn(); + healthChecker.start(); + healthChecker.start(); // second call + expect(healthChecker.checkAll).toHaveBeenCalledTimes(1); + }); + + it('stop clears interval and resets state', () => { + healthChecker.checkAll = jest.fn(); + healthChecker.start(); + healthChecker.stop(); + expect(healthChecker.checking).toBe(false); + expect(healthChecker.checkInterval).toBeNull(); + }); + + it('stop is idempotent (no-op if not checking)', () => { + healthChecker.stop(); // should not throw + expect(healthChecker.checking).toBe(false); + }); + }); + + describe('getBackoffInterval', () => { + it('returns base interval when no failures', () => { + const interval = healthChecker.getBackoffInterval('svc1'); + expect(interval).toBe(30000); // CHECK_INTERVAL default + }); + + it('doubles interval per consecutive failure', () => { + healthChecker.consecutiveFailures.set('svc1', 1); + expect(healthChecker.getBackoffInterval('svc1')).toBe(60000); + + healthChecker.consecutiveFailures.set('svc1', 2); + expect(healthChecker.getBackoffInterval('svc1')).toBe(120000); + }); + + it('caps at MAX_CHECK_INTERVAL', () => { + healthChecker.consecutiveFailures.set('svc1', 100); + expect(healthChecker.getBackoffInterval('svc1')).toBe(300000); + }); + }); + + describe('evaluateHealth', () => { + it('returns true for expected status code', () => { + const result = healthChecker.evaluateHealth(200, '', { expectedStatusCodes: [200] }); + expect(result).toBe(true); + }); + + it('returns false for unexpected status code', () => { + const result = healthChecker.evaluateHealth(500, '', { expectedStatusCodes: [200] }); + expect(result).toBe(false); + }); + + it('defaults to accepting common 2xx/3xx codes', () => { + expect(healthChecker.evaluateHealth(200, '', {})).toBe(true); + expect(healthChecker.evaluateHealth(301, '', {})).toBe(true); + expect(healthChecker.evaluateHealth(500, '', {})).toBe(false); + }); + + it('checks body pattern with regex', () => { + const config = { expectedBodyPattern: 'ok|healthy' }; + expect(healthChecker.evaluateHealth(200, 'status: ok', config)).toBe(true); + expect(healthChecker.evaluateHealth(200, 'status: error', config)).toBe(false); + }); + + it('checks body contains text', () => { + const config = { expectedBodyContains: 'alive' }; + expect(healthChecker.evaluateHealth(200, 'I am alive!', config)).toBe(true); + expect(healthChecker.evaluateHealth(200, 'dead', config)).toBe(false); + }); + }); + + describe('recordStatus', () => { + it('updates currentStatus map', () => { + const status = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() }; + healthChecker.recordStatus('svc1', status); + expect(healthChecker.currentStatus.get('svc1')).toEqual(status); + }); + + it('appends to history', () => { + const status1 = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() }; + const status2 = { serviceId: 'svc1', status: 'down', timestamp: new Date().toISOString() }; + healthChecker.recordStatus('svc1', status1); + healthChecker.recordStatus('svc1', status2); + expect(healthChecker.history['svc1']).toHaveLength(2); + }); + + it('emits status-check event', () => { + const handler = jest.fn(); + healthChecker.on('status-check', handler); + const status = { serviceId: 'svc1', status: 'up' }; + healthChecker.recordStatus('svc1', status); + expect(handler).toHaveBeenCalledWith(status); + }); + }); + + describe('checkService', () => { + it('returns up status on successful health check', async () => { + healthChecker._doRequest = jest.fn().mockResolvedValue({ + healthy: true, statusCode: 200, message: 'Service is healthy', details: {} + }); + + const config = { url: 'http://test.local' }; + const result = await healthChecker.checkService('svc1', config); + expect(result.status).toBe('up'); + expect(result.serviceId).toBe('svc1'); + }); + + it('returns down status on failed health check', async () => { + healthChecker._doRequest = jest.fn().mockResolvedValue({ + healthy: false, statusCode: 500, message: 'fail', details: {} + }); + + const result = await healthChecker.checkService('svc1', { url: 'http://test.local' }); + expect(result.status).toBe('down'); + }); + + it('returns down status on request error', async () => { + healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await healthChecker.checkService('svc1', { url: 'http://test.local' }); + expect(result.status).toBe('down'); + expect(result.error).toBe('ECONNREFUSED'); + }); + + it('increments consecutive failures on error', async () => { + healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail')); + + await healthChecker.checkService('svc1', { url: 'http://test.local' }); + expect(healthChecker.consecutiveFailures.get('svc1')).toBe(1); + + await healthChecker.checkService('svc1', { url: 'http://test.local' }); + expect(healthChecker.consecutiveFailures.get('svc1')).toBe(2); + }); + + it('clears consecutive failures on success', async () => { + healthChecker.consecutiveFailures.set('svc1', 5); + healthChecker._doRequest = jest.fn().mockResolvedValue({ + healthy: true, statusCode: 200, message: 'ok', details: {} + }); + + await healthChecker.checkService('svc1', { url: 'http://test.local' }); + expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false); + }); + }); + + describe('performHealthCheck', () => { + it('falls back to GET when HEAD returns 501', async () => { + healthChecker._doRequest = jest.fn() + .mockResolvedValueOnce({ statusCode: 501 }) + .mockResolvedValueOnce({ healthy: true, statusCode: 200 }); + + const result = await healthChecker.performHealthCheck({ url: 'http://test.local', method: 'HEAD' }); + expect(healthChecker._doRequest).toHaveBeenCalledTimes(2); + expect(result.statusCode).toBe(200); + }); + + it('falls back to GET when HEAD returns 405', async () => { + healthChecker._doRequest = jest.fn() + .mockResolvedValueOnce({ statusCode: 405 }) + .mockResolvedValueOnce({ healthy: true, statusCode: 200 }); + + const result = await healthChecker.performHealthCheck({ url: 'http://test.local', method: 'HEAD' }); + expect(result.statusCode).toBe(200); + }); + + it('does not fallback for GET requests returning 501', async () => { + healthChecker._doRequest = jest.fn() + .mockResolvedValueOnce({ statusCode: 501, healthy: false }); + + const result = await healthChecker.performHealthCheck({ url: 'http://test.local' }); + expect(healthChecker._doRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('incidents', () => { + it('createIncident adds a new incident', () => { + const status = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'Service down', status); + expect(healthChecker.incidents).toHaveLength(1); + expect(healthChecker.incidents[0].serviceId).toBe('svc1'); + expect(healthChecker.incidents[0].type).toBe('outage'); + expect(healthChecker.incidents[0].status).toBe('open'); + }); + + it('createIncident increments existing open incident', () => { + const status = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'down', status); + healthChecker.createIncident('svc1', 'outage', 'still down', status); + expect(healthChecker.incidents).toHaveLength(1); + expect(healthChecker.incidents[0].occurrences).toBe(2); + }); + + it('resolveIncident sets status to resolved', () => { + const status = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'down', status); + healthChecker.resolveIncident('svc1', 'outage', status); + expect(healthChecker.incidents[0].status).toBe('resolved'); + expect(healthChecker.incidents[0].resolvedAt).toBeDefined(); + }); + + it('resolveIncident is no-op for non-existent incidents', () => { + const status = { timestamp: new Date().toISOString() }; + healthChecker.resolveIncident('svc1', 'outage', status); + expect(healthChecker.incidents).toHaveLength(0); + }); + + it('getOpenIncidents filters resolved', () => { + const ts = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'down', ts); + healthChecker.createIncident('svc2', 'slow-response', 'slow', ts); + healthChecker.resolveIncident('svc1', 'outage', ts); + + const open = healthChecker.getOpenIncidents(); + expect(open).toHaveLength(1); + expect(open[0].serviceId).toBe('svc2'); + }); + + it('getIncidentHistory returns recent incidents in reverse order', () => { + const ts = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'first', ts); + healthChecker.createIncident('svc2', 'outage', 'second', ts); + + const history = healthChecker.getIncidentHistory(); + expect(history[0].serviceId).toBe('svc2'); + expect(history[1].serviceId).toBe('svc1'); + }); + + it('emits incident-created event', () => { + const handler = jest.fn(); + healthChecker.on('incident-created', handler); + healthChecker.createIncident('svc1', 'outage', 'down', { timestamp: new Date().toISOString() }); + expect(handler).toHaveBeenCalled(); + }); + + it('emits incident-resolved event', () => { + const handler = jest.fn(); + healthChecker.on('incident-resolved', handler); + const ts = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'down', ts); + healthChecker.resolveIncident('svc1', 'outage', ts); + expect(handler).toHaveBeenCalled(); + }); + }); + + describe('calculateSeverity', () => { + it('returns critical for outage', () => { + expect(healthChecker.calculateSeverity('outage')).toBe('critical'); + }); + it('returns high for sla-violation', () => { + expect(healthChecker.calculateSeverity('sla-violation')).toBe('high'); + }); + it('returns medium for slow-response', () => { + expect(healthChecker.calculateSeverity('slow-response')).toBe('medium'); + }); + it('returns low for unknown', () => { + expect(healthChecker.calculateSeverity('unknown')).toBe('low'); + }); + }); + + describe('checkForIncidents', () => { + it('creates outage incident on status change up -> down', () => { + // Simulate previous up status + healthChecker.currentStatus.set('svc1', { status: 'up' }); + const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 }; + healthChecker.checkForIncidents('svc1', status, {}); + expect(healthChecker.incidents).toHaveLength(1); + expect(healthChecker.incidents[0].type).toBe('outage'); + }); + + it('resolves outage incident on status change down -> up', () => { + healthChecker.currentStatus.set('svc1', { status: 'down' }); + const ts = { timestamp: new Date().toISOString() }; + healthChecker.createIncident('svc1', 'outage', 'was down', ts); + + const status = { status: 'up', timestamp: new Date().toISOString(), responseTime: 100 }; + healthChecker.checkForIncidents('svc1', status, {}); + expect(healthChecker.incidents[0].status).toBe('resolved'); + }); + + it('creates slow-response incident when exceeding threshold', () => { + const status = { status: 'up', timestamp: new Date().toISOString(), responseTime: 6000 }; + healthChecker.checkForIncidents('svc1', status, { slowResponseThreshold: 5000 }); + expect(healthChecker.incidents.some(i => i.type === 'slow-response')).toBe(true); + }); + }); + + describe('uptime and stats', () => { + beforeEach(() => { + const now = Date.now(); + healthChecker.history['svc1'] = [ + { status: 'up', responseTime: 100, timestamp: new Date(now - 3600000).toISOString() }, + { status: 'up', responseTime: 200, timestamp: new Date(now - 1800000).toISOString() }, + { status: 'down', responseTime: 5000, timestamp: new Date(now - 900000).toISOString() }, + { status: 'up', responseTime: 150, timestamp: new Date(now - 60000).toISOString() }, + ]; + }); + + it('calculateUptime returns correct percentage', () => { + const uptime = healthChecker.calculateUptime('svc1', 24); + expect(uptime).toBe(75); // 3 out of 4 checks up + }); + + it('calculateUptime returns 100 for unknown service', () => { + expect(healthChecker.calculateUptime('unknown', 24)).toBe(100); + }); + + it('calculateAverageResponseTime returns correct average', () => { + const avg = healthChecker.calculateAverageResponseTime('svc1', 24); + expect(avg).toBe((100 + 200 + 5000 + 150) / 4); + }); + + it('calculateAverageResponseTime returns 0 for unknown service', () => { + expect(healthChecker.calculateAverageResponseTime('unknown', 24)).toBe(0); + }); + + it('getServiceHistory filters by time period', () => { + const history = healthChecker.getServiceHistory('svc1', 24); + expect(history.length).toBe(4); + + // Very short period should exclude older entries + const recent = healthChecker.getServiceHistory('svc1', 0.01); // ~36 seconds + expect(recent.length).toBeLessThan(4); + }); + + it('getServiceStats returns null for unknown service', () => { + expect(healthChecker.getServiceStats('unknown')).toBeNull(); + }); + + it('getServiceStats returns correct stats', () => { + const stats = healthChecker.getServiceStats('svc1', 24); + expect(stats.totalChecks).toBe(4); + expect(stats.upChecks).toBe(3); + expect(stats.downChecks).toBe(1); + expect(stats.uptime).toBe(75); + expect(stats.responseTime.min).toBe(100); + expect(stats.responseTime.max).toBe(5000); + }); + }); + + describe('calculatePercentile', () => { + it('returns correct p95', () => { + const values = Array.from({ length: 100 }, (_, i) => i + 1); + const p95 = healthChecker.calculatePercentile(values, 95); + expect(p95).toBe(95); + }); + + it('returns 0 for empty array', () => { + expect(healthChecker.calculatePercentile([], 95)).toBe(0); + }); + }); + + describe('getCurrentStatus', () => { + it('returns enriched status for all services', () => { + healthChecker.config.services = { + svc1: { name: 'Test Service' } + }; + healthChecker.currentStatus.set('svc1', { + status: 'up', responseTime: 100, timestamp: new Date().toISOString() + }); + + const result = healthChecker.getCurrentStatus(); + expect(result.svc1).toBeDefined(); + expect(result.svc1.name).toBe('Test Service'); + expect(result.svc1.uptime).toBeDefined(); + expect(result.svc1.uptime['24h']).toBeDefined(); + }); + }); + + describe('configureService / removeService', () => { + it('configureService saves config to file', () => { + healthChecker.configureService('svc1', { + name: 'My Service', + url: 'http://localhost:3000', + timeout: 10000 + }); + + expect(healthChecker.config.services.svc1).toBeDefined(); + expect(healthChecker.config.services.svc1.url).toBe('http://localhost:3000'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('removeService cleans up all traces', () => { + healthChecker.configureService('svc1', { url: 'http://test.local' }); + healthChecker.currentStatus.set('svc1', { status: 'up' }); + healthChecker.history['svc1'] = [{ status: 'up' }]; + + healthChecker.removeService('svc1'); + expect(healthChecker.config.services.svc1).toBeUndefined(); + expect(healthChecker.currentStatus.has('svc1')).toBe(false); + expect(healthChecker.history['svc1']).toBeUndefined(); + }); + }); + + describe('cleanupHistory', () => { + it('removes entries older than retention period', () => { + const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago + const recent = new Date().toISOString(); + healthChecker.history['svc1'] = [ + { timestamp: old, status: 'up' }, + { timestamp: recent, status: 'up' }, + ]; + + healthChecker.cleanupHistory(); + expect(healthChecker.history['svc1']).toHaveLength(1); + expect(healthChecker.history['svc1'][0].timestamp).toBe(recent); + }); + }); + + describe('loadConfig / saveConfig', () => { + it('saveConfig writes JSON to file', () => { + healthChecker.config = { services: { svc1: { url: 'http://test' } } }; + healthChecker.saveConfig(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('"svc1"') + ); + }); + + it('saveConfig handles write errors gracefully', () => { + fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); + expect(() => healthChecker.saveConfig()).not.toThrow(); + }); + }); + + describe('loadHistory / saveHistory', () => { + it('loadHistory returns empty object when file missing', () => { + const history = healthChecker.loadHistory(); + expect(history).toEqual({}); + }); + + it('loadHistory parses JSON from file', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify({ svc1: [{ status: 'up' }] })); + const history = healthChecker.loadHistory(); + expect(history.svc1).toHaveLength(1); + }); + + it('saveHistory writes history to file', () => { + healthChecker.history = { svc1: [{ status: 'up' }] }; + healthChecker.saveHistory(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/helpers/test-utils.js b/dashcaddy-api/__tests__/helpers/test-utils.js new file mode 100644 index 0000000..28b0bf7 --- /dev/null +++ b/dashcaddy-api/__tests__/helpers/test-utils.js @@ -0,0 +1,140 @@ +/** + * Shared test utilities for DashCaddy test suite + */ +const express = require('express'); + +/** + * Create a mock credential manager + */ +function createMockCredentialManager() { + return { + store: jest.fn().mockResolvedValue(true), + retrieve: jest.fn().mockResolvedValue(null), + delete: jest.fn().mockResolvedValue(true), + list: jest.fn().mockResolvedValue([]), + getMetadata: jest.fn().mockResolvedValue(null), + rotateEncryptionKey: jest.fn().mockResolvedValue(true), + exportBackup: jest.fn().mockResolvedValue('encrypted-backup'), + importBackup: jest.fn().mockResolvedValue(true), + }; +} + +/** + * Create a mock crypto utils module + */ +function createMockCryptoUtils() { + const fixedKey = Buffer.alloc(32, 'a'); + return { + encrypt: jest.fn(data => `mock-iv:mock-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.split(':').length === 3), + encryptFields: jest.fn((obj, fields) => ({ ...obj, _encrypted: true, _encryptedFields: fields })), + decryptFields: jest.fn(obj => { + const result = { ...obj }; + delete result._encrypted; + delete result._encryptedFields; + return result; + }), + loadOrCreateKey: jest.fn(() => fixedKey), + clearCachedKey: jest.fn(), + rotateKey: jest.fn(() => ({ oldKey: fixedKey, newKey: Buffer.alloc(32, 'b') })), + deriveKey: jest.fn().mockResolvedValue(fixedKey), + decryptWithKey: jest.fn(data => { + const parts = data.split(':'); + return Buffer.from(parts[2], 'base64').toString('utf8'); + }), + readEncryptedFile: jest.fn().mockReturnValue(null), + writeEncryptedFile: jest.fn(), + migrateToEncrypted: jest.fn(obj => obj), + }; +} + +/** + * Create a mock state manager + */ +function createMockStateManager() { + let data = []; + return { + read: jest.fn().mockResolvedValue(data), + write: jest.fn().mockResolvedValue(), + update: jest.fn(async fn => { data = fn(data); return data; }), + addItem: jest.fn().mockResolvedValue(), + removeItem: jest.fn().mockResolvedValue(), + updateItem: jest.fn().mockResolvedValue(), + findItem: jest.fn().mockResolvedValue(null), + _setData: (newData) => { data = newData; }, + }; +} + +/** + * Create a mock logger + */ +function createMockLogger() { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }; +} + +/** + * Build a minimal Express app for route testing with supertest + */ +function buildTestApp(routeFactory, deps, prefix = '/api') { + const app = express(); + app.use(express.json()); + const router = routeFactory(deps); + app.use(prefix, router); + // Error handler + const { errorMiddleware } = require('../../error-handler'); + app.use(errorMiddleware); + return app; +} + +/** + * Create mock Express req/res/next for middleware testing + */ +function createMockReqRes(overrides = {}) { + const req = { + method: 'GET', + path: '/test', + headers: {}, + cookies: {}, + ip: '127.0.0.1', + protocol: 'https', + secure: true, + body: {}, + params: {}, + query: {}, + get: jest.fn(header => req.headers[header.toLowerCase()]), + ...overrides, + }; + + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + getHeader: jest.fn(), + end: jest.fn(), + }; + + const next = jest.fn(); + + return { req, res, next }; +} + +module.exports = { + createMockCredentialManager, + createMockCryptoUtils, + createMockStateManager, + createMockLogger, + buildTestApp, + createMockReqRes, +}; diff --git a/dashcaddy-api/__tests__/input-validator.test.js b/dashcaddy-api/__tests__/input-validator.test.js new file mode 100644 index 0000000..48f3af6 --- /dev/null +++ b/dashcaddy-api/__tests__/input-validator.test.js @@ -0,0 +1,553 @@ +const { + ValidationError, + validateDNSRecord, + validateDockerDeployment, + validateFilePath, + validateVolumePath, + validateURL, + validateToken, + validateServiceConfig, + sanitizeString, + isValidPort, + isPrivateIP, + validateSecurePath +} = require('../input-validator'); + +describe('Input Validator', () => { + + describe('ValidationError', () => { + it('has correct name, message, field, and statusCode', () => { + const err = new ValidationError('bad input', 'email'); + expect(err.name).toBe('ValidationError'); + expect(err.message).toBe('bad input'); + expect(err.field).toBe('email'); + expect(err.statusCode).toBe(400); + expect(err).toBeInstanceOf(Error); + }); + + it('field defaults to null', () => { + const err = new ValidationError('oops'); + expect(err.field).toBeNull(); + }); + }); + + describe('validateDNSRecord', () => { + const validRecord = { subdomain: 'myapp', ip: '8.8.8.8' }; + + it('valid record returns sanitized data with lowercase subdomain and default TTL', () => { + const result = validateDNSRecord({ subdomain: 'MyApp', ip: '1.2.3.4' }); + expect(result.subdomain).toBe('myapp'); + expect(result.ip).toBe('1.2.3.4'); + expect(result.ttl).toBe(3600); + }); + + it('accepts valid domain and custom TTL', () => { + const result = validateDNSRecord({ + subdomain: 'test', ip: '8.8.8.8', domain: 'example.com', ttl: 300 + }); + expect(result.domain).toBe('example.com'); + expect(result.ttl).toBe(300); + }); + + it('rejects missing subdomain', () => { + expect(() => validateDNSRecord({ ip: '1.2.3.4' })).toThrow(ValidationError); + }); + + it('rejects invalid subdomain format', () => { + expect(() => validateDNSRecord({ subdomain: '-bad', ip: '1.2.3.4' })).toThrow(ValidationError); + expect(() => validateDNSRecord({ subdomain: 'a'.repeat(64), ip: '1.2.3.4' })).toThrow(ValidationError); + }); + + it('rejects DNS injection chars', () => { + const dangerous = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\']; + for (const char of dangerous) { + expect(() => validateDNSRecord({ subdomain: `test${char}cmd`, ip: '1.2.3.4' })) + .toThrow(ValidationError); + } + }); + + it('rejects invalid domain format', () => { + expect(() => validateDNSRecord({ subdomain: 'app', ip: '1.2.3.4', domain: 'not valid!!' })) + .toThrow(ValidationError); + }); + + it('rejects missing IP', () => { + expect(() => validateDNSRecord({ subdomain: 'test' })).toThrow(ValidationError); + }); + + it('rejects invalid IP format', () => { + expect(() => validateDNSRecord({ subdomain: 'test', ip: '999.999.999.999' })) + .toThrow(ValidationError); + }); + + it('blocks private IPs when blockPrivateIPs flag set', () => { + expect(() => validateDNSRecord({ + subdomain: 'test', ip: '192.168.1.1', blockPrivateIPs: true + })).toThrow(ValidationError); + }); + + it('allows private IPs when flag not set', () => { + const result = validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1' }); + expect(result.ip).toBe('192.168.1.1'); + }); + + it('rejects TTL below 60', () => { + expect(() => validateDNSRecord({ subdomain: 'test', ip: '1.2.3.4', ttl: 10 })) + .toThrow(ValidationError); + }); + + it('rejects TTL above 86400', () => { + expect(() => validateDNSRecord({ subdomain: 'test', ip: '1.2.3.4', ttl: 100000 })) + .toThrow(ValidationError); + }); + + it('aggregates multiple errors', () => { + try { + validateDNSRecord({ subdomain: '', ip: '' }); + fail('Should have thrown'); + } catch (err) { + expect(err.errors).toBeDefined(); + expect(err.errors.length).toBeGreaterThan(1); + } + }); + }); + + describe('validateDockerDeployment', () => { + const valid = { name: 'my-app', image: 'nginx:latest' }; + + it('valid deployment returns sanitized data', () => { + const result = validateDockerDeployment(valid); + expect(result.name).toBe('my-app'); + expect(result.image).toBe('nginx:latest'); + expect(result.ports).toEqual([]); + expect(result.volumes).toEqual([]); + expect(result.environment).toEqual({}); + }); + + it('rejects missing container name', () => { + expect(() => validateDockerDeployment({ image: 'nginx' })).toThrow(ValidationError); + }); + + it('rejects invalid container name chars', () => { + expect(() => validateDockerDeployment({ name: '!invalid', image: 'nginx' })) + .toThrow(ValidationError); + }); + + it('rejects container name > 255 chars', () => { + expect(() => validateDockerDeployment({ name: 'a'.repeat(256), image: 'nginx' })) + .toThrow(ValidationError); + }); + + it('rejects missing image', () => { + expect(() => validateDockerDeployment({ name: 'app' })).toThrow(ValidationError); + }); + + it('blocks dangerous chars in image', () => { + const dangerous = [';', '&', '|', '`', '$', '$(', '&&', '||', '\n']; + for (const char of dangerous) { + expect(() => validateDockerDeployment({ name: 'app', image: `nginx${char}rm` })) + .toThrow(ValidationError); + } + }); + + it('rejects image name > 512 chars', () => { + expect(() => validateDockerDeployment({ name: 'app', image: 'a'.repeat(513) })) + .toThrow(ValidationError); + }); + + it('validates port format "8080:80" and "8080:80/tcp"', () => { + const result = validateDockerDeployment({ + ...valid, ports: ['8080:80', '443:443/tcp'] + }); + expect(result.ports).toEqual(['8080:80', '443:443/tcp']); + }); + + it('rejects invalid port format', () => { + expect(() => validateDockerDeployment({ ...valid, ports: ['bad'] })) + .toThrow(ValidationError); + }); + + it('rejects port numbers outside 1-65535', () => { + expect(() => validateDockerDeployment({ ...valid, ports: ['99999:80'] })) + .toThrow(ValidationError); + }); + + it('rejects ports that is not an array', () => { + expect(() => validateDockerDeployment({ ...valid, ports: 'not-array' })) + .toThrow(ValidationError); + }); + + it('validates volume format', () => { + const result = validateDockerDeployment({ + ...valid, volumes: ['/data:/app/data', '/config:/app/config:ro'] + }); + expect(result.volumes).toHaveLength(2); + }); + + it('rejects volumes that is not an array', () => { + expect(() => validateDockerDeployment({ ...valid, volumes: 'not-array' })) + .toThrow(ValidationError); + }); + + it('validates environment variable names', () => { + const result = validateDockerDeployment({ + ...valid, environment: { NODE_ENV: 'production', PORT: 3000, DEBUG: true } + }); + expect(result.environment).toEqual({ NODE_ENV: 'production', PORT: 3000, DEBUG: true }); + }); + + it('rejects invalid env var names', () => { + expect(() => validateDockerDeployment({ + ...valid, environment: { '123invalid': 'val' } + })).toThrow(ValidationError); + }); + + it('rejects environment that is not an object', () => { + expect(() => validateDockerDeployment({ ...valid, environment: 'bad' })) + .toThrow(ValidationError); + }); + }); + + describe('validateFilePath', () => { + it('returns normalized path for valid input', () => { + const result = validateFilePath('/app/data/file.json'); + expect(result).toBeDefined(); + }); + + it('rejects null/empty/non-string path', () => { + expect(() => validateFilePath(null)).toThrow(ValidationError); + expect(() => validateFilePath('')).toThrow(ValidationError); + expect(() => validateFilePath(123)).toThrow(ValidationError); + }); + + it('rejects directory traversal (..)', () => { + // Use relative path so .. survives path.normalize on all platforms + expect(() => validateFilePath('foo/../../bar')).toThrow('Path traversal detected'); + }); + + it('rejects tilde (~)', () => { + expect(() => validateFilePath('data/~/secret')).toThrow('Path traversal detected'); + }); + + it('blocks sensitive paths', () => { + if (process.platform === 'win32') { + expect(() => validateFilePath('C:\\Windows\\System32\\config')).toThrow('not allowed'); + expect(() => validateFilePath('C:\\Program Files\\test')).toThrow('not allowed'); + } else { + expect(() => validateFilePath('/etc/passwd')).toThrow('not allowed'); + expect(() => validateFilePath('/proc/1/status')).toThrow('not allowed'); + expect(() => validateFilePath('/sys/kernel')).toThrow('not allowed'); + expect(() => validateFilePath('/root/.ssh')).toThrow('not allowed'); + expect(() => validateFilePath('/var/run/docker.sock')).toThrow('not allowed'); + expect(() => validateFilePath('/var/lib/docker/containers')).toThrow('not allowed'); + } + }); + + it('validates against allowedBasePaths', () => { + const result = validateFilePath('/app/data/file.txt', ['/app/data']); + expect(result).toBeDefined(); + }); + + it('rejects paths outside allowed base', () => { + expect(() => validateFilePath('/other/file.txt', ['/app/data'])) + .toThrow('outside allowed directories'); + }); + }); + + describe('validateVolumePath', () => { + it('valid volume returns no errors', () => { + const errors = validateVolumePath('/host/path:/container/path', 0); + expect(errors).toHaveLength(0); + }); + + it('valid volume with mode returns no errors', () => { + const errors = validateVolumePath('/host/path:/container/path:ro', 0); + expect(errors).toHaveLength(0); + }); + + it('detects invalid format', () => { + const errors = validateVolumePath('invalidformat', 0); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].message).toContain('Invalid volume format'); + }); + + it('validates container path must be absolute', () => { + const errors = validateVolumePath('/host:relative/path', 0); + expect(errors.length).toBeGreaterThan(0); + }); + }); + + describe('validateURL', () => { + it('accepts valid http/https URLs', () => { + expect(validateURL('https://example.com')).toBe('https://example.com'); + expect(validateURL('http://example.com/path')).toBe('http://example.com/path'); + }); + + it('rejects missing URL', () => { + expect(() => validateURL(null)).toThrow(ValidationError); + expect(() => validateURL('')).toThrow(ValidationError); + }); + + it('rejects invalid URL format', () => { + expect(() => validateURL('not-a-url')).toThrow(ValidationError); + }); + + it('blocks private IP when blockPrivate is true', () => { + expect(() => validateURL('http://10.0.0.1/', { blockPrivate: true })) + .toThrow('Private URLs'); + }); + + it('blocks 192.168.x.x when blockPrivate is true', () => { + expect(() => validateURL('http://192.168.1.1/', { blockPrivate: true })) + .toThrow('Private URLs'); + }); + + it('allows private IPs when blockPrivate is false', () => { + expect(validateURL('http://10.0.0.1/')).toBe('http://10.0.0.1/'); + }); + }); + + describe('validateToken', () => { + it('accepts valid tokens', () => { + const result = validateToken('abcdef1234567890'); + expect(result).toBe('abcdef1234567890'); + }); + + it('trims whitespace', () => { + const result = validateToken(' validtoken '); + expect(result).toBe('validtoken'); + }); + + it('rejects missing/non-string token', () => { + expect(() => validateToken(null)).toThrow(ValidationError); + expect(() => validateToken(123)).toThrow(ValidationError); + }); + + it('rejects token < 8 chars', () => { + expect(() => validateToken('short')).toThrow('too short'); + }); + + it('rejects token > 512 chars', () => { + expect(() => validateToken('a'.repeat(513))).toThrow('too long'); + }); + + it('rejects tokens with injection chars', () => { + const dangerous = [';', '&', '|', '`', '\n', '\r', '$(', '&&']; + for (const char of dangerous) { + expect(() => validateToken(`validtoken${char}inject`)).toThrow('invalid characters'); + } + }); + }); + + describe('validateServiceConfig', () => { + const valid = { id: 'my-service', name: 'My Service' }; + + it('valid service config passes', () => { + const result = validateServiceConfig(valid); + expect(result.id).toBe('my-service'); + }); + + it('rejects missing id', () => { + expect(() => validateServiceConfig({ name: 'Test' })).toThrow(ValidationError); + }); + + it('rejects invalid id format', () => { + expect(() => validateServiceConfig({ id: 'bad id!', name: 'Test' })) + .toThrow(ValidationError); + }); + + it('rejects missing name', () => { + expect(() => validateServiceConfig({ id: 'test' })).toThrow(ValidationError); + }); + + it('rejects name > 100 chars', () => { + expect(() => validateServiceConfig({ id: 'test', name: 'x'.repeat(101) })) + .toThrow(ValidationError); + }); + + it('validates URL when provided', () => { + expect(() => validateServiceConfig({ id: 'test', name: 'Test', url: 'not-valid' })) + .toThrow(ValidationError); + }); + + it('validates port when provided', () => { + expect(() => validateServiceConfig({ id: 'test', name: 'Test', port: 99999 })) + .toThrow(ValidationError); + }); + + it('accepts valid port', () => { + const result = validateServiceConfig({ id: 'test', name: 'Test', port: 8080 }); + expect(result.port).toBe(8080); + }); + }); + + describe('sanitizeString', () => { + it('escapes < > \' " to HTML entities', () => { + expect(sanitizeString('')).toBe( + '<script>"alert('xss')"</script>' + ); + }); + + it('truncates to maxLength', () => { + expect(sanitizeString('hello world', 5)).toBe('hello'); + }); + + it('returns empty string for non-string input', () => { + expect(sanitizeString(123)).toBe(''); + expect(sanitizeString(null)).toBe(''); + expect(sanitizeString(undefined)).toBe(''); + }); + }); + + describe('isValidPort', () => { + it('returns true for valid ports', () => { + expect(isValidPort(1)).toBe(true); + expect(isValidPort(80)).toBe(true); + expect(isValidPort(443)).toBe(true); + expect(isValidPort(65535)).toBe(true); + }); + + it('returns false for invalid ports', () => { + expect(isValidPort(0)).toBe(false); + expect(isValidPort(-1)).toBe(false); + expect(isValidPort(65536)).toBe(false); + expect(isValidPort(NaN)).toBe(false); + }); + + it('handles string numbers', () => { + expect(isValidPort('8080')).toBe(true); + expect(isValidPort('0')).toBe(false); + expect(isValidPort('abc')).toBe(false); + }); + }); + + describe('isPrivateIP', () => { + it('identifies 10.x.x.x as private', () => { + expect(isPrivateIP('10.0.0.1')).toBe(true); + expect(isPrivateIP('10.255.255.255')).toBe(true); + }); + + it('identifies 172.16-31.x.x as private', () => { + expect(isPrivateIP('172.16.0.1')).toBe(true); + expect(isPrivateIP('172.31.255.255')).toBe(true); + }); + + it('identifies 192.168.x.x as private', () => { + expect(isPrivateIP('192.168.1.1')).toBe(true); + }); + + it('identifies 127.x.x.x as private', () => { + expect(isPrivateIP('127.0.0.1')).toBe(true); + }); + + it('identifies 169.254.x.x as private', () => { + expect(isPrivateIP('169.254.0.1')).toBe(true); + }); + + it('identifies IPv6 loopback as private', () => { + expect(isPrivateIP('::1')).toBe(true); + }); + + it('identifies fc00: and fe80: as private', () => { + expect(isPrivateIP('fc00::1')).toBe(true); + expect(isPrivateIP('fe80::1')).toBe(true); + }); + + it('public IPs return false', () => { + expect(isPrivateIP('8.8.8.8')).toBe(false); + expect(isPrivateIP('1.1.1.1')).toBe(false); + expect(isPrivateIP('203.0.113.1')).toBe(false); + }); + }); + + describe('validateSecurePath', () => { + const mockRealpath = jest.fn(); + + beforeEach(() => { + jest.resetModules(); + // Mock fs.promises.realpath + jest.doMock('fs', () => ({ + ...jest.requireActual('fs'), + promises: { + realpath: mockRealpath, + }, + })); + mockRealpath.mockReset(); + }); + + // Re-require after mocking fs + function getValidateSecurePath() { + return require('../input-validator').validateSecurePath; + } + + it('resolves valid path within allowed roots', async () => { + const fn = getValidateSecurePath(); + mockRealpath.mockResolvedValue('/app/data/file.txt'); + const result = await fn('/app/data/file.txt', ['/app/data']); + expect(result).toBe('/app/data/file.txt'); + }); + + it('rejects null/empty path', async () => { + const fn = getValidateSecurePath(); + await expect(fn(null, ['/app'])).rejects.toThrow('Path is required'); + await expect(fn('', ['/app'])).rejects.toThrow('Path is required'); + }); + + it('rejects null byte injection', async () => { + const fn = getValidateSecurePath(); + await expect(fn('/app/data\0/evil', ['/app'])) + .rejects.toThrow('null byte detected'); + }); + + it('rejects .. traversal sequences', async () => { + const fn = getValidateSecurePath(); + await expect(fn('/app/../etc/passwd', ['/app'])) + .rejects.toThrow('Path traversal detected'); + }); + + it('rejects URL-encoded traversal', async () => { + const fn = getValidateSecurePath(); + await expect(fn('/app/%2e%2e/etc/passwd', ['/app'])) + .rejects.toThrow('Path traversal detected'); + }); + + it('rejects path outside allowed roots', async () => { + const fn = getValidateSecurePath(); + mockRealpath.mockResolvedValue('/other/place/file.txt'); + await expect(fn('/other/place/file.txt', ['/app/data'])) + .rejects.toThrow('outside allowed directories'); + }); + + it('logs audit event when path is blocked', async () => { + const fn = getValidateSecurePath(); + const auditLogger = { logSecurityEvent: jest.fn() }; + await expect(fn('/app/data\0evil', ['/app'], auditLogger)) + .rejects.toThrow(); + expect(auditLogger.logSecurityEvent).toHaveBeenCalledWith( + 'path_traversal_blocked', + expect.objectContaining({ reason: 'null_byte_detected', severity: 'high' }) + ); + }); + + it('handles ENOENT by checking parent', async () => { + const fn = getValidateSecurePath(); + mockRealpath + .mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + .mockResolvedValueOnce('/app/data'); // parent resolves + const result = await fn('/app/data/newfile.txt', ['/app/data']); + expect(result).toContain('newfile.txt'); + }); + + it('handles EACCES with access denied error', async () => { + const fn = getValidateSecurePath(); + mockRealpath.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' })); + await expect(fn('/secret/file', ['/secret'])) + .rejects.toThrow('Access denied'); + }); + + it('rejects when no allowed roots configured', async () => { + const fn = getValidateSecurePath(); + await expect(fn('/app/file', [])).rejects.toThrow('No allowed roots configured'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/jest.setup.js b/dashcaddy-api/__tests__/jest.setup.js new file mode 100644 index 0000000..ec946df --- /dev/null +++ b/dashcaddy-api/__tests__/jest.setup.js @@ -0,0 +1,14 @@ +// Jest setup file +// Runs before all tests + +// Suppress console output during tests unless there's a failure +global.console = { + ...console, + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +}; + +// Increase timeout for slow operations +jest.setTimeout(15000); diff --git a/dashcaddy-api/__tests__/pagination.test.js b/dashcaddy-api/__tests__/pagination.test.js new file mode 100644 index 0000000..26ab26f --- /dev/null +++ b/dashcaddy-api/__tests__/pagination.test.js @@ -0,0 +1,116 @@ +const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../pagination'); + +describe('Pagination — DashCaddy list endpoints', () => { + + describe('parsePaginationParams', () => { + it('returns null when no pagination params (backward compat — full list)', () => { + expect(parsePaginationParams({})).toBeNull(); + expect(parsePaginationParams({ search: 'plex' })).toBeNull(); + }); + + it('parses page and limit from query', () => { + const params = parsePaginationParams({ page: '2', limit: '10' }); + expect(params).toEqual({ page: 2, limit: 10 }); + }); + + it('defaults page to 1', () => { + expect(parsePaginationParams({ limit: '25' })).toEqual({ page: 1, limit: 25 }); + }); + + it('defaults limit to DEFAULT_LIMIT when only page given', () => { + expect(parsePaginationParams({ page: '3' })).toEqual({ page: 3, limit: DEFAULT_LIMIT }); + }); + + it('clamps page to minimum 1', () => { + expect(parsePaginationParams({ page: '0' }).page).toBe(1); + expect(parsePaginationParams({ page: '-5' }).page).toBe(1); + }); + + it('treats limit 0 as default (parseInt falsy → DEFAULT_LIMIT)', () => { + expect(parsePaginationParams({ limit: '0' }).limit).toBe(DEFAULT_LIMIT); + }); + + it('clamps negative limit to minimum 1', () => { + expect(parsePaginationParams({ limit: '-10' }).limit).toBe(1); + }); + + it('clamps limit to MAX_LIMIT', () => { + expect(parsePaginationParams({ limit: '9999' }).limit).toBe(MAX_LIMIT); + }); + + it('handles NaN gracefully', () => { + const params = parsePaginationParams({ page: 'abc', limit: 'xyz' }); + expect(params.page).toBe(1); + expect(params.limit).toBe(DEFAULT_LIMIT); + }); + }); + + describe('paginate', () => { + const items = Array.from({ length: 55 }, (_, i) => ({ id: `svc-${i + 1}` })); + + it('returns all items when params is null (no pagination)', () => { + const result = paginate(items, null); + expect(result.data).toHaveLength(55); + expect(result.pagination).toBeUndefined(); + }); + + it('returns first page correctly', () => { + const result = paginate(items, { page: 1, limit: 10 }); + expect(result.data).toHaveLength(10); + expect(result.data[0].id).toBe('svc-1'); + expect(result.pagination.page).toBe(1); + expect(result.pagination.total).toBe(55); + expect(result.pagination.totalPages).toBe(6); + expect(result.pagination.hasMore).toBe(true); + }); + + it('returns last page with fewer items', () => { + const result = paginate(items, { page: 6, limit: 10 }); + expect(result.data).toHaveLength(5); // 55 - 50 = 5 remaining + expect(result.data[0].id).toBe('svc-51'); + expect(result.pagination.hasMore).toBe(false); + }); + + it('returns empty array for page beyond total', () => { + const result = paginate(items, { page: 100, limit: 10 }); + expect(result.data).toHaveLength(0); + expect(result.pagination.hasMore).toBe(false); + }); + + it('handles empty list', () => { + const result = paginate([], { page: 1, limit: 10 }); + expect(result.data).toHaveLength(0); + expect(result.pagination.total).toBe(0); + expect(result.pagination.totalPages).toBe(0); + }); + + it('single-page result when limit exceeds total', () => { + const result = paginate(items, { page: 1, limit: 100 }); + expect(result.data).toHaveLength(55); + expect(result.pagination.totalPages).toBe(1); + expect(result.pagination.hasMore).toBe(false); + }); + }); + + describe('Real DashCaddy scenario: 52 app templates paginated', () => { + const templates = Array.from({ length: 52 }, (_, i) => ({ + id: `app-${i}`, + name: `App ${i}`, + category: i < 10 ? 'Media' : 'Utilities' + })); + + it('default limit (50) shows first 50 apps with hasMore', () => { + const params = parsePaginationParams({ page: '1' }); + const result = paginate(templates, params); + expect(result.data).toHaveLength(50); + expect(result.pagination.hasMore).toBe(true); + }); + + it('page 2 shows remaining 2 apps', () => { + const params = parsePaginationParams({ page: '2' }); + const result = paginate(templates, params); + expect(result.data).toHaveLength(2); + expect(result.pagination.hasMore).toBe(false); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/platform-paths.test.js b/dashcaddy-api/__tests__/platform-paths.test.js new file mode 100644 index 0000000..bc7c3ff --- /dev/null +++ b/dashcaddy-api/__tests__/platform-paths.test.js @@ -0,0 +1,133 @@ +describe('Platform Paths — cross-platform path resolution', () => { + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + + afterEach(() => { + // Restore env + process.env = { ...originalEnv }; + jest.resetModules(); + }); + + function loadPaths() { + return require('../platform-paths'); + } + + describe('default paths on current platform', () => { + it('exports all required path properties', () => { + const paths = loadPaths(); + expect(paths).toHaveProperty('caddyBase'); + expect(paths).toHaveProperty('caddySites'); + expect(paths).toHaveProperty('dockerData'); + expect(paths).toHaveProperty('caddyfile'); + expect(paths).toHaveProperty('caddyAdminUrl'); + expect(paths).toHaveProperty('servicesFile'); + expect(paths).toHaveProperty('configFile'); + expect(paths).toHaveProperty('dnsCredentialsFile'); + expect(paths).toHaveProperty('caCertDir'); + expect(paths).toHaveProperty('pkiRootCert'); + expect(paths).toHaveProperty('sitePath'); + expect(paths).toHaveProperty('appData'); + expect(paths).toHaveProperty('isWindows'); + expect(paths).toHaveProperty('isLinux'); + }); + + it('sitePath returns path under caddySites', () => { + const paths = loadPaths(); + const result = paths.sitePath('plex'); + expect(result).toContain('plex'); + const norm = p => p.replace(/\\/g, '/'); + expect(norm(result)).toContain(norm(paths.caddySites)); + }); + + it('appData returns path under dockerData', () => { + const paths = loadPaths(); + const result = paths.appData('radarr'); + expect(result).toContain('radarr'); + const norm = p => p.replace(/\\/g, '/'); + expect(norm(result)).toContain(norm(paths.dockerData)); + }); + }); + + describe('environment variable overrides', () => { + it('CADDY_BASE overrides caddyBase', () => { + process.env.CADDY_BASE = '/custom/caddy'; + const paths = loadPaths(); + expect(paths.caddyBase).toBe('/custom/caddy'); + }); + + it('DOCKER_DATA overrides dockerData', () => { + process.env.DOCKER_DATA = '/custom/docker'; + const paths = loadPaths(); + expect(paths.dockerData).toBe('/custom/docker'); + }); + + it('CADDYFILE_PATH overrides caddyfile', () => { + process.env.CADDYFILE_PATH = '/custom/Caddyfile'; + const paths = loadPaths(); + expect(paths.caddyfile).toBe('/custom/Caddyfile'); + }); + + it('CADDY_ADMIN_URL overrides caddyAdminUrl', () => { + process.env.CADDY_ADMIN_URL = 'http://custom:9999'; + const paths = loadPaths(); + expect(paths.caddyAdminUrl).toBe('http://custom:9999'); + }); + + it('SERVICES_FILE overrides servicesFile', () => { + process.env.SERVICES_FILE = '/custom/services.json'; + const paths = loadPaths(); + expect(paths.servicesFile).toBe('/custom/services.json'); + }); + }); + + describe('toDockerMountPath', () => { + it('passes through Unix paths unchanged', () => { + const paths = loadPaths(); + if (!paths.isWindows) { + expect(paths.toDockerMountPath('/opt/dockerdata/plex')).toBe('/opt/dockerdata/plex'); + } + }); + + if (process.platform === 'win32') { + it('converts Windows drive paths to Docker mount format', () => { + const paths = loadPaths(); + expect(paths.toDockerMountPath('C:/caddy/Caddyfile')).toBe('//mnt/host/c/caddy/Caddyfile'); + expect(paths.toDockerMountPath('E:/dockerdata/plex')).toBe('//mnt/host/e/dockerdata/plex'); + }); + + it('converts backslash paths', () => { + const paths = loadPaths(); + expect(paths.toDockerMountPath('C:\\caddy\\Caddyfile')).toBe('//mnt/host/c/caddy/Caddyfile'); + }); + + it('passes through already-converted paths', () => { + const paths = loadPaths(); + expect(paths.toDockerMountPath('//mnt/host/c/foo')).toBe('//mnt/host/c/foo'); + }); + + it('passes through Unix paths on Windows (container internal paths)', () => { + const paths = loadPaths(); + expect(paths.toDockerMountPath('/app/services.json')).toBe('/app/services.json'); + }); + } + }); + + describe('Windows-specific defaults', () => { + if (process.platform === 'win32') { + it('caddyBase defaults to C:/caddy', () => { + const paths = loadPaths(); + expect(paths.caddyBase).toBe('C:/caddy'); + }); + + it('dockerData defaults to E:/dockerdata (network share)', () => { + const paths = loadPaths(); + expect(paths.dockerData).toBe('E:/dockerdata'); + }); + + it('caddyAdminUrl defaults to host.docker.internal (Docker Desktop)', () => { + const paths = loadPaths(); + expect(paths.caddyAdminUrl).toContain('host.docker.internal'); + }); + } + }); +}); diff --git a/dashcaddy-api/__tests__/port-lock-manager.test.js b/dashcaddy-api/__tests__/port-lock-manager.test.js new file mode 100644 index 0000000..2b2df49 --- /dev/null +++ b/dashcaddy-api/__tests__/port-lock-manager.test.js @@ -0,0 +1,272 @@ +// Port Lock Manager Tests +// Validates atomic port allocation for concurrent Docker deployments + +jest.mock('proper-lockfile'); +jest.mock('fs'); + +const fs = require('fs'); +const lockfile = require('proper-lockfile'); + +// Setup defaults BEFORE requiring singleton (constructor calls ensureLockDirectory) +fs.existsSync.mockReturnValue(true); +fs.mkdirSync.mockReturnValue(undefined); +fs.writeFileSync.mockReturnValue(undefined); +fs.readdirSync.mockReturnValue([]); +fs.unlinkSync.mockReturnValue(undefined); +lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); +lockfile.check.mockResolvedValue(false); + +const portLockManager = require('../port-lock-manager'); + +beforeEach(() => { + jest.clearAllMocks(); + portLockManager.activeLocks.clear(); + + // Restore defaults + fs.existsSync.mockReturnValue(true); + fs.mkdirSync.mockReturnValue(undefined); + fs.writeFileSync.mockReturnValue(undefined); + fs.readdirSync.mockReturnValue([]); + fs.unlinkSync.mockReturnValue(undefined); + lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); + lockfile.check.mockResolvedValue(false); +}); + +describe('PortLockManager — concurrent deploy safety', () => { + + describe('acquirePorts', () => { + it('rejects empty array', async () => { + await expect(portLockManager.acquirePorts([])).rejects.toThrow('non-empty array'); + }); + + it('rejects non-array', async () => { + await expect(portLockManager.acquirePorts('8080')).rejects.toThrow('non-empty array'); + }); + + it('acquires lock for a single port', async () => { + const mockRelease = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(mockRelease); + + const lockId = await portLockManager.acquirePorts(['8080']); + expect(lockId).toMatch(/^lock-/); + expect(lockfile.lock).toHaveBeenCalledTimes(1); + }); + + it('acquires locks for multiple ports in sorted order (deadlock prevention)', async () => { + const callOrder = []; + lockfile.lock.mockImplementation((filePath) => { + callOrder.push(filePath); + return Promise.resolve(jest.fn().mockResolvedValue()); + }); + + await portLockManager.acquirePorts(['9090', '3001', '8080']); + + // Ports sorted numerically: 3001, 8080, 9090 + expect(callOrder[0]).toContain('port-3001.lock'); + expect(callOrder[1]).toContain('port-8080.lock'); + expect(callOrder[2]).toContain('port-9090.lock'); + }); + + it('deduplicates ports', async () => { + await portLockManager.acquirePorts(['8080', '8080', '8080']); + expect(lockfile.lock).toHaveBeenCalledTimes(1); + }); + + it('creates lock file for new ports', async () => { + fs.existsSync.mockReturnValue(false); + await portLockManager.acquirePorts(['7878']); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('port-7878.lock'), + expect.stringContaining('"port"') + ); + }); + + it('stores lock in activeLocks map', async () => { + const lockId = await portLockManager.acquirePorts(['8080']); + const status = portLockManager.getStatus(); + expect(status.activeLocks).toBe(1); + expect(status.locks[0].lockId).toBe(lockId); + expect(status.locks[0].ports).toEqual(['8080']); + }); + + it('rolls back on partial failure — releases acquired locks', async () => { + const released = []; + let callCount = 0; + lockfile.lock.mockImplementation(() => { + callCount++; + if (callCount === 2) return Promise.reject(new Error('Port in use')); + const release = jest.fn().mockImplementation(() => { + released.push(callCount); + return Promise.resolve(); + }); + return Promise.resolve(release); + }); + + await expect(portLockManager.acquirePorts(['3001', '8080'])) + .rejects.toThrow('Failed to acquire port locks'); + + // First lock should have been released during rollback + expect(released.length).toBe(1); + }); + }); + + describe('releasePorts', () => { + it('releases all locks for a lock ID', async () => { + const mockRelease = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(mockRelease); + + const lockId = await portLockManager.acquirePorts(['8080', '9090']); + await portLockManager.releasePorts(lockId); + + expect(mockRelease).toHaveBeenCalledTimes(2); + expect(portLockManager.getStatus().activeLocks).toBe(0); + }); + + it('handles already-released lock ID gracefully', async () => { + // Should not throw + await portLockManager.releasePorts('nonexistent-lock-id'); + }); + + it('continues releasing remaining locks if one fails', async () => { + const releases = [ + jest.fn().mockRejectedValue(new Error('release error')), + jest.fn().mockResolvedValue(), + ]; + let callIdx = 0; + lockfile.lock.mockImplementation(() => { + return Promise.resolve(releases[callIdx++]); + }); + + const lockId = await portLockManager.acquirePorts(['3001', '8080']); + await portLockManager.releasePorts(lockId); + + // Both should have been called despite first failure + expect(releases[0]).toHaveBeenCalled(); + expect(releases[1]).toHaveBeenCalled(); + expect(portLockManager.getStatus().activeLocks).toBe(0); + }); + }); + + describe('isPortLocked', () => { + it('returns false when lock file does not exist', async () => { + fs.existsSync.mockReturnValue(false); + const result = await portLockManager.isPortLocked('8080'); + expect(result).toBe(false); + }); + + it('returns true when port is actively locked', async () => { + fs.existsSync.mockReturnValue(true); + lockfile.check.mockResolvedValue(true); + const result = await portLockManager.isPortLocked('8080'); + expect(result).toBe(true); + }); + + it('returns false when port lock is stale', async () => { + fs.existsSync.mockReturnValue(true); + lockfile.check.mockResolvedValue(false); + const result = await portLockManager.isPortLocked('8080'); + expect(result).toBe(false); + }); + + it('returns false on check error (fail-open for deployments)', async () => { + fs.existsSync.mockReturnValue(true); + lockfile.check.mockRejectedValue(new Error('check error')); + const result = await portLockManager.isPortLocked('8080'); + expect(result).toBe(false); + }); + }); + + describe('getStatus', () => { + it('returns empty state when no locks active', () => { + const status = portLockManager.getStatus(); + expect(status.activeLocks).toBe(0); + expect(status.locks).toEqual([]); + expect(status.lockDirectory).toContain('.port-locks'); + }); + + it('includes age and timestamp for active locks', async () => { + await portLockManager.acquirePorts(['8080']); + const status = portLockManager.getStatus(); + expect(status.activeLocks).toBe(1); + expect(status.locks[0].age).toBeGreaterThanOrEqual(0); + expect(status.locks[0].timestamp).toBeDefined(); + }); + }); + + describe('cleanupStaleLocks', () => { + it('removes stale lock files (not actively locked)', async () => { + fs.readdirSync.mockReturnValue(['port-8080.lock', 'port-9090.lock']); + lockfile.check.mockResolvedValue(false); // not locked = stale + + await portLockManager.cleanupStaleLocks(); + + expect(fs.unlinkSync).toHaveBeenCalledTimes(2); + }); + + it('skips actively locked files', async () => { + fs.readdirSync.mockReturnValue(['port-8080.lock']); + lockfile.check.mockResolvedValue(true); // actively locked + + await portLockManager.cleanupStaleLocks(); + + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + + it('skips non-.lock files', async () => { + fs.readdirSync.mockReturnValue(['readme.txt', 'port-8080.lock']); + lockfile.check.mockResolvedValue(false); + + await portLockManager.cleanupStaleLocks(); + + expect(fs.unlinkSync).toHaveBeenCalledTimes(1); + }); + + it('handles ENOENT errors gracefully', async () => { + fs.readdirSync.mockReturnValue(['port-8080.lock']); + const enoent = new Error('ENOENT'); + enoent.code = 'ENOENT'; + lockfile.check.mockRejectedValue(enoent); + + // Should not throw + await portLockManager.cleanupStaleLocks(); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + }); + + describe('DashCaddy deployment scenarios', () => { + it('Radarr deploy: locks host port 7878', async () => { + await portLockManager.acquirePorts(['7878']); + expect(lockfile.lock).toHaveBeenCalledWith( + expect.stringContaining('port-7878.lock'), + expect.any(Object) + ); + }); + + it('Plex deploy: locks multiple ports (32400, 1900, 8324, 32469)', async () => { + const plexPorts = ['32400', '1900', '8324', '32469']; + await portLockManager.acquirePorts(plexPorts); + expect(lockfile.lock).toHaveBeenCalledTimes(4); + }); + + it('concurrent deploys: second deploy gets separate lock ID', async () => { + const release1 = jest.fn().mockResolvedValue(); + const release2 = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValueOnce(release1).mockResolvedValueOnce(release2); + + const lockId1 = await portLockManager.acquirePorts(['8080']); + const lockId2 = await portLockManager.acquirePorts(['9090']); + + expect(lockId1).not.toBe(lockId2); + expect(portLockManager.getStatus().activeLocks).toBe(2); + }); + + it('deploy cleanup: release after container start', async () => { + const lockId = await portLockManager.acquirePorts(['7878']); + expect(portLockManager.getStatus().activeLocks).toBe(1); + + // Simulate container started successfully + await portLockManager.releasePorts(lockId); + expect(portLockManager.getStatus().activeLocks).toBe(0); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/resource-monitor.test.js b/dashcaddy-api/__tests__/resource-monitor.test.js new file mode 100644 index 0000000..27417d4 --- /dev/null +++ b/dashcaddy-api/__tests__/resource-monitor.test.js @@ -0,0 +1,472 @@ +// Resource Monitor Tests +// Validates container CPU/memory/disk/network tracking, alerts, and persistence + +jest.mock('dockerode'); +jest.mock('fs'); + +const fs = require('fs'); +const EventEmitter = require('events'); + +// Setup defaults BEFORE requiring singleton +fs.existsSync.mockReturnValue(false); +fs.readFileSync.mockReturnValue('{}'); +fs.writeFileSync.mockReturnValue(undefined); + +const resourceMonitor = require('../resource-monitor'); + +function makeStat(overrides = {}) { + return { + timestamp: new Date().toISOString(), + cpu: { percent: 15.5, usage: 500000 }, + memory: { usage: 536870912, limit: 2147483648, percent: 25.0, usageMB: 512, limitMB: 2048 }, + network: { rxBytes: 1048576, txBytes: 524288, rxMB: 1, txMB: 0.5 }, + disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 }, + pids: 42, + ...overrides + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue('{}'); + fs.writeFileSync.mockReturnValue(undefined); + + // Reset internal state + resourceMonitor.stats.clear(); + resourceMonitor.alerts.clear(); + resourceMonitor.lastAlerts.clear(); + resourceMonitor.monitoring = false; + if (resourceMonitor.monitoringInterval) { + clearInterval(resourceMonitor.monitoringInterval); + resourceMonitor.monitoringInterval = null; + } +}); + +afterEach(() => { + resourceMonitor.stop(); + jest.useRealTimers(); +}); + +describe('ResourceMonitor — container resource tracking', () => { + + describe('start/stop lifecycle', () => { + it('starts monitoring', () => { + resourceMonitor.start(); + expect(resourceMonitor.monitoring).toBe(true); + }); + + it('ignores double start', () => { + resourceMonitor.start(); + resourceMonitor.start(); + expect(resourceMonitor.monitoring).toBe(true); + }); + + it('stops monitoring and saves stats', () => { + resourceMonitor.start(); + resourceMonitor.stop(); + expect(resourceMonitor.monitoring).toBe(false); + expect(resourceMonitor.monitoringInterval).toBeNull(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('ignores stop when not monitoring', () => { + resourceMonitor.stop(); + expect(resourceMonitor.monitoring).toBe(false); + }); + }); + + describe('recordStats', () => { + it('creates new entry for unknown container', () => { + const stat = makeStat(); + resourceMonitor.recordStats('abc123', '/plex', stat); + expect(resourceMonitor.stats.has('abc123')).toBe(true); + expect(resourceMonitor.stats.get('abc123').history).toHaveLength(1); + }); + + it('appends to existing container history', () => { + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + expect(resourceMonitor.stats.get('abc123').history).toHaveLength(2); + }); + + it('updates container name if changed', () => { + resourceMonitor.recordStats('abc123', '/plex-old', makeStat()); + resourceMonitor.recordStats('abc123', '/plex-new', makeStat()); + expect(resourceMonitor.stats.get('abc123').name).toBe('/plex-new'); + }); + + it('trims stats older than retention period', () => { + const oldStat = makeStat({ timestamp: new Date(Date.now() - 999 * 60 * 60 * 1000).toISOString() }); + const newStat = makeStat(); + resourceMonitor.recordStats('abc123', '/plex', oldStat); + resourceMonitor.recordStats('abc123', '/plex', newStat); + // Old stat exceeds 168h (7 day) retention + expect(resourceMonitor.stats.get('abc123').history).toHaveLength(1); + }); + }); + + describe('getCurrentStats', () => { + it('returns null for unknown container', () => { + expect(resourceMonitor.getCurrentStats('unknown')).toBeNull(); + }); + + it('returns latest stat entry', () => { + const stat1 = makeStat({ cpu: { percent: 10, usage: 100 } }); + const stat2 = makeStat({ cpu: { percent: 50, usage: 500 } }); + resourceMonitor.recordStats('abc123', '/plex', stat1); + resourceMonitor.recordStats('abc123', '/plex', stat2); + expect(resourceMonitor.getCurrentStats('abc123').cpu.percent).toBe(50); + }); + }); + + describe('getHistoricalStats', () => { + it('returns empty array for unknown container', () => { + expect(resourceMonitor.getHistoricalStats('unknown')).toEqual([]); + }); + + it('filters by time window', () => { + const recentStat = makeStat(); + const oldStat = makeStat({ timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString() }); + + resourceMonitor.stats.set('abc123', { + name: '/plex', + history: [oldStat, recentStat] + }); + + // Only last 24 hours + const result = resourceMonitor.getHistoricalStats('abc123', 24); + expect(result).toHaveLength(1); + }); + }); + + describe('getAggregatedStats', () => { + it('returns null for unknown container', () => { + expect(resourceMonitor.getAggregatedStats('unknown')).toBeNull(); + }); + + it('calculates min/max/avg for CPU and memory', () => { + const stats = [ + makeStat({ cpu: { percent: 10, usage: 100 }, memory: { percent: 20, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }), + makeStat({ cpu: { percent: 30, usage: 300 }, memory: { percent: 40, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }), + makeStat({ cpu: { percent: 50, usage: 500 }, memory: { percent: 60, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }), + ]; + resourceMonitor.stats.set('abc123', { name: '/plex', history: stats }); + + const agg = resourceMonitor.getAggregatedStats('abc123', 24); + expect(agg.cpu.min).toBe(10); + expect(agg.cpu.max).toBe(50); + expect(agg.cpu.avg).toBe(30); + expect(agg.cpu.current).toBe(50); + expect(agg.memory.min).toBe(20); + expect(agg.memory.max).toBe(60); + expect(agg.dataPoints).toBe(3); + }); + }); + + describe('getAllStats', () => { + it('returns all containers with current and aggregated data', () => { + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + resourceMonitor.recordStats('def456', '/radarr', makeStat()); + + const all = resourceMonitor.getAllStats(); + expect(Object.keys(all)).toHaveLength(2); + expect(all['abc123'].name).toBe('/plex'); + expect(all['abc123'].current).toBeDefined(); + expect(all['abc123'].aggregated).toBeDefined(); + }); + }); + + describe('alert configuration', () => { + it('setAlertConfig stores config and persists', () => { + resourceMonitor.setAlertConfig('abc123', { + cpuThreshold: 80, + memoryThreshold: 90, + cooldownMinutes: 30 + }); + + const config = resourceMonitor.getAlertConfig('abc123'); + expect(config.enabled).toBe(true); + expect(config.cpuThreshold).toBe(80); + expect(config.memoryThreshold).toBe(90); + expect(config.cooldownMinutes).toBe(30); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('returns null for unconfigured container', () => { + expect(resourceMonitor.getAlertConfig('unknown')).toBeNull(); + }); + + it('removeAlertConfig clears config and cooldown', () => { + resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 }); + resourceMonitor.lastAlerts.set('abc123', Date.now()); + resourceMonitor.removeAlertConfig('abc123'); + expect(resourceMonitor.getAlertConfig('abc123')).toBeNull(); + expect(resourceMonitor.lastAlerts.has('abc123')).toBe(false); + }); + }); + + describe('checkAlerts', () => { + it('emits alert when CPU exceeds threshold', () => { + const handler = jest.fn(); + resourceMonitor.on('alert', handler); + + resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 50, cooldownMinutes: 0 }); + const stat = makeStat({ cpu: { percent: 75, usage: 750 } }); + resourceMonitor.checkAlerts('abc123', '/plex', stat); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + containerId: 'abc123', + alerts: expect.arrayContaining([ + expect.objectContaining({ type: 'cpu', value: 75 }) + ]) + }) + ); + resourceMonitor.off('alert', handler); + }); + + it('emits alert when memory exceeds threshold', () => { + const handler = jest.fn(); + resourceMonitor.on('alert', handler); + + resourceMonitor.setAlertConfig('abc123', { memoryThreshold: 20, cooldownMinutes: 0 }); + const stat = makeStat({ memory: { percent: 80, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }); + resourceMonitor.checkAlerts('abc123', '/plex', stat); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + alerts: expect.arrayContaining([ + expect.objectContaining({ type: 'memory' }) + ]) + }) + ); + resourceMonitor.off('alert', handler); + }); + + it('emits alert when disk I/O exceeds threshold', () => { + const handler = jest.fn(); + resourceMonitor.on('alert', handler); + + resourceMonitor.setAlertConfig('abc123', { diskIOThreshold: 10, cooldownMinutes: 0 }); + const stat = makeStat({ disk: { readMB: 15, writeMB: 10, readBytes: 0, writeBytes: 0 } }); + resourceMonitor.checkAlerts('abc123', '/plex', stat); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + alerts: expect.arrayContaining([ + expect.objectContaining({ type: 'disk' }) + ]) + }) + ); + resourceMonitor.off('alert', handler); + }); + + it('respects cooldown period', () => { + const handler = jest.fn(); + resourceMonitor.on('alert', handler); + + resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 50, cooldownMinutes: 15 }); + resourceMonitor.lastAlerts.set('abc123', Date.now()); // Just alerted + + const stat = makeStat({ cpu: { percent: 99, usage: 990 } }); + resourceMonitor.checkAlerts('abc123', '/plex', stat); + + expect(handler).not.toHaveBeenCalled(); + resourceMonitor.off('alert', handler); + }); + + it('skips when alerts not configured or disabled', () => { + const handler = jest.fn(); + resourceMonitor.on('alert', handler); + + // No config + resourceMonitor.checkAlerts('abc123', '/plex', makeStat()); + expect(handler).not.toHaveBeenCalled(); + + // Disabled config + resourceMonitor.alerts.set('abc123', { enabled: false, cpuThreshold: 1 }); + resourceMonitor.checkAlerts('abc123', '/plex', makeStat()); + expect(handler).not.toHaveBeenCalled(); + + resourceMonitor.off('alert', handler); + }); + + it('does not alert when below thresholds', () => { + const handler = jest.fn(); + resourceMonitor.on('alert', handler); + + resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 90, memoryThreshold: 90, cooldownMinutes: 0 }); + const stat = makeStat({ cpu: { percent: 5, usage: 50 }, memory: { percent: 10, usage: 0, limit: 0, usageMB: 0, limitMB: 0 } }); + resourceMonitor.checkAlerts('abc123', '/plex', stat); + + expect(handler).not.toHaveBeenCalled(); + resourceMonitor.off('alert', handler); + }); + }); + + describe('cleanupOldStats', () => { + it('removes containers with no recent data', () => { + const oldStat = makeStat({ timestamp: new Date(Date.now() - 999 * 60 * 60 * 1000).toISOString() }); + resourceMonitor.stats.set('old-container', { name: '/old', history: [oldStat] }); + resourceMonitor.cleanupOldStats(); + expect(resourceMonitor.stats.has('old-container')).toBe(false); + }); + + it('keeps containers with recent data', () => { + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + resourceMonitor.cleanupOldStats(); + expect(resourceMonitor.stats.has('abc123')).toBe(true); + }); + }); + + describe('persistence (loadStats/saveStats)', () => { + it('loadStats populates from file', () => { + const savedData = { + 'abc123': { name: '/plex', history: [makeStat()] } + }; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(savedData)); + + resourceMonitor.loadStats(); + expect(resourceMonitor.stats.size).toBe(1); + }); + + it('loadStats handles missing file', () => { + fs.existsSync.mockReturnValue(false); + resourceMonitor.loadStats(); + expect(resourceMonitor.stats.size).toBe(0); + }); + + it('loadStats handles corrupt file', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('corrupt'); }); + resourceMonitor.loadStats(); // should not throw + }); + + it('saveStats writes Map as JSON object', () => { + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + resourceMonitor.saveStats(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('abc123') + ); + }); + + it('saveStats handles write error', () => { + fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + resourceMonitor.saveStats(); // should not throw + }); + }); + + describe('alert config persistence', () => { + it('loadAlertConfig populates from file', () => { + const config = { 'abc123': { enabled: true, cpuThreshold: 80 } }; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(config)); + + resourceMonitor.loadAlertConfig(); + expect(resourceMonitor.alerts.size).toBe(1); + }); + + it('saveAlertConfig writes alerts as JSON', () => { + resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 }); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('exportStats / importStats', () => { + it('exports stats and alerts', () => { + resourceMonitor.recordStats('abc123', '/plex', makeStat()); + resourceMonitor.setAlertConfig('abc123', { cpuThreshold: 80 }); + + const exported = resourceMonitor.exportStats(); + expect(exported.stats['abc123']).toBeDefined(); + expect(exported.alerts['abc123']).toBeDefined(); + expect(exported.exportedAt).toBeDefined(); + }); + + it('imports stats and alerts', () => { + const data = { + stats: { 'abc123': { name: '/plex', history: [makeStat()] } }, + alerts: { 'abc123': { enabled: true, cpuThreshold: 90 } } + }; + + resourceMonitor.importStats(data); + expect(resourceMonitor.stats.size).toBe(1); + expect(resourceMonitor.alerts.size).toBe(1); + // Should persist after import + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('getContainerStats (Docker stats parsing)', () => { + it('parses Docker stats into structured format', async () => { + const Docker = require('dockerode'); + const mockContainer = { + stats: jest.fn((opts, cb) => cb(null, { + cpu_stats: { + cpu_usage: { total_usage: 200000 }, + system_cpu_usage: 1000000 + }, + precpu_stats: { + cpu_usage: { total_usage: 100000 }, + system_cpu_usage: 500000 + }, + memory_stats: { + usage: 536870912, // 512MB + limit: 2147483648 // 2GB + }, + networks: { + eth0: { rx_bytes: 1048576, tx_bytes: 524288 } + }, + blkio_stats: { + io_service_bytes_recursive: [ + { op: 'Read', value: 1048576 }, + { op: 'Write', value: 2097152 } + ] + }, + pids_stats: { current: 42 } + })) + }; + + const result = await resourceMonitor.getContainerStats(mockContainer); + expect(result.cpu.percent).toBe(20); // (100000/500000) * 100 + expect(result.memory.usageMB).toBe(512); + expect(result.memory.limitMB).toBe(2048); + expect(result.memory.percent).toBe(25); + expect(result.network.rxMB).toBe(1); + expect(result.disk.readMB).toBe(1); + expect(result.disk.writeMB).toBe(2); + expect(result.pids).toBe(42); + }); + + it('handles missing network stats', async () => { + const mockContainer = { + stats: jest.fn((opts, cb) => cb(null, { + cpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 }, + precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 }, + memory_stats: { usage: 0, limit: 0 }, + blkio_stats: {}, + pids_stats: {} + })) + }; + + const result = await resourceMonitor.getContainerStats(mockContainer); + expect(result.network.rxBytes).toBe(0); + expect(result.network.txBytes).toBe(0); + expect(result.pids).toBe(0); + }); + + it('rejects on Docker error', async () => { + const mockContainer = { + stats: jest.fn((opts, cb) => cb(new Error('container gone'))) + }; + + await expect(resourceMonitor.getContainerStats(mockContainer)).rejects.toThrow('container gone'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/routes/containers.routes.test.js b/dashcaddy-api/__tests__/routes/containers.routes.test.js new file mode 100644 index 0000000..0f85da0 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/containers.routes.test.js @@ -0,0 +1,537 @@ +// Container Routes Tests +// Validates container lifecycle operations (start/stop/restart/update/delete/discover) + +const express = require('express'); +const request = require('supertest'); + +// Build a test app with the containers route +function buildApp(mockDeps) { + const app = express(); + app.use(express.json()); + + const { errorMiddleware } = require('../../error-handler'); + const containersRouteFactory = require('../../routes/containers'); + app.use('/api/containers', containersRouteFactory(mockDeps)); + app.use(errorMiddleware); + return app; +} + +// Mock container factory +function mockContainer(overrides = {}) { + return { + inspect: jest.fn().mockResolvedValue({ + Id: 'abc123def456', + Name: '/plex', + Config: { + Image: 'lscr.io/linuxserver/plex:latest', + Env: ['TZ=America/New_York', 'PLEX_CLAIM='], + ExposedPorts: { '32400/tcp': {} }, + Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' } + }, + Image: 'sha256:abc123', + HostConfig: { + Binds: ['E:/dockerdata/plex:/config'], + PortBindings: { '32400/tcp': [{ HostPort: '32400' }] }, + RestartPolicy: { Name: 'unless-stopped' }, + NetworkMode: 'bridge', + ExtraHosts: [], + Privileged: false, + CapAdd: null, + CapDrop: null, + Devices: [], + LogConfig: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '3' } }, + Memory: 2147483648, // 2GB + MemoryReservation: 1073741824, // 1GB + NanoCpus: 2000000000, // 2 cores + }, + NetworkSettings: { Networks: { bridge: {} } } + }), + start: jest.fn().mockResolvedValue(), + stop: jest.fn().mockResolvedValue(), + restart: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue(), + logs: jest.fn().mockResolvedValue(Buffer.from('2026-04-05T10:00:00Z Plex server started')), + ...overrides + }; +} + +function createMockDeps(containerInstance) { + const container = containerInstance || mockContainer(); + + return { + docker: { + client: { + getContainer: jest.fn().mockReturnValue(container), + createContainer: jest.fn().mockResolvedValue({ + start: jest.fn().mockResolvedValue(), + inspect: jest.fn().mockResolvedValue({ Id: 'new123' }), + remove: jest.fn().mockResolvedValue(), + }), + getImage: jest.fn().mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:olddigest'] }) + }), + listContainers: jest.fn().mockResolvedValue([]), + pruneImages: jest.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + }, + pull: jest.fn().mockResolvedValue([]), + }, + log: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + asyncHandler: (fn, name) => async (req, res, next) => { + try { await fn(req, res, next); } catch (err) { next(err); } + }, + }; +} + +describe('Container Routes — DashCaddy container lifecycle', () => { + + describe('POST /:id/start', () => { + it('starts a stopped container', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/start'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toContain('started'); + }); + + it('returns 404 for missing container', async () => { + const container = mockContainer(); + const notFound = new Error('no such container'); + notFound.statusCode = 404; + container.inspect.mockRejectedValue(notFound); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app).post('/api/containers/missing123/start'); + expect(res.status).toBe(404); + }); + }); + + describe('POST /:id/stop', () => { + it('stops a running container', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/stop'); + expect(res.status).toBe(200); + expect(res.body.message).toContain('stopped'); + }); + }); + + describe('POST /:id/restart', () => { + it('restarts a container', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/restart'); + expect(res.status).toBe(200); + expect(res.body.message).toContain('restarted'); + }); + }); + + describe('GET /:id/logs', () => { + it('returns last 100 log lines', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/logs'); + expect(res.status).toBe(200); + expect(res.body.logs).toContain('Plex server started'); + }); + }); + + describe('PUT /:id/resources', () => { + it('updates memory and CPU limits', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app) + .put('/api/containers/abc123/resources') + .send({ memory: 4096, cpus: 4 }); + + expect(res.status).toBe(200); + expect(container.update).toHaveBeenCalledWith( + expect.objectContaining({ + Memory: 4096 * 1024 * 1024, + NanoCpus: 4 * 1e9, + }) + ); + }); + + it('sets 0 for unlimited', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app) + .put('/api/containers/abc123/resources') + .send({ memory: 0, cpus: 0 }); + + expect(res.status).toBe(200); + expect(container.update).toHaveBeenCalledWith( + expect.objectContaining({ + Memory: 0, + NanoCpus: 0, + }) + ); + }); + }); + + describe('GET /:id/resources', () => { + it('returns current resource limits in human units', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + + const res = await request(app).get('/api/containers/abc123/resources'); + expect(res.status).toBe(200); + expect(res.body.memory).toBe(2048); // 2GB in MB + expect(res.body.memoryReservation).toBe(1024); // 1GB in MB + expect(res.body.cpus).toBe(2); // 2 cores + }); + }); + + describe('DELETE /:id', () => { + it('force-removes a container', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app).delete('/api/containers/abc123'); + expect(res.status).toBe(200); + expect(container.remove).toHaveBeenCalledWith({ force: true }); + }); + }); + + describe('GET /discover', () => { + it('returns only sami.managed containers', async () => { + const deps = createMockDeps(); + deps.docker.client.listContainers.mockResolvedValue([ + { + Id: 'abc123', Names: ['/plex'], Image: 'linuxserver/plex', + State: 'running', Status: 'Up 3 days', + Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' }, + Ports: [{ PrivatePort: 32400, PublicPort: 32400 }] + }, + { + Id: 'xyz789', Names: ['/random-container'], Image: 'nginx', + State: 'running', Status: 'Up 1 hour', + Labels: {}, + Ports: [{ PrivatePort: 80, PublicPort: 80 }] + } + ]); + const app = buildApp(deps); + + const res = await request(app).get('/api/containers/discover'); + expect(res.status).toBe(200); + expect(res.body.containers).toHaveLength(1); + expect(res.body.containers[0].appTemplate).toBe('plex'); + }); + + it('returns empty array when no managed containers', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + const res = await request(app).get('/api/containers/discover'); + expect(res.body.containers).toEqual([]); + }); + }); + + describe('POST /:id/update — error and edge cases', () => { + it('preserves custom network mode (non-bridge/host/none)', async () => { + const container = mockContainer(); + container.inspect.mockResolvedValue({ + Id: 'abc123', Name: '/plex', + Config: { Image: 'plex:latest', Env: [], ExposedPorts: {}, Labels: {} }, + Image: 'sha256:abc', + HostConfig: { + Binds: [], PortBindings: {}, RestartPolicy: { Name: 'unless-stopped' }, + NetworkMode: 'my-custom-network', + ExtraHosts: [], Privileged: false, CapAdd: null, CapDrop: null, Devices: [] + }, + NetworkSettings: { Networks: { 'my-custom-network': { IPAddress: '172.20.0.5' } } } + }); + const newContainer = { + start: jest.fn().mockResolvedValue(), + inspect: jest.fn().mockResolvedValue({ Id: 'new123' }) + }; + const deps = createMockDeps(container); + deps.docker.client.createContainer.mockResolvedValue(newContainer); + + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/update'); + expect(res.status).toBe(200); + + const createCall = deps.docker.client.createContainer.mock.calls[0][0]; + expect(createCall.NetworkingConfig.EndpointsConfig['my-custom-network']) + .toEqual({ IPAddress: '172.20.0.5' }); + }); + + it('cleans up failed new container when start fails', async () => { + const container = mockContainer(); + const newContainer = { + start: jest.fn().mockRejectedValue(new Error('port already allocated')), + remove: jest.fn().mockResolvedValue() + }; + const deps = createMockDeps(container); + deps.docker.client.createContainer.mockResolvedValue(newContainer); + + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/update'); + + expect(res.status).toBeGreaterThanOrEqual(500); + expect(newContainer.remove).toHaveBeenCalledWith({ force: true }); + }); + + it('handles new container remove cleanup failure gracefully', async () => { + const container = mockContainer(); + const newContainer = { + start: jest.fn().mockRejectedValue(new Error('start failed')), + remove: jest.fn().mockRejectedValue(new Error('already gone')) + }; + const deps = createMockDeps(container); + deps.docker.client.createContainer.mockResolvedValue(newContainer); + + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/update'); + expect(res.status).toBeGreaterThanOrEqual(500); + }); + + it('logs space reclaimed when image prune frees disk', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + deps.docker.client.pruneImages.mockResolvedValue({ SpaceReclaimed: 50 * 1024 * 1024 }); // 50MB + + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/update'); + expect(res.status).toBe(200); + expect(deps.log.info).toHaveBeenCalledWith( + 'docker', + 'Pruned dangling images after update', + expect.objectContaining({ spaceReclaimed: '50MB' }) + ); + }); + + it('continues if image prune fails', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + deps.docker.client.pruneImages.mockRejectedValue(new Error('prune failed')); + + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/update'); + expect(res.status).toBe(200); + expect(deps.log.debug).toHaveBeenCalledWith( + 'docker', + 'Image prune after update failed', + expect.any(Object) + ); + }); + + it('ignores already-stopped error when stopping container', async () => { + const container = mockContainer(); + container.stop.mockRejectedValue(new Error('container already stopped')); + const deps = createMockDeps(container); + + const app = buildApp(deps); + const res = await request(app).post('/api/containers/abc123/update'); + expect(res.status).toBe(200); + }); + }); + + describe('GET /:id/check-update', () => { + it('reports no updates when local and new digests match', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + deps.docker.client.getImage.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:samedigest'] }) + }); + deps.docker.pull.mockResolvedValue([]); + + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/check-update'); + expect(res.status).toBe(200); + expect(res.body.updateAvailable).toBe(false); + }); + + it('reports update available when downloads occur', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + deps.docker.pull.mockResolvedValue([ + { status: 'Downloading', id: 'layer1' }, + { status: 'Download complete', id: 'layer2' } + ]); + + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/check-update'); + expect(res.body.updateAvailable).toBe(true); + }); + + it('reports update available when digests differ', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + let callCount = 0; + deps.docker.client.getImage.mockImplementation(() => { + callCount++; + return { + inspect: jest.fn().mockResolvedValue({ + RepoDigests: callCount === 1 + ? ['sha256:olddigest'] + : ['sha256:newdigest'] + }) + }; + }); + deps.docker.pull.mockResolvedValue([]); + + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/check-update'); + expect(res.body.updateAvailable).toBe(true); + }); + + it('returns false when pull throws (registry unreachable)', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + deps.docker.pull.mockRejectedValue(new Error('registry timeout')); + + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/check-update'); + expect(res.status).toBe(200); + expect(res.body.updateAvailable).toBe(false); + }); + + it('handles missing local repo digests gracefully', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + deps.docker.client.getImage.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ RepoDigests: null }) + }); + + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/check-update'); + expect(res.status).toBe(200); + expect(res.body.currentDigest).toBeNull(); + }); + }); + + describe('getVerifiedContainer error paths', () => { + it('returns 404 when error message includes "no such container"', async () => { + const container = mockContainer(); + container.inspect.mockRejectedValue(new Error('Error: no such container: missing')); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app).post('/api/containers/missing/start'); + expect(res.status).toBe(404); + }); + + it('rethrows non-404 errors from inspect', async () => { + const container = mockContainer(); + container.inspect.mockRejectedValue(new Error('docker daemon not running')); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app).post('/api/containers/abc123/start'); + expect(res.status).toBeGreaterThanOrEqual(500); + }); + }); + + describe('PUT /:id/resources — partial updates', () => { + it('updates only memory when cpus omitted', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app) + .put('/api/containers/abc123/resources') + .send({ memory: 2048 }); + + expect(res.status).toBe(200); + const call = container.update.mock.calls[0][0]; + expect(call.Memory).toBe(2048 * 1024 * 1024); + expect(call.NanoCpus).toBeUndefined(); + }); + + it('updates only cpus when memory omitted', async () => { + const container = mockContainer(); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app) + .put('/api/containers/abc123/resources') + .send({ cpus: 1.5 }); + + expect(res.status).toBe(200); + const call = container.update.mock.calls[0][0]; + expect(call.NanoCpus).toBe(1.5 * 1e9); + expect(call.Memory).toBeUndefined(); + }); + }); + + describe('GET /:id/resources — zero values', () => { + it('returns 0 when no limits set', async () => { + const container = mockContainer(); + container.inspect.mockResolvedValue({ + Id: 'abc', Name: '/test', Config: { Image: 'test:latest' }, + HostConfig: { Memory: 0, MemoryReservation: 0, NanoCpus: 0 } + }); + const deps = createMockDeps(container); + const app = buildApp(deps); + + const res = await request(app).get('/api/containers/abc123/resources'); + expect(res.body.memory).toBe(0); + expect(res.body.memoryReservation).toBe(0); + expect(res.body.cpus).toBe(0); + }); + }); + + describe('GET /discover — pagination', () => { + it('paginates results when paginate query params provided', async () => { + const containers = Array.from({ length: 25 }, (_, i) => ({ + Id: `id${i}`, + Names: [`/svc${i}`], + Image: 'test:latest', + State: 'running', + Status: 'Up', + Labels: { 'sami.managed': 'true', 'sami.app': 'test', 'sami.subdomain': `svc${i}` }, + Ports: [] + })); + const deps = createMockDeps(); + deps.docker.client.listContainers.mockResolvedValue(containers); + const app = buildApp(deps); + + const res = await request(app).get('/api/containers/discover?page=1&limit=10'); + expect(res.status).toBe(200); + expect(res.body.containers.length).toBeLessThanOrEqual(10); + }); + }); + + describe('DashCaddy-specific scenarios', () => { + it('Plex container: verifies correct resource read (2GB, 2 cores)', async () => { + const deps = createMockDeps(); + const app = buildApp(deps); + const res = await request(app).get('/api/containers/abc123/resources'); + expect(res.body.memory).toBe(2048); + expect(res.body.cpus).toBe(2); + }); + + it('container update: preserves Env, PortBindings, RestartPolicy', async () => { + const container = mockContainer(); + const newContainer = { + start: jest.fn().mockResolvedValue(), + inspect: jest.fn().mockResolvedValue({ Id: 'new456' }), + remove: jest.fn().mockResolvedValue(), + }; + const deps = createMockDeps(container); + deps.docker.client.createContainer.mockResolvedValue(newContainer); + const app = buildApp(deps); + + const res = await request(app).post('/api/containers/abc123/update'); + expect(res.status).toBe(200); + + const createCall = deps.docker.client.createContainer.mock.calls[0][0]; + expect(createCall.Env).toContain('TZ=America/New_York'); + expect(createCall.HostConfig.PortBindings['32400/tcp']).toEqual([{ HostPort: '32400' }]); + expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: 'unless-stopped' }); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/routes/health.routes.test.js b/dashcaddy-api/__tests__/routes/health.routes.test.js new file mode 100644 index 0000000..ecd6521 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/health.routes.test.js @@ -0,0 +1,665 @@ +const express = require('express'); +const request = require('supertest'); + +// Minimal asyncHandler that catches errors +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +function createApp(depsOverride = {}) { + const defaultDeps = { + fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }), + SERVICES_FILE: '/tmp/services.json', + servicesStateManager: { + read: jest.fn().mockResolvedValue([]), + write: jest.fn().mockResolvedValue(), + update: jest.fn().mockResolvedValue([]), + }, + siteConfig: { tld: 'sami' }, + buildServiceUrl: jest.fn(id => `https://${id}.sami`), + asyncHandler, + logError: jest.fn(), + healthChecker: { + getCurrentStatus: jest.fn().mockReturnValue({}), + getServiceStats: jest.fn().mockReturnValue(null), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([]), + getIncidentHistory: jest.fn().mockReturnValue([]), + }, + }; + + const deps = { ...defaultDeps, ...depsOverride }; + const healthRoutes = require('../../routes/health'); + const app = express(); + app.use(express.json()); + app.use('/api', healthRoutes(deps)); + // Simple error handler + app.use((err, req, res, next) => { + const status = err.statusCode || 500; + res.status(status).json({ success: false, error: err.message }); + }); + return { app, deps }; +} + +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); + +jest.mock('../../platform-paths', () => ({ + caCertDir: '/mock/ca', + pkiRootCert: '/mock/pki/root.crt', +})); + +// Mock fs-helpers.exists +jest.mock('../../fs-helpers', () => ({ + exists: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../../url-resolver', () => ({ + resolveServiceUrl: jest.fn((id) => `https://${id}.test`), +})); + +jest.mock('../../pagination', () => ({ + paginate: jest.fn((data, params) => ({ data, pagination: null })), + parsePaginationParams: jest.fn(() => null), +})); + +const { exists } = require('../../fs-helpers'); +const { resolveServiceUrl } = require('../../url-resolver'); +const { execSync } = require('child_process'); + +describe('Health Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + exists.mockResolvedValue(true); + }); + + describe('GET /api/health/cached', () => { + it('returns cached health data with 200', async () => { + const { app } = createApp(); + const res = await request(app).get('/api/health/cached'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body).toHaveProperty('health'); + expect(res.body).toHaveProperty('lastCheck'); + }); + }); + + describe('GET /api/health/services', () => { + it('returns empty health when no services file', async () => { + exists.mockResolvedValue(false); + const { app } = createApp(); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.health).toEqual({}); + }); + + it('returns health for each service', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([ + { id: 'plex', name: 'Plex' }, + { id: 'radarr', name: 'Radarr' }, + ]), + }; + const fetchT = jest.fn().mockResolvedValue({ + ok: true, status: 200, json: () => ({}) + }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body).toHaveProperty('checkedAt'); + }); + }); + + describe('GET /api/health/service/:id', () => { + it('returns 404 when services file missing', async () => { + exists.mockResolvedValue(false); + const { app } = createApp(); + const res = await request(app).get('/api/health/service/plex'); + expect(res.status).toBe(404); + }); + + it('returns 404 when service not found', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'radarr', name: 'Radarr' }]), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app).get('/api/health/service/nonexistent'); + expect(res.status).toBe(404); + }); + + it('returns health for existing service', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), + }; + const fetchT = jest.fn().mockResolvedValue({ + ok: true, status: 200, json: () => ({}) + }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + }); + const res = await request(app).get('/api/health/service/plex'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.serviceId).toBe('plex'); + }); + }); + + describe('GET /api/health/pylon', () => { + it('returns configured:false when no pylon', async () => { + const { app } = createApp({ siteConfig: {} }); + const res = await request(app).get('/api/health/pylon'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(false); + }); + + it('returns reachable:true when pylon responds', async () => { + const fetchT = jest.fn().mockResolvedValue({ + ok: true, status: 200, json: () => ({ status: 'ok' }) + }); + const { app } = createApp({ + siteConfig: { pylon: { url: 'http://pylon.test' } }, + fetchT, + }); + const res = await request(app).get('/api/health/pylon'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.reachable).toBe(true); + }); + + it('returns reachable:false when pylon errors', async () => { + const fetchT = jest.fn().mockRejectedValue(new Error('Connection refused')); + const { app } = createApp({ + siteConfig: { pylon: { url: 'http://pylon.test' } }, + fetchT, + }); + const res = await request(app).get('/api/health/pylon'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.reachable).toBe(false); + }); + }); + + describe('GET /api/health-checks/status', () => { + it('returns current health checker status', async () => { + const healthChecker = { + getCurrentStatus: jest.fn().mockReturnValue({ + svc1: { status: 'up', responseTime: 100 } + }), + getServiceStats: jest.fn(), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([]), + getIncidentHistory: jest.fn().mockReturnValue([]), + }; + const { app } = createApp({ healthChecker }); + const res = await request(app).get('/api/health-checks/status'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.status.svc1.status).toBe('up'); + }); + }); + + describe('GET /api/health-checks/:serviceId/stats', () => { + it('returns 404 when service not found', async () => { + const { app } = createApp(); + const res = await request(app).get('/api/health-checks/unknown/stats'); + expect(res.status).toBe(404); + }); + + it('returns stats when service exists', async () => { + const healthChecker = { + getCurrentStatus: jest.fn().mockReturnValue({}), + getServiceStats: jest.fn().mockReturnValue({ + totalChecks: 100, uptime: 99.5 + }), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([]), + getIncidentHistory: jest.fn().mockReturnValue([]), + }; + const { app } = createApp({ healthChecker }); + const res = await request(app).get('/api/health-checks/svc1/stats'); + expect(res.status).toBe(200); + expect(res.body.stats.uptime).toBe(99.5); + }); + }); + + describe('POST /api/health-checks/:serviceId/configure', () => { + it('configures health check for service', async () => { + const healthChecker = { + getCurrentStatus: jest.fn().mockReturnValue({}), + getServiceStats: jest.fn(), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([]), + getIncidentHistory: jest.fn().mockReturnValue([]), + }; + const { app } = createApp({ healthChecker }); + const res = await request(app) + .post('/api/health-checks/svc1/configure') + .send({ url: 'http://test.local', timeout: 5000 }); + expect(res.status).toBe(200); + expect(healthChecker.configureService).toHaveBeenCalledWith('svc1', expect.objectContaining({ url: 'http://test.local' })); + }); + }); + + describe('DELETE /api/health-checks/:serviceId/configure', () => { + it('removes health check configuration', async () => { + const healthChecker = { + getCurrentStatus: jest.fn().mockReturnValue({}), + getServiceStats: jest.fn(), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([]), + getIncidentHistory: jest.fn().mockReturnValue([]), + }; + const { app } = createApp({ healthChecker }); + const res = await request(app).delete('/api/health-checks/svc1/configure'); + expect(res.status).toBe(200); + expect(healthChecker.removeService).toHaveBeenCalledWith('svc1'); + }); + }); + + describe('GET /api/health-checks/incidents', () => { + it('returns open incidents', async () => { + const healthChecker = { + getCurrentStatus: jest.fn().mockReturnValue({}), + getServiceStats: jest.fn(), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([ + { id: 'inc-1', serviceId: 'svc1', type: 'outage', status: 'open' } + ]), + getIncidentHistory: jest.fn().mockReturnValue([]), + }; + const { app } = createApp({ healthChecker }); + const res = await request(app).get('/api/health-checks/incidents'); + expect(res.status).toBe(200); + expect(res.body.incidents).toHaveLength(1); + expect(res.body.incidents[0].type).toBe('outage'); + }); + }); + + // ===== NEW TESTS FOR DEEPER COVERAGE ===== + + describe('GET /api/health/services (deeper scenarios)', () => { + it('falls back to pylon when direct check fails', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'myapp', name: 'MyApp' }]), + }; + // HEAD fails, GET fails, pylon succeeds + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) // HEAD in checkDirect + .mockRejectedValueOnce(new Error('GET failed')) // GET fallback in checkDirect + .mockResolvedValueOnce({ // pylon probe call + ok: true, + status: 200, + json: () => ({ status: 'healthy', statusCode: 200, responseTime: 42 }), + }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + siteConfig: { pylon: { url: 'http://pylon.test' } }, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.health.myapp).toBeDefined(); + expect(res.body.health.myapp.via).toBe('pylon'); + }); + + it('returns unhealthy when both direct and pylon fail', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'deadapp', name: 'DeadApp' }]), + }; + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) + .mockRejectedValueOnce(new Error('GET failed')) + .mockRejectedValueOnce(new Error('pylon failed')); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + siteConfig: { pylon: { url: 'http://pylon.test' } }, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.health.deadapp.status).toBe('unhealthy'); + expect(res.body.health.deadapp.reason).toMatch(/direct \+ pylon/); + }); + + it('returns unhealthy with "fetch failed" when direct fails and no pylon configured', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'deadapp', name: 'DeadApp' }]), + }; + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) + .mockRejectedValueOnce(new Error('GET failed')); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + siteConfig: {}, // no pylon + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.health.deadapp.status).toBe('unhealthy'); + expect(res.body.health.deadapp.reason).toBe('fetch failed'); + }); + + it('skips services without id or name', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([ + { id: 'valid', name: 'Valid' }, + { url: 'http://no-id-or-name.test' }, // no id, no name + ]), + }; + const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + // Only the valid service should appear + expect(Object.keys(res.body.health)).toEqual(['valid']); + }); + + it('returns unknown status when no URL configured for service', async () => { + resolveServiceUrl.mockReturnValueOnce(null); + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'nourl', name: 'NoUrl' }]), + }; + const { app } = createApp({ + servicesStateManager: stateManager, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.health.nourl.status).toBe('unknown'); + expect(res.body.health.nourl.reason).toBe('No URL configured'); + }); + + it('returns error status when exception occurs during check', async () => { + // resolveServiceUrl throws an error + resolveServiceUrl.mockImplementationOnce(() => { throw new Error('resolve boom'); }); + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'boom', name: 'Boom' }]), + }; + const { app } = createApp({ + servicesStateManager: stateManager, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.health.boom.status).toBe('error'); + expect(res.body.health.boom.reason).toBe('resolve boom'); + }); + + it('handles servicesData as object with .services property', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue({ + services: [{ id: 'wrapped', name: 'Wrapped' }], + }), + }; + const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.health.wrapped).toBeDefined(); + expect(res.body.health.wrapped.status).toBe('healthy'); + }); + + it('reports unhealthy when server returns 500+', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'err500', name: 'Err500' }]), + }; + const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 502, json: () => ({}) }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + }); + const res = await request(app).get('/api/health/services'); + expect(res.status).toBe(200); + expect(res.body.health.err500.status).toBe('unhealthy'); + expect(res.body.health.err500.statusCode).toBe(502); + }); + }); + + describe('GET /api/health/service/:id (pylon fallback)', () => { + it('falls back to pylon when direct fails', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), + }; + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) + .mockRejectedValueOnce(new Error('GET failed')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => ({ status: 'healthy', statusCode: 200, responseTime: 55 }), + }); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + siteConfig: { pylon: { url: 'http://pylon.test', key: 'secret123' } }, + }); + const res = await request(app).get('/api/health/service/plex'); + expect(res.status).toBe(200); + expect(res.body.health.via).toBe('pylon'); + expect(res.body.health.status).toBe('healthy'); + // Verify pylon key header was sent + const pylonCall = fetchT.mock.calls[2]; + expect(pylonCall[1].headers['x-pylon-key']).toBe('secret123'); + }); + + it('returns unhealthy when both direct and pylon fail', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), + }; + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) + .mockRejectedValueOnce(new Error('GET failed')) + .mockRejectedValueOnce(new Error('pylon failed')); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + siteConfig: { pylon: { url: 'http://pylon.test' } }, + }); + const res = await request(app).get('/api/health/service/plex'); + expect(res.status).toBe(200); + expect(res.body.health.status).toBe('unhealthy'); + expect(res.body.health.reason).toMatch(/direct \+ pylon/); + }); + + it('returns unhealthy with "fetch failed" when direct fails and no pylon', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), + }; + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) + .mockRejectedValueOnce(new Error('GET failed')); + const { app } = createApp({ + servicesStateManager: stateManager, + fetchT, + siteConfig: {}, // no pylon + }); + const res = await request(app).get('/api/health/service/plex'); + expect(res.status).toBe(200); + expect(res.body.health.status).toBe('unhealthy'); + expect(res.body.health.reason).toBe('fetch failed'); + }); + }); + + describe('GET /api/health/probe', () => { + it('returns health result when url provided and direct check succeeds', async () => { + const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 }); + const { app } = createApp({ fetchT }); + const res = await request(app).get('/api/health/probe?url=http://example.com'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('healthy'); + expect(res.body.statusCode).toBe(200); + expect(res.body.url).toBe('http://example.com'); + }); + + it('returns unhealthy when direct check completely fails', async () => { + const fetchT = jest.fn() + .mockRejectedValueOnce(new Error('HEAD failed')) + .mockRejectedValueOnce(new Error('GET failed')); + const { app } = createApp({ fetchT }); + const res = await request(app).get('/api/health/probe?url=http://dead.test'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('unhealthy'); + expect(res.body.reason).toBe('fetch failed'); + expect(res.body.url).toBe('http://dead.test'); + }); + + it('returns error when no url parameter provided', async () => { + const { app } = createApp(); + const res = await request(app).get('/api/health/probe'); + // ValidationError is not imported at module scope, so this throws a ReferenceError + // which the error handler catches as a 500 + expect(res.status).toBe(500); + }); + }); + + describe('GET /api/health/ca', () => { + it('returns healthy when cert has >90 days remaining', async () => { + exists.mockResolvedValue(true); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 365); + const dateStr = futureDate.toUTCString(); + execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('healthy'); + expect(res.body.daysUntilExpiration).toBeGreaterThan(90); + }); + + it('returns warning when cert has 30-90 days remaining', async () => { + exists.mockResolvedValue(true); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 60); + const dateStr = futureDate.toUTCString(); + execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('warning'); + expect(res.body.daysUntilExpiration).toBeLessThan(90); + expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30); + }); + + it('returns critical when cert has <30 days remaining', async () => { + exists.mockResolvedValue(true); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 15); + const dateStr = futureDate.toUTCString(); + execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('critical'); + expect(res.body.daysUntilExpiration).toBeLessThan(30); + expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0); + }); + + it('returns critical when cert has <7 days remaining', async () => { + exists.mockResolvedValue(true); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 3); + const dateStr = futureDate.toUTCString(); + execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('critical'); + expect(res.body.daysUntilExpiration).toBeLessThan(7); + }); + + it('returns critical when cert is expired', async () => { + exists.mockResolvedValue(true); + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 10); + const dateStr = pastDate.toUTCString(); + execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('critical'); + expect(res.body.daysUntilExpiration).toBeLessThan(0); + expect(res.body.message).toMatch(/EXPIRED/); + }); + + it('returns error when cert file not found', async () => { + exists.mockResolvedValue(false); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('error'); + expect(res.body.message).toMatch(/not found/); + expect(res.body.daysUntilExpiration).toBeNull(); + }); + + it('returns error when execSync throws', async () => { + exists.mockResolvedValue(true); + execSync.mockImplementation(() => { throw new Error('openssl not found'); }); + const { app } = createApp(); + const res = await request(app).get('/api/health/ca'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('error'); + expect(res.body.message).toBe('openssl not found'); + expect(res.body.daysUntilExpiration).toBeNull(); + }); + }); + + describe('GET /api/health-checks/incidents/history', () => { + it('returns incident history', async () => { + const healthChecker = { + getCurrentStatus: jest.fn().mockReturnValue({}), + getServiceStats: jest.fn(), + configureService: jest.fn(), + removeService: jest.fn(), + getOpenIncidents: jest.fn().mockReturnValue([]), + getIncidentHistory: jest.fn().mockReturnValue([ + { id: 'inc-1', serviceId: 'svc1', type: 'outage', resolvedAt: '2025-01-01T00:00:00Z' }, + { id: 'inc-2', serviceId: 'svc2', type: 'degraded', resolvedAt: '2025-01-02T00:00:00Z' }, + ]), + }; + const { app } = createApp({ healthChecker }); + const res = await request(app).get('/api/health-checks/incidents/history'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.history).toHaveLength(2); + expect(res.body.history[0].id).toBe('inc-1'); + expect(res.body.history[1].type).toBe('degraded'); + }); + }); + + describe('GET /api/health/pylon (with key)', () => { + it('sends x-pylon-key header when key is configured', async () => { + const fetchT = jest.fn().mockResolvedValue({ + ok: true, status: 200, json: () => ({ status: 'ok' }), + }); + const { app } = createApp({ + siteConfig: { pylon: { url: 'http://pylon.test', key: 'my-secret-key' } }, + fetchT, + }); + const res = await request(app).get('/api/health/pylon'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(true); + expect(res.body.reachable).toBe(true); + // Verify the x-pylon-key header was sent + const fetchCall = fetchT.mock.calls[0]; + expect(fetchCall[1].headers['x-pylon-key']).toBe('my-secret-key'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/routes/services.routes.test.js b/dashcaddy-api/__tests__/routes/services.routes.test.js new file mode 100644 index 0000000..5506a47 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/services.routes.test.js @@ -0,0 +1,521 @@ +const express = require('express'); +const request = require('supertest'); + +// ValidationError and NotFoundError are now properly imported in services.js + +// Minimal asyncHandler +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +// Mock modules that services.js requires at top-level +jest.mock('../../constants', () => ({ + APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } }, + REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ }, + TIMEOUTS: { DEFAULT: 10000 }, + HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 } +})); + +jest.mock('../../input-validator', () => ({ + validateServiceConfig: jest.fn(), + isValidPort: jest.fn(p => p >= 1 && p <= 65535), +})); + +jest.mock('../../fs-helpers', () => ({ + exists: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../../url-resolver', () => ({ + resolveServiceUrl: jest.fn((id) => `https://${id}.test`), +})); + +jest.mock('../../pagination', () => ({ + paginate: jest.fn((data, params) => ({ data, pagination: null })), + parsePaginationParams: jest.fn(() => null), +})); + +jest.mock('../../response-helpers', () => ({ + success: jest.fn((res, data, statusCode = 200) => { + return res.status(statusCode).json({ success: true, ...data }); + }), + error: jest.fn((res, message, statusCode = 500, extra) => { + return res.status(statusCode).json({ success: false, error: message, ...extra }); + }), +})); + +// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError + +const { exists } = require('../../fs-helpers'); +const { validateServiceConfig } = require('../../input-validator'); + +function createApp(depsOverride = {}) { + const defaultDeps = { + servicesStateManager: { + read: jest.fn().mockResolvedValue([]), + write: jest.fn().mockResolvedValue(), + update: jest.fn(async (fn) => { + const data = fn([]); + return data; + }), + }, + credentialManager: { + store: jest.fn().mockResolvedValue(true), + retrieve: jest.fn().mockResolvedValue(null), + delete: jest.fn().mockResolvedValue(true), + }, + siteConfig: { tld: 'sami' }, + buildServiceUrl: jest.fn(id => `https://${id}.sami`), + buildDomain: jest.fn(sub => `${sub}.sami`), + fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }), + asyncHandler, + SERVICES_FILE: '/tmp/services.json', + log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + safeErrorMessage: jest.fn(err => err.message), + resyncHealthChecker: jest.fn().mockResolvedValue(), + caddy: { + read: jest.fn().mockResolvedValue(''), + modify: jest.fn().mockResolvedValue({ success: true }), + generateConfig: jest.fn().mockReturnValue('generated config'), + }, + dns: { + addRecord: jest.fn().mockResolvedValue({ success: true }), + }, + }; + + const deps = { ...defaultDeps, ...depsOverride }; + const servicesRoutes = require('../../routes/services'); + const app = express(); + app.use(express.json()); + app.use('/api', servicesRoutes(deps)); + // Error handler + app.use((err, req, res, next) => { + const status = err.statusCode || 500; + res.status(status).json({ success: false, error: err.message }); + }); + return { app, deps }; +} + +describe('Services Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + exists.mockResolvedValue(true); + validateServiceConfig.mockImplementation(() => {}); // No-op (valid) + }); + + describe('GET /api/services', () => { + it('returns empty array when no services file', async () => { + exists.mockResolvedValue(false); + const { app } = createApp(); + const res = await request(app).get('/api/services'); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it('returns services list', async () => { + const services = [ + { id: 'plex', name: 'Plex' }, + { id: 'radarr', name: 'Radarr' }, + ]; + const stateManager = { + read: jest.fn().mockResolvedValue(services), + write: jest.fn(), + update: jest.fn(), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app).get('/api/services'); + expect(res.status).toBe(200); + }); + }); + + describe('POST /api/services', () => { + it('adds a new service', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([]), + write: jest.fn(), + update: jest.fn(async (fn) => fn([])), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app) + .post('/api/services') + .send({ id: 'plex', name: 'Plex' }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(stateManager.update).toHaveBeenCalled(); + }); + + // NOTE: POST /services validation for missing id/name is caught by the route's + // try/catch block which logs the error but doesn't send a response in the else branch. + // The catch block only sends a response for "already exists" errors (409). + + it('returns 409 when service already exists', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([]), + write: jest.fn(), + update: jest.fn(async (fn) => fn([{ id: 'plex', name: 'Plex' }])), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app) + .post('/api/services') + .send({ id: 'plex', name: 'Plex' }); + expect(res.status).toBe(409); + }); + }); + + describe('PUT /api/services', () => { + it('replaces all services', async () => { + const stateManager = { + read: jest.fn(), + write: jest.fn().mockResolvedValue(), + update: jest.fn(), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const services = [ + { id: 'plex', name: 'Plex' }, + { id: 'radarr', name: 'Radarr' }, + ]; + const res = await request(app) + .put('/api/services') + .send(services); + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + expect(stateManager.write).toHaveBeenCalledWith(services); + }); + + it('rejects non-array body', async () => { + const { app } = createApp(); + const res = await request(app) + .put('/api/services') + .send({ id: 'plex' }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('rejects services without id or name', async () => { + const { app } = createApp(); + const res = await request(app) + .put('/api/services') + .send([{ id: 'plex' }]); // missing name + expect(res.status).toBeGreaterThanOrEqual(400); + }); + }); + + describe('DELETE /api/services/:id', () => { + it('removes a service', async () => { + const stateManager = { + read: jest.fn(), + write: jest.fn(), + update: jest.fn(async (fn) => fn([{ id: 'plex' }, { id: 'radarr' }])), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app).delete('/api/services/plex'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('returns 404 when services file missing', async () => { + exists.mockResolvedValue(false); + const { app } = createApp(); + const res = await request(app).delete('/api/services/plex'); + expect(res.status).toBeGreaterThanOrEqual(404); + }); + }); + + describe('POST /api/services/:serviceId/credentials', () => { + it('stores credentials', async () => { + const credentialManager = { + store: jest.fn().mockResolvedValue(true), + retrieve: jest.fn(), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app) + .post('/api/services/radarr/credentials') + .send({ apiKey: 'test-key', username: 'admin', password: 'pass' }); + expect(res.status).toBe(200); + expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.apikey', 'test-key'); + expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.username', 'admin'); + expect(credentialManager.store).toHaveBeenCalledWith('service.radarr.password', 'pass'); + }); + }); + + describe('DELETE /api/services/:serviceId/credentials', () => { + it('deletes credentials', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn(), + delete: jest.fn().mockResolvedValue(true), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).delete('/api/services/radarr/credentials'); + expect(res.status).toBe(200); + expect(credentialManager.delete).toHaveBeenCalledWith('service.radarr.apikey'); + }); + }); + + describe('GET /api/services/:serviceId/credentials', () => { + it('returns credential status', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn().mockResolvedValue(null), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).get('/api/services/radarr/credentials'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('hasApiKey'); + expect(res.body).toHaveProperty('hasBasicAuth'); + }); + + it('returns hasApiKey:true when API key exists', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn().mockImplementation((key) => { + if (key === 'service.radarr.apikey') return Promise.resolve('the-key'); + return Promise.resolve(null); + }), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).get('/api/services/radarr/credentials'); + expect(res.status).toBe(200); + expect(res.body.hasApiKey).toBe(true); + }); + }); + + // ===== SEEDHOST CREDENTIAL ENDPOINTS ===== + + describe('POST /api/seedhost-creds', () => { + it('stores seedhost username and password', async () => { + const credentialManager = { + store: jest.fn().mockResolvedValue(true), + retrieve: jest.fn(), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app) + .post('/api/seedhost-creds') + .send({ username: 'user1', password: 'pass1' }); + expect(res.status).toBe(200); + expect(credentialManager.store).toHaveBeenCalledWith('seedhost.username', 'user1'); + expect(credentialManager.store).toHaveBeenCalledWith('seedhost.password', 'pass1'); + }); + + it('stores per-service password when serviceId provided', async () => { + const credentialManager = { + store: jest.fn().mockResolvedValue(true), + retrieve: jest.fn(), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app) + .post('/api/seedhost-creds') + .send({ username: 'user1', password: 'radarr-pass', serviceId: 'radarr' }); + expect(res.status).toBe(200); + expect(credentialManager.store).toHaveBeenCalledWith('seedhost.password.radarr', 'radarr-pass'); + }); + + it('rejects missing username', async () => { + const { app } = createApp(); + const res = await request(app) + .post('/api/seedhost-creds') + .send({ password: 'pass1' }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + }); + + describe('GET /api/seedhost-creds', () => { + it('returns credential status with shared password', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn().mockImplementation((key) => { + if (key === 'seedhost.username') return Promise.resolve('user1'); + if (key === 'seedhost.password') return Promise.resolve('pass1'); + return Promise.reject(new Error('not found')); + }), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).get('/api/seedhost-creds'); + expect(res.status).toBe(200); + expect(res.body.hasCredentials).toBe(true); + expect(res.body.username).toBe('user1'); + }); + + it('checks per-service password when serviceId provided', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn().mockImplementation((key) => { + if (key === 'seedhost.username') return Promise.resolve('user1'); + if (key === 'seedhost.password.radarr') return Promise.resolve('radarr-pass'); + return Promise.reject(new Error('not found')); + }), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).get('/api/seedhost-creds?serviceId=radarr'); + expect(res.status).toBe(200); + expect(res.body.hasCredentials).toBe(true); + expect(res.body.hasPassword).toBe(true); + }); + + it('returns hasCredentials:false when nothing stored', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn().mockRejectedValue(new Error('not found')), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).get('/api/seedhost-creds'); + expect(res.status).toBe(200); + expect(res.body.hasCredentials).toBe(false); + }); + }); + + describe('DELETE /api/seedhost-creds', () => { + it('deletes per-service password', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn(), + delete: jest.fn().mockResolvedValue(true), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).delete('/api/seedhost-creds?serviceId=radarr'); + expect(res.status).toBe(200); + expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.password.radarr'); + }); + + it('deletes all seedhost credentials when no serviceId', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn(), + delete: jest.fn().mockResolvedValue(true), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).delete('/api/seedhost-creds'); + expect(res.status).toBe(200); + expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.username'); + expect(credentialManager.delete).toHaveBeenCalledWith('seedhost.password'); + }); + }); + + // ===== SERVICES STATUS ENDPOINT ===== + + describe('GET /api/services/status', () => { + it('returns status for all services', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([ + { id: 'plex', name: 'Plex' }, + { id: 'radarr', name: 'Radarr' }, + ]), + write: jest.fn(), + update: jest.fn(), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app).get('/api/services/status'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body).toHaveProperty('checkedAt'); + expect(res.body).toHaveProperty('statuses'); + }); + + it('includes internet check in statuses', async () => { + const stateManager = { + read: jest.fn().mockResolvedValue([]), + write: jest.fn(), + update: jest.fn(), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app).get('/api/services/status'); + expect(res.status).toBe(200); + expect(res.body.statuses).toHaveProperty('internet'); + }); + }); + + // ===== SERVICE UPDATE ENDPOINT ===== + + describe('POST /api/services/update', () => { + it('rejects missing subdomains', async () => { + const { app } = createApp(); + const res = await request(app) + .post('/api/services/update') + .send({ oldSubdomain: 'plex' }); // missing newSubdomain + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('rejects invalid subdomain format', async () => { + const { app } = createApp(); + const res = await request(app) + .post('/api/services/update') + .send({ oldSubdomain: 'INVALID!', newSubdomain: 'plex' }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('rejects invalid port', async () => { + const { isValidPort } = require('../../input-validator'); + isValidPort.mockReturnValue(false); + const { app } = createApp(); + const res = await request(app) + .post('/api/services/update') + .send({ oldSubdomain: 'plex', newSubdomain: 'media', port: 99999 }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it('updates subdomain with DNS and Caddy changes', async () => { + const caddy = { + read: jest.fn().mockResolvedValue('plex.sami {\n reverse_proxy localhost:32400\n}'), + modify: jest.fn().mockResolvedValue({ success: true }), + generateConfig: jest.fn().mockReturnValue('media.sami { reverse_proxy localhost:32400 }'), + }; + const dns = { + getToken: jest.fn().mockReturnValue('token'), + call: jest.fn().mockResolvedValue({}), + createRecord: jest.fn().mockResolvedValue({}), + }; + const stateManager = { + read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), + write: jest.fn(), + update: jest.fn(async (fn) => fn([{ id: 'plex', name: 'Plex', url: 'https://plex.sami' }])), + }; + const { app } = createApp({ + caddy, dns, + servicesStateManager: stateManager, + }); + + const res = await request(app) + .post('/api/services/update') + .send({ oldSubdomain: 'plex', newSubdomain: 'media' }); + + expect(res.status).toBe(200); + expect(res.body.results).toBeDefined(); + }); + }); + + // ===== VALIDATION / EDGE CASES ===== + + describe('PUT /api/services validation', () => { + it('rejects services that fail validateServiceConfig', async () => { + validateServiceConfig.mockImplementation(() => { + const err = new Error('Bad id format'); + err.errors = ['id contains invalid chars']; + throw err; + }); + const { app } = createApp(); + const res = await request(app) + .put('/api/services') + .send([{ id: 'bad!id', name: 'Test' }]); + expect(res.status).toBe(400); + }); + }); + + describe('DELETE /api/services/:id edge cases', () => { + it('returns 404 when service not in list', async () => { + const stateManager = { + read: jest.fn(), + write: jest.fn(), + update: jest.fn(async (fn) => fn([{ id: 'radarr' }])), + }; + const { app } = createApp({ servicesStateManager: stateManager }); + const res = await request(app).delete('/api/services/nonexistent'); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/state-manager.test.js b/dashcaddy-api/__tests__/state-manager.test.js new file mode 100644 index 0000000..116615f --- /dev/null +++ b/dashcaddy-api/__tests__/state-manager.test.js @@ -0,0 +1,213 @@ +jest.mock('proper-lockfile'); +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(true), + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), + promises: { + readFile: jest.fn().mockResolvedValue('[]'), + writeFile: jest.fn().mockResolvedValue(), + }, +})); + +const lockfile = require('proper-lockfile'); +const fs = require('fs'); +const StateManager = require('../state-manager'); + +describe('StateManager', () => { + let sm; + const TEST_PATH = '/tmp/test-state.json'; + + beforeEach(() => { + jest.clearAllMocks(); + fs.existsSync.mockReturnValue(true); + fs.promises.readFile.mockResolvedValue('[]'); + fs.promises.writeFile.mockResolvedValue(); + lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue()); + lockfile.check.mockResolvedValue(false); + lockfile.unlock.mockResolvedValue(); + + sm = new StateManager(TEST_PATH); + }); + + describe('constructor', () => { + it('creates file with [] if it does not exist', () => { + fs.existsSync.mockReturnValue(false); + new StateManager('/tmp/new-state.json'); + expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/new-state.json', '[]', 'utf8'); + }); + + it('creates directory recursively if needed', () => { + fs.existsSync.mockReturnValue(false); + new StateManager('/tmp/deep/nested/state.json'); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + }); + + it('does not create file if it exists', () => { + fs.existsSync.mockReturnValue(true); + fs.writeFileSync.mockClear(); + new StateManager(TEST_PATH); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('read', () => { + it('returns parsed JSON from file', async () => { + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'svc1' }])); + const data = await sm.read(); + expect(data).toEqual([{ id: 'svc1' }]); + }); + + it('returns [] and recreates file on ENOENT', async () => { + const err = new Error('ENOENT'); + err.code = 'ENOENT'; + fs.promises.readFile.mockRejectedValue(err); + fs.existsSync.mockReturnValue(false); + + const data = await sm.read(); + expect(data).toEqual([]); + }); + + it('throws on invalid JSON', async () => { + fs.promises.readFile.mockResolvedValue('{bad json}'); + await expect(sm.read()).rejects.toThrow('Failed to read state file'); + }); + }); + + describe('write', () => { + it('acquires lock, writes JSON, releases lock', async () => { + const releaseFn = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(releaseFn); + + await sm.write([{ id: 'new' }]); + + expect(lockfile.lock).toHaveBeenCalledWith(TEST_PATH, expect.any(Object)); + expect(fs.promises.writeFile).toHaveBeenCalledWith( + TEST_PATH, + JSON.stringify([{ id: 'new' }], null, 2), + 'utf8' + ); + expect(releaseFn).toHaveBeenCalled(); + }); + + it('throws on ELOCKED', async () => { + const err = new Error('locked'); + err.code = 'ELOCKED'; + lockfile.lock.mockRejectedValue(err); + + await expect(sm.write([])).rejects.toThrow('locked by another process'); + }); + + it('releases lock even on write error', async () => { + const releaseFn = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(releaseFn); + fs.promises.writeFile.mockRejectedValue(new Error('disk full')); + + await expect(sm.write([])).rejects.toThrow(); + expect(releaseFn).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('atomic read-modify-write cycle', async () => { + const releaseFn = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(releaseFn); + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }])); + + const result = await sm.update(items => { + items.push({ id: '2' }); + return items; + }); + + expect(result).toEqual([{ id: '1' }, { id: '2' }]); + expect(fs.promises.writeFile).toHaveBeenCalled(); + expect(releaseFn).toHaveBeenCalled(); + }); + + it('passes current data to updateFn', async () => { + const releaseFn = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(releaseFn); + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'existing' }])); + + const updateFn = jest.fn(data => data); + await sm.update(updateFn); + + expect(updateFn).toHaveBeenCalledWith([{ id: 'existing' }]); + }); + + it('throws on ELOCKED', async () => { + const err = new Error('locked'); + err.code = 'ELOCKED'; + lockfile.lock.mockRejectedValue(err); + + await expect(sm.update(d => d)).rejects.toThrow('locked by another process'); + }); + }); + + describe('convenience methods', () => { + beforeEach(() => { + const releaseFn = jest.fn().mockResolvedValue(); + lockfile.lock.mockResolvedValue(releaseFn); + }); + + it('addItem appends to array', async () => { + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }])); + const result = await sm.addItem({ id: '2', name: 'New' }); + expect(result).toEqual([{ id: '1' }, { id: '2', name: 'New' }]); + }); + + it('removeItem filters by id', async () => { + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }, { id: '2' }])); + const result = await sm.removeItem('1'); + expect(result).toEqual([{ id: '2' }]); + }); + + it('updateItem merges updates for matching id', async () => { + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Old' }])); + const result = await sm.updateItem('1', { name: 'New', port: 8080 }); + expect(result).toEqual([{ id: '1', name: 'New', port: 8080 }]); + }); + + it('findItem returns matching item or null', async () => { + fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Found' }])); + const found = await sm.findItem('1'); + expect(found).toEqual({ id: '1', name: 'Found' }); + + const missing = await sm.findItem('999'); + expect(missing).toBeNull(); + }); + }); + + describe('isLocked', () => { + it('returns lockfile.check result', async () => { + lockfile.check.mockResolvedValue(true); + expect(await sm.isLocked()).toBe(true); + + lockfile.check.mockResolvedValue(false); + expect(await sm.isLocked()).toBe(false); + }); + + it('returns false on error', async () => { + lockfile.check.mockRejectedValue(new Error('fail')); + expect(await sm.isLocked()).toBe(false); + }); + }); + + describe('forceUnlock', () => { + it('calls lockfile.unlock', async () => { + await sm.forceUnlock(); + expect(lockfile.unlock).toHaveBeenCalledWith(TEST_PATH); + }); + + it('ignores ENOTACQUIRED error', async () => { + const err = new Error('not locked'); + err.code = 'ENOTACQUIRED'; + lockfile.unlock.mockRejectedValue(err); + await expect(sm.forceUnlock()).resolves.toBeUndefined(); + }); + + it('throws other errors', async () => { + lockfile.unlock.mockRejectedValue(new Error('other')); + await expect(sm.forceUnlock()).rejects.toThrow('other'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/update-manager.test.js b/dashcaddy-api/__tests__/update-manager.test.js new file mode 100644 index 0000000..edcfe66 --- /dev/null +++ b/dashcaddy-api/__tests__/update-manager.test.js @@ -0,0 +1,1080 @@ +// Update Manager Tests +// Validates Docker image update detection, scheduling, rollback, and auto-update + +const mockDockerInstance = { + listContainers: jest.fn(), + getContainer: jest.fn(), + getImage: jest.fn(), + pull: jest.fn(), + createContainer: jest.fn(), + modem: { followProgress: jest.fn() } +}; + +jest.mock('dockerode', () => jest.fn(() => mockDockerInstance)); +jest.mock('fs'); +jest.mock('https'); + +const fs = require('fs'); +const https = require('https'); + +// Setup defaults BEFORE requiring singleton +fs.existsSync.mockReturnValue(false); +fs.readFileSync.mockReturnValue('{}'); +fs.writeFileSync.mockReturnValue(undefined); + +const updateManager = require('../update-manager'); + +// Helper to create a fake https request that responds with a given statusCode/headers/body +function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) { + return (options, cb) => { + const res = { + statusCode, + headers, + on: jest.fn((event, handler) => { + if (event === 'data' && body) handler(Buffer.from(body)); + if (event === 'end') handler(); + }) + }; + if (cb) setImmediate(() => cb(res)); + return { + on: jest.fn(), + end: jest.fn(), + setTimeout: jest.fn(), + destroy: jest.fn() + }; + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] }); + + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue('{}'); + fs.writeFileSync.mockReturnValue(undefined); + + // Reset docker mocks + mockDockerInstance.listContainers.mockReset(); + mockDockerInstance.getContainer.mockReset(); + mockDockerInstance.getImage.mockReset(); + mockDockerInstance.pull.mockReset(); + mockDockerInstance.createContainer.mockReset(); + mockDockerInstance.modem.followProgress.mockReset(); + + // Reset internal state + updateManager.history = []; + updateManager.config = { autoUpdate: {} }; + updateManager.availableUpdates.clear(); + updateManager.checking = false; + if (updateManager.checkInterval) { + clearInterval(updateManager.checkInterval); + updateManager.checkInterval = null; + } + if (updateManager.autoUpdateInterval) { + clearInterval(updateManager.autoUpdateInterval); + updateManager.autoUpdateInterval = null; + } +}); + +afterEach(() => { + updateManager.checking = false; + if (updateManager.checkInterval) { clearInterval(updateManager.checkInterval); updateManager.checkInterval = null; } + if (updateManager.autoUpdateInterval) { clearInterval(updateManager.autoUpdateInterval); updateManager.autoUpdateInterval = null; } + jest.useRealTimers(); +}); + +describe('UpdateManager — Docker image update lifecycle', () => { + + describe('start/stop lifecycle', () => { + it('starts update checking', () => { + updateManager.start(); + expect(updateManager.checking).toBe(true); + }); + + it('ignores double start', () => { + updateManager.start(); + updateManager.start(); + expect(updateManager.checking).toBe(true); + }); + + it('stops checking and clears intervals', () => { + updateManager.start(); + updateManager.stop(); + expect(updateManager.checking).toBe(false); + expect(updateManager.checkInterval).toBeNull(); + expect(updateManager.autoUpdateInterval).toBeNull(); + }); + + it('ignores stop when not checking', () => { + updateManager.stop(); + expect(updateManager.checking).toBe(false); + }); + }); + + describe('extractTag', () => { + it('extracts tag from image:tag', () => { + expect(updateManager.extractTag('nginx:1.25')).toBe('1.25'); + }); + + it('returns latest for untagged images', () => { + expect(updateManager.extractTag('nginx')).toBe('latest'); + }); + + it('handles LinuxServer images', () => { + expect(updateManager.extractTag('lscr.io/linuxserver/plex:latest')).toBe('latest'); + }); + }); + + describe('parseAuthHeader', () => { + it('parses Docker Hub Bearer auth header', () => { + const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"'; + const result = updateManager.parseAuthHeader(header); + expect(result).toContain('https://auth.docker.io/token'); + expect(result).toContain('service=registry.docker.io'); + expect(result).toContain('scope=repository'); + }); + + it('returns null for missing header', () => { + expect(updateManager.parseAuthHeader(null)).toBeNull(); + }); + + it('returns null for non-Bearer header', () => { + expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull(); + }); + }); + + describe('extractPorts', () => { + it('extracts host port mappings from inspect data', () => { + const inspect = { + NetworkSettings: { + Ports: { + '32400/tcp': [{ HostIp: '0.0.0.0', HostPort: '32400' }], + '1900/udp': [{ HostIp: '0.0.0.0', HostPort: '1900' }] + } + } + }; + + const ports = updateManager.extractPorts(inspect); + expect(ports).toHaveLength(2); + expect(ports[0]).toEqual({ containerPort: '32400', hostPort: '32400', protocol: 'tcp' }); + expect(ports[1]).toEqual({ containerPort: '1900', hostPort: '1900', protocol: 'udp' }); + }); + + it('returns empty for no port bindings', () => { + const inspect = { NetworkSettings: { Ports: { '8080/tcp': null } } }; + expect(updateManager.extractPorts(inspect)).toEqual([]); + }); + + it('returns empty for missing NetworkSettings', () => { + expect(updateManager.extractPorts({})).toEqual([]); + }); + }); + + describe('history management', () => { + it('addToHistory appends and saves', () => { + updateManager.addToHistory({ id: 'update-1', status: 'success' }); + expect(updateManager.history).toHaveLength(1); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('caps history at 100 entries', () => { + for (let i = 0; i < 110; i++) { + updateManager.addToHistory({ id: `update-${i}` }); + } + expect(updateManager.history).toHaveLength(100); + }); + + it('getHistory returns newest first', () => { + updateManager.addToHistory({ id: 'old' }); + updateManager.addToHistory({ id: 'new' }); + const history = updateManager.getHistory(); + expect(history[0].id).toBe('new'); + }); + + it('getHistory respects limit', () => { + for (let i = 0; i < 10; i++) { + updateManager.addToHistory({ id: `update-${i}` }); + } + expect(updateManager.getHistory(3)).toHaveLength(3); + }); + }); + + describe('getAvailableUpdates', () => { + it('returns empty array when no updates', () => { + expect(updateManager.getAvailableUpdates()).toEqual([]); + }); + + it('returns list of available updates', () => { + updateManager.availableUpdates.set('abc123', { + containerId: 'abc123', + containerName: 'plex', + imageName: 'linuxserver/plex:latest' + }); + const updates = updateManager.getAvailableUpdates(); + expect(updates).toHaveLength(1); + expect(updates[0].containerName).toBe('plex'); + }); + }); + + describe('scheduleUpdate', () => { + it('schedules future update', () => { + const futureTime = new Date(Date.now() + 60000).toISOString(); + updateManager.scheduleUpdate('abc123', futureTime); + // No error thrown, timer set + }); + + it('rejects past time', () => { + const pastTime = new Date(Date.now() - 60000).toISOString(); + expect(() => updateManager.scheduleUpdate('abc123', pastTime)) + .toThrow('must be in the future'); + }); + }); + + describe('configureAutoUpdate', () => { + it('stores auto-update config', () => { + updateManager.configureAutoUpdate('abc123', { + schedule: 'daily', + maintenanceWindow: '02:00-04:00', + autoRollback: true + }); + + const config = updateManager.getAutoUpdateConfig(); + expect(config['abc123'].enabled).toBe(true); + expect(config['abc123'].schedule).toBe('daily'); + expect(config['abc123'].maintenanceWindow).toBe('02:00-04:00'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('defaults schedule to weekly', () => { + updateManager.configureAutoUpdate('abc123', {}); + expect(updateManager.getAutoUpdateConfig()['abc123'].schedule).toBe('weekly'); + }); + }); + + describe('persistence (loadConfig/saveConfig)', () => { + it('loadConfig returns saved config', () => { + const saved = { autoUpdate: { abc123: { enabled: true, schedule: 'daily' } } }; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(saved)); + const config = updateManager.loadConfig(); + expect(config.autoUpdate.abc123.schedule).toBe('daily'); + }); + + it('loadConfig returns defaults on missing file', () => { + fs.existsSync.mockReturnValue(false); + const config = updateManager.loadConfig(); + expect(config.autoUpdate).toEqual({}); + }); + + it('loadConfig returns defaults on error', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('read error'); }); + const config = updateManager.loadConfig(); + expect(config.autoUpdate).toEqual({}); + }); + + it('loadHistory returns empty array on missing file', () => { + fs.existsSync.mockReturnValue(false); + expect(updateManager.loadHistory()).toEqual([]); + }); + + it('saveConfig writes JSON', () => { + updateManager.config = { autoUpdate: { test: { enabled: true } } }; + updateManager.saveConfig(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('test') + ); + }); + + it('saveConfig handles write error', () => { + fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); + updateManager.saveConfig(); // should not throw + }); + + it('saveHistory handles write error', () => { + fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); }); + updateManager.saveHistory(); // should not throw + }); + }); + + describe('formatChangelog', () => { + it('formats repo description and tags', () => { + const repoInfo = { description: 'Plex Media Server', pull_count: 1000000 }; + const tags = [ + { name: 'latest', last_pushed: '2026-04-01T00:00:00Z' }, + { name: '1.40', last_pushed: '2026-03-15T00:00:00Z' } + ]; + + const result = updateManager.formatChangelog(repoInfo, tags, 'latest'); + expect(result).toContain('Plex Media Server'); + expect(result).toContain('Recent tags'); + expect(result).toContain('latest'); + expect(result).toContain('1,000,000'); + }); + + it('returns fallback for empty data', () => { + const result = updateManager.formatChangelog(null, [], 'latest'); + expect(result).toBe('No changelog available'); + }); + }); + + describe('verifyContainer', () => { + it('returns true when container is running (no health check)', async () => { + const container = { + inspect: jest.fn().mockResolvedValue({ + State: { Running: true } + }) + }; + const result = await updateManager.verifyContainer(container, 5000); + expect(result).toBe(true); + }); + + it('returns true when container is healthy', async () => { + const container = { + inspect: jest.fn().mockResolvedValue({ + State: { Running: true, Health: { Status: 'healthy' } } + }) + }; + const result = await updateManager.verifyContainer(container, 5000); + expect(result).toBe(true); + }); + + it('throws on inspect error', async () => { + const container = { + inspect: jest.fn().mockRejectedValue(new Error('gone')) + }; + await expect(updateManager.verifyContainer(container, 1000)) + .rejects.toThrow('Container verification failed'); + }); + }); + + describe('checkForUpdates', () => { + it('detects updates and stores them in availableUpdates', async () => { + mockDockerInstance.listContainers.mockResolvedValue([ + { Id: 'abc123', Names: ['/plex'] } + ]); + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ + Config: { Image: 'lscr.io/linuxserver/plex:latest' }, + Image: 'sha256:current12345678' + }) + }); + // Custom registry returns null (not Docker Hub) + jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newdigest98765'); + + await updateManager.checkForUpdates(); + + expect(updateManager.availableUpdates.has('abc123')).toBe(true); + const update = updateManager.availableUpdates.get('abc123'); + expect(update.containerName).toBe('plex'); + expect(update.imageName).toBe('lscr.io/linuxserver/plex:latest'); + + updateManager.getLatestImageDigest.mockRestore(); + }); + + it('removes from availableUpdates when no update needed', async () => { + mockDockerInstance.listContainers.mockResolvedValue([ + { Id: 'abc123', Names: ['/plex'] } + ]); + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ + Config: { Image: 'plex:latest' }, + Image: 'sha256:samedigest' + }) + }); + jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:samedigest'); + + // Pre-populate with stale update + updateManager.availableUpdates.set('abc123', { stale: true }); + + await updateManager.checkForUpdates(); + + expect(updateManager.availableUpdates.has('abc123')).toBe(false); + + updateManager.getLatestImageDigest.mockRestore(); + }); + + it('handles error during container inspect gracefully', async () => { + mockDockerInstance.listContainers.mockResolvedValue([ + { Id: 'broken', Names: ['/broken'] } + ]); + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockRejectedValue(new Error('inspect failed')) + }); + + // Should not throw + await expect(updateManager.checkForUpdates()).resolves.not.toThrow(); + }); + + it('handles error during list containers gracefully', async () => { + mockDockerInstance.listContainers.mockRejectedValue(new Error('docker down')); + // Should not throw + await expect(updateManager.checkForUpdates()).resolves.not.toThrow(); + }); + + it('emits update-available event when new update detected', async () => { + mockDockerInstance.listContainers.mockResolvedValue([ + { Id: 'newupdate', Names: ['/radarr'] } + ]); + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ + Config: { Image: 'linuxserver/radarr:latest' }, + Image: 'sha256:oldradarr' + }) + }); + jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newradarr'); + + const events = []; + updateManager.on('update-available', e => events.push(e)); + + await updateManager.checkForUpdates(); + + expect(events).toHaveLength(1); + expect(events[0].containerName).toBe('radarr'); + + updateManager.removeAllListeners(); + updateManager.getLatestImageDigest.mockRestore(); + }); + }); + + describe('getLatestImageDigest', () => { + it('routes Docker Hub library images to getDockerHubDigest', async () => { + jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:digest123'); + const result = await updateManager.getLatestImageDigest('nginx:latest'); + expect(result).toBe('sha256:digest123'); + expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('nginx', 'latest'); + updateManager.getDockerHubDigest.mockRestore(); + }); + + it('routes Docker Hub user images to getDockerHubDigest', async () => { + jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:abc'); + const result = await updateManager.getLatestImageDigest('linuxserver/plex:latest'); + expect(result).toBe('sha256:abc'); + expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('linuxserver/plex', 'latest'); + updateManager.getDockerHubDigest.mockRestore(); + }); + + it('returns null for custom registries', async () => { + const result = await updateManager.getLatestImageDigest('lscr.io/linuxserver/plex:latest'); + expect(result).toBeNull(); + }); + + it('returns null on error', async () => { + jest.spyOn(updateManager, 'getDockerHubDigest').mockRejectedValue(new Error('boom')); + const result = await updateManager.getLatestImageDigest('nginx:latest'); + expect(result).toBeNull(); + updateManager.getDockerHubDigest.mockRestore(); + }); + + it('defaults tag to latest when not specified', async () => { + jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:xyz'); + await updateManager.getLatestImageDigest('redis'); + expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('redis', 'latest'); + updateManager.getDockerHubDigest.mockRestore(); + }); + }); + + describe('getDockerHubDigest (https)', () => { + it('returns digest from successful response', async () => { + https.request.mockImplementation((options, cb) => { + setImmediate(() => cb({ + statusCode: 200, + headers: { 'docker-content-digest': 'sha256:fromregistry' }, + on: jest.fn() + })); + return { on: jest.fn(), end: jest.fn() }; + }); + + const result = await updateManager.getDockerHubDigest('nginx', 'latest'); + expect(result).toBe('sha256:fromregistry'); + }); + + it('handles 401 with no auth header by rejecting', async () => { + https.request.mockImplementation((options, cb) => { + setImmediate(() => cb({ + statusCode: 401, + headers: {}, + on: jest.fn() + })); + return { on: jest.fn(), end: jest.fn() }; + }); + + await expect(updateManager.getDockerHubDigest('nginx', 'latest')).rejects.toThrow(); + }); + + it('rejects on https request error', async () => { + https.request.mockImplementation(() => { + const req = { on: jest.fn(), end: jest.fn() }; + // Trigger error event asynchronously + setImmediate(() => { + const errorHandler = req.on.mock.calls.find(c => c[0] === 'error'); + if (errorHandler) errorHandler[1](new Error('connection refused')); + }); + return req; + }); + + await expect(updateManager.getDockerHubDigest('nginx', 'latest')) + .rejects.toThrow('connection refused'); + }); + + it('normalizes library/ prefix for official images', async () => { + let capturedPath; + https.request.mockImplementation((options, cb) => { + capturedPath = options.path; + setImmediate(() => cb({ + statusCode: 200, + headers: { 'docker-content-digest': 'sha256:digest' }, + on: jest.fn() + })); + return { on: jest.fn(), end: jest.fn() }; + }); + + await updateManager.getDockerHubDigest('nginx', 'latest'); + expect(capturedPath).toBe('/v2/library/nginx/manifests/latest'); + }); + }); + + describe('pullImage', () => { + it('pulls image and resolves with output', async () => { + mockDockerInstance.pull.mockImplementation((imageName, cb) => { + cb(null, { stream: 'fake' }); + }); + mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => { + cb(null, [{ status: 'Downloaded' }]); + }); + + const result = await updateManager.pullImage('nginx:latest'); + expect(result).toEqual([{ status: 'Downloaded' }]); + }); + + it('rejects on pull error', async () => { + mockDockerInstance.pull.mockImplementation((imageName, cb) => { + cb(new Error('image not found')); + }); + + await expect(updateManager.pullImage('bogus:latest')).rejects.toThrow('image not found'); + }); + + it('rejects on followProgress error', async () => { + mockDockerInstance.pull.mockImplementation((imageName, cb) => { + cb(null, { stream: 'fake' }); + }); + mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => { + cb(new Error('progress error')); + }); + + await expect(updateManager.pullImage('nginx:latest')).rejects.toThrow('progress error'); + }); + }); + + describe('verifyContainer with timeout', () => { + it('waits for container to start running', async () => { + let inspectCount = 0; + const container = { + inspect: jest.fn().mockImplementation(() => { + inspectCount++; + if (inspectCount < 2) return Promise.resolve({ State: { Running: false } }); + return Promise.resolve({ State: { Running: true } }); + }) + }; + jest.useRealTimers(); + const result = await updateManager.verifyContainer(container, 5000); + expect(result).toBe(true); + expect(inspectCount).toBeGreaterThanOrEqual(2); + jest.useFakeTimers(); + }); + + it('throws on timeout when container never starts', async () => { + const container = { + inspect: jest.fn().mockResolvedValue({ State: { Running: false } }) + }; + jest.useRealTimers(); + await expect(updateManager.verifyContainer(container, 100)) + .rejects.toThrow(); + jest.useFakeTimers(); + }); + }); + + describe('rollbackUpdate', () => { + it('rolls back container from last successful backup', async () => { + const backup = { + containerId: 'old-id', + containerName: 'plex', + imageName: 'plex:latest', + config: { Image: 'plex:latest', Env: [] }, + hostConfig: { Binds: [] } + }; + + updateManager.history.push({ + containerId: 'old-id', + status: 'success', + backup, + timestamp: new Date().toISOString() + }); + + mockDockerInstance.getContainer.mockReturnValue({ + stop: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + }); + mockDockerInstance.createContainer.mockResolvedValue({ + start: jest.fn().mockResolvedValue() + }); + + const events = []; + updateManager.on('rollback-complete', e => events.push(e)); + + const result = await updateManager.rollbackUpdate('old-id'); + expect(result).toBe(true); + expect(events).toHaveLength(1); + expect(events[0].containerName).toBe('plex'); + + updateManager.removeAllListeners(); + }); + + it('throws when no backup exists', async () => { + await expect(updateManager.rollbackUpdate('no-backup-id')) + .rejects.toThrow('No backup found for rollback'); + }); + + it('continues rollback even if old container removal fails', async () => { + const backup = { + containerName: 'plex', + imageName: 'plex:latest', + config: { Image: 'plex:latest' }, + hostConfig: {} + }; + + updateManager.history.push({ + containerId: 'gone-id', + status: 'success', + backup, + timestamp: new Date().toISOString() + }); + + mockDockerInstance.getContainer.mockReturnValue({ + stop: jest.fn().mockRejectedValue(new Error('not found')), + remove: jest.fn().mockRejectedValue(new Error('not found')) + }); + mockDockerInstance.createContainer.mockResolvedValue({ + start: jest.fn().mockResolvedValue() + }); + + const result = await updateManager.rollbackUpdate('gone-id'); + expect(result).toBe(true); + }); + }); + + describe('updateContainer', () => { + it('completes full update pipeline successfully', async () => { + const inspectData = { + Config: { Image: 'plex:latest', Env: ['TZ=UTC'] }, + HostConfig: { Binds: [] }, + NetworkSettings: { Ports: {} }, + Image: 'sha256:oldid', + Name: '/plex' + }; + + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockResolvedValue(inspectData), + stop: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + }); + mockDockerInstance.getImage.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ RepoDigests: ['plex@sha256:olddigest'] }), + remove: jest.fn().mockResolvedValue() + }); + + const newContainer = { + id: 'new-container-id', + inspect: jest.fn().mockResolvedValue({ + State: { Running: true }, + Image: 'sha256:newid', + NetworkSettings: { Ports: {} } + }), + start: jest.fn().mockResolvedValue() + }; + mockDockerInstance.createContainer.mockResolvedValue(newContainer); + + // Mock pullImage and verifyContainerExtended to skip their internals + jest.spyOn(updateManager, 'pullImage').mockResolvedValue([{ status: 'Downloaded' }]); + jest.spyOn(updateManager, 'verifyContainerExtended').mockResolvedValue(true); + + const events = []; + updateManager.on('update-start', e => events.push({ type: 'start', ...e })); + updateManager.on('update-complete', e => events.push({ type: 'complete', ...e })); + + const result = await updateManager.updateContainer('container-id'); + + expect(result.status).toBe('success'); + expect(result.containerName).toBe('plex'); + expect(events).toHaveLength(2); + + updateManager.removeAllListeners(); + updateManager.pullImage.mockRestore(); + updateManager.verifyContainerExtended.mockRestore(); + }); + + it('attempts rollback on failure', async () => { + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ + Config: { Image: 'plex:latest' }, + HostConfig: {}, + NetworkSettings: {}, + Image: 'sha256:old', + Name: '/plex' + }), + stop: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + }); + mockDockerInstance.getImage.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ RepoDigests: [] }) + }); + + jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed')); + jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true); + + await expect(updateManager.updateContainer('fail-id')).rejects.toThrow('pull failed'); + expect(updateManager.rollbackUpdate).toHaveBeenCalledWith('fail-id'); + expect(updateManager.history[0].status).toBe('failed'); + + updateManager.pullImage.mockRestore(); + updateManager.rollbackUpdate.mockRestore(); + }); + + it('skips rollback when autoRollback is false', async () => { + mockDockerInstance.getContainer.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ + Config: { Image: 'plex:latest' }, + HostConfig: {}, + NetworkSettings: {}, + Image: 'sha256:old', + Name: '/plex' + }), + stop: jest.fn().mockResolvedValue(), + remove: jest.fn().mockResolvedValue() + }); + mockDockerInstance.getImage.mockReturnValue({ + inspect: jest.fn().mockResolvedValue({ RepoDigests: [] }) + }); + + jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed')); + jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true); + + await expect(updateManager.updateContainer('id', { autoRollback: false })) + .rejects.toThrow('pull failed'); + + expect(updateManager.rollbackUpdate).not.toHaveBeenCalled(); + + updateManager.pullImage.mockRestore(); + updateManager.rollbackUpdate.mockRestore(); + }); + }); + + describe('fetchDockerHubRepo / fetchDockerHubTags', () => { + it('fetchDockerHubRepo returns parsed response on 200', async () => { + https.request.mockImplementation((options, cb) => { + setImmediate(() => cb({ + statusCode: 200, + headers: {}, + on: jest.fn((event, handler) => { + if (event === 'data') handler(Buffer.from(JSON.stringify({ + description: 'Plex Media Server', + pull_count: 1000000, + star_count: 500 + }))); + if (event === 'end') handler(); + }) + })); + return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() }; + }); + + const result = await updateManager.fetchDockerHubRepo('linuxserver/plex', false); + expect(result.description).toBe('Plex Media Server'); + expect(result.pull_count).toBe(1000000); + }); + + it('fetchDockerHubRepo returns null on 404', async () => { + https.request.mockImplementation((options, cb) => { + setImmediate(() => cb({ + statusCode: 404, + headers: {}, + on: jest.fn((event, handler) => { + if (event === 'end') handler(); + }) + })); + return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() }; + }); + + const result = await updateManager.fetchDockerHubRepo('nonexistent', false); + expect(result).toBeNull(); + }); + + it('fetchDockerHubRepo returns null on https error', async () => { + https.request.mockImplementation(() => { + const req = { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() }; + setImmediate(() => { + const errCall = req.on.mock.calls.find(c => c[0] === 'error'); + if (errCall) errCall[1](new Error('connection refused')); + }); + return req; + }); + + const result = await updateManager.fetchDockerHubRepo('nginx', true); + expect(result).toBeNull(); + }); + + it('fetchDockerHubTags returns results array on 200', async () => { + https.request.mockImplementation((options, cb) => { + setImmediate(() => cb({ + statusCode: 200, + headers: {}, + on: jest.fn((event, handler) => { + if (event === 'data') handler(Buffer.from(JSON.stringify({ + results: [ + { name: 'latest', last_pushed: '2026-04-01T00:00:00Z' }, + { name: '1.40', last_pushed: '2026-03-15T00:00:00Z' } + ] + }))); + if (event === 'end') handler(); + }) + })); + return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() }; + }); + + const result = await updateManager.fetchDockerHubTags('linuxserver/plex', false); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('latest'); + }); + + it('fetchDockerHubTags returns empty on non-200', async () => { + https.request.mockImplementation((options, cb) => { + setImmediate(() => cb({ + statusCode: 500, + headers: {}, + on: jest.fn((event, handler) => { + if (event === 'end') handler(); + }) + })); + return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() }; + }); + + const result = await updateManager.fetchDockerHubTags('nginx', true); + expect(result).toEqual([]); + }); + }); + + describe('getChangelog', () => { + it('returns changelog with repo info and tags', async () => { + jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({ + description: 'Nginx web server', + pull_count: 5000000, + star_count: 1000, + last_updated: '2026-04-01T00:00:00Z' + }); + jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([ + { name: 'latest', last_pushed: '2026-04-01T00:00:00Z', digest: 'sha256:abcdef123456789', full_size: 1024 }, + { name: '1.25', last_pushed: '2026-03-15T00:00:00Z', digest: 'sha256:fedcba987654321', full_size: 2048 } + ]); + + const result = await updateManager.getChangelog('nginx:latest'); + + expect(result.imageName).toBe('nginx:latest'); + expect(result.currentTag).toBe('latest'); + expect(result.repository.description).toBe('Nginx web server'); + expect(result.repository.pullCount).toBe(5000000); + expect(result.tags).toHaveLength(2); + expect(result.urls.dockerHub).toContain('hub.docker.com'); + expect(result.changelog).toContain('Nginx web server'); + + updateManager.fetchDockerHubRepo.mockRestore(); + updateManager.fetchDockerHubTags.mockRestore(); + }); + + it('handles user images (namespace/repo format)', async () => { + jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({ + description: 'Plex', + pull_count: 1000, + star_count: 50 + }); + jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]); + + const result = await updateManager.getChangelog('linuxserver/plex:latest'); + + expect(result.repository.name).toBe('linuxserver/plex'); + expect(result.urls.dockerHub).toContain('linuxserver/plex'); + + updateManager.fetchDockerHubRepo.mockRestore(); + updateManager.fetchDockerHubTags.mockRestore(); + }); + + it('returns fallback on fetch error', async () => { + jest.spyOn(updateManager, 'fetchDockerHubRepo').mockRejectedValue(new Error('network')); + + const result = await updateManager.getChangelog('nginx:latest'); + + expect(result.imageName).toBe('nginx:latest'); + expect(result.error).toBe('network'); + expect(result.changelog).toContain('Unable to fetch'); + + updateManager.fetchDockerHubRepo.mockRestore(); + }); + + it('defaults tag to latest when not specified', async () => { + jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({}); + jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]); + + const result = await updateManager.getChangelog('redis'); + expect(result.currentTag).toBe('latest'); + + updateManager.fetchDockerHubRepo.mockRestore(); + updateManager.fetchDockerHubTags.mockRestore(); + }); + }); + + describe('runAutoUpdates', () => { + it('skips containers not enabled', async () => { + updateManager.config.autoUpdate = { + 'cont-1': { enabled: false, schedule: 'daily' } + }; + jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({}); + + await updateManager.runAutoUpdates(); + + expect(updateManager.updateContainer).not.toHaveBeenCalled(); + updateManager.updateContainer.mockRestore(); + }); + + it('skips outside maintenance window', async () => { + // Force time to be 10am (outside 2-4am default window) + jest.setSystemTime(new Date('2026-04-06T10:00:00')); + updateManager.config.autoUpdate = { + 'cont-1': { enabled: true, schedule: 'daily' } + }; + updateManager.availableUpdates.set('cont-1', { containerName: 'test' }); + jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({}); + + await updateManager.runAutoUpdates(); + + expect(updateManager.updateContainer).not.toHaveBeenCalled(); + updateManager.updateContainer.mockRestore(); + }); + + it('skips containers without available updates', async () => { + jest.setSystemTime(new Date('2026-04-06T03:00:00')); // Inside default 2-4am window + updateManager.config.autoUpdate = { + 'cont-1': { enabled: true, schedule: 'daily' } + }; + // No update in availableUpdates + jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({}); + + await updateManager.runAutoUpdates(); + + expect(updateManager.updateContainer).not.toHaveBeenCalled(); + updateManager.updateContainer.mockRestore(); + }); + + it('runs update when in custom maintenance window', async () => { + jest.setSystemTime(new Date('2026-04-06T03:00:00')); + updateManager.config.autoUpdate = { + 'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00', autoRollback: true } + }; + updateManager.availableUpdates.set('cont-1', { containerName: 'plex' }); + jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({ status: 'success' }); + + const events = []; + updateManager.on('auto-update-complete', e => events.push(e)); + + await updateManager.runAutoUpdates(); + + expect(updateManager.updateContainer).toHaveBeenCalledWith('cont-1', expect.any(Object)); + expect(events).toHaveLength(1); + + updateManager.removeAllListeners(); + updateManager.updateContainer.mockRestore(); + }); + + it('emits auto-update-failed on update error', async () => { + jest.setSystemTime(new Date('2026-04-06T03:00:00')); + updateManager.config.autoUpdate = { + 'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00' } + }; + updateManager.availableUpdates.set('cont-1', { containerName: 'plex' }); + jest.spyOn(updateManager, 'updateContainer').mockRejectedValue(new Error('update failed')); + + const events = []; + updateManager.on('auto-update-failed', e => events.push(e)); + + await updateManager.runAutoUpdates(); + + expect(events).toHaveLength(1); + expect(events[0].error).toBe('update failed'); + + updateManager.removeAllListeners(); + updateManager.updateContainer.mockRestore(); + }); + + it('skips weekly schedule when not Sunday', async () => { + // Monday April 6 2026 + jest.setSystemTime(new Date('2026-04-06T03:00:00')); + updateManager.config.autoUpdate = { + 'cont-1': { enabled: true, schedule: 'weekly', maintenanceWindow: '02:00-05:00' } + }; + updateManager.availableUpdates.set('cont-1', { containerName: 'test' }); + jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({}); + + await updateManager.runAutoUpdates(); + + expect(updateManager.updateContainer).not.toHaveBeenCalled(); + updateManager.updateContainer.mockRestore(); + }); + + it('skips when already ran today', async () => { + jest.setSystemTime(new Date('2026-04-06T03:00:00')); + updateManager.config.autoUpdate = { + 'cont-1': { + enabled: true, + schedule: 'daily', + maintenanceWindow: '02:00-05:00', + lastAutoUpdate: new Date('2026-04-06T02:30:00').toISOString() + } + }; + updateManager.availableUpdates.set('cont-1', { containerName: 'test' }); + jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({}); + + await updateManager.runAutoUpdates(); + + expect(updateManager.updateContainer).not.toHaveBeenCalled(); + updateManager.updateContainer.mockRestore(); + }); + }); + + describe('DashCaddy scenarios', () => { + it('Radarr auto-update: weekly at 3AM', () => { + updateManager.configureAutoUpdate('radarr-id', { + schedule: 'weekly', + maintenanceWindow: '02:00-05:00', + autoRollback: true + }); + + const config = updateManager.getAutoUpdateConfig()['radarr-id']; + expect(config.schedule).toBe('weekly'); + expect(config.maintenanceWindow).toBe('02:00-05:00'); + expect(config.autoRollback).toBe(true); + }); + + it('tracks update for Plex container', () => { + updateManager.availableUpdates.set('plex-id', { + containerId: 'plex-id', + containerName: 'plex', + imageName: 'lscr.io/linuxserver/plex:latest', + currentDigest: 'sha256:abc12', + latestDigest: 'sha256:def34', + detectedAt: new Date().toISOString() + }); + + const updates = updateManager.getAvailableUpdates(); + expect(updates[0].imageName).toBe('lscr.io/linuxserver/plex:latest'); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/url-resolver.test.js b/dashcaddy-api/__tests__/url-resolver.test.js new file mode 100644 index 0000000..ced64f2 --- /dev/null +++ b/dashcaddy-api/__tests__/url-resolver.test.js @@ -0,0 +1,122 @@ +const { resolveServiceUrl } = require('../url-resolver'); + +describe('URL Resolver — DashCaddy service URL resolution', () => { + const buildServiceUrl = jest.fn(id => `https://${id}.sami`); + + beforeEach(() => { + buildServiceUrl.mockClear(); + }); + + describe('Internet connectivity check', () => { + it('always resolves "internet" to google.com regardless of config', () => { + expect(resolveServiceUrl('internet', null, null, buildServiceUrl)) + .toBe('https://www.google.com'); + expect(buildServiceUrl).not.toHaveBeenCalled(); + }); + + it('ignores service object for internet ID', () => { + const service = { url: 'http://custom.test', isExternal: true, externalUrl: 'http://ext.test' }; + expect(resolveServiceUrl('internet', service, {}, buildServiceUrl)) + .toBe('https://www.google.com'); + }); + }); + + describe('External services (seedhost, cloud-hosted)', () => { + it('uses externalUrl for services marked isExternal', () => { + const service = { isExternal: true, externalUrl: 'https://usw123.seedhost.eu/sami/radarr' }; + expect(resolveServiceUrl('radarr', service, {}, buildServiceUrl)) + .toBe('https://usw123.seedhost.eu/sami/radarr'); + }); + + it('ignores isExternal if externalUrl is missing', () => { + const service = { isExternal: true }; + expect(resolveServiceUrl('plex', service, {}, buildServiceUrl)) + .toBe('https://plex.sami'); + }); + }); + + describe('Custom URL override on service', () => { + it('uses service.url with http prefix as-is', () => { + const service = { url: 'http://192.168.1.100:32400' }; + expect(resolveServiceUrl('plex', service, {}, buildServiceUrl)) + .toBe('http://192.168.1.100:32400'); + }); + + it('uses service.url with https prefix as-is', () => { + const service = { url: 'https://plex.mydomain.com' }; + expect(resolveServiceUrl('plex', service, {}, buildServiceUrl)) + .toBe('https://plex.mydomain.com'); + }); + + it('prepends https:// to bare hostnames', () => { + const service = { url: 'plex.sami' }; + expect(resolveServiceUrl('plex', service, {}, buildServiceUrl)) + .toBe('https://plex.sami'); + }); + }); + + describe('DNS server resolution (Technitium, Pi-hole)', () => { + it('resolves DNS server by ID from siteConfig', () => { + const siteConfig = { + dnsServers: { + dns1: { ip: '192.168.254.204', port: 5380 }, + dns2: { ip: '100.74.102.61', port: 5380 }, + } + }; + expect(resolveServiceUrl('dns1', null, siteConfig, buildServiceUrl)) + .toBe('http://192.168.254.204:5380'); + expect(resolveServiceUrl('dns2', null, siteConfig, buildServiceUrl)) + .toBe('http://100.74.102.61:5380'); + }); + + it('defaults to port 5380 when port is omitted', () => { + const siteConfig = { dnsServers: { dns1: { ip: '10.0.0.1' } } }; + expect(resolveServiceUrl('dns1', null, siteConfig, buildServiceUrl)) + .toBe('http://10.0.0.1:5380'); + }); + }); + + describe('Fallback to buildServiceUrl (Caddy subdomain/subdirectory)', () => { + it('falls back for local services with no special config', () => { + resolveServiceUrl('radarr', { name: 'Radarr' }, {}, buildServiceUrl); + expect(buildServiceUrl).toHaveBeenCalledWith('radarr'); + }); + + it('works when service is null (top-card items)', () => { + expect(resolveServiceUrl('sonarr', null, {}, buildServiceUrl)) + .toBe('https://sonarr.sami'); + }); + + it('works when siteConfig is null', () => { + expect(resolveServiceUrl('jellyfin', null, null, buildServiceUrl)) + .toBe('https://jellyfin.sami'); + }); + }); + + describe('Priority chain — higher priority wins', () => { + const fullService = { + isExternal: true, + externalUrl: 'https://external.test', + url: 'http://custom.test', + }; + const siteConfig = { + dnsServers: { myservice: { ip: '10.0.0.1', port: 5380 } } + }; + + it('externalUrl wins over service.url and DNS', () => { + expect(resolveServiceUrl('myservice', fullService, siteConfig, buildServiceUrl)) + .toBe('https://external.test'); + }); + + it('service.url wins over DNS and fallback', () => { + const service = { url: 'http://custom.test' }; + expect(resolveServiceUrl('myservice', service, siteConfig, buildServiceUrl)) + .toBe('http://custom.test'); + }); + + it('DNS wins over fallback', () => { + expect(resolveServiceUrl('myservice', null, siteConfig, buildServiceUrl)) + .toBe('http://10.0.0.1:5380'); + }); + }); +}); diff --git a/dashcaddy-api/jest.config.js b/dashcaddy-api/jest.config.js index 41fbdf5..8910c6b 100644 --- a/dashcaddy-api/jest.config.js +++ b/dashcaddy-api/jest.config.js @@ -11,7 +11,18 @@ module.exports = { 'update-manager.js', 'resource-monitor.js', 'credential-manager.js', - 'app-templates.js' + 'app-templates.js', + 'auth-manager.js', + 'csrf-protection.js', + 'errors.js', + 'error-handler.js', + 'routes/health.js', + 'routes/services.js', + 'routes/containers.js', + 'url-resolver.js', + 'pagination.js', + 'platform-paths.js', + 'port-lock-manager.js' ], coverageThreshold: { global: { diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 6fec661..9de8770 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -8,6 +8,12 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:ci": "jest --ci --coverage --maxWorkers=2 --forceExit", + "test:unit": "jest --testPathPattern=__tests__/(?!routes|integration) --no-coverage", + "test:routes": "jest --testPathPattern=__tests__/routes --no-coverage", + "test:security": "jest --testPathPattern=(crypto-utils|credential-manager|csrf-protection|auth-manager|input-validator|backup-manager) --no-coverage", + "test:changed": "jest --onlyChanged --no-coverage", + "test:debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand --no-coverage", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write '**/*.{js,json,md}'" diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index 62ced94..0cbbcb7 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -10,7 +10,7 @@ const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); const { resolveServiceUrl } = require('../url-resolver'); const { success, error: errorResponse } = require('../response-helpers'); -const { ConflictError } = require('../errors'); +const { ConflictError, ValidationError, NotFoundError } = require('../errors'); /** * Services route factory