diff --git a/integration/hello-world/e2e/exceptions.spec.ts b/integration/hello-world/e2e/exceptions.spec.ts index 22df1f9452..b0e99cab83 100644 --- a/integration/hello-world/e2e/exceptions.spec.ts +++ b/integration/hello-world/e2e/exceptions.spec.ts @@ -1,4 +1,11 @@ -import { Controller, HttpStatus, INestApplication, Post } from '@nestjs/common'; +import { + Controller, + Get, + HttpStatus, + INestApplication, + Post, + UnauthorizedException, +} from '@nestjs/common'; import { FastifyAdapter, NestFastifyApplication, @@ -10,6 +17,14 @@ import request from 'supertest'; import { ErrorsController } from '../src/errors/errors.controller.js'; import { listenOnLoopback } from '../../_support/listen-on-loopback.js'; +@Controller() +class ErrorBodyController { + @Get('error-body') + findAll() { + throw new UnauthorizedException(new Error('invalid signature')); + } +} + @Controller() class SchemaController { @RouteSchema({ body: { type: 'object', required: ['name'] } }) @@ -83,7 +98,7 @@ describe('Error messages', () => { let app: NestFastifyApplication; beforeEach(async () => { const module = await Test.createTestingModule({ - controllers: [ErrorsController, SchemaController], + controllers: [ErrorsController, SchemaController, ErrorBodyController], }).compile(); app = module.createNestApplication( @@ -155,6 +170,21 @@ describe('Error messages', () => { }); }); + it(`/GET (Unauthorized when the body of the HttpException is an Error)`, async () => { + return app + .inject({ + method: 'GET', + url: '/error-body', + }) + .then(({ payload, statusCode }) => { + expect(statusCode).toBe(HttpStatus.UNAUTHORIZED); + expect(JSON.parse(payload)).toEqual({ + statusCode: 401, + message: 'Unauthorized', + }); + }); + }); + it(`/POST (BadRequest when the route schema rejects the body)`, async () => { return app .inject({ diff --git a/integration/hello-world/e2e/middleware-fastify.spec.ts b/integration/hello-world/e2e/middleware-fastify.spec.ts index 29d5714ec2..5ec2db83cf 100644 --- a/integration/hello-world/e2e/middleware-fastify.spec.ts +++ b/integration/hello-world/e2e/middleware-fastify.spec.ts @@ -1133,6 +1133,9 @@ describe('Middleware (FastifyAdapter)', () => { if (req.headers['x-throw'] === 'error-body') { throw new UnauthorizedException(new Error('invalid signature')); } + if (req.headers['x-throw'] === 'unserializable-body') { + throw new BadRequestException({ n: 10n }); + } next(); } } @@ -1186,12 +1189,22 @@ describe('Middleware (FastifyAdapter)', () => { }); }); - // The exception layer fails to send an "Error" body on the raw response + it('should send the status of an HttpException whose body is an Error', () => { + return request(app.getHttpServer()) + .get('/protected') + .set('x-throw', 'error-body') + .expect(401, { + statusCode: 401, + message: 'Unauthorized', + }); + }); + + // The exception layer fails to serialize the body on the raw response // middleware receives it('should send the internal server error response when the exception layer fails', async () => { await request(app.getHttpServer()) .get('/protected') - .set('x-throw', 'error-body') + .set('x-throw', 'unserializable-body') .expect(500, { statusCode: 500, message: 'Internal server error', diff --git a/packages/platform-fastify/adapters/fastify-adapter.ts b/packages/platform-fastify/adapters/fastify-adapter.ts index db64eba7a8..d0be638c32 100644 --- a/packages/platform-fastify/adapters/fastify-adapter.ts +++ b/packages/platform-fastify/adapters/fastify-adapter.ts @@ -750,6 +750,20 @@ export class FastifyAdapter< body = Readable.from([]); } } + // Fastify passes an Error payload to the error handler instead of + // sending it, and the reply around a raw response (e.g. the one + // middleware gets) has none. An Error body sent with an error status, + // as for `new UnauthorizedException(err)`, gets the standard body of the + // status instead, without the properties of the Error. + if ( + body instanceof Error && + fastifyReply.statusCode >= HttpStatus.BAD_REQUEST + ) { + body = { + statusCode: fastifyReply.statusCode, + message: http.STATUS_CODES[fastifyReply.statusCode] ?? 'Error', + }; + } const responseContentType = fastifyReply.getHeader('Content-Type'); if ( typeof responseContentType === 'string' && diff --git a/packages/platform-fastify/test/adapters/fastify-adapter.spec.ts b/packages/platform-fastify/test/adapters/fastify-adapter.spec.ts index acc77e9e3a..e6eaacb143 100644 --- a/packages/platform-fastify/test/adapters/fastify-adapter.spec.ts +++ b/packages/platform-fastify/test/adapters/fastify-adapter.spec.ts @@ -119,6 +119,35 @@ describe('FastifyAdapter', () => { ); }); + // e.g. `new UnauthorizedException(err)`, whose response is `err`. On the + // raw response middleware gets, Fastify's reply has no error handler. + it.each([ + ['a raw response', true], + ['a reply', false], + ])( + 'should send the standard body of the status for an Error body on %s', + async (_, raw) => { + fastifyAdapter.get('/', (_req: FastifyRequest, reply: FastifyReply) => { + const error = Object.assign(new Error('invalid signature'), { + config: { headers: { authorization: 'Bearer secret' } }, + }); + if (raw) { + reply.hijack(); + } + fastifyAdapter.reply(raw ? reply.raw : reply, error, 401); + }); + + const res = await fastifyAdapter.inject({ method: 'GET', url: '/' }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ + statusCode: 401, + message: 'Unauthorized', + }); + await fastifyAdapter.close(); + }, + ); + it('should destroy the stream of a StreamableFile once the reply was sent', () => { // e.g. by Fastify's `handlerTimeout`: send() then drops the stream const reply = { ...createReply(), sent: true };