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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user