diff --git a/api/src/app.ts b/api/src/app.ts index 665b9effa5b..17d3d7473af 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -1,8 +1,8 @@ +import fastifyAccepts from '@fastify/accepts'; import fastifyCsrfProtection from '@fastify/csrf-protection'; import fastifySwagger from '@fastify/swagger'; import fastifySwaggerUI from '@fastify/swagger-ui'; import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; -import fastifySentry from '@immobiliarelabs/fastify-sentry'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import uriResolver from 'fast-uri'; @@ -25,6 +25,7 @@ import redirectWithMessage from './plugins/redirect-with-message'; import security from './plugins/security'; import auth from './plugins/auth'; import bouncer from './plugins/bouncer'; +import errorHandling from './plugins/error-handling'; import notFound from './plugins/not-found'; import * as publicRoutes from './routes/public'; import * as protectedRoutes from './routes/protected'; @@ -33,8 +34,7 @@ import { API_LOCATION, EMAIL_PROVIDER, FCC_ENABLE_DEV_LOGIN_MODE, - FCC_ENABLE_SWAGGER_UI, - SENTRY_DSN + FCC_ENABLE_SWAGGER_UI } from './utils/env'; import { isObjectID } from './utils/validation'; @@ -81,27 +81,10 @@ export const build = async ( fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema)); + void fastify.register(redirectWithMessage); void fastify.register(security); - - await fastify.register(fastifySentry, { - dsn: SENTRY_DSN, - // No need to initialize if DSN is not provided (e.g. in development and - // test environments) - skipInit: !SENTRY_DSN, - errorResponse: (error, _request, reply) => { - const isCSRFError = - error.code === 'FST_CSRF_INVALID_TOKEN' || - error.code === 'FST_CSRF_MISSING_SECRET'; - if (reply.statusCode === 500 || isCSRFError) { - void reply.send({ - message: 'flash.generic-error', - type: 'danger' - }); - } else { - void reply.send(error); - } - } - }); + void fastify.register(fastifyAccepts); + void fastify.register(errorHandling); await fastify.register(cors); await fastify.register(cookies); @@ -165,8 +148,6 @@ export const build = async ( fastify.log.info(`Swagger UI available at ${API_LOCATION}/documentation`); } - // redirectWithMessage must be registered before codeFlowAuth - void fastify.register(redirectWithMessage); void fastify.register(auth); void fastify.register(notFound); void fastify.register(prismaPlugin); diff --git a/api/src/plugins/error-handling.test.ts b/api/src/plugins/error-handling.test.ts new file mode 100644 index 00000000000..4dc4ac560a6 --- /dev/null +++ b/api/src/plugins/error-handling.test.ts @@ -0,0 +1,83 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import accepts from '@fastify/accepts'; + +import errorHandling from './error-handling'; +import redirectWithMessage, { formatMessage } from './redirect-with-message'; + +describe('errorHandling', () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = Fastify(); + await fastify.register(redirectWithMessage); + await fastify.register(accepts); + await fastify.register(errorHandling); + + fastify.get('/test', async (_req, _reply) => { + throw new Error('a very bad thing happened'); + return { ok: true }; + }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it('should redirect to the referer if the request does not Accept json', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + referer: 'https://www.freecodecamp.org/anything', + accept: 'text/plain' + } + }); + + expect(res.headers['location']).toEqual( + 'https://www.freecodecamp.org/anything?' + + formatMessage({ + type: 'danger', + content: 'flash.generic-error' + }) + ); + expect(res.statusCode).toEqual(302); + }); + + it('should return a json response if the request does Accept json', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + referer: 'https://www.freecodecamp.org/anything', + accept: 'application/json,text/plain' + } + }); + + expect(res.json()).toEqual({ + message: 'flash.generic-error', + type: 'danger' + }); + expect(res.statusCode).toEqual(500); + }); + + it('should redirect if the request prefers text/html to json', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/test', + headers: { + referer: 'https://www.freecodecamp.org/anything', + // this does accept json, (via the */*), but prefers text/html + accept: 'text/html,*/*' + } + }); + + expect(res.headers['location']).toEqual( + 'https://www.freecodecamp.org/anything?' + + formatMessage({ + type: 'danger', + content: 'flash.generic-error' + }) + ); + expect(res.statusCode).toEqual(302); + }); +}); diff --git a/api/src/plugins/error-handling.ts b/api/src/plugins/error-handling.ts new file mode 100644 index 00000000000..519288322c6 --- /dev/null +++ b/api/src/plugins/error-handling.ts @@ -0,0 +1,53 @@ +import type { FastifyPluginCallback } from 'fastify'; +import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import fp from 'fastify-plugin'; + +import { SENTRY_DSN } from '../utils/env'; +import { getRedirectParams } from '../utils/redirection'; + +/** + * Plugin to handle errors and send them to Sentry. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done Callback to signal that the logic has completed. + */ +const errorHandling: FastifyPluginCallback = (fastify, _options, done) => { + void fastify.register(fastifySentry, { + dsn: SENTRY_DSN, + // No need to initialize if DSN is not provided (e.g. in development and + // test environments) + skipInit: !SENTRY_DSN, + errorResponse: (error, request, reply) => { + const accepts = request.accepts().type(['json', 'html']); + const isCSRFError = + error.code === 'FST_CSRF_INVALID_TOKEN' || + error.code === 'FST_CSRF_MISSING_SECRET'; + + const { returnTo } = getRedirectParams(request); + + const message = + reply.statusCode === 500 || isCSRFError + ? 'flash.generic-error' + : error.message; + if (accepts === 'json') { + void reply.send({ + message, + type: 'danger' + }); + } else { + void reply.status(302); + void reply.redirectWithMessage(returnTo, { + type: 'danger', + content: message + }); + } + } + }); + + done(); +}; + +export default fp(errorHandling, { + dependencies: ['redirect-with-message', '@fastify/accepts'] +}); diff --git a/api/src/plugins/not-found.test.ts b/api/src/plugins/not-found.test.ts index 7481d8a3ea3..1d71a51d04a 100644 --- a/api/src/plugins/not-found.test.ts +++ b/api/src/plugins/not-found.test.ts @@ -1,4 +1,5 @@ import Fastify, { type FastifyInstance } from 'fastify'; +import accepts from '@fastify/accepts'; import notFound from './not-found'; import redirectWithMessage, { formatMessage } from './redirect-with-message'; @@ -9,6 +10,7 @@ describe('fourOhFour', () => { beforeEach(async () => { fastify = Fastify(); await fastify.register(redirectWithMessage); + await fastify.register(accepts); await fastify.register(notFound); }); diff --git a/api/src/plugins/not-found.ts b/api/src/plugins/not-found.ts index ff59bfd25bc..4ed4b187d65 100644 --- a/api/src/plugins/not-found.ts +++ b/api/src/plugins/not-found.ts @@ -1,7 +1,6 @@ import type { FastifyPluginCallback } from 'fastify'; import fp from 'fastify-plugin'; -import accepts from '@fastify/accepts'; import { getRedirectParams } from '../utils/redirection'; @@ -13,8 +12,6 @@ import { getRedirectParams } from '../utils/redirection'; * @param done Callback to signal that the logic has completed. */ const fourOhFour: FastifyPluginCallback = (fastify, _options, done) => { - void fastify.register(accepts); - // If the request accepts JSON and does not specifically prefer text/html, // this will return a 404 JSON response. Everything else will be redirected. fastify.setNotFoundHandler((request, reply) => { @@ -34,4 +31,6 @@ const fourOhFour: FastifyPluginCallback = (fastify, _options, done) => { done(); }; -export default fp(fourOhFour, { dependencies: ['redirect-with-message'] }); +export default fp(fourOhFour, { + dependencies: ['redirect-with-message', '@fastify/accepts'] +});