DC-007: Add smoke tests for 7 untested modules
This commit is contained in:
+2
-1
@@ -47,8 +47,9 @@
|
|||||||
- **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow.
|
- **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow.
|
||||||
|
|
||||||
### DC-007: Add tests for untested modules
|
### DC-007: Add tests for untested modules
|
||||||
- **status:** in-progress
|
- **status:** done
|
||||||
- **owner:** krystie
|
- **owner:** krystie
|
||||||
|
- **result:** 7 test files added (120 new tests, all passing alongside the 759 baseline → 879 total). Files: `__tests__/dns-propagation.test.js` (9), `__tests__/notification-manager.test.js` (18), `__tests__/ssl-monitor.test.js` (13), `__tests__/log-digest.test.js` (11), `__tests__/metrics.test.js` (21), `__tests__/config-drift-detector.test.js` (19), `__tests__/auto-restart-manager.test.js` (29).
|
||||||
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.
|
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user