From f7407bd882f3d4d3617fdd578634d761b0007f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 19:58:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(api):=20add=20centralized=20Prisma=20?= =?UTF-8?q?=E2=86=92=20TRPCError=20middleware=20on=20protectedProcedure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2002/P2025/P2003 now map to CONFLICT/NOT_FOUND/BAD_REQUEST with generic messages. Raw Prisma error details no longer reach the client. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/prisma-error-middleware.test.ts | 72 +++++++++++++++++++ packages/api/src/trpc.ts | 51 ++++++++++++- 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/__tests__/prisma-error-middleware.test.ts diff --git a/packages/api/src/__tests__/prisma-error-middleware.test.ts b/packages/api/src/__tests__/prisma-error-middleware.test.ts new file mode 100644 index 0000000..2fc9d4b --- /dev/null +++ b/packages/api/src/__tests__/prisma-error-middleware.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest"; +import { Prisma } from "@capakraken/db"; +import { TRPCError } from "@trpc/server"; + +// We need to stub out the prisma client import used by trpc.ts before importing +vi.mock("@capakraken/db", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + prisma: { + systemRoleConfig: { findMany: vi.fn(async () => []) }, + }, + }; +}); + +// eslint-disable-next-line import/first +import { mapPrismaError } from "../trpc.js"; + +function makePrismaError(code: string): Prisma.PrismaClientKnownRequestError { + return new Prisma.PrismaClientKnownRequestError("test message", { + code, + clientVersion: "test", + }); +} + +describe("mapPrismaError", () => { + it("maps P2002 (unique constraint) to CONFLICT", () => { + const err = makePrismaError("P2002"); + const result = mapPrismaError(err); + expect(result).toBeInstanceOf(TRPCError); + expect(result.code).toBe("CONFLICT"); + expect(result.message).toBe("A record with this value already exists."); + }); + + it("maps P2025 (record not found) to NOT_FOUND", () => { + const err = makePrismaError("P2025"); + const result = mapPrismaError(err); + expect(result).toBeInstanceOf(TRPCError); + expect(result.code).toBe("NOT_FOUND"); + expect(result.message).toBe("The requested record was not found."); + }); + + it("maps P2003 (foreign key constraint) to BAD_REQUEST", () => { + const err = makePrismaError("P2003"); + const result = mapPrismaError(err); + expect(result).toBeInstanceOf(TRPCError); + expect(result.code).toBe("BAD_REQUEST"); + expect(result.message).toBe("A referenced record does not exist."); + }); + + it("maps an unknown Prisma code (P9999) to INTERNAL_SERVER_ERROR", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const err = makePrismaError("P9999"); + const result = mapPrismaError(err); + expect(result).toBeInstanceOf(TRPCError); + expect(result.code).toBe("INTERNAL_SERVER_ERROR"); + expect(result.message).toBe("A database error occurred."); + expect(consoleSpy).toHaveBeenCalledWith("[Prisma error]", "P9999", "test message"); + consoleSpy.mockRestore(); + }); +}); + +describe("withPrismaErrors middleware (integration)", () => { + it("passes through non-Prisma errors unchanged", async () => { + // We test this by verifying mapPrismaError is only invoked for Prisma errors. + // A plain Error should not be caught by mapPrismaError. + const plain = new Error("network failure"); + // mapPrismaError only accepts PrismaClientKnownRequestError, so passing a plain + // Error through the middleware would re-throw it. We verify the type guard here: + expect(plain instanceof Prisma.PrismaClientKnownRequestError).toBe(false); + }); +}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 4bd0dc9..b567042 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -1,4 +1,4 @@ -import { prisma } from "@capakraken/db"; +import { prisma, Prisma } from "@capakraken/db"; import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared"; import { initTRPC, TRPCError } from "@trpc/server"; import { ZodError } from "zod"; @@ -89,6 +89,53 @@ export const publicProcedure = t.procedure; // eslint-disable-next-line @typescript-eslint/no-explicit-any const withLogging = t.middleware(loggingMiddleware as any); + +/** + * Maps a known Prisma error to a TRPCError with a safe generic message. + * Exported for unit testing. + */ +export function mapPrismaError(error: Prisma.PrismaClientKnownRequestError): TRPCError { + switch (error.code) { + case "P2002": // Unique constraint + return new TRPCError({ + code: "CONFLICT", + message: "A record with this value already exists.", + }); + case "P2025": // Record not found + return new TRPCError({ + code: "NOT_FOUND", + message: "The requested record was not found.", + }); + case "P2003": // Foreign key constraint + return new TRPCError({ + code: "BAD_REQUEST", + message: "A referenced record does not exist.", + }); + default: + // Log the full error server-side but don't expose internals + // eslint-disable-next-line no-console + console.error("[Prisma error]", error.code, error.message); + return new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "A database error occurred.", + }); + } +} + +/** + * Middleware that catches PrismaClientKnownRequestError and maps it to a clean TRPCError + * so DB internals never reach the client. + */ +const withPrismaErrors = t.middleware(async ({ next }) => { + try { + return await next(); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw mapPrismaError(error); + } + throw error; // re-throw non-Prisma errors unchanged + } +}); const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] !== "production"; if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "production") { @@ -100,7 +147,7 @@ if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "prod * Protected procedure — requires authenticated session AND a valid DB user record. * This prevents stale sessions from accessing data after the DB user is deleted. */ -export const protectedProcedure = t.procedure.use(withLogging).use(async ({ ctx, next }) => { +export const protectedProcedure = t.procedure.use(withPrismaErrors).use(withLogging).use(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); }