From f2fc39f51c5f54698742ca265b0c5c4e5fe40b1d Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 17:03:24 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- apps/api/src/auth/auth.controller.spec.ts | 16 +- apps/api/src/auth/auth.controller.ts | 13 +- apps/api/src/auth/auth.service.ts | 19 ++- .../force-password-change.interceptor.ts | 7 +- apps/api/src/auth/strategies/jwt.strategy.ts | 3 +- .../api/src/auth/strategies/local.strategy.ts | 3 +- apps/api/src/auth/types/auth-user.ts | 154 ++++++++++++++++++ .../src/bug-reports/bug-reports.controller.ts | 5 +- .../bug-reports/bug-reports.service.spec.ts | 3 +- .../src/bug-reports/bug-reports.service.ts | 27 +-- 10 files changed, 212 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/auth/types/auth-user.ts diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 080bda8..c0959eb 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -36,7 +36,7 @@ describe('AuthController.me', () => { authService.getMe.mockResolvedValue({ id: 'u1' }); 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'); }); @@ -46,7 +46,7 @@ describe('AuthController.me', () => { authService.getMe.mockResolvedValue({ id: 'u1' }); 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'); }); @@ -56,7 +56,7 @@ describe('AuthController.me', () => { authService.getMe.mockResolvedValue(null); 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(); }); @@ -69,7 +69,7 @@ describe('AuthController.changePassword', () => { const res = {} as any; 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, res, ); @@ -88,7 +88,7 @@ describe('AuthController.adminResetPassword', () => { const result = await controller.adminResetPassword( 'target', { 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( @@ -109,7 +109,7 @@ describe('AuthController.adminResetPassword', () => { await controller.adminResetPassword( 'target', { 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( @@ -134,7 +134,7 @@ describe('AuthController.adminResetPassword', () => { await controller.adminResetPassword( 'target', { 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); @@ -158,7 +158,7 @@ describe('AuthController.adminResetPassword', () => { controller.adminResetPassword( 'unknown', { 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')); expect(authService.adminResetPassword).not.toHaveBeenCalled(); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 5bea34b..ff75f20 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -12,7 +12,7 @@ import { } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Role } from '@prisma/client'; -import { Request, Response } from 'express'; +import { Response } from 'express'; import { UserService } from '../user/user.service'; import { AuthService } from './auth.service'; 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 { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto'; import { RolesGuard } from './guards/roles.guard'; +import type { AuthUser, LocalAuthenticatedRequest } from './types/auth-user'; @Controller('auth') export class AuthController { @@ -42,7 +43,7 @@ export class AuthController { * Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode * sieht wie vor dieser Umstellung. */ - private async resolveTargetTenantId(currentUser: any, userId: string): Promise { + private async resolveTargetTenantId(currentUser: AuthUser, userId: string): Promise { if (currentUser.role === Role.SUPER_ADMIN) { const target = await this.userService.findByIdForPlatformAdmin(userId); if (!target) { @@ -62,7 +63,7 @@ export class AuthController { @Post('login') @HttpCode(200) async login( - @Req() req: Request, + @Req() req: LocalAuthenticatedRequest, @Res({ passthrough: true }) res: Response, ) { return this.authService.login(req.user, res); @@ -91,7 +92,7 @@ export class AuthController { * Befund C). */ @Get('me') - async me(@CurrentUser() user: any) { + async me(@CurrentUser() user: AuthUser) { return this.authService.getMe(user.tenantId, user.id); } @@ -130,7 +131,7 @@ export class AuthController { @Post('change-password') @HttpCode(200) async changePassword( - @CurrentUser() user: any, + @CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto, @Res({ passthrough: true }) res: Response, ) { @@ -163,7 +164,7 @@ export class AuthController { async adminResetPassword( @Param('userId') userId: string, @Body() dto: AdminResetPasswordDto, - @CurrentUser() currentUser: any, + @CurrentUser() currentUser: AuthUser, ) { const tenantId = await this.resolveTargetTenantId(currentUser, userId); await this.authService.adminResetPassword( diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 405e073..07bef7d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -16,6 +16,7 @@ import { LdapService } from '../ldap/ldap.service'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import type { JwtPayload, LoginUser } from './types/auth-user'; /** * Zeilenform der drei auth_lookup_*-Datenbankfunktionen @@ -29,7 +30,12 @@ interface AuthLookupUserByUsernameRow { passwordHash: string | null; ldapDn: string | null; 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; mustChangePassword: boolean; } @@ -104,7 +110,10 @@ export class AuthService { * 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. */ - async validateUser(username: string, password: string): Promise { + async validateUser( + username: string, + password: string, + ): Promise { // Usernames are stored lowercase (case-insensitive login). const rows = await this.prisma.$queryRaw` SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()}) @@ -167,8 +176,8 @@ export class AuthService { * D-02: 30-day session. * T-02-02: httpOnly + secure (prod) + sameSite=lax. */ - async login(user: any, response: Response) { - const payload = { + async login(user: LoginUser, response: Response) { + const payload: JwtPayload = { sub: user.id, username: user.username, role: user.role, @@ -373,7 +382,7 @@ export class AuthService { data: { passwordHash, mustChangePassword: false }, }); - const payload = { + const payload: JwtPayload = { sub: user.id, username: user.username, role: user.role, diff --git a/apps/api/src/auth/interceptors/force-password-change.interceptor.ts b/apps/api/src/auth/interceptors/force-password-change.interceptor.ts index 9488da3..df48174 100644 --- a/apps/api/src/auth/interceptors/force-password-change.interceptor.ts +++ b/apps/api/src/auth/interceptors/force-password-change.interceptor.ts @@ -8,6 +8,7 @@ import { import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import type { AuthenticatedRequest } from '../types/auth-user'; /** * Global interceptor: forces users with mustChangePassword=true to change @@ -30,7 +31,7 @@ const ALLOWED_ROUTES = Object.freeze([ { method: 'GET', path: '/auth/me' }, ]); -function normalizePath(request: any): string { +function normalizePath(request: AuthenticatedRequest): string { const raw = request.route?.path || request.url || ''; const withoutQuery = raw.split('?')[0]; const withoutTrailingSlash = withoutQuery.replace(/\/+$/, ''); @@ -41,7 +42,7 @@ function normalizePath(request: any): string { export class ForcePasswordChangeInterceptor implements NestInterceptor { constructor(private reflector: Reflector) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler): Observable { // Skip public routes (login, health, reset-password) const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), @@ -51,7 +52,7 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor { return next.handle(); } - const request = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); const user = request.user; // No user on request (shouldn't happen after auth guard, but be defensive) diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index b80a480..48e740d 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-jwt'; import { Request } from 'express'; +import type { AuthUser, JwtPayload } from '../types/auth-user'; /** * 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 { return { id: payload.sub, username: payload.username, diff --git a/apps/api/src/auth/strategies/local.strategy.ts b/apps/api/src/auth/strategies/local.strategy.ts index 8c537b7..d1bd510 100644 --- a/apps/api/src/auth/strategies/local.strategy.ts +++ b/apps/api/src/auth/strategies/local.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-local'; import { AuthService } from '../auth.service'; +import type { LoginUser } from '../types/auth-user'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { @@ -9,7 +10,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { super({ usernameField: 'username' }); } - async validate(username: string, password: string): Promise { + async validate(username: string, password: string): Promise { const user = await this.authService.validateUser(username, password); if (!user) { // T-02-01: Generic error message - never reveal whether username or password is wrong diff --git a/apps/api/src/auth/types/auth-user.ts b/apps/api/src/auth/types/auth-user.ts new file mode 100644 index 0000000..9c88687 --- /dev/null +++ b/apps/api/src/auth/types/auth-user.ts @@ -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; +} diff --git a/apps/api/src/bug-reports/bug-reports.controller.ts b/apps/api/src/bug-reports/bug-reports.controller.ts index a7b0df4..3633c4b 100644 --- a/apps/api/src/bug-reports/bug-reports.controller.ts +++ b/apps/api/src/bug-reports/bug-reports.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { BugReportsService } from './bug-reports.service'; import { BugReportDto } from './dto/bug-report.dto'; @@ -30,9 +31,9 @@ export class BugReportsController { FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }), ) async submit( - @CurrentUser() user: any, + @CurrentUser() user: AuthUser, @Body() dto: BugReportDto, - @UploadedFile() file?: any, + @UploadedFile() file?: UploadedFileLike, ) { return this.service.submit(user, dto, file); } diff --git a/apps/api/src/bug-reports/bug-reports.service.spec.ts b/apps/api/src/bug-reports/bug-reports.service.spec.ts index f5cb814..46f38d1 100644 --- a/apps/api/src/bug-reports/bug-reports.service.spec.ts +++ b/apps/api/src/bug-reports/bug-reports.service.spec.ts @@ -4,6 +4,7 @@ import { ConflictException, HttpException, } from '@nestjs/common'; +import { Role } from '@prisma/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { BugReportsService } from './bug-reports.service'; @@ -37,7 +38,7 @@ const PNG_1x1 = Buffer.from( 'base64', ); -const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' }; +const sessionUser = { id: 'u1', username: 'anna', role: Role.USER, tenantId: 't1' }; const baseDto = { page: '/admin/users?tab=x', diff --git a/apps/api/src/bug-reports/bug-reports.service.ts b/apps/api/src/bug-reports/bug-reports.service.ts index 912e431..7f6c6d6 100644 --- a/apps/api/src/bug-reports/bug-reports.service.ts +++ b/apps/api/src/bug-reports/bug-reports.service.ts @@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { SettingsService } from '../settings/settings.service'; import { BugReportDto } from './dto/bug-report.dto'; +import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { describeOrigin } from './origin'; /** @@ -58,18 +59,22 @@ const WINDOW_MS = 10 * 60 * 1000; const MAX_PER_WINDOW = 5; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -interface SessionUser { - id: string; - username: string; - role: string; - tenantId: string; -} +/** + * Der Aufrufer, so weit dieser Dienst ihn liest. Seit quick-260921-m34 aus + * `AuthUser` abgeleitet statt danebengestellt: es soll nicht zwei + * Beschreibungen desselben Sitzungsobjekts geben, die auseinanderlaufen + * koennen. Ausgelassen wird `mustChangePassword` — dieser Dienst liest es + * nicht. + */ +type SessionUser = Pick; -interface UploadedPng { - buffer: Buffer; - size: number; - mimetype?: string; -} +/** + * Das Bildschirmfoto, so weit dieser Dienst es liest — abgeleitet aus + * `UploadedFileLike`, damit die Hochladewege eine gemeinsame Beschreibung + * haben. `originalname` fehlt bewusst: der Anhangname ist hier fest + * vorgegeben (T-M97-04), der eingereichte Name wird nie benutzt. + */ +type UploadedPng = Pick; @Injectable() export class BugReportsService {