Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/__tests__/config-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('conf', () => {
const mockConfigStore = new Map<string, any>();
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');

// 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('');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
29 changes: 25 additions & 4 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand All @@ -104,25 +105,45 @@ 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'));

const guideOrder = consoleLogOrder(/Email SMTP setup/i);
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 () => {
Expand Down
11 changes: 9 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'));
};
7 changes: 6 additions & 1 deletion src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface KDMConfig {
email_port?: number;
email_user?: string;
email_to?: string;
email_password?: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid storing SMTP password in plain-text config storage.

Persisting email_password in conf creates a secret-at-rest risk. Prefer env-only or secure OS keychain storage; if persistence is required, gate it behind explicit opt-in and document the risk clearly.

As per coding guidelines "Verify that sensitive information is not stored in plain text if possible."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/config.ts` at line 10, The config field email_password should not
be persisted in plain-text; remove or stop writing email_password to the
persistent conf storage and instead read it from an environment variable or
secure OS keychain at runtime (reference the email_password property in
src/utils/config.ts). If persistence is absolutely required add an explicit
opt-in flag (e.g., persistEmailPassword) and store only an encrypted blob or a
protected reference, and update documentation and any README to warn about the
risk; ensure code paths that previously read/write email_password now pull from
process.env or the keychain API and that any new opt-in is clearly gated and
logged.

alert_cooldown?: number; // in seconds
}

Expand All @@ -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
Expand All @@ -34,7 +36,10 @@ 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 !== undefined
? process.env.KDM_SMTP_PASSWORD
: config.get('email_password'),
},
Comment on lines 32 to 43
to: config.get('email_to'),
};
Expand Down