From 94c2d812b419bacb648a5def6ec04861d1fac5fe Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 15 Dec 2025 18:04:53 +0100 Subject: [PATCH] feat(api): reject exam submissions (#64607) --- api/src/routes/protected/challenge.test.ts | 106 ++++++++++++++++++ api/src/routes/protected/challenge.ts | 67 ++++++++++- .../challenge/backend-challenge-completed.ts | 6 + .../daily-coding-challenge-completed.ts | 16 ++- .../challenge/modern-challenge-completed.ts | 6 + .../schemas/challenge/project-completed.ts | 3 +- api/src/utils/get-challenges.ts | 39 +++++-- api/src/utils/index.ts | 13 +++ .../6721db5d9f0c116e6a0fe25a.md | 1 + .../68e6bfa120effa1586e7985a.md | 1 + .../645147516c245de4d11eb7ba.md | 1 + .../68e00b355f80c6099d47b3a3.md | 1 + .../68c462d7dc707f3ca82f8e6d.md | 1 + .../68e6bf0320effa1586e79858.md | 1 + .../68e6bf3f20effa1586e79859.md | 1 + .../68db37350b398ecddd1f5dac.md | 1 + curriculum/schema/challenge-schema.js | 1 + 17 files changed, 246 insertions(+), 19 deletions(-) diff --git a/api/src/routes/protected/challenge.test.ts b/api/src/routes/protected/challenge.test.ts index 2f30bd0480a..fd3fd869b2d 100644 --- a/api/src/routes/protected/challenge.test.ts +++ b/api/src/routes/protected/challenge.test.ts @@ -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', diff --git a/api/src/routes/protected/challenge.ts b/api/src/routes/protected/challenge.ts index 9d43ceba97f..89ceee2caeb 100644 --- a/api/src/routes/protected/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -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, - reply: FastifyReply + reply: UpdateReplyType ) { 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, - reply: FastifyReply + reply: UpdateReplyType ) { 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: { diff --git a/api/src/schemas/challenge/backend-challenge-completed.ts b/api/src/schemas/challenge/backend-challenge-completed.ts index a1ddba3af46..b433a28ae4a 100644 --- a/api/src/schemas/challenge/backend-challenge-completed.ts +++ b/api/src/schemas/challenge/backend-challenge-completed.ts @@ -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 } }; diff --git a/api/src/schemas/challenge/daily-coding-challenge-completed.ts b/api/src/schemas/challenge/daily-coding-challenge-completed.ts index 6d6ef9bbe98..4ebf79b4e9a 100644 --- a/api/src/schemas/challenge/daily-coding-challenge-completed.ts +++ b/api/src/schemas/challenge/daily-coding-challenge-completed.ts @@ -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.' + ) }) } }; diff --git a/api/src/schemas/challenge/modern-challenge-completed.ts b/api/src/schemas/challenge/modern-challenge-completed.ts index d7c7cdb292f..d7f8426a635 100644 --- a/api/src/schemas/challenge/modern-challenge-completed.ts +++ b/api/src/schemas/challenge/modern-challenge-completed.ts @@ -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 } }; diff --git a/api/src/schemas/challenge/project-completed.ts b/api/src/schemas/challenge/project-completed.ts index 1a099b76a53..f5cecff0b37 100644 --- a/api/src/schemas/challenge/project-completed.ts +++ b/api/src/schemas/challenge/project-completed.ts @@ -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 diff --git a/api/src/utils/get-challenges.ts b/api/src/utils/get-challenges.ts index fe241bc3dc0..7a5b9452b34 100644 --- a/api/src/utils/get-challenges.ts +++ b/api/src/utils/get-challenges.ts @@ -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; * 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()); + +const examChallenges = challenges.reduce((acc, curr) => { + if (curr.isExam) { + acc.add(curr.id); + } + + return acc; +}, new Set()); + +/** + * 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); diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index d34ca2c3d13..bb51742f0e3 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -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 = FastifyRequest< TypeBoxTypeProvider >; +export type UpdateReplyType = FastifyReply< + RouteGenericInterface, + RawServerDefault, + RawRequestDefaultExpression, + RawReplyDefaultExpression, + 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. diff --git a/curriculum/challenges/english/blocks/en-a2-certification-exam/6721db5d9f0c116e6a0fe25a.md b/curriculum/challenges/english/blocks/en-a2-certification-exam/6721db5d9f0c116e6a0fe25a.md index c4d916199c7..f020d5abb7c 100644 --- a/curriculum/challenges/english/blocks/en-a2-certification-exam/6721db5d9f0c116e6a0fe25a.md +++ b/curriculum/challenges/english/blocks/en-a2-certification-exam/6721db5d9f0c116e6a0fe25a.md @@ -2,6 +2,7 @@ id: 6721db5d9f0c116e6a0fe25a title: A2 English for Developers Certification Exam challengeType: 30 +isExam: true dashedName: en-a2-certification-exam prerequisites: [] lang: en-US diff --git a/curriculum/challenges/english/blocks/exam-back-end-development-and-apis-certification/68e6bfa120effa1586e7985a.md b/curriculum/challenges/english/blocks/exam-back-end-development-and-apis-certification/68e6bfa120effa1586e7985a.md index d78a0bde45b..cb9a1361f7d 100644 --- a/curriculum/challenges/english/blocks/exam-back-end-development-and-apis-certification/68e6bfa120effa1586e7985a.md +++ b/curriculum/challenges/english/blocks/exam-back-end-development-and-apis-certification/68e6bfa120effa1586e7985a.md @@ -2,6 +2,7 @@ id: 68e6bfa120effa1586e7985a title: Back End Development and APIs Certification Exam challengeType: 30 +isExam: true dashedName: exam-back-end-development-and-apis-certification --- diff --git a/curriculum/challenges/english/blocks/exam-certified-full-stack-developer/645147516c245de4d11eb7ba.md b/curriculum/challenges/english/blocks/exam-certified-full-stack-developer/645147516c245de4d11eb7ba.md index 6e81de3bf93..84f37621300 100644 --- a/curriculum/challenges/english/blocks/exam-certified-full-stack-developer/645147516c245de4d11eb7ba.md +++ b/curriculum/challenges/english/blocks/exam-certified-full-stack-developer/645147516c245de4d11eb7ba.md @@ -2,6 +2,7 @@ id: 645147516c245de4d11eb7ba title: Certified Full Stack Developer Exam challengeType: 30 +isExam: true dashedName: exam-certified-full-stack-developer --- diff --git a/curriculum/challenges/english/blocks/exam-front-end-development-libraries-certification/68e00b355f80c6099d47b3a3.md b/curriculum/challenges/english/blocks/exam-front-end-development-libraries-certification/68e00b355f80c6099d47b3a3.md index 358e7726c6b..0379e2b298a 100644 --- a/curriculum/challenges/english/blocks/exam-front-end-development-libraries-certification/68e00b355f80c6099d47b3a3.md +++ b/curriculum/challenges/english/blocks/exam-front-end-development-libraries-certification/68e00b355f80c6099d47b3a3.md @@ -2,6 +2,7 @@ id: 68e00b355f80c6099d47b3a3 title: Front End Development Libraries Certification Exam challengeType: 30 +isExam: true dashedName: exam-front-end-development-libraries-certification --- diff --git a/curriculum/challenges/english/blocks/exam-javascript-certification/68c462d7dc707f3ca82f8e6d.md b/curriculum/challenges/english/blocks/exam-javascript-certification/68c462d7dc707f3ca82f8e6d.md index 91e791c1d32..23c7af43504 100644 --- a/curriculum/challenges/english/blocks/exam-javascript-certification/68c462d7dc707f3ca82f8e6d.md +++ b/curriculum/challenges/english/blocks/exam-javascript-certification/68c462d7dc707f3ca82f8e6d.md @@ -2,6 +2,7 @@ id: 68c462d7dc707f3ca82f8e6d title: JavaScript Certification Exam challengeType: 30 +isExam: true dashedName: exam-javascript-certification --- diff --git a/curriculum/challenges/english/blocks/exam-python-certification/68e6bf0320effa1586e79858.md b/curriculum/challenges/english/blocks/exam-python-certification/68e6bf0320effa1586e79858.md index 52cd62f43a5..15a5e5568a7 100644 --- a/curriculum/challenges/english/blocks/exam-python-certification/68e6bf0320effa1586e79858.md +++ b/curriculum/challenges/english/blocks/exam-python-certification/68e6bf0320effa1586e79858.md @@ -2,6 +2,7 @@ id: 68e6bf0320effa1586e79858 title: Python Certification Exam challengeType: 30 +isExam: true dashedName: exam-python-certification --- diff --git a/curriculum/challenges/english/blocks/exam-relational-databases-certification/68e6bf3f20effa1586e79859.md b/curriculum/challenges/english/blocks/exam-relational-databases-certification/68e6bf3f20effa1586e79859.md index 623531fb0ef..cb7ee968a7e 100644 --- a/curriculum/challenges/english/blocks/exam-relational-databases-certification/68e6bf3f20effa1586e79859.md +++ b/curriculum/challenges/english/blocks/exam-relational-databases-certification/68e6bf3f20effa1586e79859.md @@ -2,6 +2,7 @@ id: 68e6bf3f20effa1586e79859 title: Relational Databases Certification Exam challengeType: 30 +isExam: true dashedName: exam-relational-databases-certification --- diff --git a/curriculum/challenges/english/blocks/exam-responsive-web-design-certification/68db37350b398ecddd1f5dac.md b/curriculum/challenges/english/blocks/exam-responsive-web-design-certification/68db37350b398ecddd1f5dac.md index 78157b567bc..0221e23e44c 100644 --- a/curriculum/challenges/english/blocks/exam-responsive-web-design-certification/68db37350b398ecddd1f5dac.md +++ b/curriculum/challenges/english/blocks/exam-responsive-web-design-certification/68db37350b398ecddd1f5dac.md @@ -2,6 +2,7 @@ id: 68db37350b398ecddd1f5dac title: Responsive Web Design Certification Exam challengeType: 30 +isExam: true dashedName: exam-responsive-web-design-certification --- diff --git a/curriculum/schema/challenge-schema.js b/curriculum/schema/challenge-schema.js index 1c496b8f95d..101f4c46363 100644 --- a/curriculum/schema/challenge-schema.js +++ b/curriculum/schema/challenge-schema.js @@ -164,6 +164,7 @@ const schema = Joi.object().keys({ otherwise: Joi.optional() }), certification: Joi.string().regex(slugWithSlashRE), + isExam: Joi.boolean(), challengeType: Joi.number().min(0).max(31).required(), // TODO: require this only for normal challenges, not certs dashedName: Joi.string().regex(slugRE),