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
This commit is contained in:
2026-09-21 17:03:24 +02:00
parent b188946e31
commit f2fc39f51c
10 changed files with 212 additions and 38 deletions
+8 -8
View File
@@ -36,7 +36,7 @@ describe('AuthController.me', () => {
authService.getMe.mockResolvedValue({ id: 'u1' }); authService.getMe.mockResolvedValue({ id: 'u1' });
const controller = new AuthController(authService, makeFakeUserService()); const controller = new AuthController(authService, makeFakeUserService());
await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' }); await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1'); expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
}); });
@@ -46,7 +46,7 @@ describe('AuthController.me', () => {
authService.getMe.mockResolvedValue({ id: 'u1' }); authService.getMe.mockResolvedValue({ id: 'u1' });
const controller = new AuthController(authService, makeFakeUserService()); const controller = new AuthController(authService, makeFakeUserService());
await controller.me({ id: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN }); await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false });
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1'); expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
}); });
@@ -56,7 +56,7 @@ describe('AuthController.me', () => {
authService.getMe.mockResolvedValue(null); authService.getMe.mockResolvedValue(null);
const controller = new AuthController(authService, makeFakeUserService()); const controller = new AuthController(authService, makeFakeUserService());
const result = await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' }); const result = await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -69,7 +69,7 @@ describe('AuthController.changePassword', () => {
const res = {} as any; const res = {} as any;
const result = await controller.changePassword( const result = await controller.changePassword(
{ id: 'u1', tenantId: 't1', role: 'USER' }, { id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false },
{ currentPassword: 'old', newPassword: 'new' } as any, { currentPassword: 'old', newPassword: 'new' } as any,
res, res,
); );
@@ -88,7 +88,7 @@ describe('AuthController.adminResetPassword', () => {
const result = await controller.adminResetPassword( const result = await controller.adminResetPassword(
'target', 'target',
{ newPassword: 'new-password' } as any, { newPassword: 'new-password' } as any,
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN }, { id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
); );
expect(authService.adminResetPassword).toHaveBeenCalledWith( expect(authService.adminResetPassword).toHaveBeenCalledWith(
@@ -109,7 +109,7 @@ describe('AuthController.adminResetPassword', () => {
await controller.adminResetPassword( await controller.adminResetPassword(
'target', 'target',
{ newPassword: 'new-password', mustChangePassword: false } as any, { newPassword: 'new-password', mustChangePassword: false } as any,
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN }, { id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
); );
expect(authService.adminResetPassword).toHaveBeenCalledWith( expect(authService.adminResetPassword).toHaveBeenCalledWith(
@@ -134,7 +134,7 @@ describe('AuthController.adminResetPassword', () => {
await controller.adminResetPassword( await controller.adminResetPassword(
'target', 'target',
{ newPassword: 'new-password' } as any, { newPassword: 'new-password' } as any,
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN }, { id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
); );
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1); expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
@@ -158,7 +158,7 @@ describe('AuthController.adminResetPassword', () => {
controller.adminResetPassword( controller.adminResetPassword(
'unknown', 'unknown',
{ newPassword: 'new-password' } as any, { newPassword: 'new-password' } as any,
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN }, { id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
), ),
).rejects.toThrow(new BadRequestException('User not found')); ).rejects.toThrow(new BadRequestException('User not found'));
expect(authService.adminResetPassword).not.toHaveBeenCalled(); expect(authService.adminResetPassword).not.toHaveBeenCalled();
+7 -6
View File
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Request, Response } from 'express'; import { Response } from 'express';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator'; import { CurrentUser } from './decorators/current-user.decorator';
@@ -22,6 +22,7 @@ import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto'; import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto';
import { RolesGuard } from './guards/roles.guard'; import { RolesGuard } from './guards/roles.guard';
import type { AuthUser, LocalAuthenticatedRequest } from './types/auth-user';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -42,7 +43,7 @@ export class AuthController {
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode * Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
* sieht wie vor dieser Umstellung. * sieht wie vor dieser Umstellung.
*/ */
private async resolveTargetTenantId(currentUser: any, userId: string): Promise<string> { private async resolveTargetTenantId(currentUser: AuthUser, userId: string): Promise<string> {
if (currentUser.role === Role.SUPER_ADMIN) { if (currentUser.role === Role.SUPER_ADMIN) {
const target = await this.userService.findByIdForPlatformAdmin(userId); const target = await this.userService.findByIdForPlatformAdmin(userId);
if (!target) { if (!target) {
@@ -62,7 +63,7 @@ export class AuthController {
@Post('login') @Post('login')
@HttpCode(200) @HttpCode(200)
async login( async login(
@Req() req: Request, @Req() req: LocalAuthenticatedRequest,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
) { ) {
return this.authService.login(req.user, res); return this.authService.login(req.user, res);
@@ -91,7 +92,7 @@ export class AuthController {
* Befund C). * Befund C).
*/ */
@Get('me') @Get('me')
async me(@CurrentUser() user: any) { async me(@CurrentUser() user: AuthUser) {
return this.authService.getMe(user.tenantId, user.id); return this.authService.getMe(user.tenantId, user.id);
} }
@@ -130,7 +131,7 @@ export class AuthController {
@Post('change-password') @Post('change-password')
@HttpCode(200) @HttpCode(200)
async changePassword( async changePassword(
@CurrentUser() user: any, @CurrentUser() user: AuthUser,
@Body() dto: ChangePasswordDto, @Body() dto: ChangePasswordDto,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
) { ) {
@@ -163,7 +164,7 @@ export class AuthController {
async adminResetPassword( async adminResetPassword(
@Param('userId') userId: string, @Param('userId') userId: string,
@Body() dto: AdminResetPasswordDto, @Body() dto: AdminResetPasswordDto,
@CurrentUser() currentUser: any, @CurrentUser() currentUser: AuthUser,
) { ) {
const tenantId = await this.resolveTargetTenantId(currentUser, userId); const tenantId = await this.resolveTargetTenantId(currentUser, userId);
await this.authService.adminResetPassword( await this.authService.adminResetPassword(
+14 -5
View File
@@ -16,6 +16,7 @@ import { LdapService } from '../ldap/ldap.service';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import type { JwtPayload, LoginUser } from './types/auth-user';
/** /**
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen * Zeilenform der drei auth_lookup_*-Datenbankfunktionen
@@ -29,7 +30,12 @@ interface AuthLookupUserByUsernameRow {
passwordHash: string | null; passwordHash: string | null;
ldapDn: string | null; ldapDn: string | null;
isActive: boolean; isActive: boolean;
role: string; /**
* Die SQL-Funktion deklariert diese Spalte als `role "Role"` (Migration
* 20260909160000, Zeile 64) — `string` war hier eine weitere Fassung
* desselben Wertes, nicht seine Beschreibung (quick-260921-m34).
*/
role: Role;
displayName: string | null; displayName: string | null;
mustChangePassword: boolean; mustChangePassword: boolean;
} }
@@ -104,7 +110,10 @@ export class AuthService {
* T-02-01: Returns null on any failure (never reveals which field is wrong). * T-02-01: Returns null on any failure (never reveals which field is wrong).
* Pitfall 6: Checks isActive to prevent deactivated users from logging in. * Pitfall 6: Checks isActive to prevent deactivated users from logging in.
*/ */
async validateUser(username: string, password: string): Promise<any> { async validateUser(
username: string,
password: string,
): Promise<AuthLookupUserByUsernameRow | null> {
// Usernames are stored lowercase (case-insensitive login). // Usernames are stored lowercase (case-insensitive login).
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>` const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()}) SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
@@ -167,8 +176,8 @@ export class AuthService {
* D-02: 30-day session. * D-02: 30-day session.
* T-02-02: httpOnly + secure (prod) + sameSite=lax. * T-02-02: httpOnly + secure (prod) + sameSite=lax.
*/ */
async login(user: any, response: Response) { async login(user: LoginUser, response: Response) {
const payload = { const payload: JwtPayload = {
sub: user.id, sub: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
@@ -373,7 +382,7 @@ export class AuthService {
data: { passwordHash, mustChangePassword: false }, data: { passwordHash, mustChangePassword: false },
}); });
const payload = { const payload: JwtPayload = {
sub: user.id, sub: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
@@ -8,6 +8,7 @@ import {
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import type { AuthenticatedRequest } from '../types/auth-user';
/** /**
* Global interceptor: forces users with mustChangePassword=true to change * Global interceptor: forces users with mustChangePassword=true to change
@@ -30,7 +31,7 @@ const ALLOWED_ROUTES = Object.freeze([
{ method: 'GET', path: '/auth/me' }, { method: 'GET', path: '/auth/me' },
]); ]);
function normalizePath(request: any): string { function normalizePath(request: AuthenticatedRequest): string {
const raw = request.route?.path || request.url || ''; const raw = request.route?.path || request.url || '';
const withoutQuery = raw.split('?')[0]; const withoutQuery = raw.split('?')[0];
const withoutTrailingSlash = withoutQuery.replace(/\/+$/, ''); const withoutTrailingSlash = withoutQuery.replace(/\/+$/, '');
@@ -41,7 +42,7 @@ function normalizePath(request: any): string {
export class ForcePasswordChangeInterceptor implements NestInterceptor { export class ForcePasswordChangeInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Skip public routes (login, health, reset-password) // Skip public routes (login, health, reset-password)
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
@@ -51,7 +52,7 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor {
return next.handle(); return next.handle();
} }
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user; const user = request.user;
// No user on request (shouldn't happen after auth guard, but be defensive) // No user on request (shouldn't happen after auth guard, but be defensive)
+2 -1
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { Request } from 'express'; import { Request } from 'express';
import type { AuthUser, JwtPayload } from '../types/auth-user';
/** /**
* Custom extractor that reads JWT from the httpOnly "session" cookie. * Custom extractor that reads JWT from the httpOnly "session" cookie.
@@ -24,7 +25,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
async validate(payload: any) { async validate(payload: JwtPayload): Promise<AuthUser> {
return { return {
id: payload.sub, id: payload.sub,
username: payload.username, username: payload.username,
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local'; import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
import type { LoginUser } from '../types/auth-user';
@Injectable() @Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) { export class LocalStrategy extends PassportStrategy(Strategy) {
@@ -9,7 +10,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
super({ usernameField: 'username' }); super({ usernameField: 'username' });
} }
async validate(username: string, password: string): Promise<any> { async validate(username: string, password: string): Promise<LoginUser> {
const user = await this.authService.validateUser(username, password); const user = await this.authService.validateUser(username, password);
if (!user) { if (!user) {
// T-02-01: Generic error message - never reveal whether username or password is wrong // T-02-01: Generic error message - never reveal whether username or password is wrong
+154
View File
@@ -0,0 +1,154 @@
import type { Role } from '@prisma/client';
import type { Request } from 'express';
/**
* Gemeinsame Beschreibung des Aufrufers (quick-260921-m34, Aufgabe 2).
*
* Zweck: an diesem Objekt haengt jede Mandanten- und Rollenentscheidung
* der API. Vor dieser Datei war es an rund 70 Stellen `any` — jede
* Berechtigungspruefung lief also ohne Begleitung durch den Compiler.
*
* Herkunft jedes Feldes, nicht gewaehlt sondern abgelesen: die einzige
* Stelle, die dieses Objekt erzeugt, ist `JwtStrategy.validate()`
* (`../strategies/jwt.strategy.ts`, Zeile 27-38). Sie liest die Ansprueche
* eines Tokens, das ausschliesslich an zwei Stellen signiert wird:
* `AuthService.login()` (`../auth.service.ts`, Zeile ~171) und
* `AuthService.changePassword()` (ebenda, Zeile ~376). Was dort nicht
* geschrieben wird, gibt es hier nicht.
*/
export interface AuthUser {
/** `payload.sub` — an beiden Signierstellen `user.id`. */
id: string;
/** An beiden Signierstellen `user.username`; Spalte `User.username` ist Pflicht. */
username: string;
/**
* An beiden Signierstellen der Spaltenwert `User.role`. Die Spalte ist in
* `apps/api/prisma/schema.prisma` als Aufzaehlung `Role` deklariert, die
* SQL-Funktion `auth_lookup_user_by_username` gibt sie als `"Role"` zurueck
* (Migration 20260909160000). Deshalb ist `Role` der ehrliche Typ und nicht
* `string`: ein Vergleich gegen eine Zeichenkette ausserhalb der
* Aufzaehlung ist ein Fehler und soll einer sein (T-M34-02).
*/
role: Role;
/**
* SICHERHEITSRELEVANTE ENTSCHEIDUNG (T-M34-01) — hergeleitet, nicht gewaehlt.
*
* Beleg 1: `apps/api/prisma/schema.prisma` deklariert `User.tenantId String`
* OHNE `?`. Die Spalte ist Pflicht, jede Benutzerzeile hat einen Mandanten.
* Beide Signierstellen schreiben genau diesen Spaltenwert, seit dem ersten
* Commit des Anmeldedienstes (6190f3d) — es gibt keine Token-Generation
* ohne diesen Anspruch.
* Beleg 2: der Bestand beschreibt dasselbe Objekt in `SessionUser`
* (`../../bug-reports/bug-reports.service.ts`) bereits als `tenantId: string`.
* Diese Datei zieht `SessionUser` auf `AuthUser` zurueck; eine zweite,
* abweichende Beschreibung desselben Objekts soll es nicht geben.
* Beleg 3: `TenantGuard` (`../../tenant/tenant.guard.ts`) haelt fuer
* SUPER_ADMIN einen Zweig ohne Mandanten vor und setzt dort
* `req.tenantId = null`.
*
* Beleg 3 spricht NICHT gegen `string`, und das ist der Punkt, an dem hier
* nicht nach Bequemlichkeit entschieden wurde: der Zweig in `TenantGuard`
* ist eine Tiefenverteidigung gegen ein Token OHNE diesen Anspruch, und er
* liest `AuthUser` gar nicht — der Waechter holt sein Anfrageobjekt
* ungetypt. Dieser Typ kann den Zweig also nicht zu totem Code machen.
*
* ACHTUNG fuer spaetere Leser: der SUPER_ADMIN-Zweig in `TenantGuard` ist
* ein Schutzzweig und darf NICHT entfernt oder wegtypisiert werden, auch
* wenn er unter diesem Typ unerreichbar aussieht. Dass es ihn gibt, steht
* unten in `AuthenticatedRequest.tenantId` als `null` weiterhin im Typsystem.
*/
tenantId: string;
/**
* `JwtStrategy.validate()` bildet diesen Wert mit `=== true` — ein aelteres
* Token ohne den Anspruch ergibt `false` (260921-fi3, D-01: keine
* Aussperrwelle). Das Ergebnis ist deshalb immer ein Wahrheitswert.
*/
mustChangePassword: boolean;
}
/**
* Anfrageobjekt nach den beiden globalen Waechtern.
*
* `user` ist ABSICHTLICH wahlfrei: `JwtAuthGuard` laesst oeffentliche Wege
* (`@Public()`: Anmeldung, Gesundheitspruefung, Kennwort-Ruecksetzung) ohne
* Aufrufer durch, und `TenantGuard` beginnt mit genau dieser Pruefung
* (`if (!user) return true`). Die abwehrenden Pruefungen in den Controllern
* ("No user context") bleiben damit lebendiger Code.
*
* `tenantId` setzt `TenantGuard`: eine Zeichenkette, ODER `null` fuer einen
* SUPER_ADMIN ohne Mandantenbezug — und gar nicht auf oeffentlichen Wegen,
* auf denen der Waechter vorzeitig zurueckkehrt. Alle drei Faelle stehen
* hier, weil alle drei vorkommen koennen.
*/
export interface AuthenticatedRequest extends Request {
user?: AuthUser;
tenantId?: string | null;
}
/**
* Anmeldeanfrage nach `AuthGuard('local')`.
*
* Hier ist `user` NICHT wahlfrei: die lokale Passport-Strategie wirft
* `UnauthorizedException`, wenn `validateUser()` nichts liefert — der Rumpf
* der Route laeuft nur mit gesetztem Aufrufer.
*/
export interface LocalAuthenticatedRequest extends Request {
user: LoginUser;
}
/**
* Was `AuthService.login()` aus dem angemeldeten Benutzer liest — nicht mehr.
* Geliefert wird das von `validateUser()` als Zeile der SQL-Funktion
* `auth_lookup_user_by_username`; `displayName` ist der einzige Unterschied
* zu `AuthUser` und der Grund, warum `login()` nicht `AuthUser` nehmen kann:
* der Anzeigename steht in der Anmeldeantwort, aber in keinem Token.
*/
export interface LoginUser {
id: string;
username: string;
role: Role;
tenantId: string;
/** Spalte `User.displayName` ist wahlfrei (`String?` in schema.prisma). */
displayName: string | null;
mustChangePassword: boolean;
}
/**
* Die Ansprueche des Sitzungstokens.
*
* Doppelrolle: dieselbe Form wird an den beiden Signierstellen geschrieben
* und in `JwtStrategy.validate()` gelesen. Deshalb ist
* `mustChangePassword` wahlfrei — geschrieben wird es immer, aber ein vor
* 260921-fi3 ausgestelltes Token traegt es nicht, und die Pruefung
* `=== true` in `validate()` haengt daran. Wer dieses Feld hier zur Pflicht
* macht, laesst jene Pruefung ueberfluessig aussehen.
*/
export interface JwtPayload {
sub: string;
username: string;
role: Role;
tenantId: string;
mustChangePassword?: boolean;
}
/**
* Eine hochgeladene Datei, so weit der Code sie liest.
*
* `Express.Multer.File` gibt es in diesem Baum nicht (`@types/multer` ist
* nicht installiert, gemessen), und Nachinstallieren ist in diesem Lauf
* ausgeschlossen. Diese Schnittstelle ist trotzdem keine Behauptung, sondern
* belegt: KEIN Aufruf von `FileInterceptor`/`FilesInterceptor` in
* `apps/api/src` setzt eine `storage`-Option (nachgezaehlt: sechs Aufrufe,
* alle nur mit `limits`). Damit gilt multers Voreinstellung memoryStorage,
* und damit ist `buffer` ein Buffer und keine Vermutung.
*
* Die Schnittstelle ersetzt keine Pruefung: die Groessengrenzen bleiben in
* den Interceptor-Optionen, die PNG-Signaturpruefung bleibt in
* `bug-reports.service.ts` (T-M34-04).
*/
export interface UploadedFileLike {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
}
@@ -1,6 +1,7 @@
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { BugReportsService } from './bug-reports.service'; import { BugReportsService } from './bug-reports.service';
import { BugReportDto } from './dto/bug-report.dto'; import { BugReportDto } from './dto/bug-report.dto';
@@ -30,9 +31,9 @@ export class BugReportsController {
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }), FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
) )
async submit( async submit(
@CurrentUser() user: any, @CurrentUser() user: AuthUser,
@Body() dto: BugReportDto, @Body() dto: BugReportDto,
@UploadedFile() file?: any, @UploadedFile() file?: UploadedFileLike,
) { ) {
return this.service.submit(user, dto, file); return this.service.submit(user, dto, file);
} }
@@ -4,6 +4,7 @@ import {
ConflictException, ConflictException,
HttpException, HttpException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { BugReportsService } from './bug-reports.service'; import { BugReportsService } from './bug-reports.service';
@@ -37,7 +38,7 @@ const PNG_1x1 = Buffer.from(
'base64', 'base64',
); );
const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' }; const sessionUser = { id: 'u1', username: 'anna', role: Role.USER, tenantId: 't1' };
const baseDto = { const baseDto = {
page: '/admin/users?tab=x', page: '/admin/users?tab=x',
+16 -11
View File
@@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service'; import { SettingsService } from '../settings/settings.service';
import { BugReportDto } from './dto/bug-report.dto'; import { BugReportDto } from './dto/bug-report.dto';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { describeOrigin } from './origin'; import { describeOrigin } from './origin';
/** /**
@@ -58,18 +59,22 @@ const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5; const MAX_PER_WINDOW = 5;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface SessionUser { /**
id: string; * Der Aufrufer, so weit dieser Dienst ihn liest. Seit quick-260921-m34 aus
username: string; * `AuthUser` abgeleitet statt danebengestellt: es soll nicht zwei
role: string; * Beschreibungen desselben Sitzungsobjekts geben, die auseinanderlaufen
tenantId: string; * koennen. Ausgelassen wird `mustChangePassword` — dieser Dienst liest es
} * nicht.
*/
type SessionUser = Pick<AuthUser, 'id' | 'username' | 'role' | 'tenantId'>;
interface UploadedPng { /**
buffer: Buffer; * Das Bildschirmfoto, so weit dieser Dienst es liest — abgeleitet aus
size: number; * `UploadedFileLike`, damit die Hochladewege eine gemeinsame Beschreibung
mimetype?: string; * haben. `originalname` fehlt bewusst: der Anhangname ist hier fest
} * vorgegeben (T-M97-04), der eingereichte Name wird nie benutzt.
*/
type UploadedPng = Pick<UploadedFileLike, 'buffer' | 'size' | 'mimetype'>;
@Injectable() @Injectable()
export class BugReportsService { export class BugReportsService {