Skip to content

Commit d47b2b9

Browse files
Merge pull request #720 from freeCodeCamp/main
Create a new pull request by comparing changes across two branches
2 parents 925b8dd + ddfe978 commit d47b2b9

File tree

690 files changed

+50775
-3765
lines changed

Some content is hidden

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

690 files changed

+50775
-3765
lines changed

api/__mocks__/env-exam.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,8 @@ export const examAttemptSansSubmissionTimeInMS: Static<
338338
export const exam: EnvExam = {
339339
id: examId,
340340
config,
341-
questionSets
341+
questionSets,
342+
prerequisites: ['67112fe1c994faa2c26d0b1d']
342343
};
343344

344345
export async function seedEnvExam() {

api/jest.utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ export const defaultUserEmail = '[email protected]';
207207
export const defaultUsername = 'fcc-test-user';
208208

209209
export const resetDefaultUser = async (): Promise<void> => {
210+
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.deleteMany(
211+
{
212+
where: { userId: defaultUserId }
213+
}
214+
);
210215
await fastifyTestInstance.prisma.user.deleteMany({
211216
where: { email: defaultUserEmail }
212217
});

api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"joi": "17.12.2",
2626
"jsonwebtoken": "9.0.2",
2727
"lodash": "4.17.21",
28-
"mongodb": "4.17.2",
28+
"mongodb": "6.10.0",
2929
"nanoid": "3",
3030
"no-profanity": "1.5.1",
3131
"nodemailer": "6.9.10",

api/prisma/schema.prisma

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,19 +143,22 @@ model user {
143143
isClassroomAccount Boolean? // Undefined
144144
145145
// Relations
146-
examAttempts EnvExamAttempt[]
146+
examAttempts EnvExamAttempt[]
147+
examEnvironmentAuthorizationToken ExamEnvironmentAuthorizationToken?
147148
}
148149

149150
// -----------------------------------
150151

151152
/// An exam for the Exam Environment App as designed by the examiners
152153
model EnvExam {
153154
/// Globally unique exam id
154-
id String @id @default(auto()) @map("_id") @db.ObjectId
155+
id String @id @default(auto()) @map("_id") @db.ObjectId
155156
/// All questions for a given exam
156-
questionSets EnvQuestionSet[]
157+
questionSets EnvQuestionSet[]
157158
/// Configuration for exam metadata
158-
config EnvConfig
159+
config EnvConfig
160+
/// ObjectIds for required challenges/blocks to take the exam
161+
prerequisites String[] @db.ObjectId
159162
160163
// Relations
161164
generatedExams EnvGeneratedExam[]
@@ -375,15 +378,13 @@ model UserToken {
375378
@@index([userId], map: "userId_1")
376379
}
377380

378-
/// TODO: Token has to outlive the exam attempt
379-
/// Validation has to be taken as the attempt is requested
380-
/// to ensure it lives long enough.
381381
model ExamEnvironmentAuthorizationToken {
382382
id String @id @map("_id")
383383
createdDate DateTime @db.Date
384-
userId String @db.ObjectId
384+
userId String @unique @db.ObjectId
385385
386-
@@index([userId], map: "userId_1")
386+
// Relations
387+
user user @relation(fields: [userId], references: [id])
387388
}
388389

389390
model sessions {

api/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export const build = async (
106106
if (FCC_ENABLE_SWAGGER_UI) {
107107
void fastify.register(fastifySwagger, {
108108
openapi: {
109+
openapi: '3.1.0',
109110
info: {
110111
title: 'freeCodeCamp API',
111112
version: '1.0.0' // API version

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,15 @@ describe('/exam-environment/', () => {
240240
describe('POST /exam-environment/generated-exam', () => {
241241
afterEach(async () => {
242242
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
243+
// Add prerequisite id to user completed challenge
244+
await fastifyTestInstance.prisma.user.update({
245+
where: { id: defaultUserId },
246+
data: {
247+
completedChallenges: [
248+
{ id: mock.exam.prerequisites.at(0)!, completedDate: Date.now() }
249+
]
250+
}
251+
});
243252
await mock.seedEnvExam();
244253
});
245254

@@ -262,8 +271,30 @@ describe('/exam-environment/', () => {
262271
expect(res.status).toBe(404);
263272
});
264273

265-
xit('should return an error if the exam prerequisites are not met', async () => {
266-
// TODO: Waiting on prerequisites
274+
it('should return an error if the exam prerequisites are not met', async () => {
275+
await fastifyTestInstance.prisma.user.update({
276+
where: { id: defaultUserId },
277+
data: {
278+
completedChallenges: []
279+
}
280+
});
281+
282+
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
283+
examId: mock.exam.id
284+
};
285+
const res = await superPost('/exam-environment/exam/generated-exam')
286+
.send(body)
287+
.set(
288+
'exam-environment-authorization-token',
289+
examEnvironmentAuthorizationToken
290+
);
291+
292+
expect(res.body).toStrictEqual({
293+
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES',
294+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
295+
message: expect.any(String)
296+
});
297+
expect(res.status).toBe(403);
267298
});
268299

269300
it('should return an error if the exam has been attempted in the last 24 hours', async () => {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ async function tokenVerifyHandler(
107107
const examEnvironmentAuthorizationToken =
108108
payload.examEnvironmentAuthorizationToken;
109109

110-
const token = await this.prisma.examEnvironmentAuthorizationToken.findFirst({
110+
const token = await this.prisma.examEnvironmentAuthorizationToken.findUnique({
111111
where: {
112112
id: examEnvironmentAuthorizationToken
113113
}
@@ -170,7 +170,7 @@ async function postExamGeneratedExamHandler(
170170

171171
// Check user has completed prerequisites
172172
const user = req.user!;
173-
const isExamPrerequisitesMet = checkPrerequisites(user, true);
173+
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
174174

175175
if (!isExamPrerequisitesMet) {
176176
void reply.code(403);
@@ -582,12 +582,13 @@ async function getExams(
582582
const exams = await this.prisma.envExam.findMany({
583583
select: {
584584
id: true,
585-
config: true
585+
config: true,
586+
prerequisites: true
586587
}
587588
});
588589

589590
const availableExams = exams.map(exam => {
590-
const isExamPrerequisitesMet = checkPrerequisites(user, true);
591+
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
591592

592593
return {
593594
id: exam.id,

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { exam, examAttempt, generatedExam } from '../../../__mocks__/env-exam';
33
import * as schemas from '../schemas';
44
import {
55
checkAttemptAgainstGeneratedExam,
6+
checkPrerequisites,
67
constructUserExam,
78
generateExam,
89
userAttemptToDatabaseAttemptQuestionSets,
@@ -56,9 +57,27 @@ describe('Exam Environment', () => {
5657
).toBe(false);
5758
});
5859
});
59-
xdescribe('checkPrequisites()', () => {
60-
// TODO: Awaiting implementation
60+
61+
describe('checkPrequisites()', () => {
62+
it("should return true if all items in the second argument exist in the first argument's `.completedChallenges[].id`", () => {
63+
const user = {
64+
completedChallenges: [{ id: '1' }, { id: '2' }]
65+
};
66+
const prerequisites = ['1', '2'];
67+
68+
expect(checkPrerequisites(user, prerequisites)).toBe(true);
69+
});
70+
71+
it("should return false if any items in the second argument do not exist in the first argument's `.completedChallenges[].id`", () => {
72+
const user = {
73+
completedChallenges: [{ id: '2' }]
74+
};
75+
const prerequisites = ['1', '2'];
76+
77+
expect(checkPrerequisites(user, prerequisites)).toBe(false);
78+
});
6179
});
80+
6281
describe('constructUserExam()', () => {
6382
it('should not provide the answers', () => {
6483
const userExam = constructUserExam(generatedExam, exam);

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,33 @@ import {
77
EnvGeneratedExam,
88
EnvMultipleChoiceQuestion,
99
EnvQuestionSet,
10-
EnvQuestionSetAttempt,
11-
user
10+
EnvQuestionSetAttempt
1211
} from '@prisma/client';
1312
import { type Static } from '@fastify/type-provider-typebox';
1413
import * as schemas from '../schemas';
1514

15+
interface CompletedChallengeId {
16+
completedChallenges: {
17+
id: string;
18+
}[];
19+
}
20+
1621
/**
1722
* Checks if all exam prerequisites have been met by the user.
18-
*
19-
* TODO: This will be done by getting the challenges required from the curriculum.
2023
*/
21-
export function checkPrerequisites(_user: user, _prerequisites: unknown) {
22-
return true;
24+
export function checkPrerequisites(
25+
user: CompletedChallengeId,
26+
prerequisites: EnvExam['prerequisites']
27+
) {
28+
return prerequisites.every(p =>
29+
user.completedChallenges.some(c => c.id === p)
30+
);
2331
}
2432

25-
export type UserExam = Omit<EnvExam, 'questionSets' | 'config' | 'id'> & {
33+
export type UserExam = Omit<
34+
EnvExam,
35+
'questionSets' | 'config' | 'id' | 'prerequisites'
36+
> & {
2637
config: Omit<EnvExam['config'], 'tags' | 'questionSets'>;
2738
questionSets: (Omit<EnvQuestionSet, 'questions'> & {
2839
questions: (Omit<

api/src/routes/protected/user.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createSuperRequest
1717
} from '../../../jest.utils';
1818
import { JWT_SECRET } from '../../utils/env';
19+
import { customNanoid } from '../../utils/ids';
1920
import { getMsTranscriptApiUrl } from './user';
2021

2122
const mockedFetch = jest.fn();
@@ -564,6 +565,11 @@ describe('userRoutes', () => {
564565
await fastifyTestInstance.prisma.userToken.deleteMany({
565566
where: { id: userTokenId }
566567
});
568+
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.deleteMany(
569+
{
570+
where: { userId: defaultUserId }
571+
}
572+
);
567573
});
568574

569575
test('GET rejects with 500 status code if the username is missing', async () => {
@@ -1130,6 +1136,72 @@ Thanks and regards,
11301136
});
11311137
});
11321138
});
1139+
1140+
describe('/user/exam-environment/token', () => {
1141+
afterEach(async () => {
1142+
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.deleteMany(
1143+
{
1144+
where: { userId: defaultUserId }
1145+
}
1146+
);
1147+
});
1148+
1149+
test('POST generates a new token if one does not exist', async () => {
1150+
const response = await superPost('/user/exam-environment/token');
1151+
const { examEnvironmentAuthorizationToken } = response.body.data;
1152+
1153+
const decodedToken = jwt.decode(examEnvironmentAuthorizationToken);
1154+
1155+
expect(decodedToken).toStrictEqual({
1156+
examEnvironmentAuthorizationToken:
1157+
expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
1158+
iat: expect.any(Number)
1159+
});
1160+
1161+
expect(() =>
1162+
jwt.verify(examEnvironmentAuthorizationToken, 'wrong-secret')
1163+
).toThrow();
1164+
expect(() =>
1165+
jwt.verify(examEnvironmentAuthorizationToken, JWT_SECRET)
1166+
).not.toThrow();
1167+
1168+
expect(response.status).toBe(200);
1169+
});
1170+
1171+
test('POST only allows for one token per user id', async () => {
1172+
const id = customNanoid();
1173+
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.create(
1174+
{
1175+
data: {
1176+
userId: defaultUserId,
1177+
id,
1178+
createdDate: new Date()
1179+
}
1180+
}
1181+
);
1182+
1183+
const response = await superPost('/user/exam-environment/token');
1184+
1185+
const { examEnvironmentAuthorizationToken } = response.body.data;
1186+
1187+
const decodedToken = jwt.decode(examEnvironmentAuthorizationToken);
1188+
1189+
expect(decodedToken).not.toHaveProperty(
1190+
'examEnvironmentAuthorizationToken',
1191+
id
1192+
);
1193+
1194+
expect(response.status).toBe(200);
1195+
1196+
const tokens =
1197+
await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.findMany(
1198+
{
1199+
where: { userId: defaultUserId }
1200+
}
1201+
);
1202+
expect(tokens).toHaveLength(1);
1203+
});
1204+
});
11331205
});
11341206

11351207
describe('Unauthenticated user', () => {

0 commit comments

Comments
 (0)