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:
2026-07-22 09:11:50 +02:00
parent 4823c245ee
commit fbcc108341
@@ -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('&lt;img');
expect(call.html).toContain('&lt;script&gt;');
});
});