32441d77c7
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>
745 lines
33 KiB
TypeScript
745 lines
33 KiB
TypeScript
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
|
import { Role } from '@prisma/client';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { validate } from 'class-validator';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
|
import { UserController } from './user.controller';
|
|
|
|
/**
|
|
* UserController — Zwei-Klienten-Nachweis (260910-das, Aufgabe 3, Befund
|
|
* C, dritte Form).
|
|
*
|
|
* Diese Steuerungsschicht hatte bisher KEINE Testdatei: sie kann auf gar
|
|
* keinen Fehler rot werden, obwohl in ihr sieben der siebzehn Zugriffe des
|
|
* Bereichs UND die gesamte Rollenlogik liegen, die entscheidet, wer wessen
|
|
* Benutzer sehen darf. `UserService` wird hier als Attrappe gestellt — sein
|
|
* Bindungsverhalten ist bereits in Aufgabe 2 geprueft (`user.service.spec.ts`).
|
|
* Der direkte Prisma-Zugriff dieses Controllers (findAll-ADMIN-Zweig, die
|
|
* vier Selbstbedienungswege) bekommt denselben Zwei-Klienten-Nachweis wie
|
|
* in `groups.service.spec.ts`.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
|
}));
|
|
|
|
vi.mock('fs', () => ({
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
existsSync: vi.fn(() => true),
|
|
unlinkSync: vi.fn(),
|
|
readFileSync: vi.fn(() => Buffer.from('fake-image-bytes')),
|
|
}));
|
|
|
|
function makeFakePrisma() {
|
|
const users = new Map<string, any>();
|
|
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
|
|
|
function projectSelect(row: any, select: any) {
|
|
const projected: any = {};
|
|
for (const key of Object.keys(select)) {
|
|
if (select[key]) projected[key] = row[key];
|
|
}
|
|
return projected;
|
|
}
|
|
|
|
function scopedFindMany(tenantId: string, args: any) {
|
|
let rows = Array.from(users.values()).filter((u) => u.tenantId === tenantId);
|
|
rows = [...rows].sort((a, b) => a.username.localeCompare(b.username));
|
|
return args?.select ? rows.map((r) => projectSelect(r, args.select)) : rows;
|
|
}
|
|
|
|
function scopedFindUnique(tenantId: string, args: any) {
|
|
const row = users.get(args.where.id);
|
|
if (!row || row.tenantId !== tenantId) return null;
|
|
return args.select ? projectSelect(row, args.select) : row;
|
|
}
|
|
|
|
function scopedUpdate(tenantId: string, args: any) {
|
|
const row = users.get(args.where.id);
|
|
if (!row || row.tenantId !== tenantId) {
|
|
const err: any = new Error('Record to update not found');
|
|
err.code = 'P2025';
|
|
throw err;
|
|
}
|
|
const updated = { ...row, ...args.data };
|
|
users.set(args.where.id, updated);
|
|
return updated;
|
|
}
|
|
|
|
const fake: any = {
|
|
__seedUser(user: any) {
|
|
users.set(user.id, user);
|
|
},
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string) {
|
|
return {
|
|
__isBoundClient: true,
|
|
__tenantId: tenantId,
|
|
user: {
|
|
findMany: async (args: any) => {
|
|
boundCallLog.push({ tenantId, model: 'user', method: 'findMany' });
|
|
return scopedFindMany(tenantId, args);
|
|
},
|
|
findUnique: async (args: any) => {
|
|
boundCallLog.push({ tenantId, model: 'user', method: 'findUnique' });
|
|
return scopedFindUnique(tenantId, args);
|
|
},
|
|
update: async (args: any) => {
|
|
boundCallLog.push({ tenantId, model: 'user', method: 'update' });
|
|
return scopedUpdate(tenantId, args);
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
return fake;
|
|
}
|
|
|
|
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
|
|
const found = prisma.__boundCallLog.some(
|
|
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
|
|
);
|
|
expect(
|
|
found,
|
|
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
|
|
).toBe(true);
|
|
}
|
|
|
|
function makeUserServiceMock() {
|
|
return {
|
|
findById: vi.fn(),
|
|
findByIdForPlatformAdmin: vi.fn(),
|
|
findAllForPlatformAdmin: vi.fn(),
|
|
findByUsername: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
deactivate: vi.fn(),
|
|
delete: vi.fn(),
|
|
};
|
|
}
|
|
|
|
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();
|
|
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
|
|
controller = new UserController(userService as any, prisma, welcomeMail as any);
|
|
});
|
|
|
|
describe('findAll', () => {
|
|
it('Test 1: die Benutzerliste eines Mandanten-Administrators steht gebunden im Protokoll, trägt die Mandantenkennung aus dem Sitzungsnachweis, und liefert keine Benutzer eines zweiten Mandanten', 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,
|
|
});
|
|
prisma.__seedUser({
|
|
id: 'u-b',
|
|
username: 'bob',
|
|
tenantId: 't2',
|
|
email: 'bob@x.invalid',
|
|
displayName: null,
|
|
role: 'USER',
|
|
isActive: true,
|
|
createdAt: new Date(),
|
|
lastLoginAt: null,
|
|
});
|
|
|
|
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
|
|
|
|
expect(result.map((u: any) => u.username)).toEqual(['alice']);
|
|
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
|
});
|
|
|
|
it('Test 2: die Benutzerliste des Plattform-Administrators geht über die neue, übergreifende Methode des Dienstes und liefert weiterhin die Benutzer aller Mandanten in der bisherigen Sortierung — die Rollenverzweigung bleibt erhalten', async () => {
|
|
const expected = [
|
|
{ id: 'u-a', username: 'alice', tenantId: 't1' },
|
|
{ id: 'u-b', username: 'bob', tenantId: 't2' },
|
|
];
|
|
userService.findAllForPlatformAdmin.mockResolvedValue(expected);
|
|
|
|
const result = await controller.findAll({
|
|
role: Role.SUPER_ADMIN,
|
|
tenantId: 't1',
|
|
id: 'super1',
|
|
username: 'super1',
|
|
mustChangePassword: false,
|
|
});
|
|
|
|
expect(result).toBe(expected);
|
|
expect(userService.findAllForPlatformAdmin).toHaveBeenCalledTimes(1);
|
|
expect(prisma.__boundCallLog).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('Zielbenutzer-Auflösung (findOne/update/remove)', () => {
|
|
it('Test 3: die drei Wege über die Kennung lösen den Zielbenutzer rollenabhängig auf — für einen Mandanten-Administrator gebunden an dessen eigenen Mandanten, für den Plattform-Administrator über die übergreifende Methode', async () => {
|
|
const targetUser = { id: 'u-x', username: 'x', tenantId: 't1' };
|
|
|
|
userService.findById.mockResolvedValue(targetUser);
|
|
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
|
|
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
|
|
|
|
userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser);
|
|
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1', username: 'super1', mustChangePassword: false });
|
|
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x');
|
|
});
|
|
|
|
it('Test 4: ein Mandanten-Administrator, der einen Benutzer eines fremden Mandanten über dessen Kennung anspricht, bekommt weiterhin eine Ablehnung — die gebundene Auflösung liefert bereits null, die Ablehnung ist eine Nicht-gefunden-Ausnahme, keine Verbotene-Ausnahme (die vorgeschaltete, ausdrückliche Mandantenprüfung bleibt trotzdem als zweite Schicht bestehen)', async () => {
|
|
userService.findById.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('Test 5: ein Mandanten-Administrator kann weiterhin keine oberste Rolle vergeben, und die Anlage eines Benutzers landet weiterhin im Mandanten des Aufrufers, wenn dieser nicht die oberste Rolle trägt', async () => {
|
|
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
|
|
await expect(
|
|
controller.create(
|
|
{ username: 'x', email: 'x@x.invalid', password: '12345678', role: Role.SUPER_ADMIN } as any,
|
|
currentUser,
|
|
),
|
|
).rejects.toBeInstanceOf(ForbiddenException);
|
|
|
|
userService.create.mockResolvedValue({ id: 'u-new', tenantId: 't1' });
|
|
await controller.create(
|
|
{ username: 'y', email: 'y@y.invalid', password: '12345678', tenantId: 't9' } as any,
|
|
currentUser,
|
|
);
|
|
expect(userService.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({ tenantId: 't1' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('remove — Selbstlöschriegel (Befund H)', () => {
|
|
it('Test 6: der Riegel gegen das Löschen des eigenen Kontos greift', async () => {
|
|
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'admin1', tenantId: 't1' });
|
|
|
|
await expect(controller.remove('admin1', currentUser)).rejects.toBeInstanceOf(
|
|
ForbiddenException,
|
|
);
|
|
expect(userService.delete).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Selbstbedienungswege (Befund G)', () => {
|
|
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me', username: 'me', mustChangePassword: false };
|
|
|
|
beforeEach(() => {
|
|
prisma.__seedUser({
|
|
id: 'me',
|
|
username: 'me',
|
|
tenantId: 't1',
|
|
avatarPath: 'user-files/avatars/me.png',
|
|
});
|
|
});
|
|
|
|
it('Test 7: alle fünf Zugriffe der vier Selbstbedienungswege stehen gebunden im Protokoll, mit der Mandantenkennung aus dem Sitzungsnachweis', async () => {
|
|
await controller.uploadAvatar(
|
|
{ buffer: Buffer.from('x'), mimetype: 'image/png', originalname: 'x.png', size: 1 },
|
|
currentUser,
|
|
);
|
|
await controller.deleteAvatar(currentUser);
|
|
await controller.updateAccentColor({ color: '#ff00aa' }, currentUser);
|
|
|
|
prisma.__seedUser({
|
|
id: 'me',
|
|
username: 'me',
|
|
tenantId: 't1',
|
|
avatarPath: 'user-files/avatars/me.png',
|
|
});
|
|
const res = { setHeader: vi.fn(), send: vi.fn() };
|
|
await controller.getAvatar(currentUser, res as any);
|
|
|
|
const ownUserCalls = prisma.__boundCallLog.filter(
|
|
(c: any) => c.tenantId === 't1' && c.model === 'user',
|
|
);
|
|
// uploadAvatar (1 update) + deleteAvatar (1 findUnique + 1 update) +
|
|
// updateAccentColor (1 update) + getAvatar (1 findUnique) = 5.
|
|
expect(ownUserCalls.length).toBe(5);
|
|
});
|
|
|
|
it('Test 8: der Weg, der ein Bild ausliefert, liefert für einen Benutzer ohne hinterlegtes Bild weiterhin die vorhandene Nicht-gefunden-Ausnahme und ändert sein Verhalten nicht', async () => {
|
|
prisma.__seedUser({ id: 'me', username: 'me', tenantId: 't1', avatarPath: null });
|
|
const res = { setHeader: vi.fn(), send: vi.fn() };
|
|
|
|
await expect(controller.getAvatar(currentUser, res as any)).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('update/remove — Zielrolle SUPER_ADMIN (WINDOWS #29)', () => {
|
|
it('Test 9: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten weder übernehmen (Kennwort setzen), noch aussperren (isActive=false), noch herabstufen (role=USER) — alle drei Angriffsformen werden mit der Zielrollen-Ausnahme abgelehnt, und der Dienst wird in keinem der drei Fälle aufgerufen', async () => {
|
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
|
|
|
|
await expect(
|
|
controller.update('boss', { password: 'fresh-password' }, admin),
|
|
).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
|
expect(userService.update).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
controller.update('boss', { isActive: false }, admin),
|
|
).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
|
expect(userService.update).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
controller.update('boss', { role: Role.USER }, admin),
|
|
).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
|
expect(userService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 10: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin ändern — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
|
|
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
|
|
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
|
id: 'boss',
|
|
tenantId: 't1',
|
|
role: Role.SUPER_ADMIN,
|
|
});
|
|
userService.update.mockResolvedValue({
|
|
id: 'boss',
|
|
tenantId: 't1',
|
|
role: Role.SUPER_ADMIN,
|
|
passwordHash: 'h',
|
|
});
|
|
|
|
const result = await controller.update('boss', { password: 'fresh-password' }, superAdmin);
|
|
|
|
expect(userService.update).toHaveBeenCalledWith(
|
|
't1',
|
|
'boss',
|
|
expect.objectContaining({ password: 'fresh-password' }),
|
|
);
|
|
expect(result).not.toHaveProperty('passwordHash');
|
|
});
|
|
|
|
it('Test 11: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin ändern — Regressionsschutz, der Zielrollen-Riegel engt bestehende Wege nicht zusätzlich ein', async () => {
|
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
|
|
userService.update.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
|
|
|
|
await controller.update('u1', { displayName: 'Neu' }, admin);
|
|
|
|
expect(userService.update).toHaveBeenCalledWith(
|
|
't1',
|
|
'u1',
|
|
expect.objectContaining({ displayName: 'Neu' }),
|
|
);
|
|
});
|
|
|
|
it('Test 12: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — ein Administrator, der (bei einer fehlerhaften Auflösung) ein Ziel eines fremden Mandanten mit der obersten Rolle erhält, bekommt die Mandanten-Meldung, nicht die Zielrollen-Meldung, und erfährt so nichts über die Rolle des fremden Benutzers', async () => {
|
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
|
|
|
|
await expect(
|
|
controller.update('boss2', { displayName: 'Neu' }, admin),
|
|
).rejects.toThrow('Cannot modify users from other tenants');
|
|
expect(userService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 13: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten nicht löschen — die Zielrollen-Ausnahme greift, und der Dienst wird nicht aufgerufen', async () => {
|
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
|
|
|
|
await expect(controller.remove('boss', admin)).rejects.toThrow(
|
|
'Cannot delete a SUPER_ADMIN user',
|
|
);
|
|
expect(userService.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 14: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin löschen — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
|
|
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
|
|
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
|
id: 'boss',
|
|
tenantId: 't1',
|
|
role: Role.SUPER_ADMIN,
|
|
});
|
|
userService.delete.mockResolvedValue({ message: 'User deleted' });
|
|
|
|
const result = await controller.remove('boss', superAdmin);
|
|
|
|
expect(userService.delete).toHaveBeenCalledWith('t1', 'boss');
|
|
expect(result).toEqual({ message: 'User deleted' });
|
|
});
|
|
|
|
it('Test 15: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin löschen — Regressionsschutz', async () => {
|
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
|
|
userService.delete.mockResolvedValue({ message: 'User deleted' });
|
|
|
|
await controller.remove('u1', admin);
|
|
|
|
expect(userService.delete).toHaveBeenCalledWith('t1', 'u1');
|
|
});
|
|
|
|
it('Test 16: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — beim Löschen bekommt ein Administrator mit einem fremdmandantigen Ziel der obersten Rolle die Mandanten-Meldung, nicht die Zielrollen-Meldung', async () => {
|
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
|
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
|
|
|
|
await expect(controller.remove('boss2', admin)).rejects.toThrow(
|
|
'Cannot delete users from other tenants',
|
|
);
|
|
expect(userService.delete).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
/**
|
|
* quick-260925-bow: "Was ist neu"-Fenster. Beide Wege lesen und schreiben
|
|
* ausschliesslich die Zeile des angemeldeten Benutzers ueber den an dessen
|
|
* Mandanten gebundenen Klienten; es gibt keinen Kennungsparameter.
|
|
* Die laufende Version kommt aus APP_VERSION (getRunningRelease()).
|
|
*/
|
|
describe('Was-ist-neu-Fenster (quick-260925-bow)', () => {
|
|
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
function seedMe(lastSeenReleaseVersion: string | null, tenantId = 't1', id = 'u-me') {
|
|
prisma.__seedUser({ id, username: id, tenantId, lastSeenReleaseVersion });
|
|
}
|
|
|
|
describe('GET me/release-notice', () => {
|
|
it('liefert laufende Version und gemerkten Stand, gelesen ueber den gebundenen Klienten des eigenen Mandanten', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
|
|
seedMe('10.1.0');
|
|
|
|
const result = await controller.getReleaseNotice(me);
|
|
|
|
expect(result).toEqual({ currentRelease: '10.2.3', lastSeenReleaseVersion: '10.1.0' });
|
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
|
});
|
|
|
|
it('Bestandsbenutzer ohne gemerkten Stand: lastSeenReleaseVersion ist null; dev-Stand: currentRelease ist null', async () => {
|
|
vi.stubEnv('APP_VERSION', 'dev');
|
|
seedMe(null);
|
|
|
|
const result = await controller.getReleaseNotice(me);
|
|
|
|
expect(result).toEqual({ currentRelease: null, lastSeenReleaseVersion: null });
|
|
});
|
|
|
|
it('ein Benutzer derselben Kennung in einem fremden Mandanten ist unsichtbar: NotFoundException', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe('10.1.0', 't2');
|
|
|
|
await expect(controller.getReleaseNotice(me)).rejects.toBeInstanceOf(NotFoundException);
|
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
|
});
|
|
});
|
|
|
|
describe('POST me/release-seen', () => {
|
|
it('merkt die laufende Version und nennt den gespeicherten Stand', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe(null);
|
|
|
|
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
|
|
|
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
|
|
const after = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
|
|
expect(after.lastSeenReleaseVersion).toBe('10.2.3');
|
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
});
|
|
|
|
it('eine aeltere, aber nicht zu hohe Version wird angenommen, wenn sie ueber dem gemerkten Stand liegt', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
|
|
seedMe('10.0.0');
|
|
|
|
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
|
|
|
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.1.0' });
|
|
});
|
|
|
|
it.each([
|
|
['v10.2.3'],
|
|
['10.2'],
|
|
['abc'],
|
|
['10.2.3-5-gabc1234'],
|
|
['010.2.3'],
|
|
[''],
|
|
[' 10.2.3'],
|
|
])('nicht kanonisches Format %j → BadRequestException, nichts gespeichert', async (version) => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe(null);
|
|
|
|
await expect(controller.markReleaseSeen({ version }, me)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it.each([[123], [null], [undefined], [{ version: '10.2.3' }], [['10.2.3']]])(
|
|
'Nicht-Zeichenkette %j → BadRequestException',
|
|
async (version) => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe(null);
|
|
|
|
await expect(
|
|
controller.markReleaseSeen({ version } as unknown as ReleaseSeenDto, me),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
},
|
|
);
|
|
|
|
it('eine Version ueber der laufenden → BadRequestException', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe(null);
|
|
|
|
await expect(controller.markReleaseSeen({ version: '10.2.4' }, me)).rejects.toBeInstanceOf(BadRequestException);
|
|
await expect(controller.markReleaseSeen({ version: '11.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it.each([['dev'], ['abc1234'], [undefined]])(
|
|
'laufende Version nicht freigegeben (%j) → BadRequestException',
|
|
async (appVersion) => {
|
|
vi.stubEnv('APP_VERSION', appVersion);
|
|
seedMe(null);
|
|
|
|
await expect(controller.markReleaseSeen({ version: '1.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
|
|
},
|
|
);
|
|
|
|
it('ein gemerkter Stand wird nie abgesenkt', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe('10.2.3');
|
|
|
|
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
|
|
|
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
|
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it('ein unparsebarer gemerkter Wert wird ueberschrieben', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe('kaputt');
|
|
|
|
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
|
|
|
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
|
|
});
|
|
|
|
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe(null, 't1', 'u-me');
|
|
seedMe(null, 't2', 'u-other');
|
|
|
|
await controller.markReleaseSeen({ version: '10.2.3' }, me);
|
|
|
|
const mine = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
|
|
const other = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-other' } });
|
|
expect(mine.lastSeenReleaseVersion).toBe('10.2.3');
|
|
expect(other.lastSeenReleaseVersion).toBeNull();
|
|
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
|
|
});
|
|
|
|
it('fehlt die eigene Zeile im eigenen Mandanten (Kennung nur in fremdem Mandanten) → NotFoundException, nichts geaendert', async () => {
|
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
|
seedMe(null, 't2', 'u-me');
|
|
|
|
await expect(controller.markReleaseSeen({ version: '10.2.3' }, me)).rejects.toBeInstanceOf(NotFoundException);
|
|
const foreign = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-me' } });
|
|
expect(foreign.lastSeenReleaseVersion).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ReleaseSeenDto (globale ValidationPipe)', () => {
|
|
async function errorsFor(body: unknown) {
|
|
return validate(plainToInstance(ReleaseSeenDto, body));
|
|
}
|
|
|
|
it('nimmt die kanonische Form X.Y.Z an', async () => {
|
|
expect(await errorsFor({ version: '1.4.0' })).toHaveLength(0);
|
|
});
|
|
|
|
it.each([
|
|
[{ version: 'v1.4.0' }],
|
|
[{ version: '1.4' }],
|
|
[{ version: '1.4.0-5-gabc1234' }],
|
|
[{ version: 123 }],
|
|
[{}],
|
|
[{ version: `1.4.0${'0'.repeat(40)}` }],
|
|
])('lehnt %j ab', async (body) => {
|
|
expect((await errorsFor(body)).length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank.
|
|
* Der Selbstbedienungsweg schreibt ausschliesslich die Zeile des
|
|
* angemeldeten Benutzers ueber den an dessen Mandanten gebundenen Klienten
|
|
* (T-ujj-02) und nimmt nur die erlaubten Formen an (T-ujj-01): gespeichert
|
|
* wird immer ein frisch aufgebautes Objekt ohne Zusatzschluessel.
|
|
*/
|
|
describe('Dashboard-Hintergrund (quick-260928-ujj)', () => {
|
|
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
|
|
const IMAGE_ID = '3f2b8c1e-9a4d-4e7f-8b21-0c5d6e7f8a9b';
|
|
|
|
function seedMe(tenantId = 't1', id = 'u-me') {
|
|
prisma.__seedUser({ id, username: id, tenantId, dashboardBackground: null });
|
|
}
|
|
|
|
async function storedFor(tenantId: string, id: string) {
|
|
const row = await prisma.__makeBoundClient(tenantId).user.findUnique({ where: { id } });
|
|
return row.dashboardBackground;
|
|
}
|
|
|
|
it.each([
|
|
[{ kind: 'none' }, { kind: 'none' }],
|
|
[{ kind: 'preset', id: 'dunes' }, { kind: 'preset', id: 'dunes' }],
|
|
[{ kind: 'preset', id: 'mosaic', extra: 'weg' }, { kind: 'preset', id: 'mosaic' }],
|
|
[{ kind: 'image', imageId: IMAGE_ID }, { kind: 'image', imageId: IMAGE_ID }],
|
|
[{ kind: 'image', imageId: IMAGE_ID.toUpperCase(), id: 'mist' }, { kind: 'image', imageId: IMAGE_ID.toUpperCase() }],
|
|
[{ kind: 'none', id: 'dunes', imageId: IMAGE_ID }, { kind: 'none' }],
|
|
])('%j wird normalisiert als %j gespeichert, gebunden an den eigenen Mandanten', async (input, expected) => {
|
|
seedMe();
|
|
|
|
const result = await controller.updateDashboardBackground({ background: input }, me);
|
|
|
|
expect(result).toEqual({ success: true, dashboardBackground: expected });
|
|
expect(await storedFor('t1', 'u-me')).toEqual(expected);
|
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
|
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
[{ kind: 'preset', id: 'regenbogen' }],
|
|
[{ kind: 'preset' }],
|
|
[{ kind: 'preset', id: 42 }],
|
|
[{ kind: 'gradient', id: 'mist' }],
|
|
[{ kind: 'image', imageId: 'img-1' }],
|
|
[{ kind: 'image', imageId: '") ; background: url("https://boese.invalid/x' }],
|
|
[{ kind: 'image', imageId: `${IMAGE_ID})` }],
|
|
[{ kind: 'image', imageId: `${IMAGE_ID}0` }],
|
|
[{ kind: 'image', imageId: '' }],
|
|
[{ kind: 'image' }],
|
|
[{}],
|
|
[null],
|
|
[undefined],
|
|
['none'],
|
|
[42],
|
|
[[{ kind: 'none' }]],
|
|
])('ungueltige Wahl %j → BadRequestException, nichts geschrieben', async (background) => {
|
|
seedMe();
|
|
|
|
await expect(controller.updateDashboardBackground({ background }, me)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
|
expect(await storedFor('t1', 'u-me')).toBeNull();
|
|
});
|
|
|
|
it('fehlender Rumpf → BadRequestException', async () => {
|
|
seedMe();
|
|
|
|
await expect(
|
|
controller.updateDashboardBackground(undefined as unknown as { background: unknown }, me),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
|
});
|
|
|
|
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
|
|
seedMe('t1', 'u-me');
|
|
seedMe('t2', 'u-other');
|
|
|
|
await controller.updateDashboardBackground({ background: { kind: 'preset', id: 'mist' } }, me);
|
|
|
|
expect(await storedFor('t1', 'u-me')).toEqual({ kind: 'preset', id: 'mist' });
|
|
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);
|
|
});
|
|
});
|
|
});
|