import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Role } from '@prisma/client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; 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(); 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; beforeEach(() => { prisma = makeFakePrisma(); userService = makeUserServiceMock(); controller = new UserController(userService as any, prisma); }); 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' }); 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', }); 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' }); expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x'); userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser); await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1' }); 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' }), ).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' }; 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' }; 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' }; 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' }, 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, ); }); }); });