refactor(quick-260921-m34): Aufgabe 2b - getypte Anfrage in elf Controllern, zwei Befunde gemeldet
(req as any) und @Req() req: any durch AuthenticatedRequest ersetzt in
dashboard, favorites, calendar, groups, module-grants, module-registry,
tenders, dkv, ldap, settings; @CurrentUser() in user.controller auf AuthUser.
Die abwehrenden Pruefungen ("No tenant context", "No user context") bleiben
lebendig, weil user auf dem Anfragetyp wahlfrei ist - genau das beschreibt
den Zustand auf oeffentlichen Wegen.
Nebengewinn ohne neue Zusicherungen: req.tenantId as string | undefined
(dkv, settings), file.buffer as Buffer und file.mimetype as string
(dkv, user) sind weggefallen, weil der Typ sie jetzt traegt.
BEFUND 1 (D-03, gemeldet) dashboard.controller.ts:74 alt: der Handler las
req.user?.role NACH extractContext und gab sie an getWidgets(role: Role)
weiter, das eine Rolle zwingend verlangt. Die Annahme "hier gibt es immer
einen Aufrufer" stimmt - die Pruefung "No user context" erzwingt sie -, aber
sie stand in einer anderen Methode, wo der Compiler sie nicht sehen konnte.
extractContext gibt die Rolle jetzt mit zurueck: keine neue Pruefung, kein
erfundener Wert, gleiche Reihenfolge, gleiche Meldungen.
BEFUND 2 (D-03, gemeldet) tenders.controller.ts:142: resolveRequestingTenantId
erklaerte string | undefined, liest aber req.tenantId, das TenantGuard fuer
einen SUPER_ADMIN ohne Mandanten auf null setzt. Die Erklaerung war also nie
vollstaendig. Erweitert auf string | null | undefined, und buildTenderWhere
nimmt string | null - beides nur Erklaerung, kein Verhalten: die Funktion
entscheidet seit jeher ueber Wahrheitswert und faellt bei beiden zu
(nur global sichtbare Ausschreibungen).
Fixtures in user.controller.spec.ts ergaenzt (username, mustChangePassword,
originalname, size). Testzahlen unveraendert.
noExplicitAny in apps/api/src: 137 -> 66. type-check 4/4, lint 5/5,
apps/api 72/1143, apps/web 73/531, tenant.guard.ts unveraendert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -153,7 +153,7 @@ describe('UserController', () => {
|
||||
lastLoginAt: null,
|
||||
});
|
||||
|
||||
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
|
||||
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');
|
||||
@@ -170,6 +170,8 @@ describe('UserController', () => {
|
||||
role: Role.SUPER_ADMIN,
|
||||
tenantId: 't1',
|
||||
id: 'super1',
|
||||
username: 'super1',
|
||||
mustChangePassword: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(expected);
|
||||
@@ -183,11 +185,11 @@ describe('UserController', () => {
|
||||
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' });
|
||||
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' });
|
||||
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1', username: 'super1', mustChangePassword: false });
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x');
|
||||
});
|
||||
|
||||
@@ -195,14 +197,14 @@ describe('UserController', () => {
|
||||
userService.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }),
|
||||
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' };
|
||||
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
|
||||
await expect(
|
||||
controller.create(
|
||||
@@ -224,7 +226,7 @@ describe('UserController', () => {
|
||||
|
||||
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' };
|
||||
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(
|
||||
@@ -235,7 +237,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
describe('Selbstbedienungswege (Befund G)', () => {
|
||||
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me' };
|
||||
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me', username: 'me', mustChangePassword: false };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.__seedUser({
|
||||
@@ -247,7 +249,10 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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.uploadAvatar(
|
||||
{ buffer: Buffer.from('x'), mimetype: 'image/png', originalname: 'x.png', size: 1 },
|
||||
currentUser,
|
||||
);
|
||||
await controller.deleteAvatar(currentUser);
|
||||
await controller.updateAccentColor({ color: '#ff00aa' }, currentUser);
|
||||
|
||||
@@ -280,7 +285,7 @@ describe('UserController', () => {
|
||||
|
||||
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' };
|
||||
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(
|
||||
@@ -300,7 +305,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
||||
id: 'boss',
|
||||
tenantId: 't1',
|
||||
@@ -324,7 +329,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
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 });
|
||||
|
||||
@@ -338,7 +343,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
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(
|
||||
@@ -348,7 +353,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
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(
|
||||
@@ -358,7 +363,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
||||
id: 'boss',
|
||||
tenantId: 't1',
|
||||
@@ -373,7 +378,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
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' });
|
||||
|
||||
@@ -383,7 +388,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
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(
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as path from 'node:path';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -65,7 +66,7 @@ export class UserController {
|
||||
* uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie
|
||||
* darf nicht eingeebnet werden.
|
||||
*/
|
||||
private async resolveTargetUser(currentUser: any, id: string) {
|
||||
private async resolveTargetUser(currentUser: AuthUser, id: string) {
|
||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||
return this.userService.findByIdForPlatformAdmin(id);
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export class UserController {
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async findAll(@CurrentUser() currentUser: any) {
|
||||
async findAll(@CurrentUser() currentUser: AuthUser) {
|
||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||
// Plattform-Administratorsicht (Befund F): die bestehende, gewollte
|
||||
// Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die
|
||||
@@ -115,7 +116,7 @@ export class UserController {
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||
async findOne(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -139,7 +140,7 @@ export class UserController {
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) {
|
||||
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: AuthUser) {
|
||||
// ADMIN can only create users in own tenant
|
||||
const tenantId =
|
||||
currentUser.role === Role.SUPER_ADMIN && dto.tenantId
|
||||
@@ -173,7 +174,7 @@ export class UserController {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
@@ -228,7 +229,7 @@ export class UserController {
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -290,14 +291,14 @@ export class UserController {
|
||||
FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }),
|
||||
)
|
||||
async uploadAvatar(
|
||||
@UploadedFile() file: any,
|
||||
@CurrentUser() currentUser: any,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
const ext = AVATAR_MIME_TO_EXT[file.mimetype as string];
|
||||
const ext = AVATAR_MIME_TO_EXT[file.mimetype];
|
||||
if (!ext) {
|
||||
throw new BadRequestException(
|
||||
'Invalid file type. Allowed: image/png, image/jpeg, image/webp',
|
||||
@@ -318,7 +319,7 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, file.buffer as Buffer);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// Persist relative path (relative to monorepo root)
|
||||
const relativePath = path.join('user-files', 'avatars', filename);
|
||||
@@ -336,7 +337,7 @@ export class UserController {
|
||||
* Remove the current user's profile picture.
|
||||
*/
|
||||
@Delete('me/avatar')
|
||||
async deleteAvatar(@CurrentUser() currentUser: any) {
|
||||
async deleteAvatar(@CurrentUser() currentUser: AuthUser) {
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: currentUser.id },
|
||||
@@ -366,7 +367,7 @@ export class UserController {
|
||||
@Patch('me/accent-color')
|
||||
async updateAccentColor(
|
||||
@Body() body: { color: string | null },
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) {
|
||||
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
|
||||
@@ -388,7 +389,7 @@ export class UserController {
|
||||
*/
|
||||
@Get('me/avatar')
|
||||
async getAvatar(
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
|
||||
Reference in New Issue
Block a user