From 7c9d7c1223ab305d0862c797ab6914732b6e2360 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 17:08:44 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- apps/api/src/calendar/calendar.controller.ts | 20 ++++---- .../api/src/dashboard/dashboard.controller.ts | 45 ++++++++++------- apps/api/src/dkv/dkv.controller.ts | 37 ++++++++------ .../api/src/favorites/favorites.controller.ts | 21 ++++---- apps/api/src/groups/groups.controller.ts | 22 ++++---- .../src/groups/module-grants.controller.ts | 14 +++--- apps/api/src/ldap/ldap.controller.ts | 23 +++++---- .../module-registry.controller.ts | 26 +++++----- apps/api/src/settings/settings.controller.ts | 13 ++--- apps/api/src/tenders/tender-query.builder.ts | 2 +- apps/api/src/tenders/tenders.controller.ts | 50 ++++++++++--------- apps/api/src/user/user.controller.spec.ts | 37 ++++++++------ apps/api/src/user/user.controller.ts | 27 +++++----- 13 files changed, 182 insertions(+), 155 deletions(-) diff --git a/apps/api/src/calendar/calendar.controller.ts b/apps/api/src/calendar/calendar.controller.ts index dbb57eb..28385b7 100644 --- a/apps/api/src/calendar/calendar.controller.ts +++ b/apps/api/src/calendar/calendar.controller.ts @@ -10,7 +10,7 @@ import { Query, Req, } from '@nestjs/common'; -import { Request } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { CalendarService } from './calendar.service'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto'; @@ -45,10 +45,10 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto'; export class CalendarController { constructor(private readonly calendarService: CalendarService) {} - private extractContext(req: Request) { - const userId = (req as any).user?.id; + private extractContext(req: AuthenticatedRequest) { + const userId = req.user?.id; const tenantId = - (req as any).tenantId ?? (req as any).user?.tenantId; + req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); @@ -66,7 +66,7 @@ export class CalendarController { * T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean. */ @Get('sources') - async getSources(@Req() req: Request) { + async getSources(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractContext(req); return this.calendarService.getSources(userId, tenantId); } @@ -78,7 +78,7 @@ export class CalendarController { */ @Post('sources') async addSource( - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Body() dto: CreateCalendarSourceDto, ) { const { userId, tenantId } = this.extractContext(req); @@ -93,7 +93,7 @@ export class CalendarController { @Patch('sources/:id') async updateSource( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Body() dto: UpdateCalendarSourceDto, ) { const { userId, tenantId } = this.extractContext(req); @@ -107,7 +107,7 @@ export class CalendarController { @Delete('sources/:id') async deleteSource( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.calendarService.deleteSource(id, userId, tenantId); @@ -132,7 +132,7 @@ export class CalendarController { @Post('sources/:id/test') async testSource( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.calendarService.testConnection(id, userId, tenantId); @@ -144,7 +144,7 @@ export class CalendarController { */ @Get('events') async getEvents( - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Query() query: CalendarEventsQueryDto, ) { const { userId, tenantId } = this.extractContext(req); diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index a04805a..12185e4 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -10,7 +10,7 @@ import { Put, Req, } from '@nestjs/common'; -import { Request } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { DashboardService } from './dashboard.service'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateWidgetDto } from './dto/create-widget.dto'; @@ -39,42 +39,53 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; export class DashboardController { constructor(private readonly dashboardService: DashboardService) {} - private extractContext(req: Request) { - const userId = (req as any).user?.id; + /** + * BEFUND quick-260921-m34 (D-03, gemeldet nicht repariert): `getWidgets` + * las die Rolle vorher als `req.user?.role` NACH dieser Pruefung und gab + * sie an `DashboardService.getWidgets(role: Role)` weiter, das eine Rolle + * zwingend verlangt. Der Bestandscode nahm also an, dass an dieser Stelle + * immer ein Aufrufer vorliegt. Die Annahme stimmt — die Pruefung "No user + * context" direkt darunter erzwingt sie seit jeher —, aber der Compiler + * konnte die beiden Stellen nicht verbinden, weil sie in zwei Methoden + * standen. Deshalb gibt diese Methode die Rolle jetzt MIT zurueck: keine + * neue Pruefung, kein erfundener Wert, gleiche Reihenfolge, gleiche + * Meldungen, gleiches Verhalten — nur sichtbar statt angenommen. + */ + private extractContext(req: AuthenticatedRequest) { + const user = req.user; const tenantId = - (req as any).tenantId ?? (req as any).user?.tenantId; + req.tenantId ?? user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } - if (!userId) { + if (!user?.id) { throw new ForbiddenException('No user context'); } - return { userId, tenantId }; + return { userId: user.id, tenantId, role: user.role }; } @Get('layout') - async getLayout(@Req() req: Request) { + async getLayout(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractContext(req); return this.dashboardService.getLayout(userId, tenantId); } @Put('layout') - async saveLayout(@Req() req: Request, @Body() dto: SaveLayoutDto) { + async saveLayout(@Req() req: AuthenticatedRequest, @Body() dto: SaveLayoutDto) { const { userId, tenantId } = this.extractContext(req); return this.dashboardService.saveLayout(userId, tenantId, dto); } @Get('widgets') - async getWidgets(@Req() req: Request) { - const { userId, tenantId } = this.extractContext(req); - const role = (req as any).user?.role; + async getWidgets(@Req() req: AuthenticatedRequest) { + const { userId, tenantId, role } = this.extractContext(req); return this.dashboardService.getWidgets(userId, tenantId, role); } @Post('widgets') - async addWidget(@Req() req: Request, @Body() dto: CreateWidgetDto) { + async addWidget(@Req() req: AuthenticatedRequest, @Body() dto: CreateWidgetDto) { const { userId, tenantId } = this.extractContext(req); return this.dashboardService.addWidget(userId, tenantId, dto); } @@ -82,7 +93,7 @@ export class DashboardController { @Patch('widgets/:id/config') async updateWidgetConfig( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Body() dto: UpdateWidgetConfigDto, ) { const { userId, tenantId } = this.extractContext(req); @@ -92,7 +103,7 @@ export class DashboardController { @Delete('widgets/:id') async removeWidget( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.dashboardService.removeWidget(id, userId, tenantId); @@ -101,14 +112,14 @@ export class DashboardController { // --- Search Providers (05-02, D-15) --- @Get('search-providers') - async getSearchProviders(@Req() req: Request) { + async getSearchProviders(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractContext(req); return this.dashboardService.getSearchProviders(userId, tenantId); } @Post('search-providers') async addSearchProvider( - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Body() dto: CreateSearchProviderDto, ) { const { userId, tenantId } = this.extractContext(req); @@ -118,7 +129,7 @@ export class DashboardController { @Delete('search-providers/:id') async removeSearchProvider( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); return this.dashboardService.removeSearchProvider(id, userId, tenantId); diff --git a/apps/api/src/dkv/dkv.controller.ts b/apps/api/src/dkv/dkv.controller.ts index e6fe793..cb81dd5 100644 --- a/apps/api/src/dkv/dkv.controller.ts +++ b/apps/api/src/dkv/dkv.controller.ts @@ -17,6 +17,11 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { Role } from '@prisma/client'; import { Roles } from '../auth/decorators/roles.decorator'; +import type { + AuthenticatedRequest, + UploadedFileLike, +} from '../auth/types/auth-user'; +import type { Response } from 'express'; import { DkvSchedulerService } from './dkv-scheduler.service'; import { DkvService } from './dkv.service'; import { DkvConfigDto } from './dto/dkv-config.dto'; @@ -58,7 +63,7 @@ export class DkvController { /** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */ @Get('config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async getConfig(@Req() req: any) { + async getConfig(@Req() req: AuthenticatedRequest) { const tenantId = this._requireTenant(req); const config = await this.dkvService.getConfigForApi(tenantId); if (!config) { @@ -75,7 +80,7 @@ export class DkvController { */ @Put('config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async saveConfig(@Req() req: any, @Body() dto: DkvConfigDto) { + async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) { const tenantId = this._requireTenant(req); const result = await this.dkvService.saveConfig(tenantId, dto); @@ -94,7 +99,7 @@ export class DkvController { /** POST /dkv/check-now — immediately run the inbox processing pipeline. */ @Post('check-now') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async checkNow(@Req() req: any) { + async checkNow(@Req() req: AuthenticatedRequest) { const tenantId = this._requireTenant(req); return this.dkvService.checkNow(tenantId); } @@ -105,7 +110,7 @@ export class DkvController { */ @Post('test-connection') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) { + async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) { const tenantId = this._requireTenant(req); return this.dkvService.testConnection(tenantId, dto); } @@ -118,7 +123,7 @@ export class DkvController { */ @Get('history') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async getHistory(@Req() req: any, @Query() query: DkvHistoryQueryDto) { + async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) { const tenantId = this._requireTenant(req); const page = query.page ?? 1; const limit = query.limit ?? 20; @@ -137,9 +142,9 @@ export class DkvController { @Get('exports/:filename') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async downloadExport( - @Req() req: any, + @Req() req: AuthenticatedRequest, @Param('filename') filename: string, - @Res() res: any, + @Res() res: Response, ) { const tenantId = this._requireTenant(req); @@ -164,7 +169,7 @@ export class DkvController { /** GET /dkv/vehicles — list all vehicle master records for this tenant. */ @Get('vehicles') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async listVehicles(@Req() req: any) { + async listVehicles(@Req() req: AuthenticatedRequest) { const tenantId = this._requireTenant(req); return this.dkvService.listVehicles(tenantId); } @@ -172,7 +177,7 @@ export class DkvController { /** POST /dkv/vehicles — create a new vehicle master record. */ @Post('vehicles') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async createVehicle(@Req() req: any, @Body() dto: CreateVehicleDto) { + async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) { const tenantId = this._requireTenant(req); return this.dkvService.createVehicle(tenantId, dto); } @@ -181,7 +186,7 @@ export class DkvController { @Put('vehicles/:id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async updateVehicle( - @Req() req: any, + @Req() req: AuthenticatedRequest, @Param('id') id: string, @Body() dto: UpdateVehicleDto, ) { @@ -192,7 +197,7 @@ export class DkvController { /** DELETE /dkv/vehicles/:id — delete a vehicle master record. */ @Delete('vehicles/:id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async deleteVehicle(@Req() req: any, @Param('id') id: string) { + async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) { const tenantId = this._requireTenant(req); return this.dkvService.deleteVehicle(tenantId, id); } @@ -213,8 +218,8 @@ export class DkvController { limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05) })) async importVehicles( - @Req() req: any, - @UploadedFile() file: any, + @Req() req: AuthenticatedRequest, + @UploadedFile() file: UploadedFileLike | undefined, @Body('mode') mode: string, ) { const tenantId = this._requireTenant(req); @@ -223,7 +228,7 @@ export class DkvController { throw new BadRequestException('No CSV file uploaded (field name must be "file")'); } - const csvText = (file.buffer as Buffer).toString('utf-8'); + const csvText = file.buffer.toString('utf-8'); const importMode = mode === 'replace' ? 'replace' : 'merge'; return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode); @@ -232,8 +237,8 @@ export class DkvController { // ─── Private helpers ─────────────────────────────────────────────────────── /** Extract and validate tenantId from request; throw BadRequestException when absent. */ - private _requireTenant(req: any): string { - const tenantId = req.tenantId as string | undefined; + private _requireTenant(req: AuthenticatedRequest): string { + const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); } diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts index a49cb5f..43e1fe3 100644 --- a/apps/api/src/favorites/favorites.controller.ts +++ b/apps/api/src/favorites/favorites.controller.ts @@ -13,7 +13,8 @@ import { Req, Res, } from '@nestjs/common'; -import { Request, Response } from 'express'; +import { Response } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; @@ -46,10 +47,10 @@ import { FavoritesService } from './favorites.service'; export class FavoritesController { constructor(private readonly favoritesService: FavoritesService) {} - private extractContext(req: Request) { - const userId = (req as any).user?.id; + private extractContext(req: AuthenticatedRequest) { + const userId = req.user?.id; const tenantId = - (req as any).tenantId ?? (req as any).user?.tenantId; + req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); @@ -64,7 +65,7 @@ export class FavoritesController { @Get() async list( @Query('widgetId', ParseUUIDPipe) widgetId: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); @@ -74,7 +75,7 @@ export class FavoritesController { @Post() async create( @Body() dto: CreateFavoriteDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); @@ -91,7 +92,7 @@ export class FavoritesController { @Put('order') async reorder( @Body() dto: ReorderFavoritesDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); @@ -110,7 +111,7 @@ export class FavoritesController { @Get(':id/icon') async getIcon( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Res() res: Response, ) { const { userId, tenantId } = this.extractContext(req); @@ -135,7 +136,7 @@ export class FavoritesController { async update( @Param('id') id: string, @Body() dto: UpdateFavoriteDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); @@ -145,7 +146,7 @@ export class FavoritesController { @Delete(':id') async remove( @Param('id') id: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractContext(req); diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts index 948499c..124b688 100644 --- a/apps/api/src/groups/groups.controller.ts +++ b/apps/api/src/groups/groups.controller.ts @@ -11,7 +11,7 @@ import { UseGuards, } from '@nestjs/common'; import { Role } from '@prisma/client'; -import { Request } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; import { AddGroupMembersDto } from './dto/add-group-members.dto'; @@ -30,8 +30,8 @@ import { GroupsService } from './groups.service'; export class GroupsController { constructor(private readonly groupsService: GroupsService) {} - private getTenantId(req: Request): string { - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + private getTenantId(req: AuthenticatedRequest): string { + const tenantId = req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } @@ -45,7 +45,7 @@ export class GroupsController { @Get() @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async list(@Req() req: Request) { + async list(@Req() req: AuthenticatedRequest) { return this.groupsService.listForTenant(this.getTenantId(req)); } @@ -56,7 +56,7 @@ export class GroupsController { @Post() @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async create(@Body() dto: CreateGroupDto, @Req() req: Request) { + async create(@Body() dto: CreateGroupDto, @Req() req: AuthenticatedRequest) { return this.groupsService.create(this.getTenantId(req), dto); } @@ -70,7 +70,7 @@ export class GroupsController { async update( @Param('id') id: string, @Body() dto: UpdateGroupDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { return this.groupsService.update(this.getTenantId(req), id, dto); } @@ -82,7 +82,7 @@ export class GroupsController { @Delete(':id') @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async remove(@Param('id') id: string, @Req() req: Request) { + async remove(@Param('id') id: string, @Req() req: AuthenticatedRequest) { return this.groupsService.remove(this.getTenantId(req), id); } @@ -93,7 +93,7 @@ export class GroupsController { @Get(':id/impact') @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async impact(@Param('id') id: string, @Req() req: Request) { + async impact(@Param('id') id: string, @Req() req: AuthenticatedRequest) { return this.groupsService.getImpact(this.getTenantId(req), id); } @@ -104,7 +104,7 @@ export class GroupsController { @Get(':id/members') @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async members(@Param('id') id: string, @Req() req: Request) { + async members(@Param('id') id: string, @Req() req: AuthenticatedRequest) { return this.groupsService.listMembers(this.getTenantId(req), id); } @@ -118,7 +118,7 @@ export class GroupsController { async addMembers( @Param('id') id: string, @Body() dto: AddGroupMembersDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds); } @@ -134,7 +134,7 @@ export class GroupsController { async removeMember( @Param('id') id: string, @Param('userId') userId: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { await this.groupsService.removeMember(this.getTenantId(req), id, userId); return { success: true }; diff --git a/apps/api/src/groups/module-grants.controller.ts b/apps/api/src/groups/module-grants.controller.ts index 07c1b9f..2a37cf4 100644 --- a/apps/api/src/groups/module-grants.controller.ts +++ b/apps/api/src/groups/module-grants.controller.ts @@ -10,7 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { Role } from '@prisma/client'; -import { Request } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; import { CreateModuleGrantDto } from './dto/create-module-grant.dto'; @@ -31,8 +31,8 @@ import { ModuleGrantsService } from './module-grants.service'; export class ModuleGrantsController { constructor(private readonly moduleGrantsService: ModuleGrantsService) {} - private getTenantId(req: Request): string { - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + private getTenantId(req: AuthenticatedRequest): string { + const tenantId = req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } @@ -46,7 +46,7 @@ export class ModuleGrantsController { @Get('matrix') @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async matrix(@Req() req: Request) { + async matrix(@Req() req: AuthenticatedRequest) { return this.moduleGrantsService.getMatrix(this.getTenantId(req)); } @@ -61,7 +61,7 @@ export class ModuleGrantsController { @Get('users/:userId') @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async userAccess(@Param('userId') userId: string, @Req() req: Request) { + async userAccess(@Param('userId') userId: string, @Req() req: AuthenticatedRequest) { return this.moduleGrantsService.getUserAccess(this.getTenantId(req), userId); } @@ -72,7 +72,7 @@ export class ModuleGrantsController { @Post() @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async create(@Body() dto: CreateModuleGrantDto, @Req() req: Request) { + async create(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) { return this.moduleGrantsService.grant(this.getTenantId(req), dto); } @@ -84,7 +84,7 @@ export class ModuleGrantsController { @Delete() @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async remove(@Body() dto: CreateModuleGrantDto, @Req() req: Request) { + async remove(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) { await this.moduleGrantsService.revoke(this.getTenantId(req), dto); return { success: true }; } diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 6caba17..ccb9bb3 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -13,6 +13,7 @@ import { } from '@nestjs/common'; import { Role } from '@prisma/client'; import { Roles } from '../auth/decorators/roles.decorator'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { CreateFieldMappingDto, CreateLdapConfigDto, @@ -41,7 +42,7 @@ export class LdapController { */ @Get('config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async getConfig(@Req() req: any) { + async getConfig(@Req() req: AuthenticatedRequest) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -65,7 +66,7 @@ export class LdapController { */ @Post('config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async createConfig(@Req() req: any, @Body() dto: CreateLdapConfigDto) { + async createConfig(@Req() req: AuthenticatedRequest, @Body() dto: CreateLdapConfigDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -92,7 +93,7 @@ export class LdapController { */ @Patch('config') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async updateConfig(@Req() req: any, @Body() dto: UpdateLdapConfigDto) { + async updateConfig(@Req() req: AuthenticatedRequest, @Body() dto: UpdateLdapConfigDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -123,7 +124,7 @@ export class LdapController { */ @Post('test-connection') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) { + async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestConnectionDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -158,7 +159,7 @@ export class LdapController { */ @Get('groups') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async listGroups(@Req() req: any) { + async listGroups(@Req() req: AuthenticatedRequest) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -189,7 +190,7 @@ export class LdapController { */ @Post('groups/import') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) { + async importGroups(@Req() req: AuthenticatedRequest, @Body() dto: ImportGroupsDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -225,7 +226,7 @@ export class LdapController { */ @Get('users/search') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async searchUsers(@Req() req: any, @Query('q') q: string) { + async searchUsers(@Req() req: AuthenticatedRequest, @Query('q') q: string) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -256,7 +257,7 @@ export class LdapController { */ @Post('users/import') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) { + async importUsers(@Req() req: AuthenticatedRequest, @Body() dto: ImportUsersDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -292,7 +293,7 @@ export class LdapController { */ @Post('sync') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async triggerSync(@Req() req: any) { + async triggerSync(@Req() req: AuthenticatedRequest) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -326,7 +327,7 @@ export class LdapController { */ @Post('config/mappings') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async addFieldMapping(@Req() req: any, @Body() dto: CreateFieldMappingDto) { + async addFieldMapping(@Req() req: AuthenticatedRequest, @Body() dto: CreateFieldMappingDto) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); @@ -351,7 +352,7 @@ export class LdapController { */ @Delete('config/mappings/:id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async removeFieldMapping(@Req() req: any, @Param('id') id: string) { + async removeFieldMapping(@Req() req: AuthenticatedRequest, @Param('id') id: string) { const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts index 901dcaf..62c9b2d 100644 --- a/apps/api/src/module-registry/module-registry.controller.ts +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -8,7 +8,7 @@ import { UseGuards, } from '@nestjs/common'; import { Role } from '@prisma/client'; -import { Request } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; import { ModuleAccessService } from './module-access.service'; @@ -53,10 +53,10 @@ export class ModuleRegistryController { * catalog (mandantenweite Sicht, kein Benutzerfilter). */ @Get('active') - async findActive(@Req() req: Request) { - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; - const userId = (req as any).user?.id; - const role = (req as any).user?.role; + async findActive(@Req() req: AuthenticatedRequest) { + const tenantId = req.tenantId ?? req.user?.tenantId; + const userId = req.user?.id; + const role = req.user?.role; if (!tenantId || !userId || !role) { throw new ForbiddenException('No user context'); } @@ -74,10 +74,10 @@ export class ModuleRegistryController { * Erreichbar für jeden authentifizierten Benutzer, wie GET /modules. */ @Get('catalog') - async findCatalog(@Req() req: Request) { - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; - const userId = (req as any).user?.id; - const role = (req as any).user?.role; + async findCatalog(@Req() req: AuthenticatedRequest) { + const tenantId = req.tenantId ?? req.user?.tenantId; + const userId = req.user?.id; + const role = req.user?.role; if (!tenantId || !userId || !role) { throw new ForbiddenException('No user context'); } @@ -105,9 +105,9 @@ export class ModuleRegistryController { @Roles(Role.ADMIN, Role.SUPER_ADMIN) async activate( @Param('moduleId') moduleId: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + const tenantId = req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } @@ -124,9 +124,9 @@ export class ModuleRegistryController { @Roles(Role.ADMIN, Role.SUPER_ADMIN) async deactivate( @Param('moduleId') moduleId: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + const tenantId = req.tenantId ?? req.user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } diff --git a/apps/api/src/settings/settings.controller.ts b/apps/api/src/settings/settings.controller.ts index bb2f7e2..afbf1fd 100644 --- a/apps/api/src/settings/settings.controller.ts +++ b/apps/api/src/settings/settings.controller.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Role } from '@prisma/client'; import { Roles } from '../auth/decorators/roles.decorator'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { SmtpConfigDto } from './dto/smtp-config.dto'; import { SettingsService } from './settings.service'; @@ -29,8 +30,8 @@ export class SettingsController { */ @Get('smtp') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async getSmtpConfig(@Req() req: any) { - const tenantId = req.tenantId as string | undefined; + async getSmtpConfig(@Req() req: AuthenticatedRequest) { + const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); } @@ -50,8 +51,8 @@ export class SettingsController { */ @Put('smtp') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) { - const tenantId = req.tenantId as string | undefined; + async saveSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) { + const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); } @@ -67,8 +68,8 @@ export class SettingsController { */ @Post('smtp/test') @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async testSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) { - const tenantId = req.tenantId as string | undefined; + async testSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) { + const tenantId = req.tenantId; if (!tenantId) { throw new BadRequestException('No tenant context'); } diff --git a/apps/api/src/tenders/tender-query.builder.ts b/apps/api/src/tenders/tender-query.builder.ts index d3aed5a..0bbab29 100644 --- a/apps/api/src/tenders/tender-query.builder.ts +++ b/apps/api/src/tenders/tender-query.builder.ts @@ -44,7 +44,7 @@ const MAX_FAV_IDS = 500; export function buildTenderWhere( dto: TenderQueryDto, favIds?: string[], - ownerTenantId?: string, + ownerTenantId?: string | null, ): Prisma.TenderWhereInput { const where: Prisma.TenderWhereInput = {}; const AND: Prisma.TenderWhereInput[] = []; diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 2e2f60a..7d942d8 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -13,7 +13,7 @@ import { Req, } from '@nestjs/common'; import { Role } from '@prisma/client'; -import { Request } from 'express'; +import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { Roles } from '../auth/decorators/roles.decorator'; import { UseModule } from '../module-registry/module.guard'; import { PrismaService } from '../prisma/prisma.service'; @@ -115,10 +115,10 @@ export class TendersController { * (`roles.guard.ts`) — one single spot in this controller resolves * account data from the request. */ - private extractTriageContext(req: Request) { - const userId = (req as any).user?.id; - const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; - const role = (req as any).user?.role; + private extractTriageContext(req: AuthenticatedRequest) { + const userId = req.user?.id; + const tenantId = req.tenantId ?? req.user?.tenantId; + const role = req.user?.role; if (!tenantId) { throw new ForbiddenException('No tenant context'); @@ -138,8 +138,10 @@ export class TendersController { * only" (fail-closed, tender-query.builder.ts) rather than 403 when no * tenant context is present. */ - private resolveRequestingTenantId(req?: Request): string | undefined { - return (req as any)?.user?.tenantId ?? (req as any)?.tenantId; + private resolveRequestingTenantId( + req?: AuthenticatedRequest, + ): string | null | undefined { + return req?.user?.tenantId ?? req?.tenantId; } // ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ──────────────────── @@ -164,7 +166,7 @@ export class TendersController { */ @Get() @UseModule('tender-radar') - async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) { + async listTenders(@Query() query: TenderQueryDto, @Req() req?: AuthenticatedRequest) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; @@ -175,7 +177,7 @@ export class TendersController { // optional type only accommodates unit tests that call this method // directly without favOnly set (T-11-10: extractTriageContext // throws ForbiddenException if req/user context is genuinely absent). - const { userId, tenantId } = this.extractTriageContext(req as Request); + const { userId, tenantId } = this.extractTriageContext(req as AuthenticatedRequest); favIds = await this.tenderTriage.favoriteIds(userId, tenantId); } @@ -263,7 +265,7 @@ export class TendersController { */ @Get('rss-feeds') @UseModule('tender-radar') - async listRssFeeds(@Req() req: Request) { + async listRssFeeds(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId); @@ -286,7 +288,7 @@ export class TendersController { */ @Post('rss-feeds') @UseModule('tender-radar') - async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) { + async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: AuthenticatedRequest) { const { userId, tenantId, role } = this.extractTriageContext(req); if (dto.scope === 'platform') { @@ -319,7 +321,7 @@ export class TendersController { */ @Delete('rss-feeds/:feedId') @UseModule('tender-radar') - async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) { + async removeRssFeed(@Param('feedId') feedId: string, @Req() req: AuthenticatedRequest) { const { userId, role } = this.extractTriageContext(req); const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN; return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin }); @@ -342,7 +344,7 @@ export class TendersController { */ @Get('email-config') @UseModule('tender-radar') - async getEmailConfig(@Req() req: Request) { + async getEmailConfig(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderEmailConfig.getConfigForApi(userId, tenantId); } @@ -356,7 +358,7 @@ export class TendersController { */ @Put('email-config') @UseModule('tender-radar') - async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { + async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto); } @@ -381,7 +383,7 @@ export class TendersController { */ @Post('email-config/test') @UseModule('tender-radar') - async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) { + async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderEmailConfig.testConnection(userId, tenantId, dto); } @@ -456,7 +458,7 @@ export class TendersController { */ @Get('triage') @UseModule('tender-radar') - async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) { + async listTriage(@Query('ids') ids: string | undefined, @Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); const tenderIds = (ids ?? '') .split(',') @@ -480,7 +482,7 @@ export class TendersController { */ @Put('triage') @UseModule('tender-radar') - async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) { + async setTriage(@Body() dto: TenderTriageDto, @Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, { @@ -502,7 +504,7 @@ export class TendersController { */ @Get('saved-searches') @UseModule('tender-radar') - async listSavedSearches(@Req() req: Request) { + async listSavedSearches(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderSavedSearch.list(userId, tenantId); } @@ -517,7 +519,7 @@ export class TendersController { @UseModule('tender-radar') async createSavedSearch( @Body() dto: CreateSavedSearchDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderSavedSearch.create(userId, tenantId, dto); @@ -535,7 +537,7 @@ export class TendersController { async updateSavedSearch( @Param('searchId') searchId: string, @Body() dto: UpdateSavedSearchDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderSavedSearch.update(searchId, userId, tenantId, dto); @@ -550,7 +552,7 @@ export class TendersController { @UseModule('tender-radar') async removeSavedSearch( @Param('searchId') searchId: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractTriageContext(req); await this.tenderSavedSearch.remove(searchId, userId, tenantId); @@ -571,7 +573,7 @@ export class TendersController { */ @Get('notification-pref') @UseModule('tender-radar') - async getNotificationPref(@Req() req: Request) { + async getNotificationPref(@Req() req: AuthenticatedRequest) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderNotificationPref.getForUser(userId, tenantId); } @@ -586,7 +588,7 @@ export class TendersController { @UseModule('tender-radar') async setNotificationPref( @Body() dto: UpdateNotificationPrefDto, - @Req() req: Request, + @Req() req: AuthenticatedRequest, ) { const { userId, tenantId } = this.extractTriageContext(req); return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval); @@ -606,7 +608,7 @@ export class TendersController { */ @Get(':id') @UseModule('tender-radar') - async getTender(@Param('id') id: string, @Req() req?: Request) { + async getTender(@Param('id') id: string, @Req() req?: AuthenticatedRequest) { const tender = await this.prisma.tender.findUnique({ where: { id }, include: { diff --git a/apps/api/src/user/user.controller.spec.ts b/apps/api/src/user/user.controller.spec.ts index 644d2fe..63796e0 100644 --- a/apps/api/src/user/user.controller.spec.ts +++ b/apps/api/src/user/user.controller.spec.ts @@ -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( diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 8cdcd9c..dd7bb95 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -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);