Skip to content

Commit be73683

Browse files
Merge branch 'freeCodeCamp:main' into main
2 parents f6e9396 + 1f268fc commit be73683

327 files changed

Lines changed: 13168 additions & 1525 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,20 @@
88
"@fastify/accepts": "4.3.0",
99
"@fastify/cookie": "9.4.0",
1010
"@fastify/csrf-protection": "6.4.1",
11+
"@fastify/multipart": "^8.3.0",
1112
"@fastify/oauth2": "7.8.1",
1213
"@fastify/swagger": "8.14.0",
1314
"@fastify/swagger-ui": "1.10.2",
1415
"@fastify/type-provider-typebox": "3.6.0",
1516
"@growthbook/growthbook": "1.3.1",
16-
"@immobiliarelabs/fastify-sentry": "7.1.1",
1717
"@prisma/client": "5.5.2",
18+
"@sentry/node": "9.1.0",
1819
"ajv": "8.12.0",
1920
"ajv-formats": "2.1.1",
2021
"date-fns": "2.30.0",
2122
"dotenv": "16.4.5",
2223
"fast-uri": "2.3.0",
23-
"fastify": "4.26.1",
24+
"fastify": "4.29.0",
2425
"fastify-plugin": "4.5.1",
2526
"joi": "17.12.2",
2627
"jsonwebtoken": "9.0.2",
@@ -44,6 +45,7 @@
4445
"@types/validator": "13.11.2",
4546
"dotenv-cli": "7.3.0",
4647
"jest": "29.7.0",
48+
"msw": "^2.7.0",
4749
"prisma": "5.5.2",
4850
"supertest": "6.3.3",
4951
"ts-jest": "29.1.2",

api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
} from './utils/env';
4848
import { isObjectID } from './utils/validation';
4949
import {
50+
examEnvironmentMultipartRoutes,
5051
examEnvironmentOpenRoutes,
5152
examEnvironmentValidatedTokenRoutes
5253
} from './exam-environment/routes/exam-environment';
@@ -209,6 +210,7 @@ export const build = async (
209210
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
210211

211212
void fastify.register(examEnvironmentValidatedTokenRoutes);
213+
void fastify.register(examEnvironmentMultipartRoutes);
212214
done();
213215
});
214216
void fastify.register(examEnvironmentOpenRoutes);

api/src/exam-environment/routes/exam-environment.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Static } from '@fastify/type-provider-typebox';
22
import jwt from 'jsonwebtoken';
33

44
import {
5+
createFetchMock,
56
createSuperRequest,
67
defaultUserId,
78
devLogin,
@@ -562,7 +563,98 @@ describe('/exam-environment/', () => {
562563
});
563564
});
564565

565-
xdescribe('POST /exam-environment/screenshot', () => {});
566+
describe('POST /exam-environment/screenshot', () => {
567+
afterEach(async () => {
568+
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
569+
});
570+
571+
it('should return 400 if request is not multipart form data', async () => {
572+
const res = await superPost('/exam-environment/screenshot').set(
573+
'exam-environment-authorization-token',
574+
examEnvironmentAuthorizationToken
575+
);
576+
577+
expect(res.status).toBe(400);
578+
expect(res.body).toStrictEqual({
579+
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT',
580+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
581+
message: expect.any(String)
582+
});
583+
});
584+
585+
it('should return 400 if image is missing', async () => {
586+
const res = await superPost('/exam-environment/screenshot')
587+
.set(
588+
'exam-environment-authorization-token',
589+
examEnvironmentAuthorizationToken
590+
)
591+
.attach('file', '');
592+
593+
expect(res.status).toBe(400);
594+
expect(res.body).toStrictEqual({
595+
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT',
596+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
597+
message: expect.any(String)
598+
});
599+
});
600+
601+
it('should return 404 if there is no ongoing exam attempt', async () => {
602+
const res = await superPost('/exam-environment/screenshot')
603+
.set(
604+
'exam-environment-authorization-token',
605+
examEnvironmentAuthorizationToken
606+
)
607+
.attach('file', Buffer.from([]));
608+
609+
expect(res.status).toBe(404);
610+
expect(res.body).toStrictEqual({
611+
code: 'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
612+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
613+
message: expect.any(String)
614+
});
615+
});
616+
617+
it('should return 400 if image is of wrong format', async () => {
618+
await fastifyTestInstance.prisma.envExamAttempt.create({
619+
data: mock.examAttempt
620+
});
621+
622+
const res = await superPost('/exam-environment/screenshot')
623+
.set(
624+
'exam-environment-authorization-token',
625+
examEnvironmentAuthorizationToken
626+
)
627+
.attach('file', Buffer.from([]));
628+
629+
expect(res.status).toBe(400);
630+
expect(res.body).toStrictEqual({
631+
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT',
632+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
633+
message: expect.any(String)
634+
});
635+
});
636+
637+
it('should return 200 if request is valid and send image to screenshot upload service', async () => {
638+
// Mock image upload service response
639+
const imageUploadRes = createFetchMock({ ok: true });
640+
jest.spyOn(globalThis, 'fetch').mockImplementation(imageUploadRes);
641+
642+
await fastifyTestInstance.prisma.envExamAttempt.create({
643+
data: mock.examAttempt
644+
});
645+
646+
const res = await superPost('/exam-environment/screenshot')
647+
.set(
648+
'exam-environment-authorization-token',
649+
examEnvironmentAuthorizationToken
650+
)
651+
.attach('file', Buffer.from([0xff, 0xd8, 0xff, 0xff]));
652+
653+
expect(res.status).toBe(200);
654+
expect(res.body).toStrictEqual({});
655+
expect(globalThis.fetch).toHaveBeenCalled();
656+
});
657+
});
566658

567659
describe('GET /exam-environment/exams', () => {
568660
it('should return 200', async () => {

api/src/exam-environment/routes/exam-environment.ts

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
22
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
3+
import fastifyMultipart from '@fastify/multipart';
34
import { PrismaClientValidationError } from '@prisma/client/runtime/library';
45
import { type FastifyInstance, type FastifyReply } from 'fastify';
56
import jwt from 'jsonwebtoken';
67

78
import * as schemas from '../schemas';
89
import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
9-
import { JWT_SECRET } from '../../utils/env';
10+
import { JWT_SECRET, SCREENSHOT_SERVICE_LOCATION } from '../../utils/env';
1011
import {
1112
checkPrerequisites,
1213
constructUserExam,
@@ -44,16 +45,32 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
4445
},
4546
postExamAttemptHandler
4647
);
47-
fastify.post(
48-
'/exam-environment/screenshot',
49-
{
50-
schema: schemas.examEnvironmentPostScreenshot
51-
},
52-
postScreenshotHandler
53-
);
5448
done();
5549
};
5650

51+
/**
52+
* Wrapper for endpoints related to the exam environment desktop app.
53+
*
54+
* Requires multipart form data to be supported.
55+
*/
56+
export const examEnvironmentMultipartRoutes: FastifyPluginCallbackTypebox = (
57+
fastify,
58+
_options,
59+
done
60+
) => {
61+
void fastify.register(fastifyMultipart);
62+
63+
fastify.post(
64+
'/exam-environment/screenshot',
65+
{
66+
schema: schemas.examEnvironmentPostScreenshot
67+
// bodyLimit: 1024 * 1024 * 5 // 5MiB
68+
},
69+
postScreenshotHandler
70+
);
71+
done();
72+
};
73+
5774
/**
5875
* Wrapper for endpoints related to the exam environment desktop app.
5976
*
@@ -544,10 +561,80 @@ async function postExamAttemptHandler(
544561
*/
545562
async function postScreenshotHandler(
546563
this: FastifyInstance,
547-
_req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
564+
req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
548565
reply: FastifyReply
549566
) {
550-
return reply.code(418);
567+
const isMultipart = req.isMultipart();
568+
569+
if (!isMultipart) {
570+
void reply.code(400);
571+
return reply.send(
572+
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT(
573+
'Request is not multipart form data.'
574+
)
575+
);
576+
}
577+
578+
const user = req.user!;
579+
const imgData = await req.file();
580+
581+
if (!imgData) {
582+
void reply.code(400);
583+
return reply.send(
584+
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT('No image provided.')
585+
);
586+
}
587+
588+
const maybeAttempt = await mapErr(
589+
this.prisma.envExamAttempt.findMany({
590+
where: {
591+
userId: user.id
592+
}
593+
})
594+
);
595+
596+
if (maybeAttempt.hasError) {
597+
void reply.code(500);
598+
return reply.send(
599+
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempt.error))
600+
);
601+
}
602+
603+
const attempt = maybeAttempt.data;
604+
605+
if (attempt.length === 0) {
606+
void reply.code(404);
607+
return reply.send(
608+
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
609+
`No exam attempts found for user '${user.id}'.`
610+
)
611+
);
612+
}
613+
614+
const imgBinary = await imgData.toBuffer();
615+
616+
// Verify image is JPG using magic number
617+
if (imgBinary[0] !== 0xff || imgBinary[1] !== 0xd8 || imgBinary[2] !== 0xff) {
618+
void reply.code(400);
619+
return reply.send(
620+
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT('Invalid image format.')
621+
);
622+
}
623+
624+
void reply.code(200).send();
625+
626+
const uploadData = {
627+
image: imgBinary.toString('base64'),
628+
examAttemptId: attempt[0]?.id
629+
};
630+
631+
await fetch(`${SCREENSHOT_SERVICE_LOCATION}/upload`, {
632+
method: 'POST',
633+
headers: {
634+
'Content-Type': 'application/json'
635+
},
636+
body: JSON.stringify(uploadData)
637+
});
551638
}
552639

553640
async function getExams(
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
// import { Type } from '@fastify/type-provider-typebox';
1+
import { Type } from '@fastify/type-provider-typebox';
2+
import { STANDARD_ERROR } from '../utils/errors';
23

34
export const examEnvironmentPostScreenshot = {
5+
headers: Type.Object({
6+
'exam-environment-authorization-token': Type.String()
7+
}),
48
response: {
5-
// 200: Type.Object({})
9+
400: STANDARD_ERROR,
10+
500: STANDARD_ERROR
611
}
712
};

api/src/exam-environment/utils/errors.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ export const ERRORS = {
3535
'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
3636
'%s'
3737
),
38-
FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s')
38+
FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s'),
39+
FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT: createError(
40+
'FCC_EINVAL_EXAM_ENVIRONMENT_SCREENSHOT',
41+
'%s'
42+
)
3943
};
4044

4145
/**

api/src/exam-environment/utils/exam.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -428,26 +428,24 @@ export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
428428
const questionSet = shuffledQuestionSets.find(qs => {
429429
if (qs.type === questionSetConfig.type) {
430430
if (qs.questions.length >= questionSetConfig.numberOfQuestions) {
431-
if (qs.questions.length >= questionSetConfig.numberOfQuestions) {
432-
// Find questionSetConfig.numberOfQuestions who have `questionSetConfig.numberOfCorrectAnswers` and `questionSetConfig.numberOfIncorrectAnswers`
433-
const questions = qs.questions.filter(q => {
434-
const numberOfCorrectAnswers = q.answers.filter(
435-
a => a.isCorrect
436-
).length;
437-
const numberOfIncorrectAnswers = q.answers.filter(
438-
a => !a.isCorrect
439-
).length;
440-
return (
441-
numberOfCorrectAnswers >=
442-
questionSetConfig.numberOfCorrectAnswers &&
443-
numberOfIncorrectAnswers >=
444-
questionSetConfig.numberOfIncorrectAnswers
445-
);
446-
});
447-
448-
if (questions.length >= questionSetConfig.numberOfQuestions) {
449-
return true;
450-
}
431+
// Find questionSetConfig.numberOfQuestions who have `questionSetConfig.numberOfCorrectAnswers` and `questionSetConfig.numberOfIncorrectAnswers`
432+
const questions = qs.questions.filter(q => {
433+
const numberOfCorrectAnswers = q.answers.filter(
434+
a => a.isCorrect
435+
).length;
436+
const numberOfIncorrectAnswers = q.answers.filter(
437+
a => !a.isCorrect
438+
).length;
439+
return (
440+
numberOfCorrectAnswers >=
441+
questionSetConfig.numberOfCorrectAnswers &&
442+
numberOfIncorrectAnswers >=
443+
questionSetConfig.numberOfIncorrectAnswers
444+
);
445+
});
446+
447+
if (questions.length >= questionSetConfig.numberOfQuestions) {
448+
return true;
451449
}
452450
}
453451
}

api/src/instrument.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/node';
2+
import type { FastifyError } from 'fastify';
3+
4+
import { SENTRY_DSN, SENTRY_ENVIRONMENT } from './utils/env';
5+
6+
const shouldIgnoreError = (error: FastifyError): boolean => {
7+
return !!error.statusCode && error.statusCode < 500;
8+
};
9+
10+
// Ensure to call this before importing any other modules!
11+
Sentry.init({
12+
dsn: SENTRY_DSN,
13+
environment: SENTRY_ENVIRONMENT,
14+
maxValueLength: 8192, // the default is 250, which is too small.
15+
beforeSend: (event, hint) =>
16+
shouldIgnoreError(hint.originalException as FastifyError) ? null : event
17+
});

0 commit comments

Comments
 (0)