test(12-02): add failing TenderMailService spec (RED)
Covers NOTIFY-04: per-send getDecryptedSmtpConfig(tenantId) SMTP resolution, fresh nodemailer transport + close() in finally (WR-01), no-throw skip on missing SmtpConfig, sectioned digest body without blind Number() coercion of estimatedValue, and HTML-escaping of tender titles/profile names (T-12-08 email-injection guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { TenderMailService } from './tender-mail.service';
|
||||
|
||||
/**
|
||||
* TenderMailService.spec — DkvMailService-Klon (Plan 12-02, Task 1, RED
|
||||
* first). Covers NOTIFY-04: mandanten-SMTP-Auflösung je Send
|
||||
* (`getDecryptedSmtpConfig(tenantId)`), ein frischer nodemailer-Transport
|
||||
* pro Send + `transport.close()` im finally (WR-01 Socket-Leck-Schutz),
|
||||
* kein Throw + falsy „skipped"-Rückgabe bei fehlender SmtpConfig, und
|
||||
* sektionierter Digest-Body ohne blinde `Number()`-Coercion von
|
||||
* `estimatedValue`.
|
||||
*
|
||||
* `nodemailer` wird gemockt (kein echter SMTP-Kontakt) — exakt wie im
|
||||
* `DkvMailService`-Vorbild beschrieben (createTransport → { sendMail, close }).
|
||||
*/
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
createTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeSettingsService(smtpConfig: unknown) {
|
||||
return {
|
||||
getDecryptedSmtpConfig: vi.fn(async (_tenantId: string) => smtpConfig),
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_SMTP_CONFIG = {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
encryption: 'starttls',
|
||||
username: 'smtp-user',
|
||||
fromAddress: 'noreply@example.com',
|
||||
decryptedPassword: 'super-secret',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TenderMailService — mandanten-SMTP-Auflösung (D-08)', () => {
|
||||
it('sendDigest calls settingsService.getDecryptedSmtpConfig with exactly the given tenantId, and builds the transport from that config', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
const close = vi.fn();
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close,
|
||||
});
|
||||
const settingsService = makeSettingsService(BASE_SMTP_CONFIG);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-42', {});
|
||||
|
||||
expect(settingsService.getDecryptedSmtpConfig).toHaveBeenCalledWith('tenant-42');
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false, // starttls -> secure=false
|
||||
requireTLS: true, // starttls -> requireTLS=true
|
||||
auth: { user: 'smtp-user', pass: 'super-secret' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves secure=true/requireTLS=false for ssl-tls encryption, and auth=undefined when no username is set', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close: vi.fn(),
|
||||
});
|
||||
const settingsService = makeSettingsService({
|
||||
...BASE_SMTP_CONFIG,
|
||||
encryption: 'ssl-tls',
|
||||
username: null,
|
||||
decryptedPassword: null,
|
||||
});
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {});
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ secure: true, requireTLS: false, auth: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TenderMailService — frischer Transport + close() (WR-01)', () => {
|
||||
it('calls nodemailer.createTransport exactly once per send, and calls transport.close() in finally even when sendMail rejects', async () => {
|
||||
const sendMail = vi.fn().mockRejectedValue(new Error('smtp boom'));
|
||||
const close = vi.fn();
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close,
|
||||
});
|
||||
const settingsService = makeSettingsService(BASE_SMTP_CONFIG);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
const result = await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {});
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(false); // send failure -> falsy, no throw
|
||||
});
|
||||
|
||||
it('sendInstant also builds exactly one fresh transport and closes it in finally', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
const close = vi.fn();
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close,
|
||||
});
|
||||
const settingsService = makeSettingsService(BASE_SMTP_CONFIG);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
await service.sendInstant(
|
||||
{ email: 'user@tenant.de' },
|
||||
'tenant-1',
|
||||
{ name: 'Straßenbau NRW' },
|
||||
[{ title: 'Tender A' }],
|
||||
);
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TenderMailService — fehlende SmtpConfig (Skip, kein Throw)', () => {
|
||||
it('sendDigest sends nothing, does NOT throw, and returns a falsy indicator when getDecryptedSmtpConfig returns null', async () => {
|
||||
const settingsService = makeSettingsService(null);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
await expect(
|
||||
service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {}),
|
||||
).resolves.toBe(false);
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendInstant sends nothing, does NOT throw, and returns a falsy indicator when getDecryptedSmtpConfig returns null', async () => {
|
||||
const settingsService = makeSettingsService(null);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
await expect(
|
||||
service.sendInstant(
|
||||
{ email: 'user@tenant.de' },
|
||||
'tenant-1',
|
||||
{ name: 'Profil' },
|
||||
[{ title: 'Tender A' }],
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TenderMailService — Betreff/Body (D-02, D-05, Sicherheit)', () => {
|
||||
it('sendDigest builds ONE mail to user.email, sectioned by profile name — estimatedValue is preserved verbatim, never blindly Number()-coerced', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close: vi.fn(),
|
||||
});
|
||||
const settingsService = makeSettingsService(BASE_SMTP_CONFIG);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
const sections = {
|
||||
'Straßenbau NRW': [
|
||||
{
|
||||
title: 'Fahrbahnsanierung B1',
|
||||
buyerName: 'Stadt Beispielstadt',
|
||||
deadlineAt: new Date('2026-08-15T00:00:00Z'),
|
||||
estimatedValue: '123000.50',
|
||||
sourceUrl: 'https://example.com/tender/a',
|
||||
},
|
||||
],
|
||||
'IT-Beschaffung': [
|
||||
{
|
||||
title: 'Serverwartung',
|
||||
buyerName: null,
|
||||
deadlineAt: null,
|
||||
estimatedValue: null,
|
||||
sourceUrl: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', sections);
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0] as { to: string; subject: string; text: string; html: string };
|
||||
expect(call.to).toBe('user@tenant.de');
|
||||
expect(call.text).toContain('Straßenbau NRW');
|
||||
expect(call.text).toContain('IT-Beschaffung');
|
||||
expect(call.text).toContain('Fahrbahnsanierung B1');
|
||||
expect(call.text).toContain('Serverwartung');
|
||||
expect(call.text).toContain('123000.50'); // verbatim string, no Number() coercion
|
||||
expect(call.text).not.toMatch(/NaN/);
|
||||
expect(call.html).toContain('IT-Beschaffung');
|
||||
});
|
||||
|
||||
it('sendInstant builds ONE mail for one profile with its full tender list', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close: vi.fn(),
|
||||
});
|
||||
const settingsService = makeSettingsService(BASE_SMTP_CONFIG);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
await service.sendInstant(
|
||||
{ email: 'user@tenant.de' },
|
||||
'tenant-1',
|
||||
{ name: 'Straßenbau NRW' },
|
||||
[
|
||||
{ title: 'Tender A', buyerName: 'Stadt X', deadlineAt: null, estimatedValue: null, sourceUrl: null },
|
||||
{ title: 'Tender B', buyerName: 'Stadt Y', deadlineAt: null, estimatedValue: null, sourceUrl: null },
|
||||
],
|
||||
);
|
||||
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
const call = sendMail.mock.calls[0][0] as { to: string; subject: string; text: string };
|
||||
expect(call.to).toBe('user@tenant.de');
|
||||
expect(call.subject).toContain('Straßenbau NRW');
|
||||
expect(call.text).toContain('Tender A');
|
||||
expect(call.text).toContain('Tender B');
|
||||
});
|
||||
|
||||
it('escapes tender titles/profile names in the HTML body — no unescaped HTML interpolation (email injection guard)', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
(nodemailer.createTransport as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
sendMail,
|
||||
close: vi.fn(),
|
||||
});
|
||||
const settingsService = makeSettingsService(BASE_SMTP_CONFIG);
|
||||
const service = new TenderMailService(settingsService as never);
|
||||
|
||||
const maliciousTitle = '<img src=x onerror=alert(1)>';
|
||||
await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {
|
||||
'<script>alert(1)</script>': [{ title: maliciousTitle }],
|
||||
});
|
||||
|
||||
const call = sendMail.mock.calls[0][0] as { html: string };
|
||||
expect(call.html).not.toContain('<img src=x onerror=alert(1)>');
|
||||
expect(call.html).not.toContain('<script>alert(1)</script>');
|
||||
expect(call.html).toContain('<img');
|
||||
expect(call.html).toContain('<script>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user