import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as nodemailer from 'nodemailer'; import { MailService, WELCOME_HEADER_CID } from './mail.service'; /** * MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich * `mail` hatte VOR diesem Durchlauf KEINE Testdatei. Festgenagelt wird die * Bauform "Transport je Versand nach Mandant des Empfaengers": * * 1. IDENTITAET FUER EINEN MANDANTEN MIT SmtpConfig: Transport aus GENAU * dieser Config, `from` = deren fromAddress, `close()` gerufen. * 2. Mandant OHNE SmtpConfig: die bisherige Umgebungs-Kette (MAIL_* vor * TESSERA_SMTP_* vor localhost:1025), `from` aus TESSERA_SMTP_FROM bzw. * Vorgabe. * 3. ZWEI Mandanten nacheinander -> zwei verschiedene Transporte, keiner * sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen). * 4. `sendMail` wirft -> kein Throw nach aussen (T-02-12), Fehler * protokolliert, `close()` trotzdem gerufen. * * `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts) * — kein echter Transport, der lokale `mailhog` aus docker-compose.dev.yml * ist nur fuer den Browser-Check gedacht. * * Erweitert in quick-260914-m97 (Fehler-melden-Knopf): Tests 5 und 6 pinnen * `sendBugReport` — Anhaenge werden 1:1 an `sendMail` durchgereicht, und * Fehler gehen bewusst NACH AUSSEN (der Anwender soll wissen, ob sein * Bericht ankam), waehrend `sendPasswordResetEmail` weiterhin verschluckt * (T-02-12 unveraendert, Gegenprobe im selben Test). */ let mockSendMail = vi.fn(async (_mail: unknown) => ({})); const mockClose = vi.fn(); vi.mock('nodemailer', () => ({ createTransport: vi.fn(() => ({ sendMail: (...args: unknown[]) => (mockSendMail as any)(...args), close: (...args: unknown[]) => (mockClose as any)(...args), })), })); interface FakeDecrypted { host: string; port: number; encryption: string; username: string | null; fromAddress: string; decryptedPassword: string | null; } function makeFakeSettings(configsByTenant: Record) { return { getDecryptedSmtpConfig: vi.fn(async (tenantId: string) => configsByTenant[tenantId] ?? null), }; } function makeFakeConfig(values: Record) { return { get: vi.fn((key: string, fallback?: unknown) => (values[key] !== undefined ? values[key] : fallback)), }; } const configA: FakeDecrypted = { host: 'smtp-a.example.invalid', port: 465, encryption: 'ssl-tls', username: 'user-a', fromAddress: 'noreply@a.example.invalid', decryptedPassword: 'geheim-a', }; const configB: FakeDecrypted = { host: 'smtp-b.example.invalid', port: 587, encryption: 'starttls', username: 'user-b', fromAddress: 'noreply@b.example.invalid', decryptedPassword: 'geheim-b', }; beforeEach(() => { vi.clearAllMocks(); mockSendMail = vi.fn(async (_mail: unknown) => ({})); }); describe('MailService — Transport je Versand nach Mandant des Empfaengers (260914-eym, WINDOWS #30)', () => { it('Test 1: Mandant MIT SmtpConfig -> getDecryptedSmtpConfig genau einmal mit dieser tenantId, createTransport mit deren host/port/secure/requireTLS/auth, from = deren fromAddress, close() gerufen (Identitaet zu heute)', async () => { const settings = makeFakeSettings({ t1: configA }); const config = makeFakeConfig({ MAIL_HOST: 'env-darf-nicht-greifen' }); const service = new MailService(settings as any, config as any); await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'); expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledTimes(1); expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledWith('t1'); expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledTimes(1); expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledWith({ host: 'smtp-a.example.invalid', port: 465, secure: true, requireTLS: false, auth: { user: 'user-a', pass: 'geheim-a' }, }); expect(mockSendMail).toHaveBeenCalledTimes(1); const sent = mockSendMail.mock.calls[0][0] as any; expect(sent.from).toBe('noreply@a.example.invalid'); expect(sent.to).toBe('alice@a.example.invalid'); expect(sent.text).toContain('/reset-password/tok-1'); expect(mockClose).toHaveBeenCalledTimes(1); }); it('Test 2: Mandant OHNE SmtpConfig -> Umgebungs-Kette: MAIL_* vor TESSERA_SMTP_* vor localhost:1025, from aus TESSERA_SMTP_FROM bzw. Vorgabe', async () => { // (a) MAIL_* gesetzt -> gewinnt vor TESSERA_SMTP_* const svcA = new MailService( makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'mail.example.invalid', MAIL_PORT: 2525, MAIL_USER: 'mail-user', MAIL_PASS: 'mail-pass', TESSERA_SMTP_HOST: 'legacy.example.invalid', TESSERA_SMTP_FROM: 'Tessera ', }) as any, ); await svcA.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne'); expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({ host: 'mail.example.invalid', port: 2525, secure: false, auth: { user: 'mail-user', pass: 'mail-pass' }, }); expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera '); // (b) nur TESSERA_SMTP_* gesetzt -> zweite Stufe const svcB = new MailService( makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid', TESSERA_SMTP_PORT: 587, TESSERA_SMTP_USER: 'legacy-user', TESSERA_SMTP_PASSWORD: 'legacy-pass', TESSERA_SMTP_SECURE: 'true', }) as any, ); await svcB.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne'); expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({ host: 'legacy.example.invalid', port: 587, secure: true, auth: { user: 'legacy-user', pass: 'legacy-pass' }, }); expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera '); // (c) nichts gesetzt -> localhost:1025 const svcC = new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any); await svcC.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne'); expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({ host: 'localhost', port: 1025, secure: false, auth: { user: '', pass: '' }, }); expect(mockClose).toHaveBeenCalledTimes(3); }); it('Test 3: zwei Mandanten nacheinander -> zwei verschiedene Transporte, keiner sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen)', async () => { const settings = makeFakeSettings({ t1: configA, t2: configB }); const service = new MailService(settings as any, makeFakeConfig({}) as any); await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1'); await service.sendWelcomeMail('t2', 'bob@b.example.invalid', { subject: 'Willkommen bei Tessera', text: 'Guten Tag bob,', html: '

bob

', }); expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']); const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any); expect(transports).toHaveLength(2); expect(transports[0].host).toBe('smtp-a.example.invalid'); expect(transports[0].auth).toEqual({ user: 'user-a', pass: 'geheim-a' }); expect(transports[1].host).toBe('smtp-b.example.invalid'); expect(transports[1].requireTLS).toBe(true); expect(transports[1].auth).toEqual({ user: 'user-b', pass: 'geheim-b' }); expect(JSON.stringify(transports[0])).not.toContain('geheim-b'); expect(JSON.stringify(transports[1])).not.toContain('geheim-a'); const sentMails = mockSendMail.mock.calls.map((c) => c[0] as any); expect(sentMails[0].from).toBe('noreply@a.example.invalid'); expect(sentMails[1].from).toBe('noreply@b.example.invalid'); expect(sentMails[1].text).toContain('bob'); expect(mockClose).toHaveBeenCalledTimes(2); }); it('Test 4: sendMail wirft -> kein Throw nach aussen (T-02-12), Fehler protokolliert ohne Kennwort, close() trotzdem gerufen', async () => { mockSendMail = vi.fn(async () => { throw new Error('ECONNREFUSED smtp-a.example.invalid'); }); const settings = makeFakeSettings({ t1: configA }); const service = new MailService(settings as any, makeFakeConfig({}) as any); const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); await expect( service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'), ).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalledTimes(1); expect(String(errorSpy.mock.calls[0][0])).toContain('Failed to send Password reset email to alice@a.example.invalid'); expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a'); expect(mockClose).toHaveBeenCalledTimes(1); }); it('Test 5 (260914-m97): sendBugReport reicht to/subject/text und den PNG-Anhang unveraendert an sendMail durch, from = fromAddress des Mandanten, close() gerufen', async () => { const settings = makeFakeSettings({ t1: configA }); const service = new MailService(settings as any, makeFakeConfig({}) as any); const png = Buffer.from([1, 2, 3]); await service.sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [{ filename: 'x.png', content: png, contentType: 'image/png' }], }); expect(mockSendMail).toHaveBeenCalledTimes(1); const sent = mockSendMail.mock.calls[0][0] as any; expect(sent.from).toBe('noreply@a.example.invalid'); expect(sent.to).toBe('fehler@a.example.invalid'); expect(sent.subject).toBe('S'); expect(sent.text).toBe('T'); expect(sent.attachments).toHaveLength(1); expect(sent.attachments[0].filename).toBe('x.png'); expect(sent.attachments[0].contentType).toBe('image/png'); expect(Buffer.isBuffer(sent.attachments[0].content)).toBe(true); expect((sent.attachments[0].content as Buffer).equals(png)).toBe(true); expect(mockClose).toHaveBeenCalledTimes(1); }); it('Test 6 (260914-m97): sendBugReport laesst Transportfehler DURCH (rejects), close() trotzdem; Gegenprobe: sendPasswordResetEmail verschluckt denselben Fehler weiterhin (T-02-12)', async () => { mockSendMail = vi.fn(async () => { throw new Error('ECONNREFUSED smtp-a.example.invalid'); }); const settings = makeFakeSettings({ t1: configA }); const service = new MailService(settings as any, makeFakeConfig({}) as any); const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); await expect( service.sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [] }), ).rejects.toThrow('ECONNREFUSED'); expect(mockClose).toHaveBeenCalledTimes(1); await expect( service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'), ).resolves.toBeUndefined(); expect(mockClose).toHaveBeenCalledTimes(2); expect(errorSpy).toHaveBeenCalled(); }); }); describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => { const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit) function make() { return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any); } it('sendet Betreff "Erinnerung: " mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => { const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', { title: 'Zahnarzt', description: 'Kartenlesegeraet mitnehmen', dueAt, }); expect(ok).toBe(true); const sent = mockSendMail.mock.calls[0][0] as any; expect(sent.to).toBe('alice@a.example.invalid'); expect(sent.from).toBe('noreply@a.example.invalid'); expect(sent.subject).toBe('Erinnerung: Zahnarzt'); expect(sent.html).toBeUndefined(); expect(sent.text).toContain('14:30 Uhr'); expect(sent.text).toContain('Zahnarzt'); expect(sent.text).toContain('Kartenlesegeraet mitnehmen'); expect(sent.text).toContain('http://localhost:3000'); expect(mockClose).toHaveBeenCalledTimes(1); }); it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => { await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`, description: '', dueAt, }); const sent = mockSendMail.mock.calls[0][0] as any; expect(sent.subject).not.toMatch(/[\r\n]/); expect(sent.subject.length).toBe(150); expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true); }); it('laesst die Beschreibung weg, wenn sie leer ist', async () => { await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt }); const sent = mockSendMail.mock.calls[0][0] as any; expect(sent.text.split('\n')).toEqual([ 'Guten Tag,', '', expect.stringContaining('eine Erinnerung für'), '', 'T', '', 'http://localhost:3000', ]); }); it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => { mockSendMail = vi.fn(async () => { throw new Error('SMTP down'); }); const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: '', dueAt, }); expect(ok).toBe(false); expect(mockClose).toHaveBeenCalledTimes(1); }); }); describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => { it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => { const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any); await service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'Willkommen bei Tessera', text: 'Text', html: ``, }); const sent = mockSendMail.mock.calls[0][0] as any; expect(sent.from).toBe('noreply@a.example.invalid'); expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`); expect(sent.text).toBe('Text'); expect(sent.attachments).toHaveLength(1); expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID); expect(sent.attachments[0].contentType).toBe('image/png'); // PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen. expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG'); mockSendMail = vi.fn(async () => { throw new Error('ECONNREFUSED'); }); await expect( service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }), ).rejects.toThrow('ECONNREFUSED'); }); it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => { expect( await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'), ).toBe(true); expect( await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'), ).toBe(true); expect( await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'), ).toBe(true); expect( await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'), ).toBe(false); }); });