feat: coderoad endpoint (#50636)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
Co-authored-by: Naomi Carrigan <nhcarrigan@gmail.com>
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
Sem Bauke
2023-08-24 21:23:04 +05:30
committed by GitHub
co-authored by Oliver Eyton-Williams Tom Naomi Carrigan Mrugesh Mohapatra
parent d435706822
commit d31e910f3a
4 changed files with 349 additions and 9 deletions
+188 -7
View File
@@ -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);
});
});
});
+141 -2
View File
@@ -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',
{
+19
View File
@@ -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 })
+1
View File
@@ -13,6 +13,7 @@ interface Block {
id: string;
tests?: { id?: string }[];
challengeType: number;
url?: string;
}[];
}