mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
fix(api): remove authn requirement for coderoad challenges (#60425)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<typeof schemas.coderoadChallengeCompleted>,
|
||||
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<typeof schemas.dailyCodingChallengeCompleted>,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user