Files
tessera-ctl/apps/api/src/mail/mail.service.spec.ts
T
schalli 32441d77c7
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00

365 lines
16 KiB
TypeScript

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<string, FakeDecrypted>) {
return {
getDecryptedSmtpConfig: vi.fn(async (tenantId: string) => configsByTenant[tenantId] ?? null),
};
}
function makeFakeConfig(values: Record<string, string | number | undefined>) {
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 <from@example.invalid>',
}) 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 <from@example.invalid>');
// (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 <tessera@tessera.local>');
// (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: '<p>bob</p>',
});
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: <Titel>" 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: `<img src="cid:${WELCOME_HEADER_CID}">`,
});
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);
});
});