diff --git a/apps/api/src/tenders/tender-mail.service.spec.ts b/apps/api/src/tenders/tender-mail.service.spec.ts new file mode 100644 index 0000000..a01ce62 --- /dev/null +++ b/apps/api/src/tenders/tender-mail.service.spec.ts @@ -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).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).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).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).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).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).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).mockReturnValue({ + sendMail, + close: vi.fn(), + }); + const settingsService = makeSettingsService(BASE_SMTP_CONFIG); + const service = new TenderMailService(settingsService as never); + + const maliciousTitle = ''; + await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', { + '': [{ title: maliciousTitle }], + }); + + const call = sendMail.mock.calls[0][0] as { html: string }; + expect(call.html).not.toContain(''); + expect(call.html).not.toContain(''); + expect(call.html).toContain('<img'); + expect(call.html).toContain('<script>'); + }); +});