189 lines
5.3 KiB
TypeScript
189 lines
5.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { SmtpEmailProvider } from '../../src/providers/email/smtp.provider';
|
|
import { AppError } from '../../src/utils/errors';
|
|
|
|
// Mock dependencies
|
|
vi.mock('nodemailer', () => {
|
|
const sendMailMock = vi.fn().mockResolvedValue({ messageId: 'test-id' });
|
|
const closeMock = vi.fn();
|
|
return {
|
|
default: {
|
|
createTransport: vi.fn().mockReturnValue({
|
|
sendMail: sendMailMock,
|
|
close: closeMock,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/utils/logger', () => ({
|
|
default: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockSmtpConfig = {
|
|
enabled: true,
|
|
host: 'smtp.example.com',
|
|
port: 465,
|
|
username: 'testuser',
|
|
password: 'testpass',
|
|
senderEmail: 'noreply@example.com',
|
|
senderName: 'Test App',
|
|
minIntervalSeconds: 60,
|
|
};
|
|
|
|
const mockTemplate = {
|
|
id: '11111111-1111-4111-8111-111111111111',
|
|
templateType: 'email-verification-code',
|
|
subject: 'Verify your email',
|
|
bodyHtml: '<p>Your code is: {{ token }}</p><p>Email: {{ email }}</p>',
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
updatedAt: '2026-01-01T00:00:00Z',
|
|
};
|
|
|
|
const getRawSmtpConfigMock = vi.fn().mockResolvedValue(mockSmtpConfig);
|
|
const getTemplateMock = vi.fn().mockResolvedValue(mockTemplate);
|
|
|
|
vi.mock('../../src/services/email/smtp-config.service', () => ({
|
|
SmtpConfigService: {
|
|
getInstance: () => ({
|
|
getRawSmtpConfig: getRawSmtpConfigMock,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/services/email/email-template.service', () => ({
|
|
EmailTemplateService: {
|
|
getInstance: () => ({
|
|
getTemplate: getTemplateMock,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
describe('SmtpEmailProvider', () => {
|
|
let provider: SmtpEmailProvider;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
provider = new SmtpEmailProvider();
|
|
});
|
|
|
|
describe('supportsTemplates', () => {
|
|
it('returns true', () => {
|
|
expect(provider.supportsTemplates()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sendWithTemplate', () => {
|
|
it('sends email with rendered template', async () => {
|
|
const nodemailer = await import('nodemailer');
|
|
const transport = nodemailer.default.createTransport();
|
|
|
|
await provider.sendWithTemplate('user@example.com', 'Test App', 'email-verification-code', {
|
|
token: '123456',
|
|
});
|
|
|
|
expect(transport.sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
from: '"Test App" <noreply@example.com>',
|
|
to: 'user@example.com',
|
|
subject: 'Verify your email',
|
|
html: expect.stringContaining('123456'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('HTML-escapes placeholder values to prevent XSS', async () => {
|
|
const nodemailer = await import('nodemailer');
|
|
const transport = nodemailer.default.createTransport();
|
|
|
|
await provider.sendWithTemplate(
|
|
'<script>alert("xss")</script>@evil.com',
|
|
'Test App',
|
|
'email-verification-code',
|
|
{ token: '<img src=x onerror=alert(1)>' }
|
|
);
|
|
|
|
expect(transport.sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
html: expect.not.stringContaining('<script>'),
|
|
})
|
|
);
|
|
|
|
const callArgs = (transport.sendMail as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
expect(callArgs.html).toContain('<img src=x onerror=alert(1)>');
|
|
expect(callArgs.html).toContain('<script>');
|
|
});
|
|
|
|
it('throws AppError when SMTP is not configured', async () => {
|
|
getRawSmtpConfigMock.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
provider.sendWithTemplate('user@example.com', 'App', 'email-verification-code', {
|
|
token: '123456',
|
|
})
|
|
).rejects.toThrow(AppError);
|
|
});
|
|
});
|
|
|
|
describe('sendRaw', () => {
|
|
it('sends raw email with SMTP sender info (prevents spoofing)', async () => {
|
|
const nodemailer = await import('nodemailer');
|
|
const transport = nodemailer.default.createTransport();
|
|
|
|
await provider.sendRaw({
|
|
to: 'recipient@example.com',
|
|
subject: 'Test Subject',
|
|
html: '<p>Hello</p>',
|
|
from: 'attacker@evil.com', // Should be overridden
|
|
});
|
|
|
|
expect(transport.sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
from: '"Test App" <noreply@example.com>', // Uses SMTP config, not caller
|
|
to: 'recipient@example.com',
|
|
subject: 'Test Subject',
|
|
html: '<p>Hello</p>',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('passes through cc, bcc, and replyTo', async () => {
|
|
const nodemailer = await import('nodemailer');
|
|
const transport = nodemailer.default.createTransport();
|
|
|
|
await provider.sendRaw({
|
|
to: 'recipient@example.com',
|
|
subject: 'Test',
|
|
html: '<p>Hi</p>',
|
|
cc: 'cc@example.com',
|
|
bcc: 'bcc@example.com',
|
|
replyTo: 'reply@example.com',
|
|
});
|
|
|
|
expect(transport.sendMail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cc: 'cc@example.com',
|
|
bcc: 'bcc@example.com',
|
|
replyTo: 'reply@example.com',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws AppError when SMTP is not configured', async () => {
|
|
getRawSmtpConfigMock.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
provider.sendRaw({
|
|
to: 'user@example.com',
|
|
subject: 'Test',
|
|
html: '<p>Hi</p>',
|
|
})
|
|
).rejects.toThrow(AppError);
|
|
});
|
|
});
|
|
});
|