diff --git a/api/src/app.ts b/api/src/app.ts index 8476a9facb3..0ca81a2e3ac 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -192,6 +192,10 @@ export const build = async ( await fastify.register(protectedRoutes.settingRedirectRoutes); }); }); + + // TODO: The route should not handle its own AuthZ + await fastify.register(protectedRoutes.challengeTokenRoutes); + // Routes for signed out users: void fastify.register(async function (fastify) { fastify.addHook('onRequest', fastify.authorize); diff --git a/api/src/routes/protected/challenge.test.ts b/api/src/routes/protected/challenge.test.ts index 3b6e36ab60c..94860a47a94 100644 --- a/api/src/routes/protected/challenge.test.ts +++ b/api/src/routes/protected/challenge.test.ts @@ -6,6 +6,7 @@ const mockVerifyTrophyWithMicrosoft = jest.fn(); import { omit } from 'lodash'; import { Static } from '@fastify/type-provider-typebox'; import { DailyCodingChallengeLanguage } from '@prisma/client'; +import request from 'supertest'; import { challengeTypes } from '../../../../shared/config/challenge-types'; import { @@ -246,7 +247,11 @@ describe('challengeRoutes', () => { const token = (tokenResponse.body as { userToken: string }).userToken; - const response = await superPost('/coderoad-challenge-completed') + // This route is special since it does not have CSRF protection OR authN + // protection. As such, we use a normal `request` to send the bare + // minimum (no extra headers or cookies). + const response = await request(fastifyTestInstance.server) + .post('/coderoad-challenge-completed') .set('coderoad-user-token', token) .send({ tutorialId: @@ -270,6 +275,28 @@ describe('challengeRoutes', () => { expect(response.status).toBe(200); }); + test('Should return an error response if something goes wrong', async () => { + jest + .spyOn(fastifyTestInstance.prisma.userToken, 'findUnique') + .mockImplementationOnce(() => { + throw new Error('Database error'); + }); + const tokenResponse = await superPost('/user/user-token'); + const token = (tokenResponse.body as { userToken: string }).userToken; + + const response = await superPost('/coderoad-challenge-completed') + .set('coderoad-user-token', token) + .send({ + tutorialId: 'freeCodeCamp/learn-celestial-bodies-database:v1.0.0' + }); + + expect(response.body).toEqual({ + msg: 'An error occurred trying to submit the challenge', + type: 'error' + }); + expect(response.status).toBe(500); + }); + test('Should complete project with code 200', async () => { const tokenResponse = await superPost('/user/user-token'); expect(tokenResponse.body).toHaveProperty('userToken'); @@ -2049,7 +2076,7 @@ describe('challengeRoutes', () => { }); const endpoints: { path: string; method: 'POST' | 'GET' }[] = [ - { path: '/coderoad-challenge-completed', method: 'POST' }, + // { path: '/coderoad-challenge-completed', method: 'POST' }, { path: '/project-completed', method: 'POST' }, { path: '/backend-challenge-completed', method: 'POST' }, { path: '/modern-challenge-completed', method: 'POST' }, diff --git a/api/src/routes/protected/challenge.ts b/api/src/routes/protected/challenge.ts index 751cd7a081e..30a95cbf376 100644 --- a/api/src/routes/protected/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -52,6 +52,8 @@ const userChallengeSelect = { savedChallenges: true }; +const challenges = getChallenges(); + /** * Plugin for the challenge submission endpoints. * @@ -64,147 +66,6 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( _options, done ) => { - const challenges = getChallenges(); - - fastify.post( - '/coderoad-challenge-completed', - { - schema: schemas.coderoadChallengeCompleted, - errorHandler(error, req, reply) { - const logger = fastify.log.child({ req, res: reply }); - if (error.validation) { - logger.warn({ validationError: error.validation }); - void reply.code(400); - return formatCoderoadChallengeCompletedValidation(error.validation); - } else { - fastify.errorHandler(error, req, reply); - } - } - }, - async (req, reply) => { - const logger = fastify.log.child({ req, res: reply }); - logger.info( - { userId: req.user?.id }, - 'User submitted a coderoad challenge' - ); - - const { 'coderoad-user-token': encodedUserToken } = req.headers; - const { tutorialId } = req.body; - - let userToken; - try { - const payload = jwt.verify(encodedUserToken, JWT_SECRET) as JwtPayload; - userToken = payload.userToken; - } catch { - logger.warn('Invalid user token'); - void reply.code(400); - return { type: 'error', msg: `invalid user token` } as const; - } - - const tutorialRepo = tutorialId.split(':')[0]; - const tutorialOrg = tutorialRepo?.split('/')?.[0]; - - if (tutorialOrg !== 'freeCodeCamp') { - logger.warn( - { tutorialId }, - 'Tutorial not hosted on freeCodeCamp GitHub account' - ); - void reply.code(400); - return { - type: 'error', - msg: `Tutorial not hosted on freeCodeCamp GitHub account` - } as const; - } - - const codeRoadChallenges = challenges.filter( - ({ challengeType }) => - challengeType === challengeTypes.codeAllyPractice || - challengeType === challengeTypes.codeAllyCert - ); - - const challenge = codeRoadChallenges.find(challenge => { - return tutorialRepo && challenge.url?.endsWith(tutorialRepo); - }); - - if (!challenge) { - logger.warn({ tutorialRepo }, 'Tutorial repo is not valid'); - void reply.code(400); - return { type: 'error', msg: 'Tutorial name is not valid' } as const; - } - - const { id: challengeId, challengeType } = challenge; - try { - const tokenInfo = await fastify.prisma.userToken.findUnique({ - where: { id: userToken } - }); - - if (!tokenInfo) { - logger.warn('User token not found'); - void reply.code(400); - return { type: 'error', msg: 'User token not found' } as const; - } - - const { userId } = tokenInfo; - - const user = await fastify.prisma.user.findFirstOrThrow({ - where: { id: userId } - }); - - if (!user) { - logger.warn('User not found'); - void reply.code(400); - return { - type: 'error', - msg: 'User for user token not found' - } as const; - } - - const completedDate = Date.now(); - const { completedChallenges = [], partiallyCompletedChallenges = [] } = - user; - - const isCompleted = completedChallenges.some( - challenge => challenge.id === challengeId - ); - - if (challengeType === challengeTypes.codeAllyCert && !isCompleted) { - const finalChallenge = { - id: challengeId, - completedDate - }; - - await fastify.prisma.user.update({ - where: { id: req.user?.id }, - data: { - partiallyCompletedChallenges: uniqBy( - [finalChallenge, ...partiallyCompletedChallenges], - 'id' - ) - } - }); - } else { - await updateUserChallengeData(fastify, user, challengeId, { - id: challengeId, - completedDate - }); - } - } catch (error) { - // TODO(Post-MVP): don't catch, just let Sentry handle this. - logger.error(error, 'Error submitting coderoad challenge'); - fastify.Sentry.captureException(error); - void reply.code(400); - return { - type: 'error', - msg: 'An error occurred trying to submit the challenge' - } as const; - } - return { - type: 'success', - msg: 'Successfully submitted challenge' - } as const; - } - ); - fastify.post( '/project-completed', { @@ -975,6 +836,163 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( done(); }; +/** + * Plugin for challenge submissions behind AuthZ, not AuthN. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ +export const challengeTokenRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.post( + '/coderoad-challenge-completed', + { + schema: schemas.coderoadChallengeCompleted, + errorHandler(error, req, reply) { + const logger = fastify.log.child({ req, res: reply }); + if (error.validation) { + logger.warn({ validationError: error.validation }); + void reply.code(400); + return formatCoderoadChallengeCompletedValidation(error.validation); + } else { + fastify.errorHandler(error, req, reply); + } + } + }, + postCoderoadChallengeCompleted + ); + + done(); +}; + +async function postCoderoadChallengeCompleted( + this: FastifyInstance, + req: UpdateReqType, + reply: FastifyReply +) { + const logger = this.log.child({ req, res: reply }); + logger.info({ userId: req.user?.id }, 'User submitted a coderoad challenge'); + + const { 'coderoad-user-token': encodedUserToken } = req.headers; + const { tutorialId } = req.body; + + let userToken; + try { + const payload = jwt.verify(encodedUserToken, JWT_SECRET) as JwtPayload; + userToken = payload.userToken; + } catch { + logger.warn('Invalid user token'); + void reply.code(400); + return reply.send({ type: 'error', msg: `invalid user token` }); + } + + const tutorialRepo = tutorialId.split(':')[0]; + const tutorialOrg = tutorialRepo?.split('/')?.[0]; + + if (tutorialOrg !== 'freeCodeCamp') { + logger.warn( + { tutorialId }, + 'Tutorial not hosted on freeCodeCamp GitHub account' + ); + void reply.code(400); + return reply.send({ + type: 'error', + msg: `Tutorial not hosted on freeCodeCamp GitHub account` + }); + } + + const codeRoadChallenges = challenges.filter( + ({ challengeType }) => + challengeType === challengeTypes.codeAllyPractice || + challengeType === challengeTypes.codeAllyCert + ); + + const challenge = codeRoadChallenges.find(challenge => { + return tutorialRepo && challenge.url?.endsWith(tutorialRepo); + }); + + if (!challenge) { + logger.warn({ tutorialRepo }, 'Tutorial repo is not valid'); + void reply.code(400); + return reply.send({ type: 'error', msg: 'Tutorial name is not valid' }); + } + + const { id: challengeId, challengeType } = challenge; + try { + const tokenInfo = await this.prisma.userToken.findUnique({ + where: { id: userToken } + }); + + if (!tokenInfo) { + logger.warn('User token not found'); + void reply.code(400); + return reply.send({ type: 'error', msg: 'User token not found' }); + } + + const { userId } = tokenInfo; + + const user = await this.prisma.user.findFirstOrThrow({ + where: { id: userId } + }); + + if (!user) { + logger.warn('User not found'); + void reply.code(400); + return { + type: 'error', + msg: 'User for user token not found' + } as const; + } + + const completedDate = Date.now(); + const { completedChallenges = [], partiallyCompletedChallenges = [] } = + user; + + const isCompleted = completedChallenges.some( + challenge => challenge.id === challengeId + ); + + if (challengeType === challengeTypes.codeAllyCert && !isCompleted) { + const finalChallenge = { + id: challengeId, + completedDate + }; + + await this.prisma.user.update({ + where: { id: userId }, + data: { + partiallyCompletedChallenges: uniqBy( + [finalChallenge, ...partiallyCompletedChallenges], + 'id' + ) + } + }); + } else { + await updateUserChallengeData(this, user, challengeId, { + id: challengeId, + completedDate + }); + } + } catch (error) { + // TODO(Post-MVP): don't catch, just let Sentry handle this. + logger.error(error, 'Error submitting coderoad challenge'); + this.Sentry.captureException(error); + void reply.code(500); + return reply.send({ + type: 'error', + msg: 'An error occurred trying to submit the challenge' + }); + } + reply.send({ + type: 'success', + msg: 'Successfully submitted challenge' + }); +} + async function postDailyCodingChallengeCompleted( this: FastifyInstance, req: UpdateReqType, diff --git a/api/src/schemas/challenge/coderoad-challenge-completed.ts b/api/src/schemas/challenge/coderoad-challenge-completed.ts index f8ba0e2ed1e..4e29b2f5fcf 100644 --- a/api/src/schemas/challenge/coderoad-challenge-completed.ts +++ b/api/src/schemas/challenge/coderoad-challenge-completed.ts @@ -1,5 +1,4 @@ import { Type } from '@fastify/type-provider-typebox'; -import { genericError } from '../types'; export const coderoadChallengeCompleted = { body: Type.Object({ @@ -15,6 +14,9 @@ export const coderoadChallengeCompleted = { type: Type.Literal('error'), msg: Type.String() }), - default: genericError + default: Type.Object({ + type: Type.Literal('error'), + msg: Type.String() + }) } };