Files
tessera-ctl/apps/api/src/user/welcome-mail.service.spec.ts
T
schalli 714f731ac9
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m14s
feat(admin): Anmeldehinweise der Willkommensmail in der Vorlage bearbeitbar
Neue Felder loginHintDirectory/loginHintLocal (Platzhalter, Pflicht, max. 1000),
Migration 20260930170000 (nullable, leer = Standard aus @tessera/shared).
Knopf Passwort festlegen und Gueltigkeitshinweis bleiben fest; local-no-link
wird nie erzeugt und bleibt fest. Vorschau springt beim Bearbeiten auf die
passende Kontoart. Lokal im Browser nachgewiesen.

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

432 lines
18 KiB
TypeScript

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<string, string | undefined>) {
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<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,
};
});
/** 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, 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>Böse</b>' });
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
expect(rendered.html).not.toContain('<b>Böse</b>');
expect(rendered.html).toContain('&lt;b&gt;Böse&lt;/b&gt;');
});
});
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 <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 &lt;b&gt;IT-Team&lt;/b&gt;');
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');
// 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: '<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('&lt;img src=x onerror=alert(1)&gt; Böse');
});
});
const DEFAULT_TEXTS: WelcomeMailTexts = { ...DEFAULT_WELCOME_MAIL_TEXTS };
describe('WelcomeMailService.preview', () => {
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<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);
});
});