docs(quick-260911-fh9): Mandantenquelle im auth-Controller festnageln, Klassifikation nachziehen, Ledger-Eintraege anlegen

- auth.controller.spec.ts: NEU. Mandantenquelle je Handler (das Claim fuer
  me/changePassword; fuer die oberste Rolle der Mandant des Ziels aus dem
  Fan-out), unbekanntes Ziel, null-Durchreichung von me, Rollen-Metadaten
  (ROLES_KEY) und Public-Metadaten (IS_PUBLIC_KEY) fuer alle sieben
  Handler — 23 Faelle; Falsifizierungsnachweis am Fan-out-Zweig
  durchgefuehrt und zurueckgenommen
- docs/mandantentrennung-zugriffsklassifikation.md: Uebersichtszeile auth
  auf 3/10 (war 8/5), Summenzeile 78/167, Bestandsaufnahme-Zeile
  auth.service.ts/user auf gebunden (keine gemischt-Zeile mehr fuer diese
  Datei), Klassen-Verteilung unveraendert bei 64 Paaren mit
  Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall,
  Etappe-3-Anmeldeweg-Punkt in "Was diese Etappe NICHT entscheidet";
  beide Dokument-Falsifizierungen durchgefuehrt und zurueckgenommen
- .planning/WINDOWS.md: zwei neue offene Eintraege (#28 verschluckte Leere
  im Frontend, Familie #23/#25/#26; #29 Rechteausweitung ADMIN->SUPER_ADMIN
  im Schwesterweg PATCH /users/:id, T-FH9-05)

Baseline wiederhergestellt: 951/951 Tests gruen in 60 Dateien (927+23
neue Faelle plus der in Aufgabe 2 erwartungsgemaess rote Test), Werkzeug
120/120, Typpruefung sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 12:01:47 +02:00
parent 92aa8c403b
commit f68beb379a
3 changed files with 284 additions and 6 deletions
+213
View File
@@ -0,0 +1,213 @@
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', tenantId: 't1', role: 'USER' });
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', tenantId: 't1', role: Role.SUPER_ADMIN });
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', tenantId: 't1', role: 'USER' });
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', tenantId: 't1', role: 'USER' },
{ 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', tenantId: 't1', role: Role.ADMIN },
);
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', tenantId: 't1', role: Role.ADMIN },
);
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', tenantId: 't1', role: Role.SUPER_ADMIN },
);
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', tenantId: 't1', role: Role.SUPER_ADMIN },
),
).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();
},
);
});