110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
import { ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
|
import { ApiExceptionFilter } from '../src/common/errors/api-exception.filter';
|
|
import { AppError } from '../src/common/errors/app-error';
|
|
import { ERRORS } from '../src/common/errors/error-catalog';
|
|
import { classifyError } from '../src/common/errors/classify-error';
|
|
import { Prisma } from '../src/generated/prisma/client';
|
|
|
|
describe('safe error responses and logs', () => {
|
|
beforeEach(() => {
|
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
|
});
|
|
afterEach(() => jest.restoreAllMocks());
|
|
function dispatch(error: unknown, headersSent = false) {
|
|
const response = {
|
|
locals: { requestId: 'server-generated-id' },
|
|
headersSent,
|
|
setHeader: jest.fn(),
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn(),
|
|
};
|
|
const host = {
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
method: 'POST',
|
|
route: { path: '/products/:id' },
|
|
body: { password: 'SECRET' },
|
|
headers: { authorization: 'Bearer SECRET' },
|
|
originalUrl: '/products?token=SECRET',
|
|
}),
|
|
getResponse: () => response,
|
|
}),
|
|
} as unknown as ArgumentsHost;
|
|
new ApiExceptionFilter().catch(error, host);
|
|
return response;
|
|
}
|
|
it('returns stable codes and correlation IDs without leaking raw faults', () => {
|
|
const response = dispatch(new Error('SQL password=SECRET token=SECRET'));
|
|
expect(response.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
code: 'INTERNAL_FAILURE',
|
|
requestId: 'server-generated-id',
|
|
statusCode: 500,
|
|
}),
|
|
);
|
|
const output = JSON.stringify([
|
|
response.json.mock.calls,
|
|
jest.mocked(Logger.prototype.error).mock.calls,
|
|
]);
|
|
expect(output).not.toContain('SECRET');
|
|
expect(output).not.toContain('password=');
|
|
expect(output).toContain('faultId');
|
|
});
|
|
it('keeps validation field names and unique business error messages', () => {
|
|
const response = dispatch(
|
|
new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', ['price']),
|
|
);
|
|
expect(response.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ fields: ['price'], code: 'REQUEST_INVALID' }),
|
|
);
|
|
const messages = Object.values(ERRORS).map((value) => value[1]);
|
|
expect(new Set(messages).size).toBe(messages.length);
|
|
});
|
|
it('does not try to write a second response', () => {
|
|
expect(
|
|
dispatch(new AppError('STOCK_INSUFFICIENT'), true).json,
|
|
).not.toHaveBeenCalled();
|
|
});
|
|
it('adds retry guidance for throttling', () => {
|
|
expect(
|
|
dispatch(new AppError('RATE_LIMITED')).setHeader,
|
|
).toHaveBeenCalledWith('Retry-After', '60');
|
|
});
|
|
it.each([
|
|
['P2002', 'RECORD_CONFLICT'],
|
|
['P2003', 'REFERENCE_INVALID'],
|
|
['P2004', 'DATA_CONSTRAINT'],
|
|
['P2025', 'RECORD_NOT_FOUND'],
|
|
['P2028', 'DATABASE_BUSY'],
|
|
['P2034', 'TRANSACTION_CONFLICT'],
|
|
['P9999', 'INTERNAL_FAILURE'],
|
|
])('classifies database error %s as %s', (databaseCode, code) => {
|
|
const error = new Prisma.PrismaClientKnownRequestError('SECRET', {
|
|
code: databaseCode,
|
|
clientVersion: 'test',
|
|
});
|
|
expect(classifyError(error).code).toBe(code);
|
|
});
|
|
it('classifies parser, transport and connection failures', () => {
|
|
expect(classifyError({ type: 'entity.too.large' }).code).toBe(
|
|
'REQUEST_TOO_LARGE',
|
|
);
|
|
expect(classifyError({ type: 'entity.parse.failed' }).code).toBe(
|
|
'REQUEST_MALFORMED',
|
|
);
|
|
expect(classifyError(new HttpException('SECRET', 404)).code).toBe(
|
|
'ROUTE_NOT_FOUND',
|
|
);
|
|
expect(classifyError(new HttpException('SECRET', 502)).code).toBe(
|
|
'INTERNAL_FAILURE',
|
|
);
|
|
expect(
|
|
classifyError(
|
|
new Prisma.PrismaClientInitializationError('SECRET', 'test'),
|
|
).code,
|
|
).toBe('DATABASE_BUSY');
|
|
expect(classifyError(null).code).toBe('INTERNAL_FAILURE');
|
|
});
|
|
});
|