diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts index 5734b76414d..b8ab313ffc6 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/challenge.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { challengeTypes } from '../../../config/challenge-types'; @@ -46,6 +47,175 @@ describe('challengeRoutes', () => { setCookies = await devLogin(); }); + describe('POST /coderoad-challenge-completed', () => { + test('should return 500 if no tutorialId', async () => { + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }); + expect(response.status).toBe(500); + }); + + test('should return 400 if no user token', async () => { + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }).send({ + tutorialId: 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0' + }); + expect(response.status).toBe(400); + expect(response.body).toEqual({ + msg: `'Coderoad-User-Token' not found in request headers`, + type: 'error' + }); + }); + + test('should return 400 if invalid user token', async () => { + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }) + .set('coderoad-user-token', 'invalid') + .send({ + tutorialId: + 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0' + }); + expect(response.status).toBe(400); + expect(response.body).toEqual({ + msg: 'invalid user token', + type: 'error' + }); + }); + + test('should return 400 if invalid tutorialId', async () => { + const tokenResponse = await superRequest('/user/user-token', { + method: 'POST', + setCookies + }); + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body).toHaveProperty('userToken'); + + const token = (tokenResponse.body as { userToken: string }).userToken; + + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }) + .set('coderoad-user-token', token) + .send({ tutorialId: 'invalid' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + msg: 'Tutorial not hosted on freeCodeCamp GitHub account', + type: 'error' + }); + }); + + test('should return 400 if invalid tutorialId but is hosted on freeCodeCamp', async () => { + const tokenResponse = await superRequest('/user/user-token', { + method: 'POST', + setCookies + }); + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body).toHaveProperty('userToken'); + + const token = (tokenResponse.body as { userToken: string }).userToken; + + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }) + .set('coderoad-user-token', token) + .send({ tutorialId: 'freeCodeCamp/invalid:V1.0.0' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + msg: 'Tutorial name is not valid', + type: 'error' + }); + }); + + test('Should complete challenge with code 200', async () => { + const tokenResponse = await superRequest('/user/user-token', { + method: 'POST', + setCookies + }); + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body).toHaveProperty('userToken'); + + const token = (tokenResponse.body as { userToken: string }).userToken; + + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }) + .set('coderoad-user-token', token) + .send({ + tutorialId: + 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0' + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + msg: 'Successfully submitted challenge', + type: 'success' + }); + + const user = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + }); + + const challengeCompleted = user?.completedChallenges.some(challenge => { + return challenge.id === '5ea8adfab628f68d805bfc5e'; + }); + + expect(challengeCompleted).toBe(true); + }); + + test('Should complete project with code 200', async () => { + const tokenResponse = await superRequest('/user/user-token', { + method: 'POST', + setCookies + }); + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body).toHaveProperty('userToken'); + + const token = (tokenResponse.body as { userToken: string }).userToken; + + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }) + .set('coderoad-user-token', token) + .send({ + tutorialId: 'freeCodeCamp/learn-celestial-bodies-database:v1.0.0' + }); + + expect(response.status).toBe(200); + + const user = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + }); + + const projectCompleted = user?.partiallyCompletedChallenges.some( + project => { + return project.id === '5f1a4ef5d5d6b5ab580fc6ae'; + } + ); + + expect(projectCompleted).toBe(true); + }); + + afterAll(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + completedChallenges: [], + progressTimestamps: [] + } + }); + }); + }); describe('/project-completed', () => { describe('validation', () => { it('POST rejects requests without ids', async () => { @@ -444,6 +614,17 @@ describe('challengeRoutes', () => { setCookies = res.get('Set-Cookie'); }); + describe('/coderoad-challenge-completed', () => { + test('POST returns 401 status code with error message', async () => { + const response = await superRequest('/coderoad-challenge-completed', { + method: 'POST', + setCookies + }); + + expect(response?.statusCode).toBe(401); + }); + }); + describe('/project-completed', () => { test('POST returns 401 status code with error message', async () => { const response = await superRequest('/project-completed', { @@ -453,15 +634,15 @@ describe('challengeRoutes', () => { expect(response.statusCode).toBe(401); }); + }); - test('POST /backend-challenge-completed returns 401 status code for un-authenticated-user', async () => { - const response = await superRequest('/backend-challenge-completed', { - method: 'POST', - setCookies - }); - - expect(response.statusCode).toBe(401); + test('POST /backend-challenge-completed returns 401 status code for un-authenticated-user', async () => { + const response = await superRequest('/backend-challenge-completed', { + method: 'POST', + setCookies }); + + expect(response.statusCode).toBe(401); }); }); }); diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index 7bac6121a3b..6601b17da39 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -1,8 +1,11 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; - -import { schemas } from '../schemas'; +import jwt from 'jsonwebtoken'; +import { uniqBy } from 'lodash'; +import { jwtSecret } from '../../../config/secrets'; +import { getChallenges } from '../utils/get-challenges'; import { updateUserChallengeData } from '../utils/common-challenge-functions'; import { formatValidationError } from '../utils/error-formatting'; +import { schemas } from '../schemas'; import { getPoints, ProgressTimestamp } from '../utils/progress'; import { challengeTypes } from '../../../config/challenge-types'; import { @@ -11,6 +14,10 @@ import { updateProject } from './helpers/challenge-helpers'; +interface JwtPayload { + userToken: string; +} + /** * Plugin for the challenge submission endpoints. * @@ -23,10 +30,142 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( _options, done ) => { + const challenges = getChallenges(); + // eslint-disable-next-line @typescript-eslint/unbound-method fastify.addHook('onRequest', fastify.csrfProtection); fastify.addHook('onRequest', fastify.authenticateSession); + fastify.post( + '/coderoad-challenge-completed', + { + schema: schemas.coderoadChallengeCompleted, + attachValidation: true + }, + async (req, reply) => { + let userToken; + + const { 'coderoad-user-token': encodedUserToken } = req.headers; + const { tutorialId } = req.body; + + if (!tutorialId) { + void reply.code(400); + return { + type: 'error', + msg: `'tutorialId' not found in request body` + } as const; + } + + if (!encodedUserToken) { + void reply.code(400); + return { + type: 'error', + msg: `'Coderoad-User-Token' not found in request headers` + } as const; + } + + try { + if (typeof encodedUserToken === 'string' && jwtSecret) { + const payload = jwt.verify(encodedUserToken, jwtSecret) as JwtPayload; + userToken = payload.userToken; + } + } catch { + 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') { + void reply.code(400); + return { + type: 'error', + msg: `Tutorial not hosted on freeCodeCamp GitHub account` + } as const; + } + + const codeRoadChallenges = challenges.filter( + ({ challengeType }) => challengeType === 12 || challengeType === 13 + ); + + const challenge = codeRoadChallenges.find(challenge => { + return tutorialRepo && challenge.url?.endsWith(tutorialRepo); + }); + + if (!challenge) { + 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) { + 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) { + 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 === 13 && !isCompleted) { + const finalChallenge = { + id: challengeId, + completedDate + }; + + await fastify.prisma.user.update({ + where: { id: req.session.user.id }, + data: { + partiallyCompletedChallenges: uniqBy( + [finalChallenge, ...partiallyCompletedChallenges], + 'id' + ) + } + }); + } else { + await updateUserChallengeData(fastify, user, challengeId, { + id: challengeId, + completedDate + }); + } + } catch { + 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', { diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 8b54ec565e5..c15789bf3a8 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -381,6 +381,25 @@ export const schemas = { }) } }, + coderoadChallengeCompleted: { + body: Type.Object({ + tutorialId: Type.String() + }), + response: { + 200: Type.Object({ + type: Type.Literal('success'), + msg: Type.String() + }), + 400: Type.Object({ + type: Type.Literal('error'), + msg: Type.String() + }), + 500: Type.Object({ + type: Type.Literal('danger'), + msg: Type.String() + }) + } + }, backendChallengeCompleted: { body: Type.Object({ id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }) diff --git a/api/src/utils/get-challenges.ts b/api/src/utils/get-challenges.ts index 991726bbe4b..ed690439a63 100644 --- a/api/src/utils/get-challenges.ts +++ b/api/src/utils/get-challenges.ts @@ -13,6 +13,7 @@ interface Block { id: string; tests?: { id?: string }[]; challengeType: number; + url?: string; }[]; }