diff --git a/src/presentation/http/http-api.test.ts b/src/presentation/http/http-api.test.ts new file mode 100644 index 00000000..b3e82fb1 --- /dev/null +++ b/src/presentation/http/http-api.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'vitest'; + +describe('HTTP API Error Handler', () => { + describe('JSON Parse Error Handler', () => { + test('Returns 400 when request body contains not complete JSON', async () => { + const response = await global.api?.fakeRequest({ + method: 'POST', + url: '/join/test-hash1', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + }, + body: '{invalid json', + }); + + expect(response?.statusCode).toBe(400); + + const body = await response?.json(); + + expect(body).toStrictEqual({ + message: 'Invalid JSON in request body', + }); + }); + + test('Returns 400 when request body contains malformed JSON', async () => { + const response = await global.api?.fakeRequest({ + method: 'POST', + url: '/join/test-hash1', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + }, + body: '{"key": "value",}', + }); + + expect(response?.statusCode).toBe(400); + + const body = await response?.json(); + + expect(body).toStrictEqual({ + message: 'Invalid JSON in request body', + }); + }); + + test('Returns 400 when JSON body is empty', async () => { + const response = await global.api?.fakeRequest({ + method: 'POST', + url: '/join/test-hash1', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + }, + body: '', + }); + + expect(response?.statusCode).toBe(400); + + const body = await response?.json(); + + expect(body).toStrictEqual({ + message: 'Invalid JSON in request body', + }); + }); + + test('Does not return 400 for valid JSON', async () => { + const response = await global.api?.fakeRequest({ + method: 'POST', + url: '/join/test-hash1', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-Type': 'application/json', + }, + body: '{"key": "value"}', + }); + + expect(response?.statusCode).not.toBe(400); + }); + }); +}); diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 6e5cb37a..e5246cba 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -34,6 +34,11 @@ import UploadRouter from './router/upload.js'; import { ajvFilePlugin } from '@fastify/multipart'; import { UploadSchema } from './schema/Upload.js'; import { NoteHierarchySchema } from './schema/NoteHierarchy.js'; +import { StatusCodes } from 'http-status-codes'; + +interface FastifyError extends Error { + code: string; +} const appServerLogger = getLogger('appServer'); @@ -78,7 +83,7 @@ export default class HttpApi implements Api { * └── your services * @see https://fastify.dev/docs/latest/Guides/Getting-Started#loading-order-of-your-plugins */ - this.domainErrorHandler(); + this.globalErrorHandler(); await this.addCookies(); await this.addOpenapiDocs(); @@ -359,9 +364,9 @@ export default class HttpApi implements Api { } /** - * Domain error handler + * Global error handler */ - private domainErrorHandler(): void { + private globalErrorHandler(): void { this.server?.setErrorHandler(function (error, request, reply) { /** * If we have an error that occurs in the domain-level we reply it with special format @@ -373,7 +378,22 @@ export default class HttpApi implements Api { return; } /** - * If error is not a domain error, we route it to the default error handler + * JSON parse errors (invalid request body) + * Errors can be either SyntaxError or FastifyError. + */ + if ((error instanceof SyntaxError && error.message.includes('JSON')) + || ((error as FastifyError).code?.startsWith('FST_ERR_CTP_') ?? false)) { + this.log.warn({ reqId: request.id }, 'Invalid JSON in request body'); + + return reply + .code(StatusCodes.BAD_REQUEST) + .type('application/json') + .send({ + message: 'Invalid JSON in request body', + }); + } + /** + * If error is not a known type, we route it to the default error handler */ throw error; });