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>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { normalizeOrigin, type WelcomeMailTarget, WelcomeMailService } 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 };
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = makePrisma();
|
||||
});
|
||||
|
||||
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);
|
||||
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||
return { service, mail };
|
||||
}
|
||||
|
||||
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('<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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user