From f96e9037109f56884babe43552364c1d5f6f55dc Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 13 Jun 2026 11:38:51 -0700 Subject: [PATCH] DC-007: Add smoke tests for 7 untested modules --- BACKLOG.md | 3 +- .../__tests__/auto-restart-manager.test.js | 367 ++++++++++++++++++ .../__tests__/config-drift-detector.test.js | 335 ++++++++++++++++ .../__tests__/dns-propagation.test.js | 106 +++++ dashcaddy-api/__tests__/log-digest.test.js | 187 +++++++++ dashcaddy-api/__tests__/metrics.test.js | 207 ++++++++++ .../__tests__/notification-manager.test.js | 217 +++++++++++ dashcaddy-api/__tests__/ssl-monitor.test.js | 203 ++++++++++ 8 files changed, 1624 insertions(+), 1 deletion(-) create mode 100644 dashcaddy-api/__tests__/auto-restart-manager.test.js create mode 100644 dashcaddy-api/__tests__/config-drift-detector.test.js create mode 100644 dashcaddy-api/__tests__/dns-propagation.test.js create mode 100644 dashcaddy-api/__tests__/log-digest.test.js create mode 100644 dashcaddy-api/__tests__/metrics.test.js create mode 100644 dashcaddy-api/__tests__/notification-manager.test.js create mode 100644 dashcaddy-api/__tests__/ssl-monitor.test.js diff --git a/BACKLOG.md b/BACKLOG.md index 35fa717..22cd337 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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. ### DC-007: Add tests for untested modules -- **status:** in-progress +- **status:** done - **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. --- diff --git a/dashcaddy-api/__tests__/auto-restart-manager.test.js b/dashcaddy-api/__tests__/auto-restart-manager.test.js new file mode 100644 index 0000000..bd90fac --- /dev/null +++ b/dashcaddy-api/__tests__/auto-restart-manager.test.js @@ -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(); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/config-drift-detector.test.js b/dashcaddy-api/__tests__/config-drift-detector.test.js new file mode 100644 index 0000000..6f23523 --- /dev/null +++ b/dashcaddy-api/__tests__/config-drift-detector.test.js @@ -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); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/dns-propagation.test.js b/dashcaddy-api/__tests__/dns-propagation.test.js new file mode 100644 index 0000000..fa2987c --- /dev/null +++ b/dashcaddy-api/__tests__/dns-propagation.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/__tests__/log-digest.test.js b/dashcaddy-api/__tests__/log-digest.test.js new file mode 100644 index 0000000..a64ed49 --- /dev/null +++ b/dashcaddy-api/__tests__/log-digest.test.js @@ -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(); + }); +}); diff --git a/dashcaddy-api/__tests__/metrics.test.js b/dashcaddy-api/__tests__/metrics.test.js new file mode 100644 index 0000000..f1e6da4 --- /dev/null +++ b/dashcaddy-api/__tests__/metrics.test.js @@ -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); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/notification-manager.test.js b/dashcaddy-api/__tests__/notification-manager.test.js new file mode 100644 index 0000000..0ff7819 --- /dev/null +++ b/dashcaddy-api/__tests__/notification-manager.test.js @@ -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(); + }); +}); diff --git a/dashcaddy-api/__tests__/ssl-monitor.test.js b/dashcaddy-api/__tests__/ssl-monitor.test.js new file mode 100644 index 0000000..d05a88a --- /dev/null +++ b/dashcaddy-api/__tests__/ssl-monitor.test.js @@ -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'); + }); +});