fix(shared): handle Prisma errors in GlobalExceptionFilter to return proper HTTP status codes
Prisma errors (P2025 record not found, P2002 unique constraint, P2003 foreign key) were falling through to the catch-all handler and returning 500 Internal Server Error instead of appropriate 404/409/400. This caused GET /listings/:id with a non-existent ID to return 500 when the Prisma layer threw before the application null check. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { DomainException } from '../../domain/domain-exception';
|
import { DomainException } from '../../domain/domain-exception';
|
||||||
import { ErrorCode } from '../../domain/error-codes';
|
import { ErrorCode } from '../../domain/error-codes';
|
||||||
@@ -95,4 +96,115 @@ describe('GlobalExceptionFilter', () => {
|
|||||||
|
|
||||||
expect(host.json).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(String) }));
|
expect(host.json).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(String) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Prisma error handling', () => {
|
||||||
|
it('should handle P2025 (record not found) as 404', () => {
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'An operation failed because it depends on one or more records that were required but not found.',
|
||||||
|
{ code: 'P2025', clientVersion: '5.0.0' },
|
||||||
|
);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
|
errorCode: ErrorCode.NOT_FOUND,
|
||||||
|
message: 'The requested resource was not found',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle P2002 (unique constraint) as 409', () => {
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'Unique constraint failed on the fields: (`email`)',
|
||||||
|
{ code: 'P2002', clientVersion: '5.0.0' },
|
||||||
|
);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.CONFLICT,
|
||||||
|
errorCode: ErrorCode.CONFLICT,
|
||||||
|
message: 'A record with the given identifier already exists',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle P2003 (foreign key constraint) as 400', () => {
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'Foreign key constraint failed on the field: `propertyId`',
|
||||||
|
{ code: 'P2003', clientVersion: '5.0.0' },
|
||||||
|
);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.BAD_REQUEST,
|
||||||
|
message: 'Referenced record does not exist',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown Prisma known errors as 500', () => {
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'Some other error',
|
||||||
|
{ code: 'P2999', clientVersion: '5.0.0' },
|
||||||
|
);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: 'Internal server error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle PrismaClientValidationError as 400', () => {
|
||||||
|
const exception = new Prisma.PrismaClientValidationError(
|
||||||
|
'Invalid query argument',
|
||||||
|
{ clientVersion: '5.0.0' },
|
||||||
|
);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
expect(host.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(host.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.BAD_REQUEST,
|
||||||
|
message: 'Invalid request parameters',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not leak internal Prisma error details', () => {
|
||||||
|
const exception = new Prisma.PrismaClientKnownRequestError(
|
||||||
|
'Detailed internal error with table names and column info',
|
||||||
|
{ code: 'P2025', clientVersion: '5.0.0' },
|
||||||
|
);
|
||||||
|
const host = createMockHost();
|
||||||
|
|
||||||
|
filter.catch(exception, host as never);
|
||||||
|
|
||||||
|
const response = host.json.mock.calls[0][0];
|
||||||
|
expect(response.message).not.toContain('table');
|
||||||
|
expect(response.message).not.toContain('column');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
|
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
|
||||||
import { ErrorCode } from '../../domain/error-codes';
|
import { ErrorCode } from '../../domain/error-codes';
|
||||||
@@ -60,6 +61,21 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Prisma-specific errors to avoid leaking 500s for known DB conditions
|
||||||
|
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
return this.buildPrismaKnownErrorResponse(exception, correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof Prisma.PrismaClientValidationError) {
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.BAD_REQUEST,
|
||||||
|
message: 'Invalid request parameters',
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
errorCode: ErrorCode.INTERNAL_ERROR,
|
errorCode: ErrorCode.INTERNAL_ERROR,
|
||||||
@@ -69,6 +85,52 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildPrismaKnownErrorResponse(
|
||||||
|
exception: Prisma.PrismaClientKnownRequestError,
|
||||||
|
correlationId?: string,
|
||||||
|
): ErrorResponseBody {
|
||||||
|
switch (exception.code) {
|
||||||
|
// P2025: Record not found (update/delete on non-existent row)
|
||||||
|
case 'P2025':
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
|
errorCode: ErrorCode.NOT_FOUND,
|
||||||
|
message: 'The requested resource was not found',
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// P2002: Unique constraint violation
|
||||||
|
case 'P2002':
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.CONFLICT,
|
||||||
|
errorCode: ErrorCode.CONFLICT,
|
||||||
|
message: 'A record with the given identifier already exists',
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// P2003: Foreign key constraint violation
|
||||||
|
case 'P2003':
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.BAD_REQUEST,
|
||||||
|
message: 'Referenced record does not exist',
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: 'Internal server error',
|
||||||
|
correlationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private httpStatusToErrorCode(status: number): ErrorCode {
|
private httpStatusToErrorCode(status: number): ErrorCode {
|
||||||
const map: Record<number, ErrorCode> = {
|
const map: Record<number, ErrorCode> = {
|
||||||
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
|
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
|
||||||
|
|||||||
Reference in New Issue
Block a user