feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s

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:
2026-09-30 15:28:52 +02:00
parent 0b34e82b21
commit 32441d77c7
24 changed files with 1540 additions and 115 deletions
+80 -2
View File
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
@@ -124,11 +124,13 @@ describe('UserController', () => {
let prisma: any;
let userService: any;
let controller: UserController;
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
beforeEach(() => {
prisma = makeFakePrisma();
userService = makeUserServiceMock();
controller = new UserController(userService as any, prisma);
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
controller = new UserController(userService as any, prisma, welcomeMail as any);
});
describe('findAll', () => {
@@ -663,4 +665,80 @@ describe('UserController', () => {
expect(await storedFor('t2', 'u-other')).toBeNull();
});
});
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
const sentAt = new Date('2026-09-30T10:00:00.000Z');
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
userService.findById.mockResolvedValue(target);
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
});
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
userService.findById.mockResolvedValue(null);
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
expect(welcomeMail.send).not.toHaveBeenCalled();
});
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
await controller.sendWelcomeMail('u-z', superAdmin);
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
});
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
});
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
welcomeMail.isAvailable.mockResolvedValue(true);
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
});
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
prisma.__seedUser({
id: 'u-a',
username: 'alice',
tenantId: 't1',
email: 'alice@x.invalid',
displayName: null,
role: 'USER',
isActive: true,
createdAt: new Date(),
lastLoginAt: null,
welcomeMailSentAt: sentAt,
});
const result = await controller.findAll(admin);
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
});
});
});