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..ffc41d8e 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'); @@ -372,6 +377,21 @@ export default class HttpApi implements Api { return; } + /** + * 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 domain error, we route it to the default error handler */