336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
/**
|
|
* 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('../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);
|
|
});
|
|
});
|
|
});
|