From 32591b6690ffab3a60340b7ee37ca25c76819463 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 21 Sep 2026 17:18:12 +0200 Subject: [PATCH] refactor(quick-260921-m34): Aufgabe 3a - 18 Fehlerfaenger auf unknown, mit echter Eingrenzung catch (e: any) in groups, module-grants, ldap, user, admin-seed, calendar und vier tenders-Diensten auf catch (e: unknown) umgestellt. Die Eingrenzung passiert an der Verwendungsstelle, nicht per Zusicherung. Neu: apps/api/src/prisma/prisma-error.ts mit prismaErrorCode() und prismaErrorTarget(). Bewusst Form-Pruefungen statt instanceof Prisma.PrismaClientKnownRequestError - gemessen: samtliche Testdoppel in apps/api werfen new Error(...) mit angehaengtem .code (groups, user, ldap, tenders, module-grants, admin-seed) und dashboard.service.spec.ts:451 ein reines { code: 'P2002' }. Ein instanceof-Test haette all diese Werte in den anderen Zweig geschickt - Verhaltensaenderung, verboten nach D-03/T-M34-06. Die Helfer bilden err?.code und err?.meta?.target eins zu eins ab. ldap.service.ts liest zusaetzlich meta.target; prismaErrorTarget() gibt unknown zurueck, weil der Bestand dort Array UND Zeichenkette getrennt behandelt - ein engerer Typ waere eine Behauptung. calendar.service.ts:341 nutzt instanceof Error statt e?.message: gemessen wirft validateUrlNotPrivate() ausschliesslich ForbiddenException (der eigene catch dort setzt jeden Fremdfehler in eine um), also trifft instanceof dieselben Faelle. Ersatzzweig 'URL not allowed' unveraendert. noExplicitAny in apps/api/src: 56 -> 38. type-check 4/4, lint 5/5 (0 error), apps/api 72/1143, apps/web 73/531, rls-access-inventory 30/30. noNonNullAssertion 56, as unknown as 33, Unterdrueckungsmarker 1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J --- apps/api/src/calendar/calendar.service.ts | 12 ++++- apps/api/src/groups/groups.service.ts | 21 ++++---- apps/api/src/groups/module-grants.service.ts | 5 +- apps/api/src/ldap/ldap.service.ts | 17 +++---- apps/api/src/prisma/prisma-error.ts | 48 +++++++++++++++++++ .../tenders/tender-email-config.service.ts | 5 +- .../tender-notification-pref.service.ts | 5 +- .../tenders/tender-saved-search.service.ts | 9 ++-- apps/api/src/tenders/tender-triage.service.ts | 5 +- apps/api/src/user/admin-seed.service.ts | 5 +- apps/api/src/user/user.service.ts | 9 ++-- 11 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/prisma/prisma-error.ts diff --git a/apps/api/src/calendar/calendar.service.ts b/apps/api/src/calendar/calendar.service.ts index ef2182b..9d1e2ed 100644 --- a/apps/api/src/calendar/calendar.service.ts +++ b/apps/api/src/calendar/calendar.service.ts @@ -338,8 +338,16 @@ export class CalendarService { try { // Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url); - } catch (e: any) { - return { success: false, error: e?.message ?? 'URL not allowed' }; + } catch (e: unknown) { + // validateUrlNotPrivate() wirft ausschliesslich ForbiddenException + // (gemessen: jeder Wurf in der Methode ist eine, und der eigene + // catch-Block dort setzt jeden Fremdfehler in eine um). instanceof + // Error trifft deshalb genau dieselben Faelle wie das bisherige + // e?.message; der Ersatzzweig bleibt derselbe (D-03). + return { + success: false, + error: e instanceof Error ? e.message : 'URL not allowed', + }; } const provider = this.getProvider(dto.type); diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index 030530c..8c274e5 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -7,6 +7,7 @@ import { import { MembershipSource } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; /** * Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit @@ -91,8 +92,8 @@ export class GroupsService { return await tenantPrisma.group.create({ data: { tenantId, name }, }); - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { throw new ConflictException( `Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`, ); @@ -204,8 +205,8 @@ export class GroupsService { where: { id }, data: updateData, }); - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { throw new ConflictException( `Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`, ); @@ -244,8 +245,8 @@ export class GroupsService { const tenantPrisma = forTenant(this.prisma, tenantId); try { return await tenantPrisma.group.delete({ where: { id } }); - } catch (err: any) { - if (err?.code === 'P2025') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2025') { throw new NotFoundException(`Gruppe '${id}' nicht gefunden`); } throw err; @@ -398,8 +399,8 @@ export class GroupsService { return group; }); - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { return null; } throw err; @@ -468,8 +469,8 @@ export class GroupsService { }); }); return true; - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { return false; } throw err; diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts index 8737719..d6a1b85 100644 --- a/apps/api/src/groups/module-grants.service.ts +++ b/apps/api/src/groups/module-grants.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; /** * Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für @@ -132,8 +133,8 @@ export class ModuleGrantsService { `Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`, ); return created; - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { const existing = await tenantPrisma.moduleGrant.findFirst({ where: { tenantId, diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index fb9b619..47748e1 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Client, EqualityFilter, Entry } from 'ldapts'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode, prismaErrorTarget } from '../prisma/prisma-error'; import { GroupsService } from '../groups/groups.service'; import { UserService } from '../user/user.service'; @@ -842,9 +843,9 @@ export class LdapService { data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid }, }); result.imported++; - } catch (createError: any) { - if (createError?.code === 'P2002') { - const target = createError?.meta?.target; + } catch (createError: unknown) { + if (prismaErrorCode(createError) === 'P2002') { + const target = prismaErrorTarget(createError); const targetsGuid = Array.isArray(target) ? target.includes('ldapObjectGuid') : String(target ?? '').includes('ldapObjectGuid'); @@ -1526,8 +1527,8 @@ export class LdapService { data: { name, ldapDn: dn }, }); result.groupsRenamed++; - } catch (updateError: any) { - if (updateError?.code === 'P2002') { + } catch (updateError: unknown) { + if (prismaErrorCode(updateError) === 'P2002') { // WR-02 (16-REVIEW.md): this update() writes BOTH name and // ldapDn in one call — @@unique([tenantId, name]) AND // @@unique([tenantId, ldapDn]) are both potential triggers @@ -1535,7 +1536,7 @@ export class LdapService { // collision when updateError.meta.target says so. Mirrors // the discrimination importGroupsByDn() already does above // for its own create() call. - const target = updateError?.meta?.target; + const target = prismaErrorTarget(updateError); const targetsName = Array.isArray(target) ? target.includes('name') : String(target ?? '').includes('name'); @@ -1591,8 +1592,8 @@ export class LdapService { await tenantPrisma.group.delete({ where: { id: group.id } }); result.groupsDeleted++; anyDeleted = true; - } catch (deleteError: any) { - if (deleteError?.code !== 'P2025') { + } catch (deleteError: unknown) { + if (prismaErrorCode(deleteError) !== 'P2025') { throw deleteError; } // Already gone (e.g. a concurrent manual delete) — not diff --git a/apps/api/src/prisma/prisma-error.ts b/apps/api/src/prisma/prisma-error.ts new file mode 100644 index 0000000..7e883bb --- /dev/null +++ b/apps/api/src/prisma/prisma-error.ts @@ -0,0 +1,48 @@ +/** + * Eingrenzung gefangener Fehlerwerte auf die Form, die der Bestandscode an + * Prisma-Fehlern tatsaechlich liest — `code` und `meta.target`, sonst nichts. + * + * Warum kein `instanceof Prisma.PrismaClientKnownRequestError`: + * Der Bestand prueft ueberall `err?.code === 'P2002'` und trifft damit JEDES + * Objekt, das ein `code`-Feld traegt. Genau darauf bauen die Testdoppel in + * `apps/api` auf — sie werfen `const err = new Error(...); err.code = 'P2002'` + * (groups, user, ldap, tenders, module-grants, admin-seed) und an einer Stelle + * ein reines `{ code: 'P2002' }` (dashboard.service.spec.ts:451). Ein + * `instanceof`-Test wuerde alle diese Werte in den jeweils ANDEREN Zweig + * schicken. Das waere eine Verhaltensaenderung und ist deshalb verboten + * (Plan 260921-m34, D-03 und T-M34-06). + * + * Diese Funktionen sind daher bewusst Form-Pruefungen ("duck typing") und + * keine Klassenpruefungen: sie bilden `err?.code` bzw. `err?.meta?.target` + * eins zu eins ab, nur eben ohne `any`. + */ + +/** + * Liefert den Prisma-Fehlercode (z.B. `P2002`, `P2025`), wenn der gefangene + * Wert ein Objekt mit einem Zeichenketten-Feld `code` ist — sonst `undefined`. + * + * Ein Feld `code`, das keine Zeichenkette ist, liefert `undefined`. Auch das + * ist verhaltensgleich: ein Vergleich `err.code === 'P2002'` waere dort + * ohnehin nie wahr geworden. + */ +export function prismaErrorCode(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null) return undefined; + const code = (err as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +/** + * Liefert `meta.target` eines Prisma-Fehlers als `unknown`. + * + * Bewusst `unknown` und nicht `string[]`: Prisma liefert hier je nach + * Fehlerart ein Feld-Array ODER den Indexnamen als Zeichenkette, und der + * Bestand in `ldap.service.ts` behandelt genau diese beiden Faelle bereits + * getrennt (`Array.isArray(target) ? ... : String(target ?? '')`). Ein + * engerer Rueckgabetyp waere hier eine Behauptung statt einer Messung. + */ +export function prismaErrorTarget(err: unknown): unknown { + if (typeof err !== 'object' || err === null) return undefined; + const meta = (err as { meta?: unknown }).meta; + if (typeof meta !== 'object' || meta === null) return undefined; + return (meta as { target?: unknown }).target; +} diff --git a/apps/api/src/tenders/tender-email-config.service.ts b/apps/api/src/tenders/tender-email-config.service.ts index 39187ca..a5874b3 100644 --- a/apps/api/src/tenders/tender-email-config.service.ts +++ b/apps/api/src/tenders/tender-email-config.service.ts @@ -6,6 +6,7 @@ import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interfa import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; import type { TenderEmailConfigDto } from './dto/tender-email-config.dto'; /** @@ -204,8 +205,8 @@ export class TenderEmailConfigService { update: { tenantId, ...data }, select: EMAIL_CONFIG_SAFE_SELECT, }); - } catch (error: any) { - if (error?.code === 'P2002') { + } catch (error: unknown) { + if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', ); diff --git a/apps/api/src/tenders/tender-notification-pref.service.ts b/apps/api/src/tenders/tender-notification-pref.service.ts index d591ea9..8cfb799 100644 --- a/apps/api/src/tenders/tender-notification-pref.service.ts +++ b/apps/api/src/tenders/tender-notification-pref.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; /** * Service for managing the per-user Tender digest interval preference @@ -70,8 +71,8 @@ export class TenderNotificationPrefService { create: { userId, tenantId, digestInterval }, update: { digestInterval }, }); - } catch (error: any) { - if (error?.code === 'P2002') { + } catch (error: unknown) { + if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', ); diff --git a/apps/api/src/tenders/tender-saved-search.service.ts b/apps/api/src/tenders/tender-saved-search.service.ts index a24d7d3..55ca8e8 100644 --- a/apps/api/src/tenders/tender-saved-search.service.ts +++ b/apps/api/src/tenders/tender-saved-search.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; /** @@ -69,8 +70,8 @@ export class TenderSavedSearchService { ...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }), }, }); - } catch (error: any) { - if (error?.code === 'P2002') { + } catch (error: unknown) { + if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Ein Suchprofil mit diesem Namen existiert bereits.', ); @@ -108,8 +109,8 @@ export class TenderSavedSearchService { where: { id }, data, }); - } catch (error: any) { - if (error?.code === 'P2002') { + } catch (error: unknown) { + if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Ein Suchprofil mit diesem Namen existiert bereits.', ); diff --git a/apps/api/src/tenders/tender-triage.service.ts b/apps/api/src/tenders/tender-triage.service.ts index 2008283..1480e1a 100644 --- a/apps/api/src/tenders/tender-triage.service.ts +++ b/apps/api/src/tenders/tender-triage.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; /** * Partial triage update accepted by setTriage(). Both fields are optional @@ -90,8 +91,8 @@ export class TenderTriageService { favoritedAt: dto.isFavorite ? now : null, }, }); - } catch (error: any) { - if (error?.code === 'P2002') { + } catch (error: unknown) { + if (prismaErrorCode(error) === 'P2002') { throw new ConflictException( 'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.', ); diff --git a/apps/api/src/user/admin-seed.service.ts b/apps/api/src/user/admin-seed.service.ts index d8054bc..e742433 100644 --- a/apps/api/src/user/admin-seed.service.ts +++ b/apps/api/src/user/admin-seed.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import { GroupsService } from '../groups/groups.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -117,7 +118,7 @@ export class AdminSeedService implements OnApplicationBootstrap { isActive: true, }, }); - } catch (err: any) { + } catch (err: unknown) { // Entschaerfung der Startsperre (260910-das, Befund I): trifft die // Erstanlage die plattformweite Eindeutigkeit von username/email // (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein @@ -129,7 +130,7 @@ export class AdminSeedService implements OnApplicationBootstrap { // festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) -- // JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine, // an dieser Stelle gleichbedeutende Fall wird ergaenzt. - if (err?.code === 'P2002') { + if (prismaErrorCode(err) === 'P2002') { this.logger.log( `Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`, ); diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index ad1f9bc..218c1e3 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable, Logger } from '@nestjs/common'; import * as argon2 from 'argon2'; import { GroupsService } from '../groups/groups.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; +import { prismaErrorCode } from '../prisma/prisma-error'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -116,8 +117,8 @@ export class UserService { passwordHash: password ? await argon2.hash(password) : null, }, }); - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { throw new ConflictException( 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', ); @@ -174,8 +175,8 @@ export class UserService { where: { id }, data: updateData, }); - } catch (err: any) { - if (err?.code === 'P2002') { + } catch (err: unknown) { + if (prismaErrorCode(err) === 'P2002') { throw new ConflictException( 'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.', );