mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(api): add exam-environment endpoints (#55662)
Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -8,3 +8,5 @@ docker/**/Dockerfile
|
||||
**/*docker-compose*
|
||||
**/node_modules
|
||||
.eslintcache
|
||||
api/__mocks__
|
||||
api/src/exam-environment/seed
|
||||
|
||||
@@ -28,3 +28,9 @@ pnpm seed
|
||||
## Login in development/testing
|
||||
|
||||
During development and testing, the api exposes the endpoint GET auth/dev-callback. Calling this will log you in as the user with the email `foo@bar.com` by setting the session cookie for that user.
|
||||
|
||||
## Generating Exams
|
||||
|
||||
```bash
|
||||
pnpm run generate-exams <ENV_EXAM_ID> <NUMBER_OF_EXAMS_TO_GENERATE>
|
||||
```
|
||||
|
||||
370
api/__mocks__/env-exam.ts
Normal file
370
api/__mocks__/env-exam.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
EnvConfig,
|
||||
EnvQuestionType,
|
||||
EnvExamAttempt,
|
||||
EnvExam,
|
||||
EnvGeneratedExam,
|
||||
EnvQuestionSet
|
||||
} from '@prisma/client';
|
||||
import { ObjectId } from 'mongodb';
|
||||
// import { defaultUserId } from '../jest.utils';
|
||||
import { examEnvironmentPostExamAttempt } from '../src/exam-environment/schemas';
|
||||
// import { generateExam } from '../src/exam-environment/utils/exam';
|
||||
|
||||
export const oid = () => new ObjectId().toString();
|
||||
|
||||
const defaultUserId = '64c7810107dd4782d32baee7';
|
||||
|
||||
export const examId = oid();
|
||||
|
||||
export const config: EnvConfig = {
|
||||
totalTimeInMS: 2 * 60 * 60 * 1000,
|
||||
tags: [],
|
||||
name: 'Test Exam',
|
||||
note: 'Some exam note...',
|
||||
questionSets: [
|
||||
{
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 2,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: EnvQuestionType.Dialogue,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 2,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const questionSets: EnvQuestionSet[] = [
|
||||
{
|
||||
id: oid(),
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
context: null,
|
||||
questions: [
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q1t1'],
|
||||
text: 'Question 1',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
context: null,
|
||||
questions: [
|
||||
{
|
||||
id: oid(),
|
||||
tags: [],
|
||||
text: 'Question 1',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
type: EnvQuestionType.Dialogue,
|
||||
context: 'Dialogue 1 context',
|
||||
questions: [
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q1t1'],
|
||||
text: 'Question 1',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q2t1', 'q2t2'],
|
||||
text: 'Question 2',
|
||||
deprecated: true,
|
||||
audio: {
|
||||
url: 'https://freecodecamp.org',
|
||||
captions: null
|
||||
},
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
tags: ['q3t1', 'q3t2'],
|
||||
text: 'Question 3',
|
||||
deprecated: false,
|
||||
audio: null,
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 1',
|
||||
isCorrect: true
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 2',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
text: 'Answer 3',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const generatedExam: EnvGeneratedExam = {
|
||||
examId,
|
||||
id: oid(),
|
||||
deprecated: false,
|
||||
questionSets: [
|
||||
{
|
||||
id: questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: questionSets[0]!.questions[0]!.id,
|
||||
answers: [
|
||||
questionSets[0]!.questions[0]!.answers[0]!.id,
|
||||
questionSets[0]!.questions[0]!.answers[1]!.id
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: questionSets[1]!.questions[0]!.id,
|
||||
answers: [
|
||||
questionSets[1]!.questions[0]!.answers[0]!.id,
|
||||
questionSets[1]!.questions[0]!.answers[1]!.id,
|
||||
questionSets[1]!.questions[0]!.answers[2]!.id
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: questionSets[2]!.questions[0]!.id,
|
||||
answers: [
|
||||
questionSets[2]!.questions[0]!.answers[0]!.id,
|
||||
questionSets[2]!.questions[0]!.answers[1]!.id,
|
||||
questionSets[2]!.questions[0]!.answers[2]!.id
|
||||
]
|
||||
},
|
||||
{
|
||||
id: questionSets[2]!.questions[1]!.id,
|
||||
answers: [
|
||||
questionSets[2]!.questions[1]!.answers[0]!.id,
|
||||
questionSets[2]!.questions[1]!.answers[1]!.id,
|
||||
questionSets[2]!.questions[1]!.answers[2]!.id
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const examAttempt: EnvExamAttempt = {
|
||||
examId,
|
||||
generatedExamId: generatedExam.id,
|
||||
id: oid(),
|
||||
needsRetake: false,
|
||||
questionSets: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[0]!.questions[0]!.answers[0]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[1]!.questions[0]!.answers[1]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[0]!.answers[1]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[1]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[1]!.answers[0]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId,
|
||||
submissionTimeInMS: null
|
||||
};
|
||||
|
||||
export const examAttemptSansSubmissionTimeInMS: Static<
|
||||
typeof examEnvironmentPostExamAttempt.body
|
||||
>['attempt'] = {
|
||||
examId,
|
||||
questionSets: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[0]!.questions[0]!.answers[0]!]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[1]!.questions[0]!.answers[1]!]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[0]!.answers[1]!]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[1]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[1]!.answers[0]!]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const exam: EnvExam = {
|
||||
id: examId,
|
||||
config,
|
||||
questionSets
|
||||
};
|
||||
|
||||
export async function seedEnvExam() {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany({});
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.deleteMany({});
|
||||
await fastifyTestInstance.prisma.envExam.deleteMany({});
|
||||
|
||||
await fastifyTestInstance.prisma.envExam.create({
|
||||
data: exam
|
||||
});
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
data: generatedExam
|
||||
});
|
||||
|
||||
// TODO: This would be nice to use, but the test logic for examAttempt need to account
|
||||
// for dynamic ids.
|
||||
// let numberOfExamsGenerated = 0;
|
||||
// while (numberOfExamsGenerated < 2) {
|
||||
// try {
|
||||
// const generatedExam = generateExam(exam);
|
||||
// await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
// data: generatedExam
|
||||
// });
|
||||
// numberOfExamsGenerated++;
|
||||
// } catch (_e) {
|
||||
// //
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -47,7 +47,8 @@
|
||||
"jest": "29.7.0",
|
||||
"prisma": "5.5.2",
|
||||
"supertest": "6.3.3",
|
||||
"ts-jest": "29.1.2"
|
||||
"ts-jest": "29.1.2",
|
||||
"tsx": "4.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
@@ -79,7 +80,9 @@
|
||||
"test": "jest --force-exit",
|
||||
"prisma": "dotenv -e ../.env prisma",
|
||||
"postinstall": "prisma generate",
|
||||
"lint": "cd .. && eslint api/src --max-warnings 0"
|
||||
"lint": "cd .. && eslint api/src --max-warnings 0",
|
||||
"generate-exams": "tsx src/exam-environment/generate/index.ts",
|
||||
"seed:env-exam": "tsx src/exam-environment/seed/index.ts"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
}
|
||||
|
||||
@@ -141,6 +141,187 @@ model user {
|
||||
website String? // Undefined
|
||||
yearsTopContributor String[] // Undefined | String[]
|
||||
isClassroomAccount Boolean? // Undefined
|
||||
|
||||
// Relations
|
||||
examAttempts EnvExamAttempt[]
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
|
||||
/// An exam for the Exam Environment App as designed by the examiners
|
||||
model EnvExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets EnvQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config EnvConfig
|
||||
|
||||
// Relations
|
||||
generatedExams EnvGeneratedExam[]
|
||||
examAttempts EnvExamAttempt[]
|
||||
}
|
||||
|
||||
/// A grouping of one or more questions of a given type
|
||||
type EnvQuestionSet {
|
||||
/// Unique question type id
|
||||
id String @db.ObjectId
|
||||
type EnvQuestionType
|
||||
/// Content related to all questions in set
|
||||
context String?
|
||||
questions EnvMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
/// A multiple choice question for the Exam Environment App
|
||||
type EnvMultipleChoiceQuestion {
|
||||
/// Unique question id
|
||||
id String @db.ObjectId
|
||||
/// Main question paragraph
|
||||
text String
|
||||
/// Zero or more tags given to categorize a question
|
||||
tags String[]
|
||||
/// Optional audio for a question
|
||||
audio EnvAudio?
|
||||
/// Available possible answers for an exam
|
||||
answers EnvAnswer[]
|
||||
/// TODO Possible "deprecated_time" to remove after all exams could possibly have been taken
|
||||
deprecated Boolean
|
||||
}
|
||||
|
||||
/// Audio for an Exam Environment App multiple choice question
|
||||
type EnvAudio {
|
||||
/// Optional text for audio
|
||||
captions String?
|
||||
/// URL to audio file
|
||||
///
|
||||
/// Expected in the format: `<url>#t=<start_time_in_seconds>,<end_time_in_seconds>`
|
||||
/// Where `start_time_in_seconds` and `end_time_in_seconds` are optional floats.
|
||||
url String
|
||||
}
|
||||
|
||||
/// Type of question for the Exam Environment App
|
||||
enum EnvQuestionType {
|
||||
/// Single question with one or more answers
|
||||
MultipleChoice
|
||||
/// Mass text
|
||||
Dialogue
|
||||
}
|
||||
|
||||
/// Answer for an Exam Environment App multiple choice question
|
||||
type EnvAnswer {
|
||||
/// Unique answer id
|
||||
id String @db.ObjectId
|
||||
/// Whether the answer is correct
|
||||
isCorrect Boolean
|
||||
/// Answer paragraph
|
||||
text String
|
||||
}
|
||||
|
||||
/// Configuration for an exam in the Exam Environment App
|
||||
type EnvConfig {
|
||||
/// Human-readable exam name
|
||||
name String
|
||||
/// Notes given about exam
|
||||
note String
|
||||
/// Category configuration for question selection
|
||||
tags EnvTagConfig[]
|
||||
/// Total time allocated for exam in milliseconds
|
||||
totalTimeInMS Int
|
||||
/// Configuration for sets of questions
|
||||
questionSets EnvQuestionSetConfig[]
|
||||
}
|
||||
|
||||
/// Configuration for a set of questions in the Exam Environment App
|
||||
type EnvQuestionSetConfig {
|
||||
type EnvQuestionType
|
||||
/// Number of this grouping of questions per exam
|
||||
numberOfSet Int
|
||||
/// Number of multiple choice questions per grouping matching this set config
|
||||
numberOfQuestions Int
|
||||
/// Number of correct answers given per multiple choice question
|
||||
numberOfCorrectAnswers Int
|
||||
/// Number of incorrect answers given per multiple choice question
|
||||
numberOfIncorrectAnswers Int
|
||||
}
|
||||
|
||||
/// Configuration for tags in the Exam Environment App
|
||||
///
|
||||
/// This configures the number of questions that should resolve to a given tag set criteria.
|
||||
type EnvTagConfig {
|
||||
/// Group of multiple choice question tags
|
||||
group String[]
|
||||
/// Number of multiple choice questions per exam that should meet the group criteria
|
||||
numberOfQuestions Int
|
||||
}
|
||||
|
||||
/// An attempt at an exam in the Exam Environment App
|
||||
model EnvExamAttempt {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foriegn key to user
|
||||
userId String @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
/// Foreign key to generated exam id
|
||||
generatedExamId String @db.ObjectId
|
||||
|
||||
questionSets EnvQuestionSetAttempt[]
|
||||
/// Time exam was started as milliseconds since epoch
|
||||
startTimeInMS Int
|
||||
/// Time exam was submitted as milliseconds since epoch
|
||||
///
|
||||
/// As attempt might not be submitted (disconnection or quit), field is optional
|
||||
submissionTimeInMS Int?
|
||||
needsRetake Boolean
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id])
|
||||
exam EnvExam @relation(fields: [examId], references: [id])
|
||||
generatedExam EnvGeneratedExam @relation(fields: [generatedExamId], references: [id])
|
||||
}
|
||||
|
||||
type EnvQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions EnvMultipleChoiceQuestionAttempt[]
|
||||
}
|
||||
|
||||
type EnvMultipleChoiceQuestionAttempt {
|
||||
/// Foreign key to question
|
||||
id String @db.ObjectId
|
||||
/// An array of foreign keys to answers
|
||||
answers String[] @db.ObjectId
|
||||
/// Time answers to question were submitted as milliseconds since epoch
|
||||
///
|
||||
/// If the question is later revisited, this field is updated
|
||||
submissionTimeInMS Int
|
||||
}
|
||||
|
||||
/// A generated exam for the Exam Environment App
|
||||
///
|
||||
/// This is the user-facing information for an exam.
|
||||
/// TODO: Add userId?
|
||||
model EnvGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
questionSets EnvGeneratedQuestionSet[]
|
||||
/// If `deprecated`, the generation should not longer be considered for users
|
||||
deprecated Boolean
|
||||
|
||||
// Relations
|
||||
exam EnvExam @relation(fields: [examId], references: [id])
|
||||
EnvExamAttempt EnvExamAttempt[]
|
||||
}
|
||||
|
||||
type EnvGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions EnvGeneratedMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
type EnvGeneratedMultipleChoiceQuestion {
|
||||
/// Foreign key to question id
|
||||
id String @db.ObjectId
|
||||
/// Each item is a foreign key to an answer
|
||||
answers String[] @db.ObjectId
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
@@ -178,7 +359,7 @@ model Donation {
|
||||
}
|
||||
|
||||
model UserRateLimit {
|
||||
id String @id @map("_id")
|
||||
id String @id @map("_id")
|
||||
counter Int
|
||||
expirationDate DateTime @db.Date
|
||||
|
||||
@@ -194,6 +375,17 @@ model UserToken {
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
/// TODO: Token has to outlive the exam attempt
|
||||
/// Validation has to be taken as the attempt is requested
|
||||
/// to ensure it lives long enough.
|
||||
model ExamEnvironmentAuthorizationToken {
|
||||
id String @id @map("_id")
|
||||
createdDate DateTime @db.Date
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model sessions {
|
||||
id String @id @map("_id")
|
||||
expires DateTime @db.Date
|
||||
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
API_LOCATION,
|
||||
EMAIL_PROVIDER,
|
||||
FCC_ENABLE_DEV_LOGIN_MODE,
|
||||
FCC_ENABLE_SWAGGER_UI
|
||||
FCC_ENABLE_SWAGGER_UI,
|
||||
FREECODECAMP_NODE_ENV
|
||||
} from './utils/env';
|
||||
import { isObjectID } from './utils/validation';
|
||||
import {
|
||||
examEnvironmentOpenRoutes,
|
||||
examEnvironmentValidatedTokenRoutes
|
||||
} from './exam-environment/routes/exam-environment';
|
||||
|
||||
type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||
RawServerDefault,
|
||||
@@ -174,6 +179,18 @@ export const build = async (
|
||||
await fastify.register(publicRoutes.authRoutes);
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: Code behind the `FREECODECAMP_NODE_ENV` var is not ready to be deployed yet.
|
||||
if (FREECODECAMP_NODE_ENV !== 'production') {
|
||||
void fastify.register(function (fastify, _opts, done) {
|
||||
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
||||
|
||||
void fastify.register(examEnvironmentValidatedTokenRoutes);
|
||||
done();
|
||||
});
|
||||
void fastify.register(examEnvironmentOpenRoutes);
|
||||
}
|
||||
|
||||
void fastify.register(publicRoutes.chargeStripeRoute);
|
||||
void fastify.register(publicRoutes.signoutRoute);
|
||||
void fastify.register(publicRoutes.emailSubscribtionRoutes);
|
||||
|
||||
54
api/src/exam-environment/generate/index.ts
Normal file
54
api/src/exam-environment/generate/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateExam } from '../utils/exam';
|
||||
import { MONGOHQ_URL } from '../../utils/env';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const ENV_EXAM_ID = args[0];
|
||||
const NUMBER_OF_EXAMS_TO_GENERATE = Number(args[1]);
|
||||
|
||||
if (!ENV_EXAM_ID) {
|
||||
throw 'First argument must be the EnvExam _id';
|
||||
}
|
||||
if (!NUMBER_OF_EXAMS_TO_GENERATE) {
|
||||
throw 'Second argument must be an unsigned integer';
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: MONGOHQ_URL
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// TODO:
|
||||
/// 1. Deprecate all previous generated exams for a given exam id?
|
||||
async function main() {
|
||||
await prisma.$connect();
|
||||
|
||||
const exam = await prisma.envExam.findUnique({
|
||||
where: {
|
||||
id: ENV_EXAM_ID
|
||||
}
|
||||
});
|
||||
|
||||
if (!exam) {
|
||||
throw Error(`No exam with id "${ENV_EXAM_ID}" found.`);
|
||||
}
|
||||
|
||||
let numberOfExamsGenerated = 0;
|
||||
|
||||
while (numberOfExamsGenerated < NUMBER_OF_EXAMS_TO_GENERATE) {
|
||||
try {
|
||||
const generatedExam = generateExam(exam);
|
||||
await prisma.envGeneratedExam.create({
|
||||
data: generatedExam
|
||||
});
|
||||
numberOfExamsGenerated++;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
594
api/src/exam-environment/routes/exam-environment.test.ts
Normal file
594
api/src/exam-environment/routes/exam-environment.test.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
createSuperRequest,
|
||||
defaultUserId,
|
||||
devLogin,
|
||||
setupServer
|
||||
} from '../../../jest.utils';
|
||||
import {
|
||||
examEnvironmentPostExamAttempt,
|
||||
examEnvironmentPostExamGeneratedExam
|
||||
} from '../schemas';
|
||||
import * as mock from '../../../__mocks__/env-exam';
|
||||
import { constructUserExam } from '../utils/exam';
|
||||
|
||||
describe('/exam-environment/', () => {
|
||||
setupServer();
|
||||
describe('Authenticated user with exam environment authorization token', () => {
|
||||
let superPost: ReturnType<typeof createSuperRequest>;
|
||||
let examEnvironmentAuthorizationToken: string;
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPost = createSuperRequest({ method: 'POST', setCookies });
|
||||
await mock.seedEnvExam();
|
||||
// Add exam environment authorization token
|
||||
const res = await superPost('/user/exam-environment/token');
|
||||
expect(res.status).toBe(200);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
examEnvironmentAuthorizationToken =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.body.data.examEnvironmentAuthorizationToken;
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/exam/attempt', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
});
|
||||
|
||||
it('should return an error if there are no current exam attempts matching the given id', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.oid(),
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// NOTE: message may not necessarily be a part of the api compatability guarantee.
|
||||
// That is, it could be changed without requiring a major version bump, because it is just
|
||||
// a human-readable/debug message.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return an error if the given exam id does not match an existing exam', async () => {
|
||||
const examId = mock.oid();
|
||||
// Create exam attempt with bad exam id
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId,
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return an error if the attempt has expired', async () => {
|
||||
// Create exam attempt with expired time
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now() - (1000 * 60 * 60 * 2 + 1000),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.examId,
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return an error if there is no matching generated exam', async () => {
|
||||
// Create exam attempt with no matching generated exam
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.examId,
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return an error if the attempt does not match the generated exam', async () => {
|
||||
const attempt = await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
|
||||
attempt.questionSets[0]!.id = mock.oid();
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// Database should mark attempt as `needsRetake`
|
||||
const updatedAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findUnique({
|
||||
where: { id: attempt.id }
|
||||
});
|
||||
expect(updatedAttempt).toHaveProperty('needsRetake', true);
|
||||
});
|
||||
|
||||
it('should return 200 if request is valid, and update attempt in database', async () => {
|
||||
const attempt = await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
userId: defaultUserId,
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: [],
|
||||
needsRetake: false
|
||||
}
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: mock.examAttemptSansSubmissionTimeInMS
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Database should update attempt
|
||||
const updatedAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findUnique({
|
||||
where: { id: attempt.id }
|
||||
});
|
||||
|
||||
expect(updatedAttempt).toMatchObject(body.attempt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/generated-exam', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
await mock.seedEnvExam();
|
||||
});
|
||||
|
||||
it('should return an error if the given exam id is invalid', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.oid()
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
xit('should return an error if the exam prerequisites are not met', async () => {
|
||||
// TODO: Waiting on prerequisites
|
||||
});
|
||||
|
||||
it('should return an error if the exam has been attempted in the last 24 hours', async () => {
|
||||
const recentExamAttempt = {
|
||||
...mock.examAttempt,
|
||||
// Set start time such that exam has just expired
|
||||
startTimeInMS: Date.now() - mock.exam.config.totalTimeInMS
|
||||
};
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 429,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES'
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.envExamAttempt.update({
|
||||
where: {
|
||||
id: recentExamAttempt.id
|
||||
},
|
||||
data: {
|
||||
// Set start time such that exam has expired, but 24 hours - 1s has passed
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 - 1000))
|
||||
}
|
||||
});
|
||||
|
||||
const body2: Static<typeof examEnvironmentPostExamGeneratedExam.body> =
|
||||
{
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res2 = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body2)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res2).toMatchObject({
|
||||
status: 429,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a new exam attempt if all previous attempts were started > 24 hours ago', async () => {
|
||||
const recentExamAttempt = structuredClone(mock.examAttempt);
|
||||
// Set start time such that exam has expired, but 24 hours + 1s has passed
|
||||
recentExamAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 + 1000));
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
// Generate new exam for user to be assigned
|
||||
const newGeneratedExam = structuredClone(mock.generatedExam);
|
||||
newGeneratedExam.id = mock.oid();
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
data: newGeneratedExam
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
// Time is greater than 24 hours. So, request should pass, and new exam should be generated
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
examAttempt: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
id: expect.not.stringMatching(mock.examAttempt.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the current attempt if it is still ongoing', async () => {
|
||||
const latestAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
examAttempt: latestAttempt
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if the database has insufficient generated exams', async () => {
|
||||
// Add completed attempt for generated exam
|
||||
const submittedAttempt = structuredClone(mock.examAttempt);
|
||||
// Long-enough ago to be considered "submitted", and not trigger cooldown
|
||||
submittedAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
24 * 60 * 60 * 1000 -
|
||||
mock.exam.config.totalTimeInMS -
|
||||
1 * 60 * 60 * 1000;
|
||||
submittedAttempt.submissionTimeInMS =
|
||||
Date.now() - mock.exam.config.totalTimeInMS - 24 * 60 * 60 * 1000;
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: submittedAttempt
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 500,
|
||||
body: {
|
||||
code: 'FCC_ERR_EXAM_ENVIRONMENT'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should record the fact the user has started an exam by creating an exam attempt', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const generatedExam =
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
|
||||
expect(generatedExam).toBeDefined();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
});
|
||||
|
||||
expect(examAttempt).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
id: expect.any(String),
|
||||
userId: defaultUserId,
|
||||
examId: mock.examId,
|
||||
generatedExamId: generatedExam!.id,
|
||||
questionSets: [],
|
||||
needsRetake: false,
|
||||
submissionTimeInMS: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
startTimeInMS: expect.any(Number)
|
||||
});
|
||||
});
|
||||
|
||||
it('should unwind (delete) the exam attempt if the user exam cannot be constructed', async () => {
|
||||
const _mockConstructUserExam = jest
|
||||
.spyOn(await import('../utils/exam'), 'constructUserExam')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
|
||||
expect(examAttempt).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the user exam with the exam attempt', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const generatedExam =
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
|
||||
expect(generatedExam).toBeDefined();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
});
|
||||
|
||||
const userExam = constructUserExam(generatedExam!, mock.exam);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
examAttempt,
|
||||
exam: userExam
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
xdescribe('POST /exam-environment/screenshot', () => {});
|
||||
});
|
||||
|
||||
describe('Authenticated user without exam environment authorization token', () => {
|
||||
let superPost: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPost = createSuperRequest({ method: 'POST', setCookies });
|
||||
await mock.seedEnvExam();
|
||||
});
|
||||
describe('POST /exam-environment/exam/attempt', () => {
|
||||
it('should return 403', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.oid(),
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.send(body)
|
||||
.set('exam-environment-authorization-token', 'invalid-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/exam/generated-exam', () => {
|
||||
it('should return 403', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.oid()
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set('exam-environment-authorization-token', 'invalid-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/screenshot', () => {
|
||||
it('should return 403', async () => {
|
||||
const res = await superPost('/exam-environment/screenshot').set(
|
||||
'exam-environment-authorization-token',
|
||||
'invalid-token'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/token/verify', () => {
|
||||
it('should allow a valid request', async () => {
|
||||
const res = await superPost('/exam-environment/token/verify').set(
|
||||
'exam-environment-authorization-token',
|
||||
'invalid-token'
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
567
api/src/exam-environment/routes/exam-environment.ts
Normal file
567
api/src/exam-environment/routes/exam-environment.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { PrismaClientValidationError } from '@prisma/client/runtime/library';
|
||||
import { type FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import * as schemas from '../schemas';
|
||||
import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
|
||||
import { JWT_SECRET } from '../../utils/env';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
checkPrerequisites,
|
||||
constructUserExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt
|
||||
} from '../utils/exam';
|
||||
import { ERRORS } from '../utils/errors';
|
||||
|
||||
/**
|
||||
* Wrapper for endpoints related to the exam environment desktop app.
|
||||
*
|
||||
* Requires exam environment authorization token to be validated.
|
||||
*/
|
||||
export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
|
||||
(fastify, _options, done) => {
|
||||
fastify.post(
|
||||
'/exam-environment/exam/generated-exam',
|
||||
{
|
||||
schema: schemas.examEnvironmentPostExamGeneratedExam
|
||||
},
|
||||
postExamGeneratedExamHandler
|
||||
);
|
||||
fastify.post(
|
||||
'/exam-environment/exam/attempt',
|
||||
{
|
||||
schema: schemas.examEnvironmentPostExamAttempt
|
||||
},
|
||||
postExamAttemptHandler
|
||||
);
|
||||
fastify.post(
|
||||
'/exam-environment/screenshot',
|
||||
{
|
||||
schema: schemas.examEnvironmentPostScreenshot
|
||||
},
|
||||
postScreenshotHandler
|
||||
);
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for endpoints related to the exam environment desktop app.
|
||||
*
|
||||
* Does not require exam environment authorization token to be validated.
|
||||
*/
|
||||
export const examEnvironmentOpenRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.post(
|
||||
'/exam-environment/token/verify',
|
||||
{
|
||||
schema: schemas.examEnvironmentTokenVerify
|
||||
},
|
||||
tokenVerifyHandler
|
||||
);
|
||||
done();
|
||||
};
|
||||
|
||||
interface JwtPayload {
|
||||
examEnvironmentAuthorizationToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an authorization token has been generated for a user.
|
||||
*
|
||||
* Does not require any authentication.
|
||||
*
|
||||
* **Note**: This has no guarantees of which user the token is for. Just that one exists in the database.
|
||||
*/
|
||||
async function tokenVerifyHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentTokenVerify>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { 'exam-environment-authorization-token': encodedToken } = req.headers;
|
||||
|
||||
try {
|
||||
jwt.verify(encodedToken, JWT_SECRET);
|
||||
} catch (e) {
|
||||
// TODO: What to send back here? Request is valid, but token is not?
|
||||
void reply.code(200);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(JSON.stringify(e))
|
||||
);
|
||||
}
|
||||
|
||||
const payload = jwt.decode(encodedToken) as JwtPayload;
|
||||
|
||||
const examEnvironmentAuthorizationToken =
|
||||
payload.examEnvironmentAuthorizationToken;
|
||||
|
||||
const token = await this.prisma.examEnvironmentAuthorizationToken.findFirst({
|
||||
where: {
|
||||
id: examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
data: 'Token does not appear to have been created.'
|
||||
});
|
||||
} else {
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
data: {
|
||||
createdDate: token.createdDate
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an exam for the user.
|
||||
*
|
||||
* Requires token to be validated and TODO: live longer than the exam attempt.
|
||||
*/
|
||||
async function postExamGeneratedExamHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentPostExamGeneratedExam>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
// Get exam from DB
|
||||
const examId = req.body.examId;
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.envExam.findUnique({
|
||||
where: {
|
||||
id: examId
|
||||
}
|
||||
})
|
||||
);
|
||||
if (maybeExam.hasError) {
|
||||
if (maybeExam.error instanceof PrismaClientValidationError) {
|
||||
void reply.code(400);
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ID(maybeExam.error.message));
|
||||
}
|
||||
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (!exam) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM('Invalid exam id given.')
|
||||
);
|
||||
}
|
||||
|
||||
// Check user has completed prerequisites
|
||||
const user = req.user!;
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, true);
|
||||
|
||||
if (!isExamPrerequisitesMet) {
|
||||
void reply.code(403);
|
||||
// TODO: Consider sending unmet prerequisites
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES(
|
||||
'User has not completed prerequisites.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check user has not completed exam in last 24 hours
|
||||
const maybeExamAttempts = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
examId: exam.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExamAttempts.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExamAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const examAttempts = maybeExamAttempts.data;
|
||||
|
||||
const lastAttempt = examAttempts.length
|
||||
? examAttempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
)
|
||||
: null;
|
||||
|
||||
if (lastAttempt) {
|
||||
const attemptIsExpired =
|
||||
lastAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
if (attemptIsExpired) {
|
||||
// If exam is not submitted, use exam start time + time allocated for exam
|
||||
const effectiveSubmissionTime =
|
||||
lastAttempt.submissionTimeInMS ??
|
||||
lastAttempt.startTimeInMS + exam.config.totalTimeInMS;
|
||||
const twentyFourHoursAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
if (effectiveSubmissionTime > twentyFourHoursAgo) {
|
||||
void reply.code(429);
|
||||
// TODO: Consider sending last completed time
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES(
|
||||
'User has completed exam too recently to retake.'
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Camper has started an attempt, but not submitted it, and there is still time left to complete it.
|
||||
// This is most likely to happen if the Camper's app closes and is reopened.
|
||||
// Send the Camper back to the exam they were working on.
|
||||
const generated = await mapErr(
|
||||
this.prisma.envGeneratedExam.findFirst({
|
||||
where: {
|
||||
id: lastAttempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (generated.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(generated.error))
|
||||
);
|
||||
}
|
||||
|
||||
if (generated.data === null) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Generated exam not found.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userExam = constructUserExam(generated.data, exam);
|
||||
|
||||
return reply.send({
|
||||
data: {
|
||||
exam: userExam,
|
||||
examAttempt: lastAttempt
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Randomly pick a generated exam for user
|
||||
const maybeGeneratedExams = await mapErr(
|
||||
this.prisma.envGeneratedExam.findMany({
|
||||
where: {
|
||||
// Find generated exams user has not already seen
|
||||
examId: exam.id,
|
||||
id: {
|
||||
notIn: examAttempts.map(a => a.generatedExamId)
|
||||
},
|
||||
deprecated: false
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExams.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(maybeGeneratedExams.error)
|
||||
);
|
||||
}
|
||||
|
||||
const generatedExams = maybeGeneratedExams.data;
|
||||
|
||||
if (generatedExams.length === 0) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
`Unable to provide a generated exam. Either all generated exams have been exhausted, or all generated exams are deprecated.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const randomGeneratedExam =
|
||||
generatedExams[Math.floor(Math.random() * generatedExams.length)]!;
|
||||
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
this.prisma.envGeneratedExam.findFirst({
|
||||
where: {
|
||||
id: randomGeneratedExam.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
// TODO: Consider more specific code
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unable to query generated exam, due to: ' +
|
||||
JSON.stringify(maybeGeneratedExam.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(`Unable to locate generated exam.`)
|
||||
);
|
||||
}
|
||||
|
||||
// Create exam attempt so, even if user disconnects, their attempt is still recorded:
|
||||
const attempt = await mapErr(
|
||||
this.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: [],
|
||||
needsRetake: false
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (attempt.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT(
|
||||
JSON.stringify(attempt.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
// NOTE: Anything that goes wrong after this point needs to unwind the exam attempt.
|
||||
|
||||
const maybeUserExam = syncMapErr(() =>
|
||||
constructUserExam(generatedExam, exam)
|
||||
);
|
||||
|
||||
if (maybeUserExam.hasError) {
|
||||
await this.prisma.envExamAttempt.delete({
|
||||
where: {
|
||||
id: attempt.data.id
|
||||
}
|
||||
});
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUserExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const userExam = maybeUserExam.data;
|
||||
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
data: {
|
||||
exam: userExam,
|
||||
examAttempt: attempt.data
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles updates to an exam attempt.
|
||||
*
|
||||
* Requires token to be validated.
|
||||
*
|
||||
* TODO: Consider validating req.user.id == lastAttempt.user_id?
|
||||
*
|
||||
* NOTE: Currently, questions can be _unanswered_ - taken away from a previous attempt submission.
|
||||
* Theorectically, this is fine. Practically, it is unclear when that would be useful.
|
||||
*/
|
||||
async function postExamAttemptHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentPostExamAttempt>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { attempt } = req.body;
|
||||
|
||||
const user = req.user!;
|
||||
|
||||
const maybeAttempts = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
where: {
|
||||
examId: attempt.examId,
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
if (attempts.length === 0) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
`No attempts found for user '${user.id}' with attempt id '${attempt.examId}'.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const latestAttempt = attempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
);
|
||||
|
||||
// TODO: Currently, submission time is set when all questions have been answered.
|
||||
// This might not necessarily be fully submitted. So, provided there is time
|
||||
// left on the clock, the attempt should still be updated, even if the submission
|
||||
// time is set.
|
||||
// The submission time just needs to be updated.
|
||||
// if (latestAttempt.submissionTimeInMS !== null) {
|
||||
// void reply.code(403);
|
||||
// return reply.send(
|
||||
// ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
// 'Attempt has already been submitted.'
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.envExam.findUnique({
|
||||
where: {
|
||||
id: attempt.examId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM('Invalid exam id given.')
|
||||
);
|
||||
}
|
||||
|
||||
const isAttemptExpired =
|
||||
latestAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
|
||||
if (isAttemptExpired) {
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
'Attempt has exceeded submission time.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get generated exam from database
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
this.prisma.envGeneratedExam.findUnique({
|
||||
where: {
|
||||
id: latestAttempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeGeneratedExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM(
|
||||
'Generated exam not found.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const databaseAttemptQuestionSets = userAttemptToDatabaseAttemptQuestionSets(
|
||||
attempt,
|
||||
latestAttempt
|
||||
);
|
||||
// Ensure attempt matches generated exam
|
||||
const maybeValidExamAttempt = syncMapErr(() =>
|
||||
validateAttempt(generatedExam, databaseAttemptQuestionSets)
|
||||
);
|
||||
|
||||
// If all questions have been answered, add submission time
|
||||
const allQuestionsAnswered = checkAttemptAgainstGeneratedExam(
|
||||
databaseAttemptQuestionSets,
|
||||
generatedExam
|
||||
);
|
||||
|
||||
// Update attempt in database
|
||||
const maybeUpdatedAttempt = await mapErr(
|
||||
this.prisma.envExamAttempt.update({
|
||||
where: {
|
||||
id: latestAttempt.id
|
||||
},
|
||||
data: {
|
||||
// NOTE: submission time is set to null, because it just depends on whether all questions have been answered.
|
||||
submissionTimeInMS: allQuestionsAnswered ? Date.now() : null,
|
||||
questionSets: databaseAttemptQuestionSets,
|
||||
// If attempt is not valid, immediately flag attempt as needing retake
|
||||
// TODO: If `needsRetake`, prevent further submissions?
|
||||
needsRetake: maybeValidExamAttempt.hasError ? true : undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeValidExamAttempt.hasError) {
|
||||
void reply.code(400);
|
||||
const message =
|
||||
maybeValidExamAttempt.error instanceof Error
|
||||
? maybeValidExamAttempt.error.message
|
||||
: 'Unknown attempt validation error';
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(message));
|
||||
}
|
||||
|
||||
if (maybeUpdatedAttempt.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUpdatedAttempt.error))
|
||||
);
|
||||
}
|
||||
|
||||
return reply.code(200).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles screenshots, sending them to the screenshot service for storage.
|
||||
*
|
||||
* Requires token to be validated.
|
||||
*/
|
||||
async function postScreenshotHandler(
|
||||
this: FastifyInstance,
|
||||
_req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
return reply.code(418);
|
||||
}
|
||||
31
api/src/exam-environment/schemas/exam-attempt.ts
Normal file
31
api/src/exam-environment/schemas/exam-attempt.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentPostExamAttempt = {
|
||||
body: Type.Object({
|
||||
attempt: Type.Object({
|
||||
examId: Type.String(),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
answers: Type.Array(Type.String())
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
// An empty 200 response cannot be typed 🤷♂️
|
||||
400: STANDARD_ERROR,
|
||||
403: STANDARD_ERROR,
|
||||
404: STANDARD_ERROR,
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
23
api/src/exam-environment/schemas/exam-generated-exam.ts
Normal file
23
api/src/exam-environment/schemas/exam-generated-exam.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentPostExamGeneratedExam = {
|
||||
body: Type.Object({
|
||||
examId: Type.String()
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
data: Type.Object({
|
||||
exam: Type.Record(Type.String(), Type.Unknown()),
|
||||
examAttempt: Type.Record(Type.String(), Type.Unknown())
|
||||
})
|
||||
}),
|
||||
403: STANDARD_ERROR,
|
||||
404: STANDARD_ERROR,
|
||||
429: STANDARD_ERROR,
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
4
api/src/exam-environment/schemas/index.ts
Normal file
4
api/src/exam-environment/schemas/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { examEnvironmentPostExamAttempt } from './exam-attempt';
|
||||
export { examEnvironmentPostExamGeneratedExam } from './exam-generated-exam';
|
||||
export { examEnvironmentPostScreenshot } from './screenshot';
|
||||
export { examEnvironmentTokenVerify } from './token-verify';
|
||||
7
api/src/exam-environment/schemas/screenshot.ts
Normal file
7
api/src/exam-environment/schemas/screenshot.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const examEnvironmentPostScreenshot = {
|
||||
response: {
|
||||
// 200: Type.Object({})
|
||||
}
|
||||
};
|
||||
21
api/src/exam-environment/schemas/token-verify.ts
Normal file
21
api/src/exam-environment/schemas/token-verify.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentTokenVerify = {
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Union([
|
||||
Type.Object({
|
||||
data: Type.Union([
|
||||
Type.String(),
|
||||
Type.Object({
|
||||
createdDate: Type.String({ format: 'date-time' })
|
||||
})
|
||||
])
|
||||
}),
|
||||
STANDARD_ERROR
|
||||
])
|
||||
}
|
||||
};
|
||||
24
api/src/exam-environment/seed/index.ts
Normal file
24
api/src/exam-environment/seed/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as mocks from '../../../__mocks__/env-exam';
|
||||
import { MONGOHQ_URL } from '../../utils/env';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: MONGOHQ_URL
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await prisma.$connect();
|
||||
|
||||
await prisma.envExamAttempt.deleteMany({});
|
||||
await prisma.envGeneratedExam.deleteMany({});
|
||||
await prisma.envExam.deleteMany({});
|
||||
|
||||
await prisma.envExam.create({ data: mocks.exam });
|
||||
await prisma.envGeneratedExam.create({ data: mocks.generatedExam });
|
||||
}
|
||||
|
||||
void main();
|
||||
59
api/src/exam-environment/utils/errors.ts
Normal file
59
api/src/exam-environment/utils/errors.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { format } from 'util';
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const ERRORS = {
|
||||
FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN: createError(
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN',
|
||||
'%s'
|
||||
),
|
||||
FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES: createError(
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES',
|
||||
'%s'
|
||||
),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT: createError(
|
||||
'FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT: createError('FCC_ERR_EXAM_ENVIRONMENT', '%s'),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN',
|
||||
'%s'
|
||||
),
|
||||
FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
|
||||
'%s'
|
||||
),
|
||||
FCC_EINVAL_EXAM_ID: createError('FCC_EINVAL_EXAM_ID', '%s')
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a function which optionally takes arguments to format an error message.
|
||||
* @param code - Identifier for the error.
|
||||
* @param message - Human-readable error message.
|
||||
* @returns Function which optionally takes arguments to format an error message.
|
||||
*/
|
||||
function createError(code: string, message: string) {
|
||||
return (...args: unknown[]) => {
|
||||
return {
|
||||
code,
|
||||
message: format(message, ...args)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const STANDARD_ERROR = Type.Object({
|
||||
code: Type.String(),
|
||||
message: Type.String()
|
||||
});
|
||||
292
api/src/exam-environment/utils/exam.test.ts
Normal file
292
api/src/exam-environment/utils/exam.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import { exam, examAttempt, generatedExam } from '../../../__mocks__/env-exam';
|
||||
import * as schemas from '../schemas';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
constructUserExam,
|
||||
generateExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt
|
||||
} from './exam';
|
||||
|
||||
// NOTE: Whilst the tests could be run against a single generation of exam,
|
||||
// it is more useful to run the tests against a new generation each time.
|
||||
// This helps ensure the config/logic is _reasonably_ likely to be able to
|
||||
// generate a valid exam.
|
||||
// Another option is to call `generateExam` hundreds of times in a loop test :shrug:
|
||||
describe('Exam Environment', () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Math, 'random').mockReturnValue(0.123456789);
|
||||
});
|
||||
describe('checkAttemptAgainstGeneratedExam()', () => {
|
||||
it('should return true if all questions are answered', () => {
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
examAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if one or more questions are not answered', () => {
|
||||
const badExamAttempt = structuredClone(examAttempt);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = [];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = ['bad-answer'];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions = [];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
xdescribe('checkPrequisites()', () => {
|
||||
// TODO: Awaiting implementation
|
||||
});
|
||||
describe('constructUserExam()', () => {
|
||||
it('should not provide the answers', () => {
|
||||
const userExam = constructUserExam(generatedExam, exam);
|
||||
expect(userExam).not.toHaveProperty('answers.isCorrect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateExam()', () => {
|
||||
it('should generate a randomized exam without throwing', () => {
|
||||
const _randomizedExam = generateExam(exam);
|
||||
});
|
||||
|
||||
it('should generate an exam matching with the correct number of question sets', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
// { [type]: numberOfType }
|
||||
// E.g. { MultipleChoice: 2, Dialogue: 1 }
|
||||
const generatedNumberOfSets = generatedExam.questionSets.reduce(
|
||||
(acc, curr) => {
|
||||
const eqs = exam.questionSets.find(eqs => eqs.id === curr.id);
|
||||
|
||||
if (!eqs) {
|
||||
throw new Error('Generated question set not found in exam config');
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[eqs.type]: (acc[eqs.type] || 0) + 1
|
||||
};
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const configNumberOfSets = exam.config.questionSets.reduce(
|
||||
(acc, curr) => {
|
||||
return {
|
||||
...acc,
|
||||
[curr.type]: (acc[curr.type] || 0) + curr.numberOfSet
|
||||
};
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
expect(generatedNumberOfSets).toEqual(configNumberOfSets);
|
||||
});
|
||||
|
||||
it('should not generate any deprecated questions', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
const allQuestions = exam.questionSets.flatMap(qs => qs.questions);
|
||||
|
||||
const deprecatedQuestions = generatedExam.questionSets
|
||||
.flatMap(qs => qs.questions)
|
||||
.filter(q => {
|
||||
const eq = allQuestions.find(eq => eq.id === q.id);
|
||||
if (!eq) {
|
||||
throw new Error('Generated question not found in exam');
|
||||
}
|
||||
return eq.deprecated;
|
||||
});
|
||||
|
||||
expect(deprecatedQuestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not generate an exam with duplicate questions', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
const questionIds = generatedExam.questionSets.flatMap(qs =>
|
||||
qs.questions.map(q => q.id)
|
||||
);
|
||||
|
||||
const duplicateQuestions = questionIds.filter(
|
||||
(id, index) => questionIds.indexOf(id) !== index
|
||||
);
|
||||
|
||||
expect(duplicateQuestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not generate an exam with duplicate answers', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
const answerIds = generatedExam.questionSets.flatMap(qs =>
|
||||
qs.questions.flatMap(q => q.answers)
|
||||
);
|
||||
|
||||
const duplicateAnswers = answerIds.filter(
|
||||
(id, index) => answerIds.indexOf(id) !== index
|
||||
);
|
||||
|
||||
expect(duplicateAnswers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw if the exam config is invalid', () => {
|
||||
const invalidExam = {
|
||||
...exam,
|
||||
config: {
|
||||
...exam.config,
|
||||
tags: [
|
||||
{
|
||||
group: ['non-existant-tag'],
|
||||
numberOfQuestions: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
expect(() => generateExam(invalidExam)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttempt()', () => {
|
||||
it('should validate a correct attempt', () => {
|
||||
validateAttempt(generatedExam, examAttempt.questionSets);
|
||||
});
|
||||
|
||||
it('should invalidate an incorrect attempt', () => {
|
||||
const badExamAttempt = structuredClone(examAttempt);
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = ['bad-answer'];
|
||||
expect(() =>
|
||||
validateAttempt(generatedExam, badExamAttempt.questionSets)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('userAttemptToDatabaseAttemptQuestionSets()', () => {
|
||||
it('should add submission time to all questions', () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
latestAttempt.questionSets = [];
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const allQuestions = databaseAttemptQuestionSets.flatMap(
|
||||
qs => qs.questions
|
||||
);
|
||||
expect(allQuestions.every(q => q.submissionTimeInMS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not change the submission time of any questions that have not changed', () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const submissionTimes = databaseAttemptQuestionSets.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTimeInMS)
|
||||
);
|
||||
|
||||
const sameAttempt = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{ ...latestAttempt, questionSets: databaseAttemptQuestionSets }
|
||||
);
|
||||
|
||||
const sameSubmissionTimes = sameAttempt.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTimeInMS)
|
||||
);
|
||||
|
||||
expect(submissionTimes).toEqual(sameSubmissionTimes);
|
||||
});
|
||||
|
||||
it('should change all submission times of questions that have changed', async () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
userAttempt.questionSets[0]!.questions[0]!.answers = ['001'];
|
||||
|
||||
// The `userAttemptToDatabaseAttemptQuestionSets` function uses `Date.now()`
|
||||
// to set the submission time, so we need to wait a bit to ensure differences.
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const newAttemptQuestionSets = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{
|
||||
...latestAttempt,
|
||||
questionSets: databaseAttemptQuestionSets
|
||||
}
|
||||
);
|
||||
|
||||
expect(
|
||||
newAttemptQuestionSets[0]?.questions[0]?.submissionTimeInMS
|
||||
).not.toEqual(
|
||||
databaseAttemptQuestionSets[0]?.questions[0]?.submissionTimeInMS
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
630
api/src/exam-environment/utils/exam.ts
Normal file
630
api/src/exam-environment/utils/exam.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
||||
import {
|
||||
EnvAnswer,
|
||||
EnvConfig,
|
||||
EnvExam,
|
||||
EnvExamAttempt,
|
||||
EnvGeneratedExam,
|
||||
EnvMultipleChoiceQuestion,
|
||||
EnvQuestionSet,
|
||||
EnvQuestionSetAttempt,
|
||||
user
|
||||
} from '@prisma/client';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import * as schemas from '../schemas';
|
||||
|
||||
/**
|
||||
* Checks if all exam prerequisites have been met by the user.
|
||||
*
|
||||
* TODO: This will be done by getting the challenges required from the curriculum.
|
||||
*/
|
||||
export function checkPrerequisites(_user: user, _prerequisites: unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export type UserExam = Omit<EnvExam, 'questionSets' | 'config' | 'id'> & {
|
||||
config: Omit<EnvExam['config'], 'tags' | 'questionSets'>;
|
||||
questionSets: (Omit<EnvQuestionSet, 'questions'> & {
|
||||
questions: (Omit<
|
||||
EnvMultipleChoiceQuestion,
|
||||
'answers' | 'tags' | 'deprecated'
|
||||
> & {
|
||||
answers: Omit<EnvAnswer, 'isCorrect'>[];
|
||||
})[];
|
||||
})[];
|
||||
} & { generatedExamId: string; examId: string };
|
||||
|
||||
/**
|
||||
* Takes the generated exam and the original exam, and creates the user-facing exam.
|
||||
*/
|
||||
export function constructUserExam(
|
||||
generatedExam: EnvGeneratedExam,
|
||||
exam: EnvExam
|
||||
): UserExam {
|
||||
// Map generated exam to user exam (a.k.a. public exam information for user)
|
||||
const userQuestionSets = generatedExam.questionSets.map(gqs => {
|
||||
// Get matching question from `exam`, but remove `is_correct` from `exam.questions[].answers[]`
|
||||
const examQuestionSet = exam.questionSets.find(eqs => eqs.id === gqs.id)!;
|
||||
|
||||
const { questions } = examQuestionSet;
|
||||
|
||||
const userQuestions = gqs.questions.map(gq => {
|
||||
const examQuestion = questions.find(eq => eq.id === gq.id)!;
|
||||
|
||||
// Remove `isCorrect` from question answers
|
||||
const answers = gq.answers.map(generatedAnswerId => {
|
||||
const examAnswer = examQuestion.answers.find(
|
||||
ea => ea.id === generatedAnswerId
|
||||
)!;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { isCorrect, ...answer } = examAnswer;
|
||||
return answer;
|
||||
});
|
||||
|
||||
return {
|
||||
id: examQuestion.id,
|
||||
audio: examQuestion.audio,
|
||||
text: examQuestion.text,
|
||||
answers
|
||||
};
|
||||
});
|
||||
|
||||
const userQuestionSet = {
|
||||
type: examQuestionSet.type,
|
||||
questions: userQuestions,
|
||||
id: examQuestionSet.id,
|
||||
context: examQuestionSet.context
|
||||
};
|
||||
return userQuestionSet;
|
||||
});
|
||||
|
||||
// Order questionSets in same order as original exam
|
||||
const orderedUserQuestionSets = userQuestionSets.sort((a, b) => {
|
||||
return (
|
||||
exam.questionSets.findIndex(qs => qs.id === a.id) -
|
||||
exam.questionSets.findIndex(qs => qs.id === b.id)
|
||||
);
|
||||
});
|
||||
|
||||
const config = {
|
||||
totalTimeInMS: exam.config.totalTimeInMS,
|
||||
name: exam.config.name,
|
||||
note: exam.config.note
|
||||
};
|
||||
|
||||
const userExam: UserExam = {
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
config,
|
||||
questionSets: orderedUserQuestionSets
|
||||
};
|
||||
|
||||
return userExam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all questions and answers in the attempt are from the generated exam.
|
||||
*/
|
||||
export function validateAttempt(
|
||||
generatedExam: EnvGeneratedExam,
|
||||
questionSets: EnvExamAttempt['questionSets']
|
||||
) {
|
||||
for (const attemptQuestionSet of questionSets) {
|
||||
const generatedQuestionSet = generatedExam.questionSets.find(
|
||||
qt => qt.id === attemptQuestionSet.id
|
||||
);
|
||||
if (!generatedQuestionSet) {
|
||||
throw new Error(
|
||||
`Question type ${attemptQuestionSet.id} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const attemptQuestion of attemptQuestionSet.questions) {
|
||||
const generatedQuestion = generatedQuestionSet.questions.find(
|
||||
q => q.id === attemptQuestion.id
|
||||
);
|
||||
if (!generatedQuestion) {
|
||||
throw new Error(
|
||||
`Question ${attemptQuestion.id} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const attemptAnswer of attemptQuestion.answers) {
|
||||
const generatedAnswer = generatedQuestion.answers.find(
|
||||
a => a === attemptAnswer
|
||||
);
|
||||
if (!generatedAnswer) {
|
||||
throw new Error(
|
||||
`Answer ${attemptAnswer} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all question sets and questions in the generated exam are in the attempt.
|
||||
*
|
||||
* TODO: Consider throwing with specific issue.
|
||||
*
|
||||
* @param questionSets An exam attempt.
|
||||
* @param generatedExam The corresponding generated exam.
|
||||
* @returns Whether or not the attempt can be considered finished.
|
||||
*/
|
||||
export function checkAttemptAgainstGeneratedExam(
|
||||
questionSets: EnvQuestionSetAttempt[],
|
||||
generatedExam: Pick<EnvGeneratedExam, 'questionSets'>
|
||||
): boolean {
|
||||
// Check all question sets and questions are in generated exam
|
||||
for (const generatedQuestionSet of generatedExam.questionSets) {
|
||||
const attemptQuestionSet = questionSets.find(
|
||||
q => q.id === generatedQuestionSet.id
|
||||
);
|
||||
if (!attemptQuestionSet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const generatedQuestion of generatedQuestionSet.questions) {
|
||||
const attemptQuestion = attemptQuestionSet.questions.find(
|
||||
q => q.id === generatedQuestion.id
|
||||
);
|
||||
if (!attemptQuestion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const atLeastOneAnswer = attemptQuestion.answers.length > 0;
|
||||
if (!atLeastOneAnswer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All answers in attempt must be in generated exam
|
||||
const allAnswersInGeneratedExam = attemptQuestion.answers.every(a =>
|
||||
generatedQuestion.answers.includes(a)
|
||||
);
|
||||
if (!allAnswersInGeneratedExam) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the current time submission time to all questions in the attempt if the question answer has changed.
|
||||
*/
|
||||
export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
>,
|
||||
latestAttempt: EnvExamAttempt
|
||||
): EnvExamAttempt['questionSets'] {
|
||||
const databaseAttemptQuestionSets: EnvExamAttempt['questionSets'] = [];
|
||||
|
||||
for (const questionSet of userAttempt.questionSets) {
|
||||
const latestQuestionSet = latestAttempt.questionSets.find(
|
||||
qs => qs.id === questionSet.id
|
||||
);
|
||||
|
||||
// If no latest attempt, add submission time to all questions
|
||||
if (!latestQuestionSet) {
|
||||
databaseAttemptQuestionSets.push({
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
})
|
||||
});
|
||||
} else {
|
||||
const databaseAttemptQuestionSet = {
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
const latestQuestion = latestQuestionSet.questions.find(
|
||||
lq => lq.id === q.id
|
||||
);
|
||||
|
||||
// If no latest question, add submission time
|
||||
if (!latestQuestion) {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
}
|
||||
|
||||
// If answers have changed, add submission time
|
||||
if (
|
||||
JSON.stringify(q.answers) !== JSON.stringify(latestQuestion.answers)
|
||||
) {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
}
|
||||
|
||||
return latestQuestion;
|
||||
})
|
||||
};
|
||||
|
||||
databaseAttemptQuestionSets.push(databaseAttemptQuestionSet);
|
||||
}
|
||||
}
|
||||
|
||||
return databaseAttemptQuestionSets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an exam for the user, based on the exam configuration.
|
||||
*/
|
||||
export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
|
||||
const examCopy = structuredClone(exam);
|
||||
|
||||
const TIMEOUT_IN_MS = 5_000;
|
||||
const START_TIME = Date.now();
|
||||
|
||||
const shuffledQuestionSets = shuffleArray(examCopy.questionSets).map(qs => {
|
||||
const shuffledQuestions = shuffleArray(
|
||||
qs.questions.filter(q => !q.deprecated)
|
||||
).map(q => {
|
||||
const shuffledAnswers = shuffleArray(q.answers);
|
||||
return {
|
||||
...q,
|
||||
answers: shuffledAnswers
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...qs,
|
||||
questions: shuffledQuestions
|
||||
};
|
||||
});
|
||||
|
||||
// Convert question set config by type: [[all question sets of type], [another type], ...]
|
||||
const typeConvertedQuestionSetsConfig = examCopy.config.questionSets.reduce(
|
||||
(acc, curr) => {
|
||||
// If type is already in accumulator, add to it.
|
||||
const typeIndex = acc.findIndex(a => a[0]?.type === curr.type);
|
||||
acc[typeIndex]?.push(curr) ?? acc.push([curr]);
|
||||
return acc;
|
||||
},
|
||||
[] as unknown as [EnvConfig['questionSets']]
|
||||
);
|
||||
|
||||
// Heuristic:
|
||||
// TODO: The lower the number of questions able to fulfill the criteria, the harder the question set.
|
||||
// TODO: Sort difficulty, push question, sort new difficulty, push question, ...
|
||||
typeConvertedQuestionSetsConfig.forEach(qsc => {
|
||||
// Currently, the sorted order is random as this allows the existing algorithm to be retried until
|
||||
// a successful exam generation.
|
||||
qsc.sort(() => Math.round(Math.random() * 2 - 1));
|
||||
});
|
||||
|
||||
const sortedQuestionSetsConfig = typeConvertedQuestionSetsConfig.flat();
|
||||
|
||||
// Move all questions from set that are used to fulfill tag config.
|
||||
const questionSetsConfigWithQuestions = sortedQuestionSetsConfig.map(qsc => {
|
||||
return {
|
||||
...qsc,
|
||||
questionSets: [] as EnvQuestionSet[]
|
||||
};
|
||||
});
|
||||
|
||||
// Sort tag config by number of tags in descending order.
|
||||
const sortedTagConfig = examCopy.config.tags.sort(
|
||||
(a, b) => b.group.length - a.group.length
|
||||
);
|
||||
|
||||
questionSetsConfigWithQuestionsLoop: for (const questionSetConfig of questionSetsConfigWithQuestions) {
|
||||
sortedTagConfigLoop: for (const tagConfig of sortedTagConfig) {
|
||||
shuffledQuestionSetsLoop: for (const questionSet of shuffledQuestionSets.filter(
|
||||
sqs => sqs.type === questionSetConfig.type
|
||||
)) {
|
||||
// If questionSet does not have enough questions for config, do not consider.
|
||||
if (
|
||||
questionSetConfig.numberOfQuestions > questionSet.questions.length
|
||||
) {
|
||||
continue shuffledQuestionSetsLoop;
|
||||
}
|
||||
// If tagConfig is finished, skip.
|
||||
if (tagConfig.numberOfQuestions === 0) {
|
||||
continue sortedTagConfigLoop;
|
||||
}
|
||||
// If questionSetConfig has been fulfilled, skip.
|
||||
if (isQuestionSetConfigFulfilled(questionSetConfig)) {
|
||||
continue questionSetsConfigWithQuestionsLoop;
|
||||
}
|
||||
|
||||
// Find question with at least all tags in the set.
|
||||
const questions = questionSet.questions.filter(q =>
|
||||
tagConfig.group.every(t => q.tags.some(qt => qt === t))
|
||||
);
|
||||
|
||||
questionsLoop: for (const question of questions) {
|
||||
// Does question fulfill criteria for questionSetConfig:
|
||||
const numberOfCorrectAnswers = question.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = question.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
|
||||
if (
|
||||
questionSetConfig.numberOfCorrectAnswers <=
|
||||
numberOfCorrectAnswers &&
|
||||
questionSetConfig.numberOfIncorrectAnswers <=
|
||||
numberOfIncorrectAnswers
|
||||
) {
|
||||
if (isQuestionSetConfigFulfilled(questionSetConfig)) {
|
||||
continue questionSetsConfigWithQuestionsLoop;
|
||||
}
|
||||
// Push questionSet if it does not exist. Otherwise, just push question
|
||||
const qscqs = questionSetConfig.questionSets.find(
|
||||
qs => qs.id === questionSet.id
|
||||
);
|
||||
const questionWithCorrectNumberOfAnswers = {
|
||||
...question,
|
||||
answers: getRandomAnswers(question, questionSetConfig)
|
||||
};
|
||||
if (!qscqs) {
|
||||
if (
|
||||
questionSetConfig.numberOfSet ===
|
||||
questionSetConfig.questionSets.length
|
||||
) {
|
||||
break questionsLoop;
|
||||
}
|
||||
const newQuestionSetWithQuestion = {
|
||||
...questionSet,
|
||||
questions: [questionWithCorrectNumberOfAnswers]
|
||||
};
|
||||
questionSetConfig.questionSets.push(newQuestionSetWithQuestion);
|
||||
} else {
|
||||
if (
|
||||
qscqs.questions.length === questionSetConfig.numberOfQuestions
|
||||
) {
|
||||
break questionsLoop;
|
||||
}
|
||||
qscqs.questions.push(questionWithCorrectNumberOfAnswers);
|
||||
}
|
||||
|
||||
// TODO: Issue is question set is not being removed. So, one question set is used multiple times to fulfill config.
|
||||
// Just remove question set once used? Evaluate:
|
||||
shuffledQuestionSets.splice(
|
||||
shuffledQuestionSets.findIndex(qs => qs.id === questionSet.id),
|
||||
1
|
||||
);
|
||||
// New issue: Once the set is removed, tag config might not be able to be fulfilled.
|
||||
|
||||
// Remove question from questionSet, decrement tagConfig.numberOfQuestions and `questionSetConfig.numberOfQuestions`
|
||||
questionSet.questions.splice(
|
||||
questionSet.questions.findIndex(q => q.id === question.id),
|
||||
1
|
||||
);
|
||||
tagConfig.numberOfQuestions -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add questions to questionSetsConfigWithQuestions until fulfilled.
|
||||
while (!isQuestionSetConfigFulfilled(questionSetConfig)) {
|
||||
if (Date.now() - START_TIME > TIMEOUT_IN_MS) {
|
||||
throw `Unable to generate exam within ${TIMEOUT_IN_MS}ms`;
|
||||
}
|
||||
// Ensure all questionSets ARE FULL
|
||||
if (
|
||||
questionSetConfig.numberOfSet > questionSetConfig.questionSets.length
|
||||
) {
|
||||
const questionSet = shuffledQuestionSets.find(qs => {
|
||||
if (qs.type === questionSetConfig.type) {
|
||||
if (qs.questions.length >= questionSetConfig.numberOfQuestions) {
|
||||
if (qs.questions.length >= questionSetConfig.numberOfQuestions) {
|
||||
// Find questionSetConfig.numberOfQuestions who have `questionSetConfig.numberOfCorrectAnswers` and `questionSetConfig.numberOfIncorrectAnswers`
|
||||
const questions = qs.questions.filter(q => {
|
||||
const numberOfCorrectAnswers = q.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = q.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
return (
|
||||
numberOfCorrectAnswers >=
|
||||
questionSetConfig.numberOfCorrectAnswers &&
|
||||
numberOfIncorrectAnswers >=
|
||||
questionSetConfig.numberOfIncorrectAnswers
|
||||
);
|
||||
});
|
||||
|
||||
if (questions.length >= questionSetConfig.numberOfQuestions) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!questionSet) {
|
||||
throw `Invalid Exam Configuration for ${examCopy.id}. Not enough questions for question type ${questionSetConfig.type}.`;
|
||||
}
|
||||
// Remove questionSet from shuffledQuestionSets
|
||||
shuffledQuestionSets.splice(
|
||||
shuffledQuestionSets.findIndex(qs => qs.id === questionSet.id),
|
||||
1
|
||||
);
|
||||
|
||||
const questions = questionSet.questions.filter(q => {
|
||||
const numberOfCorrectAnswers = q.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = q.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
return (
|
||||
numberOfCorrectAnswers >=
|
||||
questionSetConfig.numberOfCorrectAnswers &&
|
||||
numberOfIncorrectAnswers >=
|
||||
questionSetConfig.numberOfIncorrectAnswers
|
||||
);
|
||||
});
|
||||
|
||||
const questionSetWithCorrectNumberOfAnswers = {
|
||||
...questionSet,
|
||||
questions: questions.map(q => ({
|
||||
...q,
|
||||
answers: getRandomAnswers(q, questionSetConfig)
|
||||
}))
|
||||
};
|
||||
|
||||
questionSetConfig.questionSets.push(
|
||||
questionSetWithCorrectNumberOfAnswers
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure all existing questionSets have correct number of questions
|
||||
for (const questionSet of questionSetConfig.questionSets) {
|
||||
if (
|
||||
questionSet.questions.length < questionSetConfig.numberOfQuestions
|
||||
) {
|
||||
const questions = shuffledQuestionSets
|
||||
.find(qs => qs.id === questionSet.id)
|
||||
?.questions.filter(
|
||||
q => !questionSet.questions.find(qsq => qsq.id === q.id)
|
||||
);
|
||||
if (!questions) {
|
||||
throw `Invalid Exam Configuration for ${examCopy.id}. Not enough questions for question type ${questionSetConfig.type}.`;
|
||||
}
|
||||
|
||||
const questionsWithEnoughAnswers = questions.filter(q => {
|
||||
const numberOfCorrectAnswers = q.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = q.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
return (
|
||||
numberOfCorrectAnswers >=
|
||||
questionSetConfig.numberOfCorrectAnswers &&
|
||||
numberOfIncorrectAnswers >=
|
||||
questionSetConfig.numberOfIncorrectAnswers
|
||||
);
|
||||
});
|
||||
|
||||
// Push as many questions as needed to fulfill questionSetConfig
|
||||
const questionsToAdd = questionsWithEnoughAnswers.splice(
|
||||
0,
|
||||
questionSetConfig.numberOfQuestions - questionSet.questions.length
|
||||
);
|
||||
|
||||
questionSet.questions.push(
|
||||
...questionsToAdd.map(q => ({
|
||||
...q,
|
||||
answers: getRandomAnswers(q, questionSetConfig)
|
||||
}))
|
||||
);
|
||||
|
||||
// Remove questions from shuffledQuestionSets
|
||||
questionsToAdd.forEach(q => {
|
||||
const index = shuffledQuestionSets
|
||||
.find(qs => qs.id === questionSet.id)!
|
||||
.questions.findIndex(qs => qs.id === q.id);
|
||||
|
||||
shuffledQuestionSets
|
||||
.find(qs => qs.id === questionSet.id)
|
||||
?.questions.splice(index, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tagConfig of sortedTagConfig) {
|
||||
if (tagConfig.numberOfQuestions > 0) {
|
||||
throw `Invalid Exam Configuration for exam "${examCopy.id}". Not enough questions for tag group "${tagConfig.group.join(',')}".`;
|
||||
}
|
||||
}
|
||||
|
||||
const questionSets = questionSetsConfigWithQuestions.flatMap(qsc => {
|
||||
const questionSets = qsc.questionSets;
|
||||
return questionSets.map(qs => {
|
||||
const questions = qs.questions.map(q => {
|
||||
const answers = q.answers.map(a => a.id);
|
||||
return {
|
||||
id: q.id,
|
||||
answers
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: qs.id,
|
||||
questions
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
examId: examCopy.id,
|
||||
questionSets,
|
||||
deprecated: false
|
||||
};
|
||||
}
|
||||
|
||||
function isQuestionSetConfigFulfilled(
|
||||
questionSetConfig: EnvConfig['questionSets'][number] & {
|
||||
questionSets: EnvQuestionSet[];
|
||||
}
|
||||
) {
|
||||
return (
|
||||
questionSetConfig.numberOfSet === questionSetConfig.questionSets.length &&
|
||||
questionSetConfig.questionSets.every(qs => {
|
||||
return qs.questions.length === questionSetConfig.numberOfQuestions;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets random answers for a question.
|
||||
*/
|
||||
function getRandomAnswers(
|
||||
question: EnvMultipleChoiceQuestion,
|
||||
questionSetConfig: EnvConfig['questionSets'][number]
|
||||
): EnvMultipleChoiceQuestion['answers'] {
|
||||
const { numberOfCorrectAnswers, numberOfIncorrectAnswers } =
|
||||
questionSetConfig;
|
||||
|
||||
const randomAnswers = shuffleArray(question.answers);
|
||||
const incorrectAnswers = randomAnswers
|
||||
.filter(a => !a.isCorrect)
|
||||
.splice(0, numberOfIncorrectAnswers);
|
||||
const correctAnswers = randomAnswers
|
||||
.filter(a => a.isCorrect)
|
||||
.splice(0, numberOfCorrectAnswers);
|
||||
|
||||
if (!incorrectAnswers || !correctAnswers) {
|
||||
throw new Error(
|
||||
`Question ${question.id} does not have enough correct/incorrect answers.`
|
||||
);
|
||||
}
|
||||
|
||||
const answers = incorrectAnswers.concat(correctAnswers);
|
||||
return answers;
|
||||
}
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Shuffles an array using the Fisher-Yates algorithm.
|
||||
*
|
||||
* https://bost.ocks.org/mike/shuffle/
|
||||
*/
|
||||
function shuffleArray<T>(array: Array<T>) {
|
||||
let m = array.length;
|
||||
let t;
|
||||
let i;
|
||||
|
||||
// While there remain elements to shuffle…
|
||||
while (m) {
|
||||
// Pick a remaining element…
|
||||
i = Math.floor(Math.random() * m--);
|
||||
|
||||
// And swap it with the current element.
|
||||
t = array[m]!;
|
||||
array[m] = array[i]!;
|
||||
array[i] = t;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
/* eslint-enable jsdoc/require-description-complete-sentence */
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import { FastifyPluginCallback, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { type user } from '@prisma/client';
|
||||
|
||||
import { JWT_SECRET } from '../utils/env';
|
||||
import { type Token, isExpired } from '../utils/tokens';
|
||||
import { ERRORS } from '../exam-environment/utils/errors';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
@@ -19,6 +20,10 @@ declare module 'fastify' {
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
authorizeExamEnvironmentToken: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +75,94 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
req.user = user;
|
||||
};
|
||||
|
||||
async function handleExamEnvironmentTokenAuth(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { 'exam-environment-authorization-token': encodedToken } =
|
||||
req.headers;
|
||||
|
||||
if (!encodedToken || typeof encodedToken !== 'string') {
|
||||
void reply.code(400);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN header is a required string.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(encodedToken, JWT_SECRET);
|
||||
} catch (e) {
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
JSON.stringify(e)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const payload = jwt.decode(encodedToken);
|
||||
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'Unreachable. Decoded token has been verified.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const examEnvironmentAuthorizationToken =
|
||||
payload['examEnvironmentAuthorizationToken'];
|
||||
|
||||
// if (typeof examEnvironmentAuthorizationToken !== 'string') {
|
||||
// // TODO: This code is debatable, because the token would have to have been signed by the api
|
||||
// // which means it is valid, but, somehow, got signed as an object instead of a string.
|
||||
// void reply.code(400+500);
|
||||
// return reply.send(
|
||||
// ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
// 'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN is not valid.'
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
assertIsString(examEnvironmentAuthorizationToken);
|
||||
|
||||
const token =
|
||||
await fastify.prisma.examEnvironmentAuthorizationToken.findFirst({
|
||||
where: {
|
||||
id: examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
message: 'Token not found'
|
||||
};
|
||||
}
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: token.userId }
|
||||
});
|
||||
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
||||
req.user = user;
|
||||
}
|
||||
|
||||
fastify.decorate('authorize', handleAuth);
|
||||
fastify.decorate(
|
||||
'authorizeExamEnvironmentToken',
|
||||
handleExamEnvironmentTokenAuth
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
function assertIsString(some: unknown): asserts some is string {
|
||||
if (typeof some !== 'string') {
|
||||
throw new Error('Expected a string');
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(auth, { name: 'auth', dependencies: ['cookies'] });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type PrismaPromise } from '@prisma/client';
|
||||
import { Certification } from '../../../../shared/config/certification-settings';
|
||||
import {
|
||||
defaultUserEmail,
|
||||
@@ -85,7 +84,10 @@ describe('certificate routes', () => {
|
||||
jest
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
|
||||
.mockImplementation(
|
||||
() => Promise.resolve(null) as PrismaPromise<null>
|
||||
() =>
|
||||
Promise.resolve(null) as ReturnType<
|
||||
typeof fastifyTestInstance.prisma.user.findUnique
|
||||
>
|
||||
);
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import _ from 'lodash';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import * as schemas from '../../schemas';
|
||||
import { createResetProperties } from '../../utils/create-user';
|
||||
@@ -16,11 +19,13 @@ import {
|
||||
normalizeTwitter,
|
||||
removeNulls
|
||||
} from '../../utils/normalize';
|
||||
import type { UpdateReqType } from '../../utils';
|
||||
import {
|
||||
getCalendar,
|
||||
getPoints,
|
||||
ProgressTimestamp
|
||||
} from '../../utils/progress';
|
||||
import { JWT_SECRET } from '../../utils/env';
|
||||
|
||||
/**
|
||||
* Helper function to get the api url from the shared transcript link.
|
||||
@@ -71,6 +76,9 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
await fastify.prisma.user.delete({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.examEnvironmentAuthorizationToken.deleteMany({
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
reply.clearOurCookies();
|
||||
|
||||
return {};
|
||||
@@ -356,9 +364,59 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/user/exam-environment/token',
|
||||
{
|
||||
schema: schemas.userExamEnvironmentToken
|
||||
},
|
||||
examEnvironmentTokenHandler
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-param
|
||||
/**
|
||||
* Generate a new authorization token for the given user, and invalidates any existing tokens.
|
||||
*
|
||||
* Requires the user to be authenticated.
|
||||
*/
|
||||
async function examEnvironmentTokenHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.userExamEnvironmentToken>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
throw new Error('Unreachable. User should be authenticated.');
|
||||
}
|
||||
// Delete (invalidate) any existing tokens for the user.
|
||||
await this.prisma.examEnvironmentAuthorizationToken.deleteMany({
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
const token = await this.prisma.examEnvironmentAuthorizationToken.create({
|
||||
data: {
|
||||
createdDate: new Date(),
|
||||
id: customNanoid(),
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
const examEnvironmentAuthorizationToken = jwt.sign(
|
||||
{ examEnvironmentAuthorizationToken: token.id },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
void reply.send({
|
||||
data: {
|
||||
examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin containing GET routes for user account management. They are kept
|
||||
* separate because they do not require CSRF protection.
|
||||
|
||||
@@ -39,3 +39,4 @@ export { postMsUsername } from './schemas/user/post-ms-username';
|
||||
export { reportUser } from './schemas/user/report-user';
|
||||
export { resetMyProgress } from './schemas/user/reset-my-progress';
|
||||
export { submitSurvey } from './schemas/user/submit-survey';
|
||||
export { userExamEnvironmentToken } from './schemas/user/exam-environment-token';
|
||||
|
||||
11
api/src/schemas/user/exam-environment-token.ts
Normal file
11
api/src/schemas/user/exam-environment-token.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const userExamEnvironmentToken = {
|
||||
response: {
|
||||
200: Type.Object({
|
||||
data: Type.Object({
|
||||
examEnvironmentAuthorizationToken: Type.String()
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,12 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { type TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
type FastifyRequest,
|
||||
type FastifySchema,
|
||||
type RawRequestDefaultExpression,
|
||||
type RawServerDefault,
|
||||
type RouteGenericInterface
|
||||
} from 'fastify';
|
||||
|
||||
/**
|
||||
* Utility to encode a buffer to a base64 URI.
|
||||
@@ -19,3 +27,84 @@ function sha256(buf: Buffer) {
|
||||
return createHash('sha256').update(buf).digest();
|
||||
}
|
||||
export const challenge = base64URLEncode(sha256(Buffer.from(verifier)));
|
||||
|
||||
export type UpdateReqType<Schema extends FastifySchema> = FastifyRequest<
|
||||
RouteGenericInterface,
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression<RawServerDefault>,
|
||||
Schema,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Wrapper around a promise to catch errors and return them as part of the promise.
|
||||
*
|
||||
* This is most useful to prevent callback / try...catch hell.
|
||||
*
|
||||
* ## Example:
|
||||
*
|
||||
* ```ts
|
||||
* const maybeWhatIWant = await mapErr(
|
||||
* this.prisma.whatIWantCollection.create({
|
||||
* data: {}
|
||||
* })
|
||||
* );
|
||||
*
|
||||
* if (maybeWhatIWant.hasError) {
|
||||
* void reply.code(500);
|
||||
* return reply.send('Unable to generate exam, due to: ' +
|
||||
* JSON.stringify(maybeWhatIWant.error)
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* const whatIWant = maybeWhatIWant.data;
|
||||
* ```
|
||||
*
|
||||
* @param promise - any promise to be tried.
|
||||
* @returns a promise with either the data or the caught error
|
||||
*/
|
||||
export async function mapErr<T>(promise: Promise<T>): Promise<Result<T>> {
|
||||
try {
|
||||
return { hasError: false, data: await promise };
|
||||
} catch (error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around a synchronise function to catch throws and return them as part of the value.
|
||||
*
|
||||
* This is most useful to prevent try...catch hell.
|
||||
*
|
||||
* ## Example:
|
||||
*
|
||||
* ```ts
|
||||
* const maybeWhatIWant = await syncMapErr(
|
||||
* () => chai.assert.deepEqual({}, {})
|
||||
* );
|
||||
*
|
||||
* if (maybeWhatIWant.hasError) {
|
||||
* void reply.code(500);
|
||||
* return reply.send('Unable to generate exam, due to: ' +
|
||||
* JSON.stringify(maybeWhatIWant.error)
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* const whatIWant = maybeWhatIWant.data;
|
||||
* ```
|
||||
*
|
||||
* @param fn - any function to be tried.
|
||||
* @returns the data or the caught error
|
||||
*/
|
||||
export function syncMapErr<T>(fn: () => T): Result<T> {
|
||||
try {
|
||||
return { hasError: false, data: fn() };
|
||||
} catch (error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
}
|
||||
|
||||
export type Result<T> =
|
||||
| { hasError: false; data: T }
|
||||
| { hasError: true; error: unknown };
|
||||
|
||||
@@ -60,9 +60,10 @@
|
||||
"preseed": "npm-run-all create:shared",
|
||||
"playwright:install-build-tools": "npx playwright install --with-deps",
|
||||
"rename-challenges": "ts-node tools/challenge-helper-scripts/rename-challenge-files.ts",
|
||||
"seed": "pnpm seed:surveys && pnpm seed:exams && DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user",
|
||||
"seed": "pnpm seed:surveys && pnpm seed:exams && pnpm seed:env-exam && DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user",
|
||||
"seed:certified-user": "pnpm seed:surveys && pnpm seed:exams && pnpm seed:ms-username && DEBUG=fcc:* node ./tools/scripts/seed/seed-demo-user --certified-user",
|
||||
"seed:exams": "DEBUG=fcc:* node tools/scripts/seed-exams/create-exams",
|
||||
"seed:env-exam": "cd api && pnpm run seed:env-exam",
|
||||
"seed:surveys": "DEBUG=fcc:* node ./tools/scripts/seed/seed-surveys",
|
||||
"seed:ms-username": "DEBUG=fcc:* node ./tools/scripts/seed/seed-ms-username",
|
||||
"serve:client": "cd ./client && pnpm run serve",
|
||||
|
||||
292
pnpm-lock.yaml
generated
292
pnpm-lock.yaml
generated
@@ -256,6 +256,9 @@ importers:
|
||||
ts-jest:
|
||||
specifier: 29.1.2
|
||||
version: 29.1.2(@babel/core@7.23.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.7))(jest@29.7.0(@types/node@20.12.8)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.12.8)(typescript@5.4.5)))(typescript@5.4.5)
|
||||
tsx:
|
||||
specifier: 4.19.1
|
||||
version: 4.19.1
|
||||
|
||||
api-server:
|
||||
dependencies:
|
||||
@@ -2673,138 +2676,282 @@ packages:
|
||||
resolution: {integrity: sha512-R1w57YlVA6+YE01wch3GPYn6bCsrOV3YW/5oGGE2tmX6JcL9Nr+b5IikrjMPF+v9CV3ay+obImEdsDhovhJrzw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.23.1':
|
||||
resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.23.1':
|
||||
resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.18.20':
|
||||
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.23.1':
|
||||
resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.18.20':
|
||||
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.23.1':
|
||||
resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.23.1':
|
||||
resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.18.20':
|
||||
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.23.1':
|
||||
resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.23.1':
|
||||
resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.18.20':
|
||||
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.23.1':
|
||||
resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.23.1':
|
||||
resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.18.20':
|
||||
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.23.1':
|
||||
resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.18.20':
|
||||
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.23.1':
|
||||
resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.18.20':
|
||||
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.23.1':
|
||||
resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.18.20':
|
||||
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.23.1':
|
||||
resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.18.20':
|
||||
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.23.1':
|
||||
resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.18.20':
|
||||
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.23.1':
|
||||
resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.18.20':
|
||||
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.23.1':
|
||||
resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.18.20':
|
||||
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.23.1':
|
||||
resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.18.20':
|
||||
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.23.1':
|
||||
resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.23.1':
|
||||
resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.18.20':
|
||||
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.23.1':
|
||||
resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.18.20':
|
||||
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.23.1':
|
||||
resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.18.20':
|
||||
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.23.1':
|
||||
resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.18.20':
|
||||
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.23.1':
|
||||
resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.18.20':
|
||||
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.23.1':
|
||||
resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.4.0':
|
||||
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -6615,6 +6762,11 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.23.1:
|
||||
resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
escalade@3.1.1:
|
||||
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -7497,6 +7649,9 @@ packages:
|
||||
get-tsconfig@4.7.2:
|
||||
resolution: {integrity: sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==}
|
||||
|
||||
get-tsconfig@4.8.1:
|
||||
resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==}
|
||||
|
||||
get-uri@6.0.3:
|
||||
resolution: {integrity: sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -12582,6 +12737,11 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
|
||||
|
||||
tsx@4.19.1:
|
||||
resolution: {integrity: sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tty-browserify@0.0.1:
|
||||
resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==}
|
||||
|
||||
@@ -14156,7 +14316,7 @@ snapshots:
|
||||
'@babel/traverse': 7.23.7
|
||||
'@babel/types': 7.23.9
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -16369,7 +16529,7 @@ snapshots:
|
||||
'@babel/helper-split-export-declaration': 7.22.6
|
||||
'@babel/parser': 7.23.6
|
||||
'@babel/types': 7.23.9
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
globals: 11.12.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -16474,72 +16634,144 @@ snapshots:
|
||||
esquery: 1.5.0
|
||||
jsdoc-type-pratt-parser: 4.0.0
|
||||
|
||||
'@esbuild/aix-ppc64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.23.1':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)':
|
||||
dependencies:
|
||||
eslint: 8.57.0
|
||||
@@ -18855,7 +19087,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -19187,7 +19419,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@fastify/error': 3.4.1
|
||||
archy: 1.0.0
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
fastq: 1.17.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -20749,10 +20981,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
debug@4.3.4:
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
|
||||
debug@4.3.4(supports-color@8.1.1):
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
@@ -21438,6 +21666,33 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.18.20
|
||||
'@esbuild/win32-x64': 0.18.20
|
||||
|
||||
esbuild@0.23.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.23.1
|
||||
'@esbuild/android-arm': 0.23.1
|
||||
'@esbuild/android-arm64': 0.23.1
|
||||
'@esbuild/android-x64': 0.23.1
|
||||
'@esbuild/darwin-arm64': 0.23.1
|
||||
'@esbuild/darwin-x64': 0.23.1
|
||||
'@esbuild/freebsd-arm64': 0.23.1
|
||||
'@esbuild/freebsd-x64': 0.23.1
|
||||
'@esbuild/linux-arm': 0.23.1
|
||||
'@esbuild/linux-arm64': 0.23.1
|
||||
'@esbuild/linux-ia32': 0.23.1
|
||||
'@esbuild/linux-loong64': 0.23.1
|
||||
'@esbuild/linux-mips64el': 0.23.1
|
||||
'@esbuild/linux-ppc64': 0.23.1
|
||||
'@esbuild/linux-riscv64': 0.23.1
|
||||
'@esbuild/linux-s390x': 0.23.1
|
||||
'@esbuild/linux-x64': 0.23.1
|
||||
'@esbuild/netbsd-x64': 0.23.1
|
||||
'@esbuild/openbsd-arm64': 0.23.1
|
||||
'@esbuild/openbsd-x64': 0.23.1
|
||||
'@esbuild/sunos-x64': 0.23.1
|
||||
'@esbuild/win32-arm64': 0.23.1
|
||||
'@esbuild/win32-ia32': 0.23.1
|
||||
'@esbuild/win32-x64': 0.23.1
|
||||
|
||||
escalade@3.1.1: {}
|
||||
|
||||
escalade@3.1.2: {}
|
||||
@@ -23029,6 +23284,10 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
get-tsconfig@4.8.1:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
get-uri@6.0.3:
|
||||
dependencies:
|
||||
basic-ftp: 5.0.5
|
||||
@@ -23626,7 +23885,7 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -24150,7 +24409,7 @@ snapshots:
|
||||
|
||||
istanbul-lib-source-maps@4.0.1:
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
istanbul-lib-coverage: 3.2.0
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
@@ -24696,7 +24955,7 @@ snapshots:
|
||||
|
||||
json-schema-resolver@2.0.0:
|
||||
dependencies:
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
rfdc: 1.3.0
|
||||
uri-js: 4.4.1
|
||||
transitivePeerDependencies:
|
||||
@@ -28478,7 +28737,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@hapi/hoek': 11.0.4
|
||||
'@hapi/wreck': 18.1.0
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
joi: 17.12.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -29060,7 +29319,7 @@ snapshots:
|
||||
dependencies:
|
||||
component-emitter: 1.3.0
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.3.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.0
|
||||
formidable: 2.1.2
|
||||
@@ -29456,6 +29715,13 @@ snapshots:
|
||||
tslib: 1.14.1
|
||||
typescript: 5.4.5
|
||||
|
||||
tsx@4.19.1:
|
||||
dependencies:
|
||||
esbuild: 0.23.1
|
||||
get-tsconfig: 4.8.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tty-browserify@0.0.1: {}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
|
||||
Reference in New Issue
Block a user