diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 2ff5d47b..367e8323 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -22,6 +22,7 @@ import { canAccessCustomer, getPortalAllowedCustomerIds, } from '../utils/accessControl.js'; +import { ApiError } from '../utils/apiError.js'; // Customer CRUD export async function getCustomers(req: AuthRequest, res: Response): Promise { @@ -1293,7 +1294,10 @@ export async function getSalutationPreference(req: AuthRequest, res: Response): const result = await customerService.getSalutationPreference(userId, customerId); res.json({ success: true, data: result } as ApiResponse); } catch (error) { - res.status(500).json({ + // Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500 + // bei nicht-existierendem Kunden). + const status = error instanceof ApiError ? error.statusCode : 500; + res.status(status).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz', } as ApiResponse); @@ -1325,7 +1329,8 @@ export async function setSalutationPreference(req: AuthRequest, res: Response): const result = await customerService.getSalutationPreference(userId, customerId); res.json({ success: true, data: result } as ApiResponse); } catch (error) { - res.status(500).json({ + const status = error instanceof ApiError ? error.statusCode : 500; + res.status(status).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz', } as ApiResponse); @@ -1349,7 +1354,8 @@ export async function clearSalutationPreference(req: AuthRequest, res: Response) const result = await customerService.getSalutationPreference(userId, customerId); res.json({ success: true, data: result } as ApiResponse); } catch (error) { - res.status(500).json({ + const status = error instanceof ApiError ? error.statusCode : 500; + res.status(status).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz', } as ApiResponse); diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index ef5b741a..0ffe80ce 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -1,6 +1,7 @@ import { CustomerType, ContractStatus } from '@prisma/client'; import prisma from '../lib/prisma.js'; import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js'; +import { ApiError } from '../utils/apiError.js'; import fs from 'fs'; import path from 'path'; @@ -1028,6 +1029,22 @@ function isValidPreference(v: unknown): v is SalutationPreference { return v === 'DU' || v === 'SIE'; } +/** + * Wirft ApiError(404), wenn kein Kunde mit dieser ID existiert. Vor + * jedem Salutation-Preference-Zugriff aufrufen – ohne den Check würde + * `upsert`/`deleteMany` einen Prisma-FK-Error werfen, den der Controller + * als 500 mappt (Pentest R104.1). + */ +async function assertCustomerExists(customerId: number): Promise { + const exists = await prisma.customer.findUnique({ + where: { id: customerId }, + select: { id: true }, + }); + if (!exists) { + throw new ApiError(404, 'Kunde nicht gefunden'); + } +} + /** * Liefert die effektive Anrede für einen User/Kunde: eigene Präferenz * bevorzugt, sonst Kunden-Default. `source` sagt der UI, ob's ein eigener @@ -1051,7 +1068,10 @@ export async function getSalutationPreference( select: { useInformalAddress: true }, }); if (!customer) { - throw new Error('Kunde nicht gefunden'); + // Pentest R104.1 (INFO, 2026-07-04): vorher `throw new Error(...)`, + // was der Controller-Catch als 500 mappte. Sauberes 404 macht die + // Response semantisch korrekt (existiert nicht / gerade gelöscht). + throw new ApiError(404, 'Kunde nicht gefunden'); } return { preference: customer.useInformalAddress ? 'DU' : 'SIE', @@ -1064,6 +1084,7 @@ export async function setSalutationPreference( customerId: number, preference: SalutationPreference, ) { + await assertCustomerExists(customerId); return prisma.userCustomerSalutation.upsert({ where: { userId_customerId: { userId, customerId } }, create: { userId, customerId, preference }, @@ -1077,6 +1098,7 @@ export async function setSalutationPreference( * idempotent gedacht ("stelle sicher, dass kein Override existiert"). */ export async function clearSalutationPreference(userId: number, customerId: number) { + await assertCustomerExists(customerId); await prisma.userCustomerSalutation.deleteMany({ where: { userId, customerId }, }); diff --git a/docs/todo.md b/docs/todo.md index de1d425c..65b8cd09 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,17 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 Pentest R104.1 – Salutation-Endpunkte: 404 statt 500** + - INFO-Finding: `GET/PUT /api/customers/:id/salutation-preference` + warfen bei nicht-existierendem Customer 500 statt 404. + - Ursache: `canAccessCustomer` prüft für Staff-User nur Portal-Flag, + kein Existenz-Check. Service warf `new Error(...)`, Controller- + Catch mappte generisch auf 500. + - Fix: neuer Helper `assertCustomerExists()` im Service wirft + `ApiError(404, ...)`. Die drei Controller-Catches respektieren + jetzt `ApiError.statusCode`. Auch `set`/`clear` prüfen Existenz + (sonst käme ein Prisma-P2003 als 500 durch). + - [x] **🔒 Pentest R102 – Interne Vertragsnummer nachziehen** - INFO-Finding: `contractNumber` (interne CRM-Nummer) hatte im Update-Path keine Whitelist. `` →