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:
Shaun Hamilton
2024-10-04 16:20:18 +02:00
committed by GitHub
parent ced457fed5
commit ea44135808
26 changed files with 3435 additions and 21 deletions

View File

@@ -8,3 +8,5 @@ docker/**/Dockerfile
**/*docker-compose*
**/node_modules
.eslintcache
api/__mocks__
api/src/exam-environment/seed

View File

@@ -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
View 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) {
// //
// }
// }
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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);

View 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();

View 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'
}
});
});
});
});
});

View 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);
}

View 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
}
};

View 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
}
};

View 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';

View File

@@ -0,0 +1,7 @@
// import { Type } from '@fastify/type-provider-typebox';
export const examEnvironmentPostScreenshot = {
response: {
// 200: Type.Object({})
}
};

View 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
])
}
};

View 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();

View 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()
});

View 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
);
});
});
});

View 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 */

View File

@@ -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'] });

View File

@@ -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',

View File

@@ -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.

View File

@@ -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';

View 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()
})
})
}
};

View File

@@ -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 };

View File

@@ -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
View File

@@ -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: