import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import Fastify, { FastifyError, type FastifyInstance } from 'fastify'; import accepts from '@fastify/accepts'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; vi.mock('../utils/env.js', async importOriginal => { const actual = await importOriginal(); return { ...actual, SENTRY_DSN: 'https://anything@goes/123' }; }); import '../instrument'; import errorHandling from './error-handling.js'; import redirectWithMessage, { formatMessage } from './redirect-with-message.js'; const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 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) => { const error = Error('a very bad thing happened') as FastifyError; error.statusCode = 500; throw error; }); fastify.get('/test-bad-request', async (_req, _reply) => { const error = Error('a very bad thing happened') as FastifyError; error.statusCode = 400; throw error; }); fastify.get('/test-csrf-token', async (_req, _reply) => { const error = Error() as FastifyError; error.code = 'FST_CSRF_INVALID_TOKEN'; error.statusCode = 403; throw error; }); fastify.get('/test-csrf-secret', async (_req, _reply) => { const error = Error() as FastifyError; error.code = 'FST_CSRF_MISSING_SECRET'; error.statusCode = 403; throw error; }); }); afterEach(async () => { await fastify.close(); vi.clearAllMocks(); }); test('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.statusCode).toEqual(302); }); test('should add a generic flash message if it is a server error (i.e. 500+)', 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' }) ); }); test('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.statusCode).toEqual(500); expect(res.json()).toEqual({ message: 'flash.generic-error', type: 'danger' }); }); test('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.statusCode).toEqual(302); }); test('should respect the error status code', async () => { const res = await fastify.inject({ method: 'GET', url: '/test-bad-request' }); expect(res.statusCode).toEqual(400); }); test('should return the error message if the status is not 500', async () => { const res = await fastify.inject({ method: 'GET', url: '/test-bad-request' }); expect(res.json()).toEqual({ message: 'a very bad thing happened', type: 'danger' }); }); test('should convert CSRF errors to a generic error message', async () => { const resToken = await fastify.inject({ method: 'GET', url: '/test-csrf-token' }); const resSecret = await fastify.inject({ method: 'GET', url: '/test-csrf-secret' }); expect(resToken.json()).toEqual({ message: 'flash.generic-error', type: 'danger' }); expect(resSecret.json()).toEqual({ message: 'flash.generic-error', type: 'danger' }); }); test('should call fastify.log.error when an unhandled error occurs', async () => { const logSpy = vi.spyOn(fastify.log, 'error'); await fastify.inject({ method: 'GET', url: '/test' }); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ message: 'a very bad thing happened' }), 'Error in request' ); }); test('should call fastify.log.warn when a bad request error occurs', async () => { const logSpy = vi.spyOn(fastify.log, 'warn'); await fastify.inject({ method: 'GET', url: '/test-bad-request' }); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ message: 'a very bad thing happened' }), 'CSRF error in request' ); }); test('should NOT log when a CSRF error is thrown', async () => { const errorLogSpy = vi.spyOn(fastify.log, 'error'); const warnLogSpy = vi.spyOn(fastify.log, 'warn'); await fastify.inject({ method: 'GET', url: '/test-csrf-token' }); expect(errorLogSpy).not.toHaveBeenCalled(); expect(warnLogSpy).not.toHaveBeenCalled(); await fastify.inject({ method: 'GET', url: '/test-csrf-secret' }); expect(errorLogSpy).not.toHaveBeenCalled(); expect(warnLogSpy).not.toHaveBeenCalled(); }); describe('Sentry integration', () => { let mockServer: ReturnType; beforeAll(() => { // The assumption is that Sentry is the only library making requests. Also, we // only want to know if a request was made, not what it was. const sentryHandler = http.post('*', () => HttpResponse.json({ success: true }) ); mockServer = setupServer(sentryHandler); mockServer.listen(); }); afterEach(() => { mockServer.resetHandlers(); }); afterAll(() => { mockServer.close(); }); const createRequestListener = () => new Promise(resolve => { mockServer.events.on('request:start', () => { resolve(true); }); }); test.skip('should capture the error with Sentry', async () => { const receivedRequest = createRequestListener(); await fastify.inject({ method: 'GET', url: '/test' }); expect(await Promise.race([receivedRequest, delay(2000)])).toBe(true); }); test('should NOT capture CSRF token errors with Sentry', async () => { const receivedRequest = createRequestListener(); await fastify.inject({ method: 'GET', url: '/test-csrf-token' }); expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined(); }); test('should NOT capture CSRF secret errors with Sentry', async () => { const receivedRequest = createRequestListener(); await fastify.inject({ method: 'GET', url: '/test-csrf-secret' }); expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined(); }); test('should NOT capture bad requests with Sentry', async () => { const receivedRequest = createRequestListener(); await fastify.inject({ method: 'GET', url: '/test-bad-request' }); expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined(); }); }); });