/** * Smoke tests for config-drift-detector.js * Verifies the ConfigDriftDetector class detects drift across all categories, * exposes polling control, extracts container ports, and dispatches * drift notifications. */ const EventEmitter = require('events'); const { ConfigDriftDetector } = require('../src/managers/config-drift-detector'); function makeContainer(overrides = {}) { return { Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', Names: ['/dashcaddy-test'], Image: 'nginx:latest', State: 'running', Status: 'Up 5 minutes', Ports: [], Labels: {}, ...overrides, }; } function makeDetector(overrides = {}) { const servicesStateManager = { read: jest.fn().mockResolvedValue([]), update: jest.fn().mockImplementation(async (updater) => { const data = await servicesStateManager.read(); const list = Array.isArray(data) ? data : (data?.services || []); const next = updater(list); return next; }), ...(overrides.servicesStateManager || {}), }; const docker = { client: { listContainers: jest.fn().mockResolvedValue([]), ...(overrides.dockerClient || {}), }, }; const notification = { send: jest.fn().mockResolvedValue({ success: true }), ...(overrides.notification || {}), }; const ctx = { docker, servicesStateManager, notification, log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), }, logError: jest.fn(), }; const detector = new ConfigDriftDetector(ctx); return { detector, ctx, docker, servicesStateManager, notification }; } describe('ConfigDriftDetector', () => { describe('constructor', () => { test('extends EventEmitter and stores ctx dependencies', () => { const { detector, ctx } = makeDetector(); expect(detector).toBeInstanceOf(EventEmitter); expect(detector.ctx).toBe(ctx); expect(detector.docker).toBe(ctx.docker); expect(detector.servicesStateManager).toBe(ctx.servicesStateManager); expect(detector.notification).toBe(ctx.notification); expect(detector.lastReport).toBeNull(); expect(detector.isPolling()).toBe(false); }); }); describe('detect()', () => { test('returns a clean report when services and containers are empty', async () => { const { detector } = makeDetector(); const report = await detector.detect(); expect(report).toHaveProperty('checkedAt'); expect(report.missingContainers).toEqual([]); expect(report.unknownContainers).toEqual([]); expect(report.portMismatch).toEqual([]); expect(report.stateMismatch).toEqual([]); expect(report.staleRecords).toEqual([]); expect(report.hasDrift).toBe(false); }); test('flags missing containers when service containerId is not in Docker', async () => { const services = [{ id: 'svc-1', name: 'svc-1', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef0000000000000000', }]; const { detector, servicesStateManager, docker } = makeDetector(); servicesStateManager.read.mockResolvedValue(services); docker.client.listContainers.mockResolvedValue([]); const report = await detector.detect(); expect(report.staleRecords).toHaveLength(1); expect(report.staleRecords[0].serviceId).toBe('svc-1'); expect(report.hasDrift).toBe(true); }); test('flags port mismatches between service config and container', async () => { const services = [{ id: 'svc-1', name: 'svc-1', port: 8080, containerId: 'abcdef012345', }]; const containers = [makeContainer({ Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', Ports: [{ PublicPort: 9090, PrivatePort: 80, Type: 'tcp' }], })]; const { detector, servicesStateManager, docker } = makeDetector(); servicesStateManager.read.mockResolvedValue(services); docker.client.listContainers.mockResolvedValue(containers); const report = await detector.detect(); expect(report.portMismatch).toHaveLength(1); expect(report.portMismatch[0].configuredPort).toBe(8080); expect(report.portMismatch[0].actualPorts).toEqual([9090]); }); test('flags state mismatch when service is not running', async () => { const services = [{ id: 'svc-1', name: 'svc-1', containerId: 'abcdef012345', }]; const containers = [makeContainer({ State: 'exited', Status: 'Exited (1) 5 minutes ago' })]; const { detector, servicesStateManager, docker } = makeDetector(); servicesStateManager.read.mockResolvedValue(services); docker.client.listContainers.mockResolvedValue(containers); const report = await detector.detect(); expect(report.missingContainers).toHaveLength(1); expect(report.stateMismatch).toHaveLength(1); expect(report.stateMismatch[0].actualState).toBe('exited'); }); test('flags unknown managed containers not in services.json', async () => { const containers = [makeContainer({ Labels: { 'sami.managed': 'true', 'sami.app': 'whoami' }, })]; const { detector, docker, servicesStateManager } = makeDetector(); docker.client.listContainers.mockResolvedValue(containers); servicesStateManager.read.mockResolvedValue([]); const report = await detector.detect(); expect(report.unknownContainers).toHaveLength(1); expect(report.unknownContainers[0].name).toBe('dashcaddy-test'); expect(report.unknownContainers[0].app).toBe('whoami'); }); test('emits drift-detected and sends notification when drift exists', async () => { const services = [{ id: 'svc-1', name: 'svc-1', containerId: 'missingcontainer00', }]; const { detector, servicesStateManager, docker, notification } = makeDetector(); servicesStateManager.read.mockResolvedValue(services); docker.client.listContainers.mockResolvedValue([]); const onDrift = jest.fn(); detector.on('drift-detected', onDrift); await detector.detect(); expect(onDrift).toHaveBeenCalledTimes(1); expect(notification.send).toHaveBeenCalledTimes(1); expect(notification.send.mock.calls[0][0]).toBe('drift-detected'); const payload = notification.send.mock.calls[0][1]; expect(payload.text).toMatch(/drift/i); expect(payload.report).toBeDefined(); }); test('caches the report on the instance', async () => { const { detector } = makeDetector(); const report = await detector.detect(); expect(detector.lastReport).toBe(report); }); test('handles services as a wrapper object with .services field', async () => { const { detector, servicesStateManager } = makeDetector(); servicesStateManager.read.mockResolvedValue({ services: [] }); const report = await detector.detect(); expect(report).toBeDefined(); expect(report.hasDrift).toBe(false); }); test('tolerates Docker listContainers failure (logs and continues)', async () => { const { detector, docker, ctx } = makeDetector(); docker.client.listContainers.mockRejectedValue(new Error('docker daemon down')); const report = await detector.detect(); expect(report).toBeDefined(); expect(report.hasDrift).toBe(false); expect(ctx.log.error).toHaveBeenCalled(); }); }); describe('autoFix()', () => { test('removes stale records via servicesStateManager.update', async () => { const services = [ { id: 'svc-good', name: 'svc-good', containerId: 'liveid0000000000000000000000000000' }, { id: 'svc-stale', name: 'svc-stale', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef00000000' }, ]; const containers = [makeContainer({ Id: 'liveid0000000000000000000000000000000000000000000000000000000000', })]; const { detector, servicesStateManager, docker } = makeDetector(); servicesStateManager.read.mockResolvedValue(services); servicesStateManager.update.mockImplementation(async (updater) => { const next = updater(services); return next; }); docker.client.listContainers.mockResolvedValue(containers); const result = await detector.autoFix(); expect(result.staleRemoved).toBe(1); expect(result.unknownFlagged).toBe(0); expect(servicesStateManager.update).toHaveBeenCalledTimes(1); }); }); describe('polling', () => { afterEach(() => { jest.useRealTimers(); }); test('startPolling/stopPolling toggles isPolling', () => { const { detector } = makeDetector(); expect(detector.isPolling()).toBe(false); detector.startPolling(60000); expect(detector.isPolling()).toBe(true); detector.stopPolling(); expect(detector.isPolling()).toBe(false); }); test('startPolling clears any existing timer before starting a new one', () => { const { detector } = makeDetector(); detector.startPolling(60000); const firstTimer = detector._pollTimer; detector.startPolling(120000); expect(detector._pollTimer).not.toBe(firstTimer); detector.stopPolling(); }); test('stopPolling is a safe no-op when not started', () => { const { detector } = makeDetector(); expect(() => detector.stopPolling()).not.toThrow(); expect(detector.isPolling()).toBe(false); }); test('runs detect on the polling interval', async () => { jest.useFakeTimers(); const { detector } = makeDetector(); const detectSpy = jest.spyOn(detector, 'detect').mockResolvedValue({ checkedAt: new Date().toISOString(), missingContainers: [], unknownContainers: [], portMismatch: [], stateMismatch: [], staleRecords: [], hasDrift: false, }); detector.startPolling(1000); jest.advanceTimersByTime(3500); // 3 intervals should have fired (1000, 2000, 3000) expect(detectSpy.mock.calls.length).toBeGreaterThanOrEqual(3); detector.stopPolling(); detectSpy.mockRestore(); }); }); describe('_extractContainerPorts', () => { test('returns mapped public ports', () => { const { detector } = makeDetector(); const ports = detector._extractContainerPorts({ Ports: [ { PublicPort: 8080, PrivatePort: 80, Type: 'tcp' }, { PublicPort: 8443, PrivatePort: 443, Type: 'tcp' }, { PrivatePort: 53, Type: 'udp' }, // No PublicPort → not exposed ], }); expect(ports).toEqual([8080, 8443]); }); test('returns [] when container has no Ports field', () => { const { detector } = makeDetector(); expect(detector._extractContainerPorts({})).toEqual([]); expect(detector._extractContainerPorts({ Ports: null })).toEqual([]); }); }); describe('_sendDriftNotification', () => { test('returns early when no notification manager is present', async () => { const { detector } = makeDetector({ notification: null }); // Replace the field with null/undefined to simulate missing detector.notification = null; const result = await detector._sendDriftNotification({ hasDrift: true }); expect(result.success).toBe(false); expect(result.reason).toMatch(/no-notification-manager/i); }); test('formats message with one line per drift category', async () => { const { detector, notification } = makeDetector(); const report = { missingContainers: [{ name: 'app-a' }], unknownContainers: [{ name: 'app-b' }], portMismatch: [{ name: 'app-c' }], stateMismatch: [], staleRecords: [{ name: 'app-d' }], hasDrift: true, }; await detector._sendDriftNotification(report); expect(notification.send).toHaveBeenCalledTimes(1); const payload = notification.send.mock.calls[0][1]; expect(payload.text).toMatch(/Missing containers: app-a/); expect(payload.text).toMatch(/Unknown managed containers: app-b/); expect(payload.text).toMatch(/Port mismatches: app-c/); expect(payload.text).toMatch(/Stale records: app-d/); expect(payload.report).toBe(report); }); }); });