feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import type { WelcomeMailTexts } from '@tessera/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } from './welcome-mail.service';
|
||||
import {
|
||||
normalizeOrigin,
|
||||
WelcomeMailService,
|
||||
type WelcomeMailTarget,
|
||||
} from './welcome-mail.service';
|
||||
|
||||
/**
|
||||
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||
@@ -34,6 +39,10 @@ function makePrisma() {
|
||||
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;
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -73,36 +82,74 @@ const ldapUser: WelcomeMailTarget = {
|
||||
};
|
||||
|
||||
let prisma: ReturnType<typeof makePrisma>;
|
||||
/** 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,
|
||||
};
|
||||
});
|
||||
|
||||
function make(mail = makeMail(), config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' })) {
|
||||
const service = new WelcomeMailService(prisma as any, mail as any, config as any);
|
||||
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
|
||||
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
|
||||
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 };
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -182,18 +229,27 @@ describe('WelcomeMailService.send — Versand', () => {
|
||||
|
||||
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');
|
||||
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');
|
||||
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('https://alpha.example.invalid')).toBe(
|
||||
'https://alpha.example.invalid',
|
||||
);
|
||||
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
@@ -203,3 +259,145 @@ describe('WelcomeMailService.resolveAppUrl', () => {
|
||||
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}}',
|
||||
closing: 'Grüße vom <b>IT-Team</b>',
|
||||
};
|
||||
|
||||
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<br>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('<b>IT-Team</b>');
|
||||
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');
|
||||
});
|
||||
|
||||
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: '<img src=x onerror=alert(1)> Böse' });
|
||||
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||
expect(rendered.html).not.toContain('<img src=x');
|
||||
expect(rendered.html).toContain('<img src=x onerror=alert(1)> Böse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.preview', () => {
|
||||
const texts: WelcomeMailTexts = {
|
||||
subject: 'Betreff {{name}}',
|
||||
heading: 'Hallo {{vorname}}',
|
||||
intro: 'Bei {{firma}}',
|
||||
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).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', texts, 'directory');
|
||||
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WelcomeMailService.sendTest', () => {
|
||||
const texts: WelcomeMailTexts = {
|
||||
subject: 'Test {{name}}',
|
||||
heading: 'Hallo {{vorname}}',
|
||||
intro: 'Text',
|
||||
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');
|
||||
expect(rendered.text).not.toContain('reset-password');
|
||||
});
|
||||
|
||||
it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => {
|
||||
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), 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<typeof selfRow>), 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user