import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common'; import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { normalizeOrigin, WelcomeMailService, type WelcomeMailTarget, } from './welcome-mail.service'; /** * WelcomeMailService — Willkommensmail aus der Benutzerverwaltung. * * Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer * deaktivierte Benutzer, * 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann * KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den * Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows- * Passwort, kein Token; lokal: Token-Link mit der Frist des * "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)), })); function makePrisma() { const log: { tenantId: string; model: string; method: string; args: any }[] = []; return { __log: log, __bound(tenantId: string) { return { passwordResetToken: { create: vi.fn(async (args: any) => { log.push({ tenantId, model: 'passwordResetToken', method: 'create', args }); return { id: 'tok-row', ...args.data }; }), }, user: { update: vi.fn(async (args: any) => { log.push({ tenantId, model: 'user', method: 'update', args }); return { welcomeMailSentAt: args.data.welcomeMailSentAt }; }), findFirst: vi.fn(async (args: any) => { log.push({ tenantId, model: 'user', method: 'findFirst', args }); return selfRow; }), }, }; }, }; } function makeMail(opts: { available?: boolean; fail?: boolean } = {}) { return { hasConfiguredTransport: vi.fn(async () => opts.available ?? true), sendWelcomeMail: vi.fn(async () => { if (opts.fail) throw new Error('ECONNREFUSED'); }), }; } function makeConfig(values: Record) { return { get: vi.fn((key: string) => values[key]) }; } const localUser: WelcomeMailTarget = { id: 'u-local', tenantId: 't1', username: 'max.muster', displayName: 'Max Muster', email: 'max@example.invalid', ldapDn: null, isActive: true, }; const ldapUser: WelcomeMailTarget = { ...localUser, id: 'u-ldap', username: 'erika', displayName: null, email: 'erika@example.invalid', ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid', }; let prisma: ReturnType; /** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */ let selfRow: { username: string; displayName: string | null; email: string | null; ldapDn: string | null; } | null; beforeEach(() => { prisma = makePrisma(); selfRow = { username: 'admin.lokal', displayName: 'Ada Admin', email: 'ada@example.invalid', ldapDn: null, }; }); /** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */ function makeTemplates(custom: Record = {}) { return { getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null), getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`), }; } function make( mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }), templates = makeTemplates(), ) { const service = new WelcomeMailService( prisma as any, mail as any, config as any, templates as any, ); vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined); vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); return { service, mail, templates }; } describe('WelcomeMailService.send — Vorbedingungen', () => { it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => { const { service, mail } = make(); await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget); await service.send({ ...localUser, lastLoginAt: new Date(), role: 'SUPER_ADMIN', } as WelcomeMailTarget); expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2); expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2); }); it('deaktiviertes Konto → ConflictException', async () => { const { service, mail } = make(); await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf( ConflictException, ); expect(mail.sendWelcomeMail).not.toHaveBeenCalled(); }); it('ohne E-Mail-Adresse → BadRequestException', async () => { const { service, mail } = make(); await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf( BadRequestException, ); expect(mail.sendWelcomeMail).not.toHaveBeenCalled(); }); it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => { const mail = makeMail({ available: false }); const { service } = make(mail); await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP'); expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1'); expect(mail.sendWelcomeMail).not.toHaveBeenCalled(); expect(prisma.__log).toHaveLength(0); }); }); describe('WelcomeMailService.send — Versand', () => { it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => { const { service, mail } = make(); const before = Date.now(); const result = await service.send(localUser); const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken'); expect(tokenCall?.tenantId).toBe('t1'); expect(tokenCall?.args.data.userId).toBe('u-local'); const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before; expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000); expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000); expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1); const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[]; expect(tenantId).toBe('t1'); expect(to).toBe('max@example.invalid'); const token = (tokenCall as { args: any }).args.data.token; expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`); expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`); expect(rendered.html).toContain('Passwort festlegen'); expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!'); expect(rendered.text).toContain('Benutzername: max.muster'); expect(rendered.html).toContain('https://tessera.example.invalid/login'); expect(rendered.text).not.toContain('Windows-Passwort'); const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update'); expect(updateCall?.tenantId).toBe('t1'); expect(updateCall?.args.where).toEqual({ id: 'u-local' }); expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date); expect(result.to).toBe('max@example.invalid'); expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt); }); it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => { const { service, mail } = make(); await service.send(ldapUser); expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.text).toContain('gewohnten Windows-Passwort'); expect(rendered.html).toContain('gewohnten Windows-Passwort'); expect(rendered.html).not.toContain('reset-password'); expect(rendered.html).not.toContain('Passwort festlegen'); expect(rendered.html).toContain('Willkommen bei Tessera, erika!'); }); it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => { const { service } = make(makeMail({ fail: true })); await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException); expect(prisma.__log.some((c) => c.model === 'user')).toBe(false); }); it('Anzeigename mit Markup wird im HTML maskiert', async () => { const { service, mail } = make(); await service.send({ ...ldapUser, displayName: 'Böse' }); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.html).not.toContain('Böse'); expect(rendered.html).toContain('<b>Böse</b>'); }); }); describe('WelcomeMailService.resolveAppUrl', () => { it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => { const { service } = make( makeMail(), makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }), ); expect(service.resolveAppUrl('https://anders.example.invalid')).toBe( 'https://tessera.example.invalid', ); }); it('ohne Konfiguration: Origin der Anfrage', () => { const { service } = make(makeMail(), makeConfig({})); expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe( 'https://alpha.example.invalid', ); }); it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => { const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' })); expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe( 'https://alpha.example.invalid', ); expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000'); }); it('ungueltiger Origin wird ignoriert', () => { expect(normalizeOrigin('javascript:alert(1)')).toBeNull(); expect(normalizeOrigin('kein origin')).toBeNull(); expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid'); }); }); describe('WelcomeMailService.send — eigene Vorlage', () => { const custom: WelcomeMailTexts = { subject: 'Hallo {{vorname}} bei {{firma}}', heading: 'Schön, dass Sie da sind, {{name}}!', intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}', loginHintDirectory: 'Bitte mit dem Kennwort von {{firma}} anmelden, {{vorname}}.', loginHintLocal: 'Zuerst ein eigenes Passwort vergeben, {{vorname}}.', closing: 'Grüße vom IT-Team', }; it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => { const { service, mail, templates } = make(); await service.send({ ...ldapUser, tenantId: 't9' }); expect(templates.getCustomTexts).toHaveBeenCalledWith('t9'); expect(templates.getTenantName).toHaveBeenCalledWith('t9'); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.subject).toBe('Willkommen bei Tessera'); expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App'); }); it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => { const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom })); await service.send(localUser); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.subject).toBe('Hallo Max bei Firma t1'); expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!'); expect(rendered.html).toContain('Erste Zeile
zweite Zeile'); expect(rendered.html).toContain( 'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid', ); expect(rendered.html).toContain('Grüße vom <b>IT-Team</b>'); expect(rendered.html).not.toContain('IT-Team'); expect(rendered.html).not.toContain('Desktop-App'); // feste Bausteine expect(rendered.html).toContain('Passwort festlegen'); expect(rendered.html).toContain('/reset-password/'); expect(rendered.html).toContain('Zu Tessera'); expect(rendered.html).toContain('Benutzername'); expect(rendered.text).toContain('Erste Zeile\nzweite Zeile'); // Anmeldehinweis fuer lokale Konten aus der Vorlage expect(rendered.html).toContain('Zuerst ein eigenes Passwort vergeben, Max.'); expect(rendered.html).not.toContain('persönliches Passwort fest'); }); it('Verzeichniskonto mit eigener Vorlage: eigener Verzeichnis-Hinweis statt Windows-Standard', async () => { const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom })); await service.send(ldapUser); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.html).toContain('Bitte mit dem Kennwort von Firma t1 anmelden'); expect(rendered.html).not.toContain('gewohnten Windows-Passwort'); expect(rendered.html).not.toContain('Zuerst ein eigenes Passwort vergeben'); expect(rendered.text).toContain('Bitte mit dem Kennwort von Firma t1 anmelden'); }); it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => { const { service, mail } = make(undefined, undefined, makeTemplates({ t2: custom })); await service.send(localUser); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.subject).toBe('Willkommen bei Tessera'); }); it('Platzhalterwerte mit Markup werden escaped (Anzeigename in {{name}}/{{vorname}})', async () => { const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom })); await service.send({ ...ldapUser, displayName: ' Böse' }); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.html).not.toContain(' { const texts: WelcomeMailTexts = { subject: 'Betreff {{name}}', heading: 'Hallo {{vorname}}', intro: 'Bei {{firma}}', loginHintDirectory: 'Verzeichnis-Hinweis {{benutzername}}', loginHintLocal: 'Lokal-Hinweis {{benutzername}}', closing: '', }; it('Beispielwerte, Firmenname des eigenen Mandanten, kein Token, kein Versand, kein cid:', async () => { const { service, mail, templates } = make(); const rendered = await service.preview('t1', texts, 'local'); expect(templates.getTenantName).toHaveBeenCalledWith('t1'); expect(rendered.subject).toBe('Betreff Max Mustermann'); expect(rendered.html).toContain('Hallo Max'); expect(rendered.html).toContain('Bei Firma t1'); expect(rendered.html).toContain('/reset-password/beispiel'); expect(rendered.html).toContain('Lokal-Hinweis max.mustermann'); expect(rendered.html).not.toContain('Verzeichnis-Hinweis'); expect(rendered.html).not.toContain('cid:'); expect(prisma.__log).toHaveLength(0); expect(mail.sendWelcomeMail).not.toHaveBeenCalled(); }); it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => { const { service } = make(); const rendered = await service.preview('t1', DEFAULT_TEXTS, 'directory'); expect(rendered.html).toContain('gewohnten Windows-Passwort'); expect(rendered.html).not.toContain('Passwort festlegen'); const custom = await service.preview('t1', texts, 'directory'); expect(custom.html).toContain('Verzeichnis-Hinweis max.mustermann'); expect(custom.html).not.toContain('Lokal-Hinweis'); }); }); describe('WelcomeMailService.sendTest', () => { const texts: WelcomeMailTexts = { subject: 'Test {{name}}', heading: 'Hallo {{vorname}}', intro: 'Text', loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory, loginHintLocal: 'Eigener Lokal-Hinweis für {{vorname}}', closing: 'Gruß', }; it('geht an die eigene Adresse, liest das eigene Konto gebunden, erzeugt KEIN Token und setzt KEIN welcomeMailSentAt', async () => { const { service, mail } = make(); const result = await service.sendTest('t1', 'u-self', texts); expect(result.to).toBe('ada@example.invalid'); const find = prisma.__log.find((c) => c.method === 'findFirst'); expect(find?.tenantId).toBe('t1'); expect(find?.args.where).toEqual({ id: 'u-self', tenantId: 't1' }); expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false); expect(prisma.__log.some((c) => c.method === 'update')).toBe(false); const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[]; expect(tenantId).toBe('t1'); expect(to).toBe('ada@example.invalid'); expect(rendered.subject).toBe('Test Ada Admin'); expect(rendered.html).toContain('Testmail'); // lokales Konto: Beispiel-Link zur Anmeldeseite, kein reset-password expect(rendered.html).toContain('Passwort festlegen'); expect(rendered.html).toContain('Beispiel-Link'); expect(rendered.html).not.toContain('reset-password'); // Anmeldehinweis fuer lokale Konten aus dem Formularinhalt expect(rendered.html).toContain('Eigener Lokal-Hinweis für Ada'); expect(rendered.text).not.toContain('reset-password'); }); it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => { selfRow = { ...(selfRow as NonNullable), ldapDn: 'CN=Ada' }; const { service, mail } = make(); await service.sendTest('t1', 'u-self', texts); const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2]; expect(rendered.html).toContain('gewohnten Windows-Passwort'); expect(rendered.html).not.toContain('Passwort festlegen'); }); it('ohne eigene Adresse → BadRequestException, kein Versand', async () => { selfRow = { ...(selfRow as NonNullable), email: null }; const { service, mail } = make(); await expect(service.sendTest('t1', 'u-self', texts)).rejects.toBeInstanceOf( BadRequestException, ); expect(mail.sendWelcomeMail).not.toHaveBeenCalled(); }); it('ohne Versandweg → ConflictException; Versandfehler → BadGatewayException', async () => { await expect( make(makeMail({ available: false })).service.sendTest('t1', 'u-self', texts), ).rejects.toBeInstanceOf(ConflictException); await expect( make(makeMail({ fail: true })).service.sendTest('t1', 'u-self', texts), ).rejects.toBeInstanceOf(BadGatewayException); }); });