fix(api): add centralized Prisma → TRPCError middleware on protectedProcedure
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof import("@capakraken/db")>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user