54121c1721
- SmtpConfig.bugReportRecipient (nullable, additive Migration 20260914170000), DTO @IsOptional @IsEmail, SAFE_SELECT, getBugReportRecipient gebunden - MailService: Versandkern deliver (wirft, Anhaenge), sendViaTenantTransport bleibt verschluckender Mantel (T-02-12), sendBugReport laesst Fehler durch - POST /bug-reports: Multipart 4 MiB je Route, alle angemeldeten Rollen, Drossel 5/10 min -> 429, PNG-Signatur -> 400, kein Empfaenger -> 409, Versandfehler -> 502, eine Protokollzeile - Falsifizierungen (a)-(d) als Specs; @Expose() im DTO, damit errors auch bei fehlendem Feld zu [] wird - Doku-Zeile fuer rls-access-inventory, TESSERA_BUGREPORT_TO in docker-compose.prod.yml Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import * as nodemailer from 'nodemailer';
|
|
import { MailService } 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.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
|
|
|
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();
|
|
});
|
|
});
|