DC-007: Add smoke tests for 7 untested modules
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

This commit is contained in:
Hermes
2026-06-13 11:38:51 -07:00
parent 5b1d631870
commit f96e903710
8 changed files with 1624 additions and 1 deletions
@@ -0,0 +1,367 @@
/**
* Smoke tests for auto-restart-manager.js
* Verifies the AutoRestartManager class:
* - Policy CRUD (set/get/list/remove)
* - handleContainerDown: cooldown, max-retries, restart attempt, failure
* - handleContainerUp: retry counter reset
* - _handleStatusCheck: healthy→unhealthy and unhealthy→healthy transitions
* - _resolveContainerId: lookup precedence
*/
const EventEmitter = require('events');
const { AutoRestartManager, DEFAULT_POLICY } = require('../auto-restart-manager');
jest.mock('../fs-helpers', () => ({
readJsonFile: jest.fn().mockResolvedValue({}),
writeJsonFile: jest.fn().mockResolvedValue(undefined),
}));
const fsHelpers = require('../fs-helpers');
function makeManager(overrides = {}) {
const servicesStateManager = {
read: jest.fn().mockResolvedValue([]),
...(overrides.servicesStateManager || {}),
};
const docker = {
client: {
getContainer: jest.fn(),
...(overrides.dockerClient || {}),
},
};
const healthChecker = new EventEmitter();
if (overrides.healthChecker) {
Object.assign(healthChecker, overrides.healthChecker);
}
const notification = {
send: jest.fn().mockResolvedValue({ success: true }),
...(overrides.notification || {}),
};
const ctx = {
docker,
healthChecker,
notification,
servicesStateManager,
SERVICES_FILE: '/tmp/dc-test/services.json',
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
logError: jest.fn(),
};
const manager = new AutoRestartManager(ctx);
return { manager, ctx, docker, healthChecker, notification, servicesStateManager };
}
describe('AutoRestartManager', () => {
beforeEach(() => {
jest.clearAllMocks();
fsHelpers.readJsonFile.mockResolvedValue({});
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
});
describe('constants & construction', () => {
test('DEFAULT_POLICY has the documented fields and sensible defaults', () => {
expect(DEFAULT_POLICY).toEqual({
enabled: true,
maxRetries: 3,
retryIntervalMs: 5000,
windowMinutes: 10,
currentRetries: 0,
lastRestartAt: null,
cooldownUntil: null,
});
});
test('manager extends EventEmitter and stores ctx deps', () => {
const { manager, ctx } = makeManager();
expect(manager).toBeInstanceOf(EventEmitter);
expect(manager.docker).toBe(ctx.docker);
expect(manager.healthChecker).toBe(ctx.healthChecker);
expect(manager.notification).toBe(ctx.notification);
expect(manager.policies).toBeInstanceOf(Map);
});
});
describe('lifecycle', () => {
test('start() loads persisted policies from fs-helpers', async () => {
fsHelpers.readJsonFile.mockResolvedValue({
'svc-1': { enabled: false, maxRetries: 7 },
});
const { manager } = makeManager();
await manager.start();
expect(manager.policies.has('svc-1')).toBe(true);
const policy = manager.getPolicy('svc-1');
expect(policy.maxRetries).toBe(7);
expect(policy.enabled).toBe(false);
});
test('start() is idempotent (second call does nothing new)', async () => {
const { manager, healthChecker } = makeManager();
await manager.start();
const listenerCount = healthChecker.listenerCount('status-check');
await manager.start();
expect(healthChecker.listenerCount('status-check')).toBe(listenerCount);
});
test('stop() removes the status-check listener', async () => {
const { manager, healthChecker } = makeManager();
await manager.start();
expect(healthChecker.listenerCount('status-check')).toBe(1);
manager.stop();
expect(healthChecker.listenerCount('status-check')).toBe(0);
});
});
describe('policy CRUD', () => {
test('setPolicy throws on missing serviceId', async () => {
const { manager } = makeManager();
await expect(manager.setPolicy('', { enabled: true })).rejects.toThrow(/serviceId/);
await expect(manager.setPolicy(null, {})).rejects.toThrow(/serviceId/);
});
test('setPolicy merges fields with existing policy', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 5 });
await manager.setPolicy('svc-1', { enabled: false });
const policy = manager.getPolicy('svc-1');
expect(policy.maxRetries).toBe(5); // preserved from earlier
expect(policy.enabled).toBe(false); // updated by second call
});
test('setPolicy persists via fs-helpers.writeJsonFile', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 4 });
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
const [filePath, payload] = fsHelpers.writeJsonFile.mock.calls[0];
expect(filePath).toMatch(/auto-restart-policies\.json$/);
expect(payload['svc-1'].maxRetries).toBe(4);
});
test('getPolicy returns a copy, not the internal reference', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 2 });
const a = manager.getPolicy('svc-1');
a.maxRetries = 999;
const b = manager.getPolicy('svc-1');
expect(b.maxRetries).toBe(2);
});
test('getPolicy returns null for unknown service', () => {
const { manager } = makeManager();
expect(manager.getPolicy('does-not-exist')).toBeNull();
});
test('listPolicies returns array of all policies', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 1 });
await manager.setPolicy('svc-2', { maxRetries: 2 });
const list = manager.listPolicies();
expect(Array.isArray(list)).toBe(true);
expect(list).toHaveLength(2);
const ids = list.map(p => p.serviceId);
expect(ids).toEqual(expect.arrayContaining(['svc-1', 'svc-2']));
});
test('removePolicy returns true and deletes the policy', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 1 });
expect(await manager.removePolicy('svc-1')).toBe(true);
expect(manager.getPolicy('svc-1')).toBeNull();
});
test('removePolicy returns false for unknown service', async () => {
const { manager } = makeManager();
expect(await manager.removePolicy('does-not-exist')).toBe(false);
});
});
describe('handleContainerDown', () => {
test('returns ignored/no-policy when no policy exists', async () => {
const { manager } = makeManager();
const result = await manager.handleContainerDown('unknown', 'cid');
expect(result.action).toBe('ignored');
expect(result.reason).toBe('no-policy');
});
test('returns ignored/disabled when policy.enabled is false', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { enabled: false });
const result = await manager.handleContainerDown('svc-1', 'cid');
expect(result.action).toBe('ignored');
expect(result.reason).toBe('disabled');
});
test('returns skipped/cooldown when cooldownUntil is in the future', async () => {
const { manager } = makeManager();
// setPolicy() intentionally guards runtime fields; we have to set
// cooldownUntil via the internal map to simulate an in-progress cooldown
await manager.setPolicy('svc-1', { maxRetries: 3 });
manager.policies.get('svc-1').cooldownUntil = Date.now() + 60_000;
const result = await manager.handleContainerDown('svc-1', 'cid');
expect(result.action).toBe('skipped');
expect(result.reason).toBe('cooldown');
});
test('increments currentRetries and calls docker.start on a successful restart', async () => {
const { manager, docker } = makeManager();
docker.client.getContainer.mockReturnValue({
start: jest.fn().mockResolvedValue(undefined),
});
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
const onAttempt = jest.fn();
const onSuccess = jest.fn();
manager.on('auto-restart-attempt', onAttempt);
manager.on('auto-restart-success', onSuccess);
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
expect(result.action).toBe('restarted');
expect(result.attempt).toBe(1);
expect(result.serviceId).toBe('svc-1');
expect(docker.client.getContainer).toHaveBeenCalledWith('cid-abc');
expect(onAttempt).toHaveBeenCalledTimes(1);
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
});
test('emits auto-restart-failed and increments currentRetries when docker.start throws', async () => {
const { manager, docker } = makeManager();
docker.client.getContainer.mockReturnValue({
start: jest.fn().mockRejectedValue(new Error('docker daemon down')),
});
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
const onFailed = jest.fn();
manager.on('auto-restart-failed', onFailed);
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
expect(result.action).toBe('failed');
expect(result.error).toMatch(/docker daemon down/);
expect(onFailed).toHaveBeenCalledTimes(1);
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
});
test('emits auto-restart-max-reached and sets cooldown when maxRetries exceeded', async () => {
const { manager, docker } = makeManager();
docker.client.getContainer.mockReturnValue({
start: jest.fn().mockResolvedValue(undefined),
});
await manager.setPolicy('svc-1', { maxRetries: 2, retryIntervalMs: 0 });
const onMax = jest.fn();
manager.on('auto-restart-max-reached', onMax);
// First attempt: currentRetries=0 -> succeeds, increments to 1
await manager.handleContainerDown('svc-1', 'cid');
// Second: 1 -> succeeds, increments to 2
await manager.handleContainerDown('svc-1', 'cid');
// Third: 2 >= maxRetries(2) -> max-reached, currentRetries reset to 0
const result = await manager.handleContainerDown('svc-1', 'cid');
expect(result.action).toBe('max-reached');
expect(onMax).toHaveBeenCalledTimes(1);
const policy = manager.getPolicy('svc-1');
expect(policy.currentRetries).toBe(0);
expect(policy.cooldownUntil).toBeGreaterThan(Date.now());
});
});
describe('handleContainerUp', () => {
test('resets currentRetries and cooldownUntil when service is tracked', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { currentRetries: 2, cooldownUntil: Date.now() + 10000 });
// Mutate via internal map (bypassing the setter guard)
manager.policies.get('svc-1').currentRetries = 2;
manager.policies.get('svc-1').cooldownUntil = Date.now() + 10000;
await manager.handleContainerUp('svc-1');
const policy = manager.getPolicy('svc-1');
expect(policy.currentRetries).toBe(0);
expect(policy.cooldownUntil).toBeNull();
});
test('is a no-op when service is not tracked', async () => {
const { manager } = makeManager();
await expect(manager.handleContainerUp('unknown')).resolves.toBeUndefined();
});
});
describe('_handleStatusCheck', () => {
test('triggers handleContainerDown on healthy→unhealthy transition', async () => {
const { manager, docker } = makeManager();
docker.client.getContainer.mockReturnValue({
start: jest.fn().mockResolvedValue(undefined),
});
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
// Pre-set previous health
manager._previousHealth.set('svc-1', 'up');
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
await manager._handleStatusCheck({
serviceId: 'svc-1',
status: 'down',
details: { containerId: 'cid-1' },
});
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-1');
});
test('triggers handleContainerUp on unhealthy→healthy transition', async () => {
const { manager } = makeManager();
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
manager._previousHealth.set('svc-1', 'down');
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'up' });
expect(handleUpSpy).toHaveBeenCalledWith('svc-1');
});
test('does nothing for services without a policy', async () => {
const { manager } = makeManager();
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
await manager._handleStatusCheck({ serviceId: 'untracked', status: 'down' });
expect(handleDownSpy).not.toHaveBeenCalled();
expect(handleUpSpy).not.toHaveBeenCalled();
});
test('ignores status with no serviceId', async () => {
const { manager } = makeManager();
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
await manager._handleStatusCheck({ status: 'down' });
expect(handleDownSpy).not.toHaveBeenCalled();
});
});
describe('_resolveContainerId', () => {
test('returns containerId from status.details when present', () => {
const { manager } = makeManager();
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
expect(cid).toBe('cid-details');
});
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
const { manager, healthChecker } = makeManager();
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
const cid = manager._resolveContainerId('svc-1', { details: {} });
expect(cid).toBe('cid-hc');
});
test('falls back to servicesStateManager.read when sync list is returned', () => {
const { manager, servicesStateManager } = makeManager();
servicesStateManager.read.mockReturnValue([
{ id: 'svc-1', containerId: 'cid-state' },
]);
const cid = manager._resolveContainerId('svc-1', { details: {} });
expect(cid).toBe('cid-state');
});
test('returns null when no source has a containerId', () => {
const { manager } = makeManager();
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
expect(cid).toBeNull();
});
});
});
@@ -0,0 +1,335 @@
/**
* 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);
});
});
});
@@ -0,0 +1,106 @@
/**
* Smoke tests for dns-propagation.js
* Verifies DNS propagation checker module loads, exposes the expected
* interface, and basic methods (verifyRecord, startVerification,
* getVerificationStatus, getAllVerifications, cleanup) work without throwing.
*/
// The module does `const dns = require('dns').promises;` then `new dns.Resolver()`.
// We mock the dns module so that .promises exposes our Resolver class.
jest.mock('dns', () => {
class MockResolver {
setServers() { return this; }
setTimeout() { return this; }
resolve4(domain) {
if (domain === 'propagated.sami') {
return Promise.resolve(['1.2.3.4']);
}
return Promise.resolve(['9.9.9.9']);
}
}
return {
promises: { Resolver: MockResolver },
Resolver: MockResolver,
};
});
const DNSPropagationChecker = require('../dns-propagation');
describe('DNSPropagationChecker', () => {
let checker;
beforeEach(() => {
const ctx = {
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
notification: { send: jest.fn().mockResolvedValue({ success: true }) },
};
checker = new DNSPropagationChecker(ctx);
});
test('is an EventEmitter', () => {
expect(typeof checker.on).toBe('function');
expect(typeof checker.emit).toBe('function');
});
test('starts with an empty verifications map', () => {
expect(checker.verifications).toBeInstanceOf(Map);
expect(checker.verifications.size).toBe(0);
});
test('verifyRecord returns expected shape and detects propagated domain', async () => {
const result = await checker.verifyRecord('propagated.sami', '1.2.3.4', {
timeout: 5000,
interval: 100,
resolvers: ['1.1.1.1'],
});
expect(result).toHaveProperty('domain', 'propagated.sami');
expect(result).toHaveProperty('expectedIp', '1.2.3.4');
expect(result).toHaveProperty('propagated', true);
expect(Array.isArray(result.results)).toBe(true);
expect(result.results.length).toBeGreaterThan(0);
expect(typeof result.totalTime).toBe('number');
expect(typeof result.checkedAt).toBe('string');
});
test('verifyRecord reports not-propagated when IP does not match', async () => {
const result = await checker.verifyRecord('notpropagated.sami', '5.6.7.8', {
timeout: 200,
interval: 50,
resolvers: ['1.1.1.1'],
});
expect(result.propagated).toBe(false);
});
test('startVerification returns a job object with running status', () => {
const job = checker.startVerification('job.sami', '1.1.1.1', {
timeout: 100,
interval: 50,
resolvers: ['1.1.1.1'],
});
expect(job).toMatchObject({
domain: 'job.sami',
expectedIp: '1.1.1.1',
status: 'running',
});
expect(job.startedAt).toBeDefined();
});
test('startVerification returns the same job when called twice for one domain', () => {
const a = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
const b = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
expect(a).toBe(b);
});
test('getVerificationStatus returns null for unknown domain', () => {
expect(checker.getVerificationStatus('nope.sami')).toBeNull();
});
test('getAllVerifications returns an array', () => {
expect(Array.isArray(checker.getAllVerifications())).toBe(true);
});
test('cleanup is a no-op on empty verifications', () => {
expect(() => checker.cleanup()).not.toThrow();
expect(checker.verifications.size).toBe(0);
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* Smoke tests for log-digest.js
* Verifies the singleton LogDigest exposes the expected interface, parses
* Docker multiplexed log streams, formats digests, and supports on-demand
* daily digest generation with mocked Docker.
*/
const fsReal = require('fs');
const os = require('os');
const path = require('path');
jest.mock('dockerode', () => {
const listContainers = jest.fn().mockResolvedValue([]);
const getContainer = jest.fn(() => ({
logs: jest.fn().mockResolvedValue(Buffer.from([])),
}));
function Docker() {}
Docker.prototype.listContainers = listContainers;
Docker.prototype.getContainer = getContainer;
return Docker;
});
jest.mock('fs', () => {
const actual = jest.requireActual('fs');
return {
...actual,
existsSync: jest.fn().mockReturnValue(true),
mkdirSync: jest.fn(),
};
});
jest.mock('../docker-maintenance', () => ({
getDiskUsage: jest.fn().mockResolvedValue(null),
}));
const Docker = require('dockerode');
const fs = require('fs');
const logDigest = require('../log-digest');
describe('LogDigest (singleton)', () => {
let dockerInstance;
let tempDir;
beforeEach(() => {
// Each test gets a fresh Docker() mock instance
jest.clearAllMocks();
fs.existsSync.mockReturnValue(true);
// Use a real, writable temp directory so writeFile inside generateDailyDigest
// does not blow up. Each test gets a fresh dir to avoid cross-test pollution.
tempDir = fsReal.mkdtempSync(path.join(os.tmpdir(), 'dc-digest-test-'));
logDigest.hourlySummaries = [];
logDigest.lastCollect = null;
logDigest.running = false;
logDigest.digestDir = null;
if (logDigest.collectInterval) {
clearInterval(logDigest.collectInterval);
logDigest.collectInterval = null;
}
if (logDigest.digestTimeout) {
clearTimeout(logDigest.digestTimeout);
logDigest.digestTimeout = null;
}
dockerInstance = new Docker();
});
afterEach(() => {
logDigest.stop();
if (tempDir && fsReal.existsSync(tempDir)) {
fsReal.rmSync(tempDir, { recursive: true, force: true });
}
});
test('is an EventEmitter and exposes the documented API', () => {
expect(typeof logDigest.on).toBe('function');
expect(typeof logDigest.emit).toBe('function');
expect(typeof logDigest.start).toBe('function');
expect(typeof logDigest.stop).toBe('function');
expect(typeof logDigest.generateDailyDigest).toBe('function');
expect(typeof logDigest.getLatestDigest).toBe('function');
expect(typeof logDigest.getDigestByDate).toBe('function');
expect(typeof logDigest.getDigestText).toBe('function');
expect(typeof logDigest.listDigests).toBe('function');
expect(typeof logDigest.getLiveData).toBe('function');
expect(typeof logDigest.getStatus).toBe('function');
});
test('getStatus returns current state', () => {
const status = logDigest.getStatus();
expect(status).toEqual({
running: false,
lastCollect: null,
hourlySummaries: 0,
digestDir: null,
});
});
test('start sets running and digestDir', () => {
logDigest.start(tempDir);
expect(logDigest.running).toBe(true);
expect(logDigest.digestDir).toBe(tempDir);
});
test('start is idempotent — second call does nothing new', () => {
logDigest.start(tempDir);
const firstInterval = logDigest.collectInterval;
logDigest.start(tempDir);
expect(logDigest.collectInterval).toBe(firstInterval);
});
test('_parseDockerLogs decodes multiplexed log frames into lines', () => {
// Stream type byte: 0=stdin, 1=stdout, 2=stderr
// Header: [type, 0, 0, 0, size-BE-uint32]
function frame(streamType, text) {
const buf = Buffer.from(text, 'utf8');
const header = Buffer.alloc(8);
header[0] = streamType;
header.writeUInt32BE(buf.length, 4);
return Buffer.concat([header, buf]);
}
const multiplexed = Buffer.concat([
frame(1, 'hello world\n'),
frame(2, '2026-03-13T12:00:00.000Z an error happened\n'),
]);
const lines = logDigest._parseDockerLogs(multiplexed);
expect(lines).toHaveLength(2);
expect(lines[0]).toEqual({
stream: 'stdout',
text: 'hello world',
timestamp: null,
});
expect(lines[1].stream).toBe('stderr');
expect(lines[1].text).toBe('an error happened');
expect(lines[1].timestamp).toBe('2026-03-13T12:00:00');
});
test('generateDailyDigest with empty summaries produces minimal digest', async () => {
logDigest.start(tempDir);
const digest = await logDigest.generateDailyDigest('2099-01-01');
expect(digest.date).toBe('2099-01-01');
expect(digest.services).toEqual({});
expect(digest.summary.totalServices).toBe(0);
expect(digest.summary.totalErrors).toBe(0);
expect(Array.isArray(digest.notableEvents)).toBe(true);
// Confirm the file was actually written
const writtenPath = path.join(tempDir, 'digest-2099-01-01.log');
expect(fsReal.existsSync(writtenPath)).toBe(true);
const jsonPath = path.join(tempDir, 'digest-2099-01-01.json');
expect(fsReal.existsSync(jsonPath)).toBe(true);
});
test('getLiveData returns shape with date, hoursCollected, services', () => {
const data = logDigest.getLiveData();
expect(data).toHaveProperty('date');
expect(data).toHaveProperty('hoursCollected');
expect(data).toHaveProperty('services');
expect(data).toHaveProperty('lastCollect');
});
test('getLatestDigest returns null when digestDir is null', async () => {
logDigest.digestDir = null;
const result = await logDigest.getLatestDigest();
expect(result).toBeNull();
});
test('getDigestByDate returns null when no file exists', async () => {
logDigest.digestDir = '/nonexistent/path';
const result = await logDigest.getDigestByDate('2020-01-01');
expect(result).toBeNull();
});
test('listDigests returns empty array when digestDir is null', async () => {
logDigest.digestDir = null;
const result = await logDigest.listDigests();
expect(result).toEqual([]);
});
test('stop clears intervals and timeouts', () => {
logDigest.start(tempDir);
logDigest.stop();
expect(logDigest.running).toBe(false);
expect(logDigest.collectInterval).toBeNull();
expect(logDigest.digestTimeout).toBeNull();
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* Smoke tests for metrics.js
* Verifies the Metrics singleton exposes the expected interface, accumulates
* request/error/business counters, normalizes paths, formats uptime, and resets.
*
* The module exports a singleton instance, so we import it once and mutate its
* state in beforeEach.
*/
const metrics = require('../metrics');
describe('Metrics (singleton)', () => {
beforeEach(() => {
metrics.reset();
});
test('exposes the documented public API', () => {
expect(typeof metrics.recordRequest).toBe('function');
expect(typeof metrics.recordError).toBe('function');
expect(typeof metrics.recordBusinessEvent).toBe('function');
expect(typeof metrics.normalizePath).toBe('function');
expect(typeof metrics.getSummary).toBe('function');
expect(typeof metrics.formatUptime).toBe('function');
expect(typeof metrics.reset).toBe('function');
});
describe('recordRequest', () => {
test('increments total request count', () => {
metrics.recordRequest('GET', '/api/services', 200, 12);
metrics.recordRequest('GET', '/api/services', 200, 8);
expect(metrics.requests.total).toBe(2);
});
test('aggregates by status code', () => {
metrics.recordRequest('GET', '/a', 200, 5);
metrics.recordRequest('GET', '/b', 200, 5);
metrics.recordRequest('POST', '/c', 500, 5);
expect(metrics.requests.byStatus[200]).toBe(2);
expect(metrics.requests.byStatus[500]).toBe(1);
});
test('aggregates by HTTP method', () => {
metrics.recordRequest('GET', '/a', 200, 1);
metrics.recordRequest('GET', '/b', 200, 1);
metrics.recordRequest('DELETE', '/c', 200, 1);
expect(metrics.requests.byMethod.GET).toBe(2);
expect(metrics.requests.byMethod.DELETE).toBe(1);
});
test('aggregates by normalized path with totalDuration', () => {
// Real-looking UUID and long hex hash; both should normalize to /:id
const id1 = '550e8400-e29b-41d4-a716-446655440000';
const id2 = 'abcdef0123456789abcdef0123456789';
metrics.recordRequest('GET', `/api/services/${id1}`, 200, 10);
metrics.recordRequest('GET', `/api/services/${id2}`, 200, 20);
const entry = metrics.requests.byPath['/api/services/:id'];
expect(entry).toBeDefined();
expect(entry.count).toBe(2);
expect(entry.totalDuration).toBe(30);
});
});
describe('recordError', () => {
test('increments total error count and per-type counts', () => {
metrics.recordError('ValidationError');
metrics.recordError('ValidationError');
metrics.recordError('DockerError');
expect(metrics.errors.total).toBe(3);
expect(metrics.errors.byType.ValidationError).toBe(2);
expect(metrics.errors.byType.DockerError).toBe(1);
});
});
describe('recordBusinessEvent', () => {
test('increments known business counters', () => {
metrics.recordBusinessEvent('containersDeployed');
metrics.recordBusinessEvent('containersDeployed');
metrics.recordBusinessEvent('dnsRecordsCreated');
expect(metrics.business.containersDeployed).toBe(2);
expect(metrics.business.dnsRecordsCreated).toBe(1);
});
test('ignores unknown event types without throwing', () => {
expect(() => metrics.recordBusinessEvent('not-a-real-event')).not.toThrow();
expect(metrics.business.notARealEvent).toBeUndefined();
});
});
describe('normalizePath', () => {
test('replaces UUIDs with /:id', () => {
const normalized = metrics.normalizePath('/api/services/550e8400-e29b-41d4-a716-446655440000');
expect(normalized).toBe('/api/services/:id');
});
test('replaces long hex segments with /:id', () => {
expect(metrics.normalizePath('/api/containers/abc123def4567890'))
.toBe('/api/containers/:id');
});
test('replaces numeric path segments with /:n', () => {
expect(metrics.normalizePath('/api/services/42/edit'))
.toBe('/api/services/:n/edit');
});
test('leaves static paths unchanged', () => {
expect(metrics.normalizePath('/api/health')).toBe('/api/health');
expect(metrics.normalizePath('/')).toBe('/');
});
});
describe('getSummary', () => {
test('returns an object with the documented top-level shape', () => {
const summary = metrics.getSummary();
expect(summary).toHaveProperty('uptime');
expect(summary.uptime).toHaveProperty('ms');
expect(summary.uptime).toHaveProperty('human');
expect(summary).toHaveProperty('requests');
expect(summary.requests).toHaveProperty('total');
expect(summary.requests).toHaveProperty('perSecond');
expect(summary.requests).toHaveProperty('byStatus');
expect(summary.requests).toHaveProperty('byMethod');
expect(summary.requests).toHaveProperty('topEndpoints');
expect(Array.isArray(summary.requests.topEndpoints)).toBe(true);
expect(summary).toHaveProperty('errors');
expect(summary.errors).toHaveProperty('total');
expect(summary.errors).toHaveProperty('rate');
expect(summary.errors).toHaveProperty('byType');
expect(summary).toHaveProperty('business');
expect(summary).toHaveProperty('process');
expect(summary.process).toHaveProperty('pid');
});
test('reflects recorded activity', () => {
metrics.recordRequest('GET', '/api/foo', 200, 10);
metrics.recordError('BoomError');
const summary = metrics.getSummary();
expect(summary.requests.total).toBe(1);
expect(summary.requests.byStatus[200]).toBe(1);
expect(summary.errors.total).toBe(1);
expect(summary.errors.byType.BoomError).toBe(1);
// 1 error / 1 request = 100% error rate
expect(summary.errors.rate).toBe(100);
});
test('topEndpoints is sorted by count descending and capped at 15', () => {
// /a gets 3 hits, /b gets 1, /c gets 2
metrics.recordRequest('GET', '/a', 200, 1);
metrics.recordRequest('GET', '/a', 200, 2);
metrics.recordRequest('GET', '/a', 200, 3);
metrics.recordRequest('GET', '/b', 200, 1);
metrics.recordRequest('GET', '/c', 200, 1);
metrics.recordRequest('GET', '/c', 200, 2);
const top = metrics.getSummary().requests.topEndpoints;
expect(top[0].path).toBe('/a');
expect(top[0].count).toBe(3);
expect(top[0].avgMs).toBe(2);
});
});
describe('formatUptime', () => {
test('formats seconds-only when under a minute', () => {
expect(metrics.formatUptime(0)).toBe('0s');
expect(metrics.formatUptime(45)).toBe('45s');
});
test('formats minutes and seconds when under an hour', () => {
expect(metrics.formatUptime(60)).toBe('1m 0s');
expect(metrics.formatUptime(125)).toBe('2m 5s');
});
test('formats hours/minutes/seconds when under a day', () => {
expect(metrics.formatUptime(3600)).toBe('1h 0m 0s');
expect(metrics.formatUptime(3725)).toBe('1h 2m 5s');
});
test('formats days/hours/minutes when over a day', () => {
expect(metrics.formatUptime(86400)).toBe('1d 0h 0m');
// 1 day, 2 hours, 5 minutes, 0 seconds
expect(metrics.formatUptime(86400 + 2 * 3600 + 5 * 60)).toBe('1d 2h 5m');
});
});
describe('reset', () => {
test('clears request counters and error counters', () => {
metrics.recordRequest('GET', '/x', 200, 1);
metrics.recordError('E');
metrics.reset();
expect(metrics.requests.total).toBe(0);
expect(metrics.errors.total).toBe(0);
expect(metrics.requests.byStatus).toEqual({});
expect(metrics.requests.byMethod).toEqual({});
expect(metrics.requests.byPath).toEqual({});
expect(metrics.errors.byType).toEqual({});
});
test('resets startTime so uptime is small after reset', () => {
const before = metrics.startTime;
// Sleep a tick so Date.now() moves forward
const start = Date.now();
while (Date.now() - start < 5) {} // ~5ms busy-wait
metrics.reset();
expect(metrics.startTime).toBeGreaterThanOrEqual(before);
const summary = metrics.getSummary();
expect(summary.uptime.ms).toBeLessThan(5000);
});
});
});
@@ -0,0 +1,217 @@
/**
* Smoke tests for notification-manager.js
* Verifies the NotificationManager loads, exposes the expected interface,
* handles config loading/saving, sends notifications via providers, and
* correctly tracks history.
*/
jest.mock('fs', () => ({
existsSync: jest.fn().mockReturnValue(false),
readFileSync: jest.fn().mockReturnValue('{}'),
writeFileSync: jest.fn(),
mkdirSync: jest.fn(),
}));
jest.mock('nodemailer', () => ({
createTransport: jest.fn(() => ({
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
})),
}));
const fs = require('fs');
const nodemailer = require('nodemailer');
const NotificationManager = require('../notification-manager');
describe('NotificationManager', () => {
let nm;
const NOTIF_FILE = '/tmp/dc-notif-test.json';
beforeEach(() => {
jest.clearAllMocks();
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockReturnValue('{}');
fs.writeFileSync.mockReturnValue(undefined);
fs.mkdirSync.mockReturnValue(undefined);
nm = new NotificationManager({
NOTIFICATIONS_FILE: NOTIF_FILE,
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
fetchT: jest.fn(),
docker: null,
});
});
afterEach(() => {
nm.stopHealthDaemon();
});
test('initializes with default config', () => {
const cfg = nm.getConfig();
expect(cfg.enabled).toBe(true);
expect(cfg.providers).toHaveProperty('discord');
expect(cfg.providers).toHaveProperty('telegram');
expect(cfg.providers).toHaveProperty('ntfy');
expect(cfg.providers).toHaveProperty('email');
});
test('starts with empty history and null lastSent', () => {
expect(nm.getHistory()).toEqual([]);
expect(nm.lastSent).toBeNull();
});
test('saveConfig writes the config to disk and creates parent dir', async () => {
fs.existsSync.mockReturnValue(false);
await nm.saveConfig();
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalled();
const callArgs = fs.writeFileSync.mock.calls[0];
expect(callArgs[0]).toBe(NOTIF_FILE);
expect(callArgs[1]).toContain('enabled');
});
test('loadConfig merges file content with defaults', () => {
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(JSON.stringify({ enabled: false }));
const loaded = new NotificationManager({
NOTIFICATIONS_FILE: NOTIF_FILE,
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
});
expect(loaded.getConfig().enabled).toBe(false);
});
test('clearHistory empties the history array', () => {
nm.history.push({ event: 'test', timestamp: new Date().toISOString() });
expect(nm.getHistory().length).toBe(1);
nm.clearHistory();
expect(nm.getHistory().length).toBe(0);
});
test('send returns disabled when notifications are off', async () => {
nm.config.enabled = false;
const result = await nm.send('alert', { text: 'hi' });
expect(result.success).toBe(false);
expect(result.error).toMatch(/disabled/i);
});
test('send returns event-not-enabled for unknown events', async () => {
nm.config.events['some-disabled-event'] = false;
const result = await nm.send('some-disabled-event', { text: 'hi' });
expect(result.success).toBe(false);
expect(result.error).toMatch(/not enabled/i);
});
test('send with no providers enabled records history and returns success:false', async () => {
const result = await nm.send('alert', { text: 'hello' });
expect(result).toHaveProperty('results');
expect(Array.isArray(result.results)).toBe(true);
expect(nm.getHistory().length).toBe(1);
expect(nm.getHistory()[0].event).toBe('alert');
});
test('sendDiscord calls ctx.fetchT and returns success on 2xx', async () => {
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
const result = await nm.sendDiscord('msg', { title: 'T' });
expect(result.success).toBe(true);
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
'https://hook.test/x',
expect.objectContaining({ method: 'POST' })
);
});
test('sendDiscord throws on non-2xx response', async () => {
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: false, status: 500 });
await expect(nm.sendDiscord('msg', null)).rejects.toThrow(/Discord/);
});
test('sendTelegram calls Telegram API', async () => {
nm.config.providers.telegram = { enabled: true, botToken: 'TOK', chatId: '123' };
nm.ctx.fetchT = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) });
const result = await nm.sendTelegram('hello');
expect(result.success).toBe(true);
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
expect.stringContaining('api.telegram.org'),
expect.objectContaining({ method: 'POST' })
);
});
test('sendNtfy posts to the configured serverUrl + topic', async () => {
nm.config.providers.ntfy = { enabled: true, topic: 'dashcaddy', serverUrl: 'https://ntfy.sh' };
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
const result = await nm.sendNtfy('body', 'title');
expect(result.success).toBe(true);
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
'https://ntfy.sh/dashcaddy',
expect.objectContaining({ method: 'POST' })
);
});
test('sendEmail uses nodemailer transporter', async () => {
nm.config.providers.email = {
enabled: true,
host: 'smtp.test',
port: 587,
to: 'me@test',
from: 'from@test',
username: 'u',
password: 'p',
};
const result = await nm.sendEmail('subject', 'body');
expect(result.success).toBe(true);
expect(nodemailer.createTransport).toHaveBeenCalled();
});
test('sendAlert, sendBackupComplete, sendServiceEvent do not throw', async () => {
const alertResult = await nm.sendAlert({
containerName: 'web',
alerts: [{ type: 'cpu', severity: 'warning', message: 'high' }],
timestamp: new Date().toISOString(),
});
expect(alertResult).toBeDefined();
const backupResult = await nm.sendBackupComplete({
name: 'daily',
status: 'success',
});
expect(backupResult).toBeDefined();
const serviceResult = await nm.sendServiceEvent('container-down', {
name: 'web',
containerName: 'sami-web',
});
expect(serviceResult).toBeDefined();
});
test('checkHealth returns checked:false when no docker client', async () => {
nm.ctx.docker = null;
const r = await nm.checkHealth();
expect(r.checked).toBe(false);
});
test('checkHealth with mocked docker returns checked:true', async () => {
nm.ctx.docker = {
listContainers: jest.fn().mockResolvedValue([
{ Id: 'aaaabbbbcccc', Names: ['/web'], State: 'running', Status: 'Up' },
{ Id: 'ddddeeeeffff', Names: ['/api'], State: 'exited', Status: 'Exited' },
]),
};
nm.config.healthCheck = { enabled: true, intervalMinutes: 5 };
const r = await nm.checkHealth();
expect(r.checked).toBe(true);
expect(r.containersMonitored).toBe(2);
});
test('formatTitle returns a string for known events', () => {
expect(typeof nm._formatTitle('alert')).toBe('string');
expect(typeof nm._formatTitle('unknown')).toBe('string');
});
test('startHealthDaemon and stopHealthDaemon are idempotent', () => {
nm.startHealthDaemon();
nm.startHealthDaemon(); // should not double-schedule
nm.stopHealthDaemon();
nm.stopHealthDaemon();
expect(nm.healthDaemonInterval).toBeNull();
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* Smoke tests for ssl-monitor.js
* Verifies SSLMonitor loads, exposes the expected interface, can check
* certificates via mocked TLS, manage state, and persist cache.
*/
jest.mock('tls', () => ({
connect: jest.fn(),
}));
jest.mock('../fs-helpers', () => ({
readJsonFile: jest.fn().mockResolvedValue(null),
writeJsonFile: jest.fn().mockResolvedValue(undefined),
}));
const tls = require('tls');
const fsHelpers = require('../fs-helpers');
const SSLMonitor = require('../ssl-monitor');
function makeSocket({ cert = null, error = null } = {}) {
const { EventEmitter } = require('events');
const socket = new EventEmitter();
socket.destroy = jest.fn();
socket.getPeerCertificate = jest.fn(() => cert);
socket.setTimeout = jest.fn();
// Simulate 'connect' on next tick (or 'error')
process.nextTick(() => {
if (error) socket.emit('error', error);
});
return socket;
}
describe('SSLMonitor', () => {
let monitor;
const fakeStateManager = {
read: jest.fn().mockResolvedValue([]),
};
beforeEach(() => {
jest.clearAllMocks();
fsHelpers.readJsonFile.mockResolvedValue(null);
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
fakeStateManager.read.mockResolvedValue([]);
monitor = new SSLMonitor({
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
servicesStateManager: fakeStateManager,
siteConfig: {},
buildServiceUrl: id => `https://${id}.sami`,
notification: null,
});
});
afterEach(() => {
monitor.stop();
});
test('initializes with empty maps and default config', () => {
expect(monitor.certStatus).toBeInstanceOf(Map);
expect(monitor.notifiedThresholds).toBeInstanceOf(Map);
expect(monitor.hostnameToServiceId).toBeInstanceOf(Map);
expect(monitor.intervalHandle).toBeNull();
expect(monitor.config.enabled).toBe(true);
expect(typeof monitor.config.intervalMs).toBe('number');
});
test('getConfig returns a copy of the current config', () => {
const cfg = monitor.getConfig();
expect(cfg).toEqual(monitor.config);
cfg.enabled = false;
// The internal config must not be mutated
expect(monitor.config.enabled).toBe(true);
});
test('updateConfig updates enabled and intervalMs', () => {
monitor.updateConfig({ enabled: false, intervalMs: 60000 });
expect(monitor.config.enabled).toBe(false);
expect(monitor.config.intervalMs).toBe(60000);
});
test('updateConfig rejects intervalMs below 60000', () => {
const original = monitor.config.intervalMs;
monitor.updateConfig({ intervalMs: 1000 });
expect(monitor.config.intervalMs).toBe(original);
});
test('getStatus returns an empty object when no checks have run', () => {
expect(monitor.getStatus()).toEqual({});
});
test('getServiceCertStatus returns null for unknown service', () => {
expect(monitor.getServiceCertStatus('unknown-svc')).toBeNull();
});
test('checkCert rejects when peer cert is empty', async () => {
tls.connect.mockImplementation((_opts, onConnect) => {
const sock = makeSocket({ cert: {} });
// Simulate immediate 'connect'
setImmediate(() => onConnect && onConnect());
return sock;
});
await expect(monitor.checkCert('empty.sami')).rejects.toThrow(/No certificate/);
});
test('checkCert resolves with cert details on success', async () => {
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // +60d
const validTo = futureDate.toUTCString();
tls.connect.mockImplementation((_opts, onConnect) => {
const sock = makeSocket({
cert: {
subject: { CN: 'test.sami' },
issuer: { O: "Sami's CA" },
valid_from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toUTCString(),
valid_to: validTo,
fingerprint: 'AA:BB:CC',
},
});
setImmediate(() => onConnect && onConnect());
return sock;
});
const result = await monitor.checkCert('test.sami', 443);
expect(result.hostname).toBe('test.sami');
expect(result.port).toBe(443);
expect(result.subject).toBe('test.sami');
expect(result.daysRemaining).toBeGreaterThan(0);
expect(typeof result.isExpiring).toBe('boolean');
expect(typeof result.checkedAt).toBe('string');
});
test('checkCert rejects with TLS error event', async () => {
tls.connect.mockImplementation(() => {
const sock = makeSocket({ error: new Error('TLS boom') });
return sock;
});
await expect(monitor.checkCert('broken.sami')).rejects.toThrow(/TLS/);
});
test('checkAll returns empty status when no services configured', async () => {
const status = await monitor.checkAll();
expect(status).toEqual({});
});
test('checkAll handles HTTPS services and stores results', async () => {
fakeStateManager.read.mockResolvedValue([
{ id: 'web', name: 'Web', url: 'https://web.sami' },
]);
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
tls.connect.mockImplementation((_opts, onConnect) => {
const sock = makeSocket({
cert: {
subject: { CN: 'web.sami' },
issuer: { O: "Sami's CA" },
valid_from: new Date().toUTCString(),
valid_to: futureDate.toUTCString(),
fingerprint: 'AA:BB:CC',
},
});
setImmediate(() => onConnect && onConnect());
return sock;
});
const status = await monitor.checkAll();
expect(status['web.sami']).toBeDefined();
expect(status['web.sami'].hostname).toBe('web.sami');
expect(monitor.getServiceCertStatus('web')).not.toBeNull();
});
test('start() schedules periodic checks and stop() clears them', () => {
jest.useFakeTimers();
const originalCheckAll = monitor.checkAll.bind(monitor);
monitor.checkAll = jest.fn().mockResolvedValue(undefined);
monitor.start(120000);
expect(monitor.intervalHandle).not.toBeNull();
monitor.stop();
expect(monitor.intervalHandle).toBeNull();
monitor.checkAll = originalCheckAll;
jest.useRealTimers();
});
test('_saveCache and _loadCache round-trip via fs-helpers', async () => {
await monitor._saveCache();
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
fsHelpers.readJsonFile.mockResolvedValue({
lastChecked: new Date().toISOString(),
certs: { 'a.sami': { hostname: 'a.sami', daysRemaining: 30 } },
hostnameToServiceId: { 'a.sami': 'svc-a' },
});
const fresh = new SSLMonitor({
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
servicesStateManager: fakeStateManager,
siteConfig: {},
buildServiceUrl: id => `https://${id}.sami`,
});
await fresh._loadCache();
expect(fresh.certStatus.get('a.sami')).toBeDefined();
expect(fresh.hostnameToServiceId.get('a.sami')).toBe('svc-a');
});
});