feat(api): reject exam submissions (#64607)

This commit is contained in:
Oliver Eyton-Williams
2025-12-15 18:04:53 +01:00
committed by GitHub
parent c06d35c95e
commit 94c2d812b4
17 changed files with 246 additions and 19 deletions
+106
View File
@@ -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',
+63 -4
View File
@@ -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
+29 -10
View File
@@ -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);
+13
View File
@@ -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.