From ea441358084784a628cd39d1adbf3b466621a356 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Fri, 4 Oct 2024 16:20:18 +0200 Subject: [PATCH] feat(api): add exam-environment endpoints (#55662) Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams --- .dockerignore | 2 + api/README.md | 6 + api/__mocks__/env-exam.ts | 370 ++++++++++ api/package.json | 7 +- api/prisma/schema.prisma | 194 +++++- api/src/app.ts | 19 +- api/src/exam-environment/generate/index.ts | 54 ++ .../routes/exam-environment.test.ts | 594 +++++++++++++++++ .../routes/exam-environment.ts | 567 ++++++++++++++++ .../exam-environment/schemas/exam-attempt.ts | 31 + .../schemas/exam-generated-exam.ts | 23 + api/src/exam-environment/schemas/index.ts | 4 + .../exam-environment/schemas/screenshot.ts | 7 + .../exam-environment/schemas/token-verify.ts | 21 + api/src/exam-environment/seed/index.ts | 24 + api/src/exam-environment/utils/errors.ts | 59 ++ api/src/exam-environment/utils/exam.test.ts | 292 ++++++++ api/src/exam-environment/utils/exam.ts | 630 ++++++++++++++++++ api/src/plugins/auth.ts | 92 ++- api/src/routes/protected/certificate.test.ts | 6 +- api/src/routes/protected/user.ts | 58 ++ api/src/schemas.ts | 1 + .../schemas/user/exam-environment-token.ts | 11 + api/src/utils/index.ts | 89 +++ package.json | 3 +- pnpm-lock.yaml | 292 +++++++- 26 files changed, 3435 insertions(+), 21 deletions(-) create mode 100644 api/__mocks__/env-exam.ts create mode 100644 api/src/exam-environment/generate/index.ts create mode 100644 api/src/exam-environment/routes/exam-environment.test.ts create mode 100644 api/src/exam-environment/routes/exam-environment.ts create mode 100644 api/src/exam-environment/schemas/exam-attempt.ts create mode 100644 api/src/exam-environment/schemas/exam-generated-exam.ts create mode 100644 api/src/exam-environment/schemas/index.ts create mode 100644 api/src/exam-environment/schemas/screenshot.ts create mode 100644 api/src/exam-environment/schemas/token-verify.ts create mode 100644 api/src/exam-environment/seed/index.ts create mode 100644 api/src/exam-environment/utils/errors.ts create mode 100644 api/src/exam-environment/utils/exam.test.ts create mode 100644 api/src/exam-environment/utils/exam.ts create mode 100644 api/src/schemas/user/exam-environment-token.ts diff --git a/.dockerignore b/.dockerignore index ed2e7b45a94..56b5d68054e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,5 @@ docker/**/Dockerfile **/*docker-compose* **/node_modules .eslintcache +api/__mocks__ +api/src/exam-environment/seed diff --git a/api/README.md b/api/README.md index 90f4afed1b9..14919f7207f 100644 --- a/api/README.md +++ b/api/README.md @@ -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 +``` diff --git a/api/__mocks__/env-exam.ts b/api/__mocks__/env-exam.ts new file mode 100644 index 00000000000..7f690c2cc76 --- /dev/null +++ b/api/__mocks__/env-exam.ts @@ -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) { + // // + // } + // } +} diff --git a/api/package.json b/api/package.json index ebf004495c7..9a0e509ba31 100644 --- a/api/package.json +++ b/api/package.json @@ -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" } diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 182fec37e43..4d823ea6af9 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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: `#t=,` + /// 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 diff --git a/api/src/app.ts b/api/src/app.ts index 0177fbf8814..9c92a80f9d3 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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); diff --git a/api/src/exam-environment/generate/index.ts b/api/src/exam-environment/generate/index.ts new file mode 100644 index 00000000000..1f3d10b5122 --- /dev/null +++ b/api/src/exam-environment/generate/index.ts @@ -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(); diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts new file mode 100644 index 00000000000..959cdc13514 --- /dev/null +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -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; + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = + { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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; + + // 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 = { + 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 = { + 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' + } + }); + }); + }); + }); +}); diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts new file mode 100644 index 00000000000..b17da669557 --- /dev/null +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -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, + 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, + 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, + 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, + reply: FastifyReply +) { + return reply.code(418); +} diff --git a/api/src/exam-environment/schemas/exam-attempt.ts b/api/src/exam-environment/schemas/exam-attempt.ts new file mode 100644 index 00000000000..e7ceb75c30e --- /dev/null +++ b/api/src/exam-environment/schemas/exam-attempt.ts @@ -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 + } +}; diff --git a/api/src/exam-environment/schemas/exam-generated-exam.ts b/api/src/exam-environment/schemas/exam-generated-exam.ts new file mode 100644 index 00000000000..d0f45adb940 --- /dev/null +++ b/api/src/exam-environment/schemas/exam-generated-exam.ts @@ -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 + } +}; diff --git a/api/src/exam-environment/schemas/index.ts b/api/src/exam-environment/schemas/index.ts new file mode 100644 index 00000000000..254e1018ae9 --- /dev/null +++ b/api/src/exam-environment/schemas/index.ts @@ -0,0 +1,4 @@ +export { examEnvironmentPostExamAttempt } from './exam-attempt'; +export { examEnvironmentPostExamGeneratedExam } from './exam-generated-exam'; +export { examEnvironmentPostScreenshot } from './screenshot'; +export { examEnvironmentTokenVerify } from './token-verify'; diff --git a/api/src/exam-environment/schemas/screenshot.ts b/api/src/exam-environment/schemas/screenshot.ts new file mode 100644 index 00000000000..ddb60f3d9ec --- /dev/null +++ b/api/src/exam-environment/schemas/screenshot.ts @@ -0,0 +1,7 @@ +// import { Type } from '@fastify/type-provider-typebox'; + +export const examEnvironmentPostScreenshot = { + response: { + // 200: Type.Object({}) + } +}; diff --git a/api/src/exam-environment/schemas/token-verify.ts b/api/src/exam-environment/schemas/token-verify.ts new file mode 100644 index 00000000000..d8042da65f1 --- /dev/null +++ b/api/src/exam-environment/schemas/token-verify.ts @@ -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 + ]) + } +}; diff --git a/api/src/exam-environment/seed/index.ts b/api/src/exam-environment/seed/index.ts new file mode 100644 index 00000000000..4c0830edf1f --- /dev/null +++ b/api/src/exam-environment/seed/index.ts @@ -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(); diff --git a/api/src/exam-environment/utils/errors.ts b/api/src/exam-environment/utils/errors.ts new file mode 100644 index 00000000000..ec1cf60bc8c --- /dev/null +++ b/api/src/exam-environment/utils/errors.ts @@ -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() +}); diff --git a/api/src/exam-environment/utils/exam.test.ts b/api/src/exam-environment/utils/exam.test.ts new file mode 100644 index 00000000000..db223bfe137 --- /dev/null +++ b/api/src/exam-environment/utils/exam.test.ts @@ -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 + ); + + const configNumberOfSets = exam.config.questionSets.reduce( + (acc, curr) => { + return { + ...acc, + [curr.type]: (acc[curr.type] || 0) + curr.numberOfSet + }; + }, + {} as Record + ); + + 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 + ); + }); + }); +}); diff --git a/api/src/exam-environment/utils/exam.ts b/api/src/exam-environment/utils/exam.ts new file mode 100644 index 00000000000..d6e8b92da3c --- /dev/null +++ b/api/src/exam-environment/utils/exam.ts @@ -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 & { + config: Omit; + questionSets: (Omit & { + questions: (Omit< + EnvMultipleChoiceQuestion, + 'answers' | 'tags' | 'deprecated' + > & { + answers: Omit[]; + })[]; + })[]; +} & { 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 +): 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 { + 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(array: Array) { + 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 */ diff --git a/api/src/plugins/auth.ts b/api/src/plugins/auth.ts index dec00c38987..aa227eae3c6 100644 --- a/api/src/plugins/auth.ts +++ b/api/src/plugins/auth.ts @@ -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'] }); diff --git a/api/src/routes/protected/certificate.test.ts b/api/src/routes/protected/certificate.test.ts index 9fe1238adce..4466346372e 100644 --- a/api/src/routes/protected/certificate.test.ts +++ b/api/src/routes/protected/certificate.test.ts @@ -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 + () => + Promise.resolve(null) as ReturnType< + typeof fastifyTestInstance.prisma.user.findUnique + > ); const response = await superRequest('/certificate/verify', { method: 'PUT', diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 2e8d294b716..07c33e93063 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -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, + 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. diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 28849f28537..d359ed7d3f3 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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'; diff --git a/api/src/schemas/user/exam-environment-token.ts b/api/src/schemas/user/exam-environment-token.ts new file mode 100644 index 00000000000..c324802e861 --- /dev/null +++ b/api/src/schemas/user/exam-environment-token.ts @@ -0,0 +1,11 @@ +import { Type } from '@fastify/type-provider-typebox'; + +export const userExamEnvironmentToken = { + response: { + 200: Type.Object({ + data: Type.Object({ + examEnvironmentAuthorizationToken: Type.String() + }) + }) + } +}; diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index 18097405724..d34ca2c3d13 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -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 = FastifyRequest< + RouteGenericInterface, + RawServerDefault, + RawRequestDefaultExpression, + 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(promise: Promise): Promise> { + 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(fn: () => T): Result { + try { + return { hasError: false, data: fn() }; + } catch (error) { + return { hasError: true, error }; + } +} + +export type Result = + | { hasError: false; data: T } + | { hasError: true; error: unknown }; diff --git a/package.json b/package.json index 358e3237c43..a6b7fb8c1d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44d2049bfa6..27e2f0c11ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: