feat(api): redirect on error if request ACCEPTs html (#56445)

This commit is contained in:
Oliver Eyton-Williams
2024-10-04 01:10:25 +02:00
committed by GitHub
parent 250f313dad
commit cb4061c250
5 changed files with 147 additions and 29 deletions
+6 -25
View File
@@ -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);
+83
View File
@@ -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);
});
});
+53
View File
@@ -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']
});
+2
View File
@@ -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);
});
+3 -4
View File
@@ -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']
});