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