/** * 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'); }); });