From 2187072880cc5a024cac89c38b33486731936df7 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 24 May 2026 12:30:44 +0530 Subject: [PATCH 1/2] feat: implement optional SMTP password support --- src/__tests__/config-utils.test.ts | 87 ++++++++++++++++++++++++++++++ src/__tests__/config.test.ts | 29 ++++++++-- src/commands/config.ts | 11 +++- src/utils/config.ts | 4 +- 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/config-utils.test.ts diff --git a/src/__tests__/config-utils.test.ts b/src/__tests__/config-utils.test.ts new file mode 100644 index 0000000..51d87e0 --- /dev/null +++ b/src/__tests__/config-utils.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('conf', () => { + const mockConfigStore = new Map(); + const mockConfInstance = { + store: {}, + set: vi.fn((key, val) => { + mockConfigStore.set(key, val); + }), + get: vi.fn((key) => { + return mockConfigStore.get(key); + }), + delete: vi.fn((key) => { + mockConfigStore.delete(key); + }), + clear: vi.fn(() => { + mockConfigStore.clear(); + }), + }; + + (globalThis as any).mockConfigStore = mockConfigStore; + (globalThis as any).mockConfInstance = mockConfInstance; + + return { + default: class MockConf { + constructor() { + return mockConfInstance; + } + }, + }; +}); + +import { getSMTPSettings, clearNotificationCredentials } from '../utils/config'; + +describe('config utils', () => { + const originalEnv = process.env; + let mockConfInstance: any; + let mockConfigStore: any; + + beforeEach(() => { + mockConfInstance = (globalThis as any).mockConfInstance; + mockConfigStore = (globalThis as any).mockConfigStore; + mockConfigStore.clear(); + vi.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should clear all notification credentials, including email_password', () => { + mockConfigStore.set('discord_webhook', 'http://webhook'); + mockConfigStore.set('email_host', 'smtp.test.com'); + mockConfigStore.set('email_port', 587); + mockConfigStore.set('email_user', 'user@test.com'); + mockConfigStore.set('email_to', 'to@test.com'); + mockConfigStore.set('email_password', 'secret'); + + clearNotificationCredentials(); + + expect(mockConfInstance.delete).toHaveBeenCalledWith('discord_webhook'); + expect(mockConfInstance.delete).toHaveBeenCalledWith('email_host'); + expect(mockConfInstance.delete).toHaveBeenCalledWith('email_port'); + expect(mockConfInstance.delete).toHaveBeenCalledWith('email_user'); + expect(mockConfInstance.delete).toHaveBeenCalledWith('email_to'); + expect(mockConfInstance.delete).toHaveBeenCalledWith('email_password'); + }); + + it('should get SMTP settings with precedence given to environment variables over email_password', () => { + mockConfigStore.set('email_host', 'smtp.test.com'); + mockConfigStore.set('email_port', 587); + mockConfigStore.set('email_user', 'user@test.com'); + mockConfigStore.set('email_to', 'to@test.com'); + mockConfigStore.set('email_password', 'config-password'); + + // Case 1: No env variable set, should use stored config password + delete process.env.KDM_SMTP_PASSWORD; + let settings = getSMTPSettings(); + expect(settings.auth.pass).toBe('config-password'); + + // Case 2: Env variable set, should take precedence + process.env.KDM_SMTP_PASSWORD = 'env-password'; + settings = getSMTPSettings(); + expect(settings.auth.pass).toBe('env-password'); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index e468b80..b7bf32e 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -89,13 +89,14 @@ describe('config command', () => { expect(webhookPrompt.validate('not-a-webhook')).toBe('Must be a valid Discord webhook URL (including ID and Token)'); }); - it('should call select, multiple inputs and setConfig on email setup', async () => { + it('should call select, multiple inputs and setConfig on email setup without password', async () => { vi.mocked(tui.select).mockResolvedValue('email'); vi.mocked(tui.input) .mockResolvedValueOnce('smtp.gmail.com') // host .mockResolvedValueOnce('587') // port .mockResolvedValueOnce('user@test.com') // user - .mockResolvedValueOnce('to@test.com'); // to + .mockResolvedValueOnce('to@test.com') // to + .mockResolvedValueOnce(''); // password (empty) await program.parseAsync(['node', 'test', 'config', 'setup']); @@ -104,6 +105,7 @@ describe('config command', () => { expect(configUtils.setConfig).toHaveBeenCalledWith('email_port', 587); expect(configUtils.setConfig).toHaveBeenCalledWith('email_user', 'user@test.com'); expect(configUtils.setConfig).toHaveBeenCalledWith('email_to', 'to@test.com'); + expect(configUtils.setConfig).not.toHaveBeenCalledWith('email_password', expect.any(String)); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringMatching(/Email SMTP setup/i)); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('KDM_SMTP_PASSWORD')); @@ -111,18 +113,37 @@ describe('config command', () => { expect(guideOrder).toBeLessThan(firstInputOrder()); }); - it('should require an SMTP host during email setup', async () => { + it('should save email_password if provided during email setup', async () => { + vi.mocked(tui.select).mockResolvedValue('email'); + vi.mocked(tui.input) + .mockResolvedValueOnce('smtp.gmail.com') // host + .mockResolvedValueOnce('587') // port + .mockResolvedValueOnce('user@test.com') // user + .mockResolvedValueOnce('to@test.com') // to + .mockResolvedValueOnce('pass123'); // password + + await program.parseAsync(['node', 'test', 'config', 'setup']); + + expect(configUtils.setConfig).toHaveBeenCalledWith('email_password', 'pass123'); + }); + + it('should require an SMTP host during email setup and validate optional SMTP password', async () => { vi.mocked(tui.select).mockResolvedValue('email'); vi.mocked(tui.input) .mockResolvedValueOnce('smtp.gmail.com') .mockResolvedValueOnce('587') .mockResolvedValueOnce('user@test.com') - .mockResolvedValueOnce('to@test.com'); + .mockResolvedValueOnce('to@test.com') + .mockResolvedValueOnce(''); await program.parseAsync(['node', 'test', 'config', 'setup']); const smtpHostPrompt = vi.mocked(tui.input).mock.calls[0][0]; expect(smtpHostPrompt.validate('')).toBe('Host is required'); + + const smtpPasswordPrompt = vi.mocked(tui.input).mock.calls[4][0]; + expect(smtpPasswordPrompt.validate('')).toBe(true); + expect(smtpPasswordPrompt.validate('anything')).toBe(true); }); it('should call setConfig on config set', async () => { diff --git a/src/commands/config.ts b/src/commands/config.ts index 939924c..1d42758 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -68,12 +68,19 @@ export const registerConfigCommand = (program: Command) => { message: 'Alert Recipient Email:', validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Must be a valid email address', }); + const password = await input({ + message: 'SMTP Password (optional, press Enter to skip):', + validate: () => true, + }); clearNotificationCredentials(); setConfig('email_host', host); setConfig('email_port', parseInt(portStr, 10)); setConfig('email_user', user); setConfig('email_to', to); + if (password) { + setConfig('email_password', password); + } setConfig('notification_service', 'email'); console.log(chalk.green('\n✓ Email SMTP configured.')); @@ -120,7 +127,7 @@ export const registerConfigCommand = (program: Command) => { } console.log(chalk.gray('──────────────────────────────────────────────────')); - console.log(chalk.dim('\n Note: SMTP passwords must be set via KDM_SMTP_PASSWORD env var.\n')); + console.log(chalk.dim('\n Note: SMTP password can be set either in config or via the KDM_SMTP_PASSWORD environment variable, which takes precedence if both are set.\n')); }); config @@ -149,7 +156,7 @@ const printEmailSmtpGuide = () => { console.log(chalk.white(' 1. Find your provider SMTP settings before continuing.')); console.log(chalk.white(' 2. Common hosts: smtp.gmail.com for Gmail, smtp.office365.com for Outlook.')); console.log(chalk.white(' 3. Use port 587 for STARTTLS unless your provider says otherwise.')); - console.log(chalk.white(' 4. Set the SMTP password in KDM_SMTP_PASSWORD before sending alerts.')); + console.log(chalk.white(' 4. Provide the SMTP password during setup or via the KDM_SMTP_PASSWORD environment variable.')); console.log(chalk.dim(' Gmail accounts with 2FA usually require an App Password.')); console.log(chalk.gray('──────────────────────────────────────────────────\n')); }; diff --git a/src/utils/config.ts b/src/utils/config.ts index a7d3036..df7a35e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -7,6 +7,7 @@ interface KDMConfig { email_port?: number; email_user?: string; email_to?: string; + email_password?: string; alert_cooldown?: number; // in seconds } @@ -25,6 +26,7 @@ export const clearNotificationCredentials = () => { config.delete('email_port'); config.delete('email_user'); config.delete('email_to'); + config.delete('email_password'); }; // Helper for sensitive data - always use environment variables @@ -34,7 +36,7 @@ export const getSMTPSettings = () => { port: config.get('email_port') || 587, auth: { user: config.get('email_user'), - pass: process.env.KDM_SMTP_PASSWORD, + pass: process.env.KDM_SMTP_PASSWORD || config.get('email_password'), }, to: config.get('email_to'), }; From aadcd6c366515ebc0477fdef0474dda87dd850b6 Mon Sep 17 00:00:00 2001 From: utkarsh patrikar Date: Sun, 24 May 2026 12:37:47 +0530 Subject: [PATCH 2/2] fix(config): ensure strict SMTP password environment variable precedence --- src/__tests__/config-utils.test.ts | 5 +++++ src/utils/config.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/__tests__/config-utils.test.ts b/src/__tests__/config-utils.test.ts index 51d87e0..e69f510 100644 --- a/src/__tests__/config-utils.test.ts +++ b/src/__tests__/config-utils.test.ts @@ -83,5 +83,10 @@ describe('config utils', () => { process.env.KDM_SMTP_PASSWORD = 'env-password'; settings = getSMTPSettings(); expect(settings.auth.pass).toBe('env-password'); + + // Case 3: Env variable set to empty string, should be honored instead of falling back to config password + process.env.KDM_SMTP_PASSWORD = ''; + settings = getSMTPSettings(); + expect(settings.auth.pass).toBe(''); }); }); diff --git a/src/utils/config.ts b/src/utils/config.ts index df7a35e..f18cb42 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -36,7 +36,10 @@ export const getSMTPSettings = () => { port: config.get('email_port') || 587, auth: { user: config.get('email_user'), - pass: process.env.KDM_SMTP_PASSWORD || config.get('email_password'), + pass: + process.env.KDM_SMTP_PASSWORD !== undefined + ? process.env.KDM_SMTP_PASSWORD + : config.get('email_password'), }, to: config.get('email_to'), };