Files
tessera-ctl/apps/api/src/auth/auth.controller.spec.ts
T
schalli f2fc39f51c refactor(quick-260921-m34): Aufgabe 2a - gemeinsamer Aufrufer-Typ, aus den Signierstellen abgeleitet
apps/api/src/auth/types/auth-user.ts angelegt: AuthUser, AuthenticatedRequest,
LocalAuthenticatedRequest, LoginUser, JwtPayload, UploadedFileLike. Jedes Feld
traegt seine Herkunft als Kommentar.

tenantId ist string, hergeleitet und nicht gewaehlt: die Spalte User.tenantId
ist in schema.prisma Pflicht, beide Signierstellen schreiben genau sie, und
der Bestand beschreibt dasselbe Objekt in SessionUser schon so. Der
SUPER_ADMIN-Zweig in TenantGuard spricht nicht dagegen - der Waechter liest
AuthUser gar nicht, und dass es den Zweig gibt, steht als null in
AuthenticatedRequest.tenantId weiter im Typsystem. tenant.guard.ts bleibt
unberuehrt.

role ist die Aufzaehlung Role: schema.prisma deklariert die Spalte so, die
SQL-Funktion auth_lookup_user_by_username gibt sie als "Role" zurueck. Die
Handannotation role: string in AuthLookupUserByUsernameRow war eine zweite
Fassung desselben Wertes und faellt damit weg.

SessionUser und UploadedPng in bug-reports.service.ts sind jetzt Pick<> der
neuen Typen statt eigener Beschreibungen.

Fixtures in auth.controller.spec.ts ergaenzt: sie uebergaben einen Aufrufer
ohne username und ohne mustChangePassword - eine Form, die JwtStrategy nie
erzeugt. Testzahlen unveraendert.

noExplicitAny in apps/api/src: 149 -> 137. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 17:03:24 +02:00

214 lines
8.5 KiB
TypeScript

import 'reflect-metadata';
import { BadRequestException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { IS_PUBLIC_KEY } from './decorators/public.decorator';
import { ROLES_KEY } from './decorators/roles.decorator';
import { AuthController } from './auth.controller';
/**
* auth.controller.spec.ts — NEU (260911-fh9, Aufgabe 3). Nagelt die
* Mandantenquelle je Handler fest: `me`/`changePassword` reichen
* ausschliesslich `user.tenantId` aus dem Sitzungsnachweis (`@CurrentUser()`)
* durch; `adminResetPassword` verzweigt nach Rolle des AUFRUFERS — ADMIN
* bindet an den eigenen Mandanten, SUPER_ADMIN loest den Mandanten des
* ZIELS ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
* auf. Form: `tenant.controller.spec.ts` (Dienst-Attrappen, Rollen-Metadaten
* ueber `Reflect.getMetadata`, `reflect-metadata`).
*/
function makeFakeAuthService() {
return {
getMe: vi.fn(),
changePassword: vi.fn(),
adminResetPassword: vi.fn(),
} as any;
}
function makeFakeUserService() {
return {
findByIdForPlatformAdmin: vi.fn(),
} as any;
}
describe('AuthController.me', () => {
it('reicht den Mandanten des Aufrufers und dessen Kennung GENAU durch (das Claim, nicht die Guard-Kennung)', async () => {
const authService = makeFakeAuthService();
authService.getMe.mockResolvedValue({ id: 'u1' });
const controller = new AuthController(authService, makeFakeUserService());
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
});
it('SUPER_ADMIN, dessen Claim t1 traegt: ebenfalls (\'t1\', \'u1\') — keine Kopfzeile und keine Guard-Kennung koennten das aendern, weil der Handler nur @CurrentUser() liest', async () => {
const authService = makeFakeAuthService();
authService.getMe.mockResolvedValue({ id: 'u1' });
const controller = new AuthController(authService, makeFakeUserService());
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false });
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
});
it('liefert null, wenn der Dienst null liefert — wirft NICHT (das ist der Beginn des leeren Rumpfs, (h3))', async () => {
const authService = makeFakeAuthService();
authService.getMe.mockResolvedValue(null);
const controller = new AuthController(authService, makeFakeUserService());
const result = await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
expect(result).toBeNull();
});
});
describe('AuthController.changePassword', () => {
it('reicht Mandant, Kennung, beide Kennwoerter und die Antwort durch, liefert die Erfolgsmeldung', async () => {
const authService = makeFakeAuthService();
const controller = new AuthController(authService, makeFakeUserService());
const res = {} as any;
const result = await controller.changePassword(
{ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false },
{ currentPassword: 'old', newPassword: 'new' } as any,
res,
);
expect(authService.changePassword).toHaveBeenCalledWith('t1', 'u1', 'old', 'new', res);
expect(result).toEqual({ message: 'Password changed successfully.' });
});
});
describe('AuthController.adminResetPassword', () => {
it('Aufrufer ADMIN (tenantId t1), Ziel "target": Dienst mit (\'t1\', \'ADMIN\', \'target\', \'new-password\', true) aufgerufen; findByIdForPlatformAdmin NICHT aufgerufen; Erfolgsmeldung', async () => {
const authService = makeFakeAuthService();
const userService = makeFakeUserService();
const controller = new AuthController(authService, userService);
const result = await controller.adminResetPassword(
'target',
{ newPassword: 'new-password' } as any,
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
);
expect(authService.adminResetPassword).toHaveBeenCalledWith(
't1',
Role.ADMIN,
'target',
'new-password',
true,
);
expect(userService.findByIdForPlatformAdmin).not.toHaveBeenCalled();
expect(result).toEqual({ message: 'User password has been reset.' });
});
it('Aufrufer ADMIN, mustChangePassword: false im Rumpf: false wird durchgereicht', async () => {
const authService = makeFakeAuthService();
const controller = new AuthController(authService, makeFakeUserService());
await controller.adminResetPassword(
'target',
{ newPassword: 'new-password', mustChangePassword: false } as any,
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
);
expect(authService.adminResetPassword).toHaveBeenCalledWith(
't1',
Role.ADMIN,
'target',
'new-password',
false,
);
});
it('Aufrufer SUPER_ADMIN (tenantId t1), Fan-out liefert { id: "target", tenantId: "t9", role: "USER" }: Dienst mit (\'t9\', \'SUPER_ADMIN\', \'target\', ...) — der Mandant des ZIELS, nicht der des Aufrufers; findByIdForPlatformAdmin genau einmal mit "target"', async () => {
const authService = makeFakeAuthService();
const userService = makeFakeUserService();
userService.findByIdForPlatformAdmin.mockResolvedValue({
id: 'target',
tenantId: 't9',
role: 'USER',
});
const controller = new AuthController(authService, userService);
await controller.adminResetPassword(
'target',
{ newPassword: 'new-password' } as any,
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
);
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('target');
expect(authService.adminResetPassword).toHaveBeenCalledWith(
't9',
Role.SUPER_ADMIN,
'target',
'new-password',
true,
);
});
it('Aufrufer SUPER_ADMIN, Fan-out liefert null: BadRequestException mit Meldung "User not found", Dienst NICHT aufgerufen', async () => {
const authService = makeFakeAuthService();
const userService = makeFakeUserService();
userService.findByIdForPlatformAdmin.mockResolvedValue(null);
const controller = new AuthController(authService, userService);
await expect(
controller.adminResetPassword(
'unknown',
{ newPassword: 'new-password' } as any,
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
),
).rejects.toThrow(new BadRequestException('User not found'));
expect(authService.adminResetPassword).not.toHaveBeenCalled();
});
});
describe('AuthController — Rollen-Metadaten (T-FH9)', () => {
it('adminResetPassword traegt genau [Role.ADMIN, Role.SUPER_ADMIN]', () => {
const roles = Reflect.getMetadata(ROLES_KEY, AuthController.prototype.adminResetPassword);
expect(roles).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
});
it.each(['me', 'changePassword', 'logout', 'login', 'requestReset', 'resetPassword'] as const)(
'Handler %s traegt KEINE Rollenmetadaten',
(handlerName) => {
const handlerRoles = Reflect.getMetadata(
ROLES_KEY,
(AuthController.prototype as any)[handlerName],
);
expect(handlerRoles).toBeUndefined();
},
);
it('die Klasse selbst traegt KEINE Rollenmetadaten (die Grenze aus Befund B liegt je Handler, nicht klassenweit)', () => {
const classRoles = Reflect.getMetadata(ROLES_KEY, AuthController);
expect(classRoles).toBeUndefined();
});
});
describe('AuthController — Public-Metadaten (die Grenze aus Befund B als Metadaten-Test)', () => {
it.each(['login', 'requestReset', 'resetPassword'] as const)(
'Handler %s (Anmeldeweg) ist @Public()',
(handlerName) => {
const isPublic = Reflect.getMetadata(
IS_PUBLIC_KEY,
(AuthController.prototype as any)[handlerName],
);
expect(isPublic).toBe(true);
},
);
it.each(['me', 'changePassword', 'adminResetPassword', 'logout'] as const)(
'Handler %s (Nach-Anmeldung) ist NICHT @Public() — waere er es, liefe die Bindung an das Claim ins Leere',
(handlerName) => {
const isPublic = Reflect.getMetadata(
IS_PUBLIC_KEY,
(AuthController.prototype as any)[handlerName],
);
expect(isPublic).toBeUndefined();
},
);
});