mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): reject exam submissions (#64607)
This commit is contained in:
committed by
GitHub
parent
c06d35c95e
commit
94c2d812b4
@@ -222,6 +222,8 @@ const dailyCodingChallengeBody = {
|
||||
language: DailyCodingChallengeLanguage.javascript
|
||||
};
|
||||
|
||||
const examId = '6721db5d9f0c116e6a0fe25a';
|
||||
|
||||
describe('challengeRoutes', () => {
|
||||
setupServer();
|
||||
describe('Authenticated user', () => {
|
||||
@@ -410,6 +412,21 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
describe('/project-completed', () => {
|
||||
describe('validation', () => {
|
||||
test('should reject exam submissions', async () => {
|
||||
const response = await superPost('/project-completed').send({
|
||||
id: examId,
|
||||
challengeType: challengeTypes.backEndProject,
|
||||
solution: 'http://localhost:3000',
|
||||
githubLink: 'http://localhost:3000'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('POST rejects requests without ids', async () => {
|
||||
const response = await superPost('/project-completed').send({});
|
||||
|
||||
@@ -674,6 +691,23 @@ describe('challengeRoutes', () => {
|
||||
|
||||
describe('/backend-challenge-completed', () => {
|
||||
describe('validation', () => {
|
||||
test('should reject exam submissions', async () => {
|
||||
const response = await superPost('/backend-challenge-completed').send(
|
||||
{
|
||||
id: examId,
|
||||
challengeType: challengeTypes.backEndProject,
|
||||
solution: 'http://localhost:3000',
|
||||
githubLink: 'http://localhost:3000'
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('POST rejects requests without ids', async () => {
|
||||
const response = await superPost('/backend-challenge-completed');
|
||||
|
||||
@@ -790,6 +824,21 @@ describe('challengeRoutes', () => {
|
||||
|
||||
describe('/modern-challenge-completed', () => {
|
||||
describe('validation', () => {
|
||||
test('should reject exam submissions', async () => {
|
||||
const response = await superPost('/modern-challenge-completed').send({
|
||||
id: examId,
|
||||
challengeType: challengeTypes.backEndProject,
|
||||
solution: 'http://localhost:3000',
|
||||
githubLink: 'http://localhost:3000'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('POST rejects requests without ids', async () => {
|
||||
const response = await superPost('/modern-challenge-completed');
|
||||
|
||||
@@ -1062,6 +1111,23 @@ describe('challengeRoutes', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
test('should reject exam submissions', async () => {
|
||||
const response = await superPost(
|
||||
'/encoded/modern-challenge-completed'
|
||||
).send({
|
||||
id: examId,
|
||||
challengeType: challengeTypes.backEndProject,
|
||||
solution: 'http://localhost:3000',
|
||||
githubLink: 'http://localhost:3000'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
// JS Project(5), Multi-file Cert Project(14)
|
||||
test('POST accepts challenges with files present', async () => {
|
||||
const now = Date.now();
|
||||
@@ -1240,6 +1306,21 @@ describe('challengeRoutes', () => {
|
||||
|
||||
describe('/daily-coding-challenge-completed', () => {
|
||||
describe('validation', () => {
|
||||
test('should reject exam submissions', async () => {
|
||||
const response = await superPost(
|
||||
'/daily-coding-challenge-completed'
|
||||
).send({
|
||||
id: examId,
|
||||
language: 'javascript'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('POST rejects requests without an id', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, ...noIdReqBody } = dailyCodingChallengeBody;
|
||||
@@ -1904,6 +1985,31 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test('should reject exam submissions', async () => {
|
||||
const response = await superPost('/exam-challenge-completed').send({
|
||||
id: examId,
|
||||
challengeType: 17,
|
||||
userCompletedExam: {
|
||||
examTimeInSeconds: 111,
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: 'q-id',
|
||||
question: '?',
|
||||
answer: {
|
||||
id: 'a-id',
|
||||
answer: 'a'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
test('POST rejects requests with no body', async () => {
|
||||
const response = await superRequest('/exam-challenge-completed', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
formatCoderoadChallengeCompletedValidation,
|
||||
formatProjectCompletedValidation
|
||||
} from '../../utils/error-formatting.js';
|
||||
import { challenges, savableChallenges } from '../../utils/get-challenges.js';
|
||||
import {
|
||||
challenges,
|
||||
savableChallenges,
|
||||
isExamId
|
||||
} from '../../utils/get-challenges.js';
|
||||
import { ProgressTimestamp, getPoints } from '../../utils/progress.js';
|
||||
import {
|
||||
validateExamFromDbSchema,
|
||||
@@ -36,7 +40,7 @@ import {
|
||||
decodeFiles,
|
||||
verifyTrophyWithMicrosoft
|
||||
} from '../helpers/challenge-helpers.js';
|
||||
import { UpdateReqType } from '../../utils/index.js';
|
||||
import { UpdateReplyType, UpdateReqType } from '../../utils/index.js';
|
||||
import {
|
||||
normalizeChallengeType,
|
||||
normalizeDate
|
||||
@@ -92,6 +96,15 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
const { id: projectId, challengeType, solution, githubLink } = req.body;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (isExamId(req.body.id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
}
|
||||
|
||||
// If `backEndProject`:
|
||||
// - `solution` needs to exist, but does not have to be valid URL
|
||||
// - `githubLink` needs to exist and be valid URL
|
||||
@@ -183,6 +196,15 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
`User submitted a backend challenge`
|
||||
);
|
||||
|
||||
if (isExamId(req.body.id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id },
|
||||
|
||||
@@ -240,6 +262,16 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
const { id, files, challengeType } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
}
|
||||
|
||||
return await postModernChallengeCompleted(fastify, {
|
||||
id,
|
||||
files,
|
||||
@@ -276,6 +308,16 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
);
|
||||
|
||||
const { id, files: encodedFiles, challengeType } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
}
|
||||
|
||||
const files = encodedFiles ? decodeFiles(encodedFiles) : undefined;
|
||||
return await postModernChallengeCompleted(fastify, {
|
||||
id,
|
||||
@@ -597,6 +639,14 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
const userId = req.user?.id;
|
||||
const { userCompletedExam, id, challengeType } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
error: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
}
|
||||
|
||||
const { completedChallenges, completedExams, progressTimestamps } =
|
||||
await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
@@ -885,7 +935,7 @@ export const challengeTokenRoutes: FastifyPluginCallbackTypebox = (
|
||||
async function postCoderoadChallengeCompleted(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.coderoadChallengeCompleted>,
|
||||
reply: FastifyReply
|
||||
reply: UpdateReplyType<typeof schemas.coderoadChallengeCompleted>
|
||||
) {
|
||||
const logger = this.log.child({ req, res: reply });
|
||||
logger.info({ userId: req.user?.id }, 'User submitted a coderoad challenge');
|
||||
@@ -1009,13 +1059,22 @@ async function postCoderoadChallengeCompleted(
|
||||
async function postDailyCodingChallengeCompleted(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.dailyCodingChallengeCompleted>,
|
||||
reply: FastifyReply
|
||||
reply: UpdateReplyType<typeof schemas.dailyCodingChallengeCompleted>
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
logger.info(`User ${req.user?.id} submitted a daily coding challenge`);
|
||||
|
||||
const { id, language } = req.body;
|
||||
|
||||
if (isExamId(id)) {
|
||||
logger.warn('User attempted to submit an exam');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
type: 'error',
|
||||
message: 'Exam submissions are not allowed on this endpoint.'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id },
|
||||
select: {
|
||||
|
||||
@@ -17,6 +17,12 @@ export const backendChallengeCompleted = {
|
||||
'That does not appear to be a valid challenge submission.'
|
||||
)
|
||||
}),
|
||||
403: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal(
|
||||
'Exam submissions are not allowed on this endpoint.'
|
||||
)
|
||||
}),
|
||||
default: genericError
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { DailyCodingChallengeLanguage } from '@prisma/client';
|
||||
|
||||
const languages = Object.values(DailyCodingChallengeLanguage).map(k =>
|
||||
Type.Literal(k)
|
||||
);
|
||||
// This has to be declared as a tuple, because Type.Union expects a
|
||||
// tuple of types, not an array of unions of said types.
|
||||
const languages: [Type.TLiteral<'javascript'>, Type.TLiteral<'python'>] = [
|
||||
Type.Literal('javascript'),
|
||||
Type.Literal('python')
|
||||
];
|
||||
|
||||
export const dailyCodingChallengeCompleted = {
|
||||
body: Type.Object({
|
||||
@@ -28,6 +30,12 @@ export const dailyCodingChallengeCompleted = {
|
||||
message: Type.Literal(
|
||||
'That does not appear to be a valid challenge submission.'
|
||||
)
|
||||
}),
|
||||
403: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal(
|
||||
'Exam submissions are not allowed on this endpoint.'
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,6 +30,12 @@ export const modernChallengeCompleted = {
|
||||
'That does not appear to be a valid challenge submission.'
|
||||
)
|
||||
}),
|
||||
403: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal(
|
||||
'Exam submissions are not allowed on this endpoint.'
|
||||
)
|
||||
}),
|
||||
default: genericError
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,7 +37,8 @@ export const projectCompleted = {
|
||||
),
|
||||
Type.Literal(
|
||||
'That does not appear to be a valid challenge submission.'
|
||||
)
|
||||
),
|
||||
Type.Literal('Exam submissions are not allowed on this endpoint.')
|
||||
])
|
||||
}),
|
||||
genericError
|
||||
|
||||
@@ -14,15 +14,18 @@ const curriculum = JSON.parse(
|
||||
readFileSync(join(__dirname, CURRICULUM_PATH), 'utf-8')
|
||||
) as Curriculum;
|
||||
|
||||
interface Challenge {
|
||||
id: string;
|
||||
tests?: { id?: string }[];
|
||||
challengeType: number;
|
||||
url?: string;
|
||||
msTrophyId?: string;
|
||||
saveSubmissionToDB?: boolean;
|
||||
isExam?: boolean;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
challenges: {
|
||||
id: string;
|
||||
tests?: { id?: string }[];
|
||||
challengeType: number;
|
||||
url?: string;
|
||||
msTrophyId?: string;
|
||||
saveSubmissionToDB?: boolean;
|
||||
}[];
|
||||
challenges: Challenge[];
|
||||
}
|
||||
|
||||
type SuperBlock = {
|
||||
@@ -35,12 +38,12 @@ type Curriculum = Record<string, SuperBlock>;
|
||||
* Get all challenges including all certifications as "challenges" (ids and tests).
|
||||
* @returns The whole curricula reduced to an array.
|
||||
*/
|
||||
export function getChallenges(): Block['challenges'] {
|
||||
export function getChallenges(): Challenge[] {
|
||||
const curricula = Object.values(curriculum);
|
||||
|
||||
return curricula
|
||||
.map(v => v.blocks)
|
||||
.reduce((acc: Block['challenges'], superBlock) => {
|
||||
.reduce((acc: Challenge[], superBlock) => {
|
||||
const blockKeys = Object.keys(superBlock);
|
||||
const challengesForBlock = blockKeys.map(k => {
|
||||
const block = superBlock[k];
|
||||
@@ -62,3 +65,19 @@ export const savableChallenges = challenges.reduce((acc, curr) => {
|
||||
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
|
||||
const examChallenges = challenges.reduce((acc, curr) => {
|
||||
if (curr.isExam) {
|
||||
acc.add(curr.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
|
||||
/**
|
||||
* Checks if a challenge id is an exam challenge.
|
||||
*
|
||||
* @param id The challenge id to check.
|
||||
* @returns A boolean indicating if the challenge id is an exam challenge.
|
||||
*/
|
||||
export const isExamId = (id: string): boolean => examChallenges.has(id);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { type TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
ContextConfigDefault,
|
||||
FastifyReply,
|
||||
RawReplyDefaultExpression,
|
||||
type FastifyRequest,
|
||||
type FastifySchema,
|
||||
type RawRequestDefaultExpression,
|
||||
@@ -36,6 +39,16 @@ export type UpdateReqType<Schema extends FastifySchema> = FastifyRequest<
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
export type UpdateReplyType<Schema extends FastifySchema> = FastifyReply<
|
||||
RouteGenericInterface,
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression<RawServerDefault>,
|
||||
RawReplyDefaultExpression<RawServerDefault>,
|
||||
ContextConfigDefault,
|
||||
Schema,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Wrapper around a promise to catch errors and return them as part of the promise.
|
||||
|
||||
Reference in New Issue
Block a user