Files
tessera-ctl/apps/api/src/user/user.controller.spec.ts
T
schalli 63f9df0afb docs(quick-260914-ebg): Kopfkommentar adminResetPassword — Schwesterwege PATCH/DELETE /users/:id geschlossen (WINDOWS #29)
- auth.service.ts: letzter Satz des Kopfkommentars ueber adminResetPassword nennt T-FH9-05 nicht mehr als offen, sondern verweist auf den seit 260914-ebg (WINDOWS #29) identischen Riegel in UserController.update()/remove()
- Falsifizierung: Rueckbau des Task-1-Commits (git apply -R) macht Test 9 und Test 13 rot (Tests  2 failed | 14 passed (16)), danach byte-identisch wiederhergestellt (git checkout --, git status --porcelain leer)
- Rule 1 Nebenfund: acht neue Tests in user.controller.spec.ts trugen sechs ueberfluessige `as any`-Umschreibungen (UpdateUserDto ist vollstaendig optional, siehe planning_measurements), die die Biome-Warnungen dieser Datei von 25 auf 31 trieben — entfernt, damit die relative Biome-Schwelle der Baseline (25) wieder eingehalten wird, ohne die Schwelle anzuheben

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 10:37:40 +02:00

396 lines
16 KiB
TypeScript

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<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;
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,
);
});
});
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' };
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' };
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' };
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' };
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' };
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' };
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' };
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' };
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();
});
});
});