mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-10-25 10:18:05 +08:00
feat(api): add exam-environment endpoints (#55662)
Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
co-authored by
moT01
Oliver Eyton-Williams
parent
ced457fed5
commit
ea44135808
+18
-1
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -0,0 +1,594 @@
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
createSuperRequest,
|
||||
defaultUserId,
|
||||
devLogin,
|
||||
setupServer
|
||||
} from '../../../jest.utils';
|
||||
import {
|
||||
examEnvironmentPostExamAttempt,
|
||||
examEnvironmentPostExamGeneratedExam
|
||||
} from '../schemas';
|
||||
import * as mock from '../../../__mocks__/env-exam';
|
||||
import { constructUserExam } from '../utils/exam';
|
||||
|
||||
describe('/exam-environment/', () => {
|
||||
setupServer();
|
||||
describe('Authenticated user with exam environment authorization token', () => {
|
||||
let superPost: ReturnType<typeof createSuperRequest>;
|
||||
let examEnvironmentAuthorizationToken: string;
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPost = createSuperRequest({ method: 'POST', setCookies });
|
||||
await mock.seedEnvExam();
|
||||
// Add exam environment authorization token
|
||||
const res = await superPost('/user/exam-environment/token');
|
||||
expect(res.status).toBe(200);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
examEnvironmentAuthorizationToken =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
res.body.data.examEnvironmentAuthorizationToken;
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/exam/attempt', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
});
|
||||
|
||||
it('should return an error if there are no current exam attempts matching the given id', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.oid(),
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// NOTE: message may not necessarily be a part of the api compatability guarantee.
|
||||
// That is, it could be changed without requiring a major version bump, because it is just
|
||||
// a human-readable/debug message.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return an error if the given exam id does not match an existing exam', async () => {
|
||||
const examId = mock.oid();
|
||||
// Create exam attempt with bad exam id
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId,
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return an error if the attempt has expired', async () => {
|
||||
// Create exam attempt with expired time
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now() - (1000 * 60 * 60 * 2 + 1000),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.examId,
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return an error if there is no matching generated exam', async () => {
|
||||
// Create exam attempt with no matching generated exam
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.examId,
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return an error if the attempt does not match the generated exam', async () => {
|
||||
const attempt = await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
|
||||
attempt.questionSets[0]!.id = mock.oid();
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// Database should mark attempt as `needsRetake`
|
||||
const updatedAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findUnique({
|
||||
where: { id: attempt.id }
|
||||
});
|
||||
expect(updatedAttempt).toHaveProperty('needsRetake', true);
|
||||
});
|
||||
|
||||
it('should return 200 if request is valid, and update attempt in database', async () => {
|
||||
const attempt = await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
userId: defaultUserId,
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: [],
|
||||
needsRetake: false
|
||||
}
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: mock.examAttemptSansSubmissionTimeInMS
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
)
|
||||
.send(body);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Database should update attempt
|
||||
const updatedAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findUnique({
|
||||
where: { id: attempt.id }
|
||||
});
|
||||
|
||||
expect(updatedAttempt).toMatchObject(body.attempt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/generated-exam', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
await mock.seedEnvExam();
|
||||
});
|
||||
|
||||
it('should return an error if the given exam id is invalid', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.oid()
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
xit('should return an error if the exam prerequisites are not met', async () => {
|
||||
// TODO: Waiting on prerequisites
|
||||
});
|
||||
|
||||
it('should return an error if the exam has been attempted in the last 24 hours', async () => {
|
||||
const recentExamAttempt = {
|
||||
...mock.examAttempt,
|
||||
// Set start time such that exam has just expired
|
||||
startTimeInMS: Date.now() - mock.exam.config.totalTimeInMS
|
||||
};
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 429,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES'
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.envExamAttempt.update({
|
||||
where: {
|
||||
id: recentExamAttempt.id
|
||||
},
|
||||
data: {
|
||||
// Set start time such that exam has expired, but 24 hours - 1s has passed
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 - 1000))
|
||||
}
|
||||
});
|
||||
|
||||
const body2: Static<typeof examEnvironmentPostExamGeneratedExam.body> =
|
||||
{
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res2 = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body2)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res2).toMatchObject({
|
||||
status: 429,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a new exam attempt if all previous attempts were started > 24 hours ago', async () => {
|
||||
const recentExamAttempt = structuredClone(mock.examAttempt);
|
||||
// Set start time such that exam has expired, but 24 hours + 1s has passed
|
||||
recentExamAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 + 1000));
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
// Generate new exam for user to be assigned
|
||||
const newGeneratedExam = structuredClone(mock.generatedExam);
|
||||
newGeneratedExam.id = mock.oid();
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
data: newGeneratedExam
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
// Time is greater than 24 hours. So, request should pass, and new exam should be generated
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
examAttempt: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
id: expect.not.stringMatching(mock.examAttempt.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the current attempt if it is still ongoing', async () => {
|
||||
const latestAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
examAttempt: latestAttempt
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if the database has insufficient generated exams', async () => {
|
||||
// Add completed attempt for generated exam
|
||||
const submittedAttempt = structuredClone(mock.examAttempt);
|
||||
// Long-enough ago to be considered "submitted", and not trigger cooldown
|
||||
submittedAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
24 * 60 * 60 * 1000 -
|
||||
mock.exam.config.totalTimeInMS -
|
||||
1 * 60 * 60 * 1000;
|
||||
submittedAttempt.submissionTimeInMS =
|
||||
Date.now() - mock.exam.config.totalTimeInMS - 24 * 60 * 60 * 1000;
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: submittedAttempt
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 500,
|
||||
body: {
|
||||
code: 'FCC_ERR_EXAM_ENVIRONMENT'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should record the fact the user has started an exam by creating an exam attempt', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const generatedExam =
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
|
||||
expect(generatedExam).toBeDefined();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
});
|
||||
|
||||
expect(examAttempt).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
id: expect.any(String),
|
||||
userId: defaultUserId,
|
||||
examId: mock.examId,
|
||||
generatedExamId: generatedExam!.id,
|
||||
questionSets: [],
|
||||
needsRetake: false,
|
||||
submissionTimeInMS: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
startTimeInMS: expect.any(Number)
|
||||
});
|
||||
});
|
||||
|
||||
it('should unwind (delete) the exam attempt if the user exam cannot be constructed', async () => {
|
||||
const _mockConstructUserExam = jest
|
||||
.spyOn(await import('../utils/exam'), 'constructUserExam')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
|
||||
expect(examAttempt).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the user exam with the exam attempt', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.examId
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const generatedExam =
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
|
||||
expect(generatedExam).toBeDefined();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
});
|
||||
|
||||
const userExam = constructUserExam(generatedExam!, mock.exam);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
examAttempt,
|
||||
exam: userExam
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
xdescribe('POST /exam-environment/screenshot', () => {});
|
||||
});
|
||||
|
||||
describe('Authenticated user without exam environment authorization token', () => {
|
||||
let superPost: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPost = createSuperRequest({ method: 'POST', setCookies });
|
||||
await mock.seedEnvExam();
|
||||
});
|
||||
describe('POST /exam-environment/exam/attempt', () => {
|
||||
it('should return 403', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: {
|
||||
examId: mock.oid(),
|
||||
questionSets: []
|
||||
}
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
.send(body)
|
||||
.set('exam-environment-authorization-token', 'invalid-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/exam/generated-exam', () => {
|
||||
it('should return 403', async () => {
|
||||
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
|
||||
examId: mock.oid()
|
||||
};
|
||||
const res = await superPost('/exam-environment/exam/generated-exam')
|
||||
.send(body)
|
||||
.set('exam-environment-authorization-token', 'invalid-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/screenshot', () => {
|
||||
it('should return 403', async () => {
|
||||
const res = await superPost('/exam-environment/screenshot').set(
|
||||
'exam-environment-authorization-token',
|
||||
'invalid-token'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/token/verify', () => {
|
||||
it('should allow a valid request', async () => {
|
||||
const res = await superPost('/exam-environment/token/verify').set(
|
||||
'exam-environment-authorization-token',
|
||||
'invalid-token'
|
||||
);
|
||||
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
code: 'FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { PrismaClientValidationError } from '@prisma/client/runtime/library';
|
||||
import { type FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import * as schemas from '../schemas';
|
||||
import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
|
||||
import { JWT_SECRET } from '../../utils/env';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
checkPrerequisites,
|
||||
constructUserExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt
|
||||
} from '../utils/exam';
|
||||
import { ERRORS } from '../utils/errors';
|
||||
|
||||
/**
|
||||
* Wrapper for endpoints related to the exam environment desktop app.
|
||||
*
|
||||
* Requires exam environment authorization token to be validated.
|
||||
*/
|
||||
export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
|
||||
(fastify, _options, done) => {
|
||||
fastify.post(
|
||||
'/exam-environment/exam/generated-exam',
|
||||
{
|
||||
schema: schemas.examEnvironmentPostExamGeneratedExam
|
||||
},
|
||||
postExamGeneratedExamHandler
|
||||
);
|
||||
fastify.post(
|
||||
'/exam-environment/exam/attempt',
|
||||
{
|
||||
schema: schemas.examEnvironmentPostExamAttempt
|
||||
},
|
||||
postExamAttemptHandler
|
||||
);
|
||||
fastify.post(
|
||||
'/exam-environment/screenshot',
|
||||
{
|
||||
schema: schemas.examEnvironmentPostScreenshot
|
||||
},
|
||||
postScreenshotHandler
|
||||
);
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for endpoints related to the exam environment desktop app.
|
||||
*
|
||||
* Does not require exam environment authorization token to be validated.
|
||||
*/
|
||||
export const examEnvironmentOpenRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.post(
|
||||
'/exam-environment/token/verify',
|
||||
{
|
||||
schema: schemas.examEnvironmentTokenVerify
|
||||
},
|
||||
tokenVerifyHandler
|
||||
);
|
||||
done();
|
||||
};
|
||||
|
||||
interface JwtPayload {
|
||||
examEnvironmentAuthorizationToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an authorization token has been generated for a user.
|
||||
*
|
||||
* Does not require any authentication.
|
||||
*
|
||||
* **Note**: This has no guarantees of which user the token is for. Just that one exists in the database.
|
||||
*/
|
||||
async function tokenVerifyHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentTokenVerify>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { 'exam-environment-authorization-token': encodedToken } = req.headers;
|
||||
|
||||
try {
|
||||
jwt.verify(encodedToken, JWT_SECRET);
|
||||
} catch (e) {
|
||||
// TODO: What to send back here? Request is valid, but token is not?
|
||||
void reply.code(200);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(JSON.stringify(e))
|
||||
);
|
||||
}
|
||||
|
||||
const payload = jwt.decode(encodedToken) as JwtPayload;
|
||||
|
||||
const examEnvironmentAuthorizationToken =
|
||||
payload.examEnvironmentAuthorizationToken;
|
||||
|
||||
const token = await this.prisma.examEnvironmentAuthorizationToken.findFirst({
|
||||
where: {
|
||||
id: examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
data: 'Token does not appear to have been created.'
|
||||
});
|
||||
} else {
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
data: {
|
||||
createdDate: token.createdDate
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an exam for the user.
|
||||
*
|
||||
* Requires token to be validated and TODO: live longer than the exam attempt.
|
||||
*/
|
||||
async function postExamGeneratedExamHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentPostExamGeneratedExam>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
// Get exam from DB
|
||||
const examId = req.body.examId;
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.envExam.findUnique({
|
||||
where: {
|
||||
id: examId
|
||||
}
|
||||
})
|
||||
);
|
||||
if (maybeExam.hasError) {
|
||||
if (maybeExam.error instanceof PrismaClientValidationError) {
|
||||
void reply.code(400);
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ID(maybeExam.error.message));
|
||||
}
|
||||
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (!exam) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM('Invalid exam id given.')
|
||||
);
|
||||
}
|
||||
|
||||
// Check user has completed prerequisites
|
||||
const user = req.user!;
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, true);
|
||||
|
||||
if (!isExamPrerequisitesMet) {
|
||||
void reply.code(403);
|
||||
// TODO: Consider sending unmet prerequisites
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES(
|
||||
'User has not completed prerequisites.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check user has not completed exam in last 24 hours
|
||||
const maybeExamAttempts = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
examId: exam.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExamAttempts.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExamAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const examAttempts = maybeExamAttempts.data;
|
||||
|
||||
const lastAttempt = examAttempts.length
|
||||
? examAttempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
)
|
||||
: null;
|
||||
|
||||
if (lastAttempt) {
|
||||
const attemptIsExpired =
|
||||
lastAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
if (attemptIsExpired) {
|
||||
// If exam is not submitted, use exam start time + time allocated for exam
|
||||
const effectiveSubmissionTime =
|
||||
lastAttempt.submissionTimeInMS ??
|
||||
lastAttempt.startTimeInMS + exam.config.totalTimeInMS;
|
||||
const twentyFourHoursAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
if (effectiveSubmissionTime > twentyFourHoursAgo) {
|
||||
void reply.code(429);
|
||||
// TODO: Consider sending last completed time
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_PREREQUISITES(
|
||||
'User has completed exam too recently to retake.'
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Camper has started an attempt, but not submitted it, and there is still time left to complete it.
|
||||
// This is most likely to happen if the Camper's app closes and is reopened.
|
||||
// Send the Camper back to the exam they were working on.
|
||||
const generated = await mapErr(
|
||||
this.prisma.envGeneratedExam.findFirst({
|
||||
where: {
|
||||
id: lastAttempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (generated.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(generated.error))
|
||||
);
|
||||
}
|
||||
|
||||
if (generated.data === null) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Generated exam not found.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userExam = constructUserExam(generated.data, exam);
|
||||
|
||||
return reply.send({
|
||||
data: {
|
||||
exam: userExam,
|
||||
examAttempt: lastAttempt
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Randomly pick a generated exam for user
|
||||
const maybeGeneratedExams = await mapErr(
|
||||
this.prisma.envGeneratedExam.findMany({
|
||||
where: {
|
||||
// Find generated exams user has not already seen
|
||||
examId: exam.id,
|
||||
id: {
|
||||
notIn: examAttempts.map(a => a.generatedExamId)
|
||||
},
|
||||
deprecated: false
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExams.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(maybeGeneratedExams.error)
|
||||
);
|
||||
}
|
||||
|
||||
const generatedExams = maybeGeneratedExams.data;
|
||||
|
||||
if (generatedExams.length === 0) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
`Unable to provide a generated exam. Either all generated exams have been exhausted, or all generated exams are deprecated.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const randomGeneratedExam =
|
||||
generatedExams[Math.floor(Math.random() * generatedExams.length)]!;
|
||||
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
this.prisma.envGeneratedExam.findFirst({
|
||||
where: {
|
||||
id: randomGeneratedExam.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
// TODO: Consider more specific code
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unable to query generated exam, due to: ' +
|
||||
JSON.stringify(maybeGeneratedExam.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(`Unable to locate generated exam.`)
|
||||
);
|
||||
}
|
||||
|
||||
// Create exam attempt so, even if user disconnects, their attempt is still recorded:
|
||||
const attempt = await mapErr(
|
||||
this.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: [],
|
||||
needsRetake: false
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (attempt.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT(
|
||||
JSON.stringify(attempt.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
// NOTE: Anything that goes wrong after this point needs to unwind the exam attempt.
|
||||
|
||||
const maybeUserExam = syncMapErr(() =>
|
||||
constructUserExam(generatedExam, exam)
|
||||
);
|
||||
|
||||
if (maybeUserExam.hasError) {
|
||||
await this.prisma.envExamAttempt.delete({
|
||||
where: {
|
||||
id: attempt.data.id
|
||||
}
|
||||
});
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUserExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const userExam = maybeUserExam.data;
|
||||
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
data: {
|
||||
exam: userExam,
|
||||
examAttempt: attempt.data
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles updates to an exam attempt.
|
||||
*
|
||||
* Requires token to be validated.
|
||||
*
|
||||
* TODO: Consider validating req.user.id == lastAttempt.user_id?
|
||||
*
|
||||
* NOTE: Currently, questions can be _unanswered_ - taken away from a previous attempt submission.
|
||||
* Theorectically, this is fine. Practically, it is unclear when that would be useful.
|
||||
*/
|
||||
async function postExamAttemptHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentPostExamAttempt>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { attempt } = req.body;
|
||||
|
||||
const user = req.user!;
|
||||
|
||||
const maybeAttempts = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
where: {
|
||||
examId: attempt.examId,
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
if (attempts.length === 0) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
`No attempts found for user '${user.id}' with attempt id '${attempt.examId}'.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const latestAttempt = attempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
);
|
||||
|
||||
// TODO: Currently, submission time is set when all questions have been answered.
|
||||
// This might not necessarily be fully submitted. So, provided there is time
|
||||
// left on the clock, the attempt should still be updated, even if the submission
|
||||
// time is set.
|
||||
// The submission time just needs to be updated.
|
||||
// if (latestAttempt.submissionTimeInMS !== null) {
|
||||
// void reply.code(403);
|
||||
// return reply.send(
|
||||
// ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
// 'Attempt has already been submitted.'
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.envExam.findUnique({
|
||||
where: {
|
||||
id: attempt.examId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM('Invalid exam id given.')
|
||||
);
|
||||
}
|
||||
|
||||
const isAttemptExpired =
|
||||
latestAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
|
||||
if (isAttemptExpired) {
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
'Attempt has exceeded submission time.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get generated exam from database
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
this.prisma.envGeneratedExam.findUnique({
|
||||
where: {
|
||||
id: latestAttempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeGeneratedExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_GENERATED_EXAM(
|
||||
'Generated exam not found.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const databaseAttemptQuestionSets = userAttemptToDatabaseAttemptQuestionSets(
|
||||
attempt,
|
||||
latestAttempt
|
||||
);
|
||||
// Ensure attempt matches generated exam
|
||||
const maybeValidExamAttempt = syncMapErr(() =>
|
||||
validateAttempt(generatedExam, databaseAttemptQuestionSets)
|
||||
);
|
||||
|
||||
// If all questions have been answered, add submission time
|
||||
const allQuestionsAnswered = checkAttemptAgainstGeneratedExam(
|
||||
databaseAttemptQuestionSets,
|
||||
generatedExam
|
||||
);
|
||||
|
||||
// Update attempt in database
|
||||
const maybeUpdatedAttempt = await mapErr(
|
||||
this.prisma.envExamAttempt.update({
|
||||
where: {
|
||||
id: latestAttempt.id
|
||||
},
|
||||
data: {
|
||||
// NOTE: submission time is set to null, because it just depends on whether all questions have been answered.
|
||||
submissionTimeInMS: allQuestionsAnswered ? Date.now() : null,
|
||||
questionSets: databaseAttemptQuestionSets,
|
||||
// If attempt is not valid, immediately flag attempt as needing retake
|
||||
// TODO: If `needsRetake`, prevent further submissions?
|
||||
needsRetake: maybeValidExamAttempt.hasError ? true : undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeValidExamAttempt.hasError) {
|
||||
void reply.code(400);
|
||||
const message =
|
||||
maybeValidExamAttempt.error instanceof Error
|
||||
? maybeValidExamAttempt.error.message
|
||||
: 'Unknown attempt validation error';
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(message));
|
||||
}
|
||||
|
||||
if (maybeUpdatedAttempt.hasError) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUpdatedAttempt.error))
|
||||
);
|
||||
}
|
||||
|
||||
return reply.code(200).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles screenshots, sending them to the screenshot service for storage.
|
||||
*
|
||||
* Requires token to be validated.
|
||||
*/
|
||||
async function postScreenshotHandler(
|
||||
this: FastifyInstance,
|
||||
_req: UpdateReqType<typeof schemas.examEnvironmentPostScreenshot>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
return reply.code(418);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { examEnvironmentPostExamAttempt } from './exam-attempt';
|
||||
export { examEnvironmentPostExamGeneratedExam } from './exam-generated-exam';
|
||||
export { examEnvironmentPostScreenshot } from './screenshot';
|
||||
export { examEnvironmentTokenVerify } from './token-verify';
|
||||
@@ -0,0 +1,7 @@
|
||||
// import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const examEnvironmentPostScreenshot = {
|
||||
response: {
|
||||
// 200: Type.Object({})
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
])
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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()
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import { exam, examAttempt, generatedExam } from '../../../__mocks__/env-exam';
|
||||
import * as schemas from '../schemas';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
constructUserExam,
|
||||
generateExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt
|
||||
} from './exam';
|
||||
|
||||
// NOTE: Whilst the tests could be run against a single generation of exam,
|
||||
// it is more useful to run the tests against a new generation each time.
|
||||
// This helps ensure the config/logic is _reasonably_ likely to be able to
|
||||
// generate a valid exam.
|
||||
// Another option is to call `generateExam` hundreds of times in a loop test :shrug:
|
||||
describe('Exam Environment', () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Math, 'random').mockReturnValue(0.123456789);
|
||||
});
|
||||
describe('checkAttemptAgainstGeneratedExam()', () => {
|
||||
it('should return true if all questions are answered', () => {
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
examAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if one or more questions are not answered', () => {
|
||||
const badExamAttempt = structuredClone(examAttempt);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = [];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = ['bad-answer'];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
badExamAttempt.questionSets[0]!.questions = [];
|
||||
expect(
|
||||
checkAttemptAgainstGeneratedExam(
|
||||
badExamAttempt.questionSets,
|
||||
generatedExam
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
xdescribe('checkPrequisites()', () => {
|
||||
// TODO: Awaiting implementation
|
||||
});
|
||||
describe('constructUserExam()', () => {
|
||||
it('should not provide the answers', () => {
|
||||
const userExam = constructUserExam(generatedExam, exam);
|
||||
expect(userExam).not.toHaveProperty('answers.isCorrect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateExam()', () => {
|
||||
it('should generate a randomized exam without throwing', () => {
|
||||
const _randomizedExam = generateExam(exam);
|
||||
});
|
||||
|
||||
it('should generate an exam matching with the correct number of question sets', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
// { [type]: numberOfType }
|
||||
// E.g. { MultipleChoice: 2, Dialogue: 1 }
|
||||
const generatedNumberOfSets = generatedExam.questionSets.reduce(
|
||||
(acc, curr) => {
|
||||
const eqs = exam.questionSets.find(eqs => eqs.id === curr.id);
|
||||
|
||||
if (!eqs) {
|
||||
throw new Error('Generated question set not found in exam config');
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[eqs.type]: (acc[eqs.type] || 0) + 1
|
||||
};
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const configNumberOfSets = exam.config.questionSets.reduce(
|
||||
(acc, curr) => {
|
||||
return {
|
||||
...acc,
|
||||
[curr.type]: (acc[curr.type] || 0) + curr.numberOfSet
|
||||
};
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
expect(generatedNumberOfSets).toEqual(configNumberOfSets);
|
||||
});
|
||||
|
||||
it('should not generate any deprecated questions', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
const allQuestions = exam.questionSets.flatMap(qs => qs.questions);
|
||||
|
||||
const deprecatedQuestions = generatedExam.questionSets
|
||||
.flatMap(qs => qs.questions)
|
||||
.filter(q => {
|
||||
const eq = allQuestions.find(eq => eq.id === q.id);
|
||||
if (!eq) {
|
||||
throw new Error('Generated question not found in exam');
|
||||
}
|
||||
return eq.deprecated;
|
||||
});
|
||||
|
||||
expect(deprecatedQuestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not generate an exam with duplicate questions', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
const questionIds = generatedExam.questionSets.flatMap(qs =>
|
||||
qs.questions.map(q => q.id)
|
||||
);
|
||||
|
||||
const duplicateQuestions = questionIds.filter(
|
||||
(id, index) => questionIds.indexOf(id) !== index
|
||||
);
|
||||
|
||||
expect(duplicateQuestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not generate an exam with duplicate answers', () => {
|
||||
const generatedExam = generateExam(exam);
|
||||
|
||||
const answerIds = generatedExam.questionSets.flatMap(qs =>
|
||||
qs.questions.flatMap(q => q.answers)
|
||||
);
|
||||
|
||||
const duplicateAnswers = answerIds.filter(
|
||||
(id, index) => answerIds.indexOf(id) !== index
|
||||
);
|
||||
|
||||
expect(duplicateAnswers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw if the exam config is invalid', () => {
|
||||
const invalidExam = {
|
||||
...exam,
|
||||
config: {
|
||||
...exam.config,
|
||||
tags: [
|
||||
{
|
||||
group: ['non-existant-tag'],
|
||||
numberOfQuestions: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
expect(() => generateExam(invalidExam)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttempt()', () => {
|
||||
it('should validate a correct attempt', () => {
|
||||
validateAttempt(generatedExam, examAttempt.questionSets);
|
||||
});
|
||||
|
||||
it('should invalidate an incorrect attempt', () => {
|
||||
const badExamAttempt = structuredClone(examAttempt);
|
||||
badExamAttempt.questionSets[0]!.questions[0]!.answers = ['bad-answer'];
|
||||
expect(() =>
|
||||
validateAttempt(generatedExam, badExamAttempt.questionSets)
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('userAttemptToDatabaseAttemptQuestionSets()', () => {
|
||||
it('should add submission time to all questions', () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
latestAttempt.questionSets = [];
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const allQuestions = databaseAttemptQuestionSets.flatMap(
|
||||
qs => qs.questions
|
||||
);
|
||||
expect(allQuestions.every(q => q.submissionTimeInMS)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not change the submission time of any questions that have not changed', () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const submissionTimes = databaseAttemptQuestionSets.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTimeInMS)
|
||||
);
|
||||
|
||||
const sameAttempt = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{ ...latestAttempt, questionSets: databaseAttemptQuestionSets }
|
||||
);
|
||||
|
||||
const sameSubmissionTimes = sameAttempt.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTimeInMS)
|
||||
);
|
||||
|
||||
expect(submissionTimes).toEqual(sameSubmissionTimes);
|
||||
});
|
||||
|
||||
it('should change all submission times of questions that have changed', async () => {
|
||||
const userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
> = {
|
||||
examId: '0',
|
||||
questionSets: [
|
||||
{
|
||||
id: '0',
|
||||
questions: [{ id: '00', answers: ['000'] }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
questions: [{ id: '10', answers: ['100'] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const latestAttempt = structuredClone(examAttempt);
|
||||
|
||||
const databaseAttemptQuestionSets =
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
userAttempt.questionSets[0]!.questions[0]!.answers = ['001'];
|
||||
|
||||
// The `userAttemptToDatabaseAttemptQuestionSets` function uses `Date.now()`
|
||||
// to set the submission time, so we need to wait a bit to ensure differences.
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const newAttemptQuestionSets = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{
|
||||
...latestAttempt,
|
||||
questionSets: databaseAttemptQuestionSets
|
||||
}
|
||||
);
|
||||
|
||||
expect(
|
||||
newAttemptQuestionSets[0]?.questions[0]?.submissionTimeInMS
|
||||
).not.toEqual(
|
||||
databaseAttemptQuestionSets[0]?.questions[0]?.submissionTimeInMS
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,630 @@
|
||||
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
||||
import {
|
||||
EnvAnswer,
|
||||
EnvConfig,
|
||||
EnvExam,
|
||||
EnvExamAttempt,
|
||||
EnvGeneratedExam,
|
||||
EnvMultipleChoiceQuestion,
|
||||
EnvQuestionSet,
|
||||
EnvQuestionSetAttempt,
|
||||
user
|
||||
} from '@prisma/client';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import * as schemas from '../schemas';
|
||||
|
||||
/**
|
||||
* Checks if all exam prerequisites have been met by the user.
|
||||
*
|
||||
* TODO: This will be done by getting the challenges required from the curriculum.
|
||||
*/
|
||||
export function checkPrerequisites(_user: user, _prerequisites: unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export type UserExam = Omit<EnvExam, 'questionSets' | 'config' | 'id'> & {
|
||||
config: Omit<EnvExam['config'], 'tags' | 'questionSets'>;
|
||||
questionSets: (Omit<EnvQuestionSet, 'questions'> & {
|
||||
questions: (Omit<
|
||||
EnvMultipleChoiceQuestion,
|
||||
'answers' | 'tags' | 'deprecated'
|
||||
> & {
|
||||
answers: Omit<EnvAnswer, 'isCorrect'>[];
|
||||
})[];
|
||||
})[];
|
||||
} & { generatedExamId: string; examId: string };
|
||||
|
||||
/**
|
||||
* Takes the generated exam and the original exam, and creates the user-facing exam.
|
||||
*/
|
||||
export function constructUserExam(
|
||||
generatedExam: EnvGeneratedExam,
|
||||
exam: EnvExam
|
||||
): UserExam {
|
||||
// Map generated exam to user exam (a.k.a. public exam information for user)
|
||||
const userQuestionSets = generatedExam.questionSets.map(gqs => {
|
||||
// Get matching question from `exam`, but remove `is_correct` from `exam.questions[].answers[]`
|
||||
const examQuestionSet = exam.questionSets.find(eqs => eqs.id === gqs.id)!;
|
||||
|
||||
const { questions } = examQuestionSet;
|
||||
|
||||
const userQuestions = gqs.questions.map(gq => {
|
||||
const examQuestion = questions.find(eq => eq.id === gq.id)!;
|
||||
|
||||
// Remove `isCorrect` from question answers
|
||||
const answers = gq.answers.map(generatedAnswerId => {
|
||||
const examAnswer = examQuestion.answers.find(
|
||||
ea => ea.id === generatedAnswerId
|
||||
)!;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { isCorrect, ...answer } = examAnswer;
|
||||
return answer;
|
||||
});
|
||||
|
||||
return {
|
||||
id: examQuestion.id,
|
||||
audio: examQuestion.audio,
|
||||
text: examQuestion.text,
|
||||
answers
|
||||
};
|
||||
});
|
||||
|
||||
const userQuestionSet = {
|
||||
type: examQuestionSet.type,
|
||||
questions: userQuestions,
|
||||
id: examQuestionSet.id,
|
||||
context: examQuestionSet.context
|
||||
};
|
||||
return userQuestionSet;
|
||||
});
|
||||
|
||||
// Order questionSets in same order as original exam
|
||||
const orderedUserQuestionSets = userQuestionSets.sort((a, b) => {
|
||||
return (
|
||||
exam.questionSets.findIndex(qs => qs.id === a.id) -
|
||||
exam.questionSets.findIndex(qs => qs.id === b.id)
|
||||
);
|
||||
});
|
||||
|
||||
const config = {
|
||||
totalTimeInMS: exam.config.totalTimeInMS,
|
||||
name: exam.config.name,
|
||||
note: exam.config.note
|
||||
};
|
||||
|
||||
const userExam: UserExam = {
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
config,
|
||||
questionSets: orderedUserQuestionSets
|
||||
};
|
||||
|
||||
return userExam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all questions and answers in the attempt are from the generated exam.
|
||||
*/
|
||||
export function validateAttempt(
|
||||
generatedExam: EnvGeneratedExam,
|
||||
questionSets: EnvExamAttempt['questionSets']
|
||||
) {
|
||||
for (const attemptQuestionSet of questionSets) {
|
||||
const generatedQuestionSet = generatedExam.questionSets.find(
|
||||
qt => qt.id === attemptQuestionSet.id
|
||||
);
|
||||
if (!generatedQuestionSet) {
|
||||
throw new Error(
|
||||
`Question type ${attemptQuestionSet.id} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const attemptQuestion of attemptQuestionSet.questions) {
|
||||
const generatedQuestion = generatedQuestionSet.questions.find(
|
||||
q => q.id === attemptQuestion.id
|
||||
);
|
||||
if (!generatedQuestion) {
|
||||
throw new Error(
|
||||
`Question ${attemptQuestion.id} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const attemptAnswer of attemptQuestion.answers) {
|
||||
const generatedAnswer = generatedQuestion.answers.find(
|
||||
a => a === attemptAnswer
|
||||
);
|
||||
if (!generatedAnswer) {
|
||||
throw new Error(
|
||||
`Answer ${attemptAnswer} not found in generated exam.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks all question sets and questions in the generated exam are in the attempt.
|
||||
*
|
||||
* TODO: Consider throwing with specific issue.
|
||||
*
|
||||
* @param questionSets An exam attempt.
|
||||
* @param generatedExam The corresponding generated exam.
|
||||
* @returns Whether or not the attempt can be considered finished.
|
||||
*/
|
||||
export function checkAttemptAgainstGeneratedExam(
|
||||
questionSets: EnvQuestionSetAttempt[],
|
||||
generatedExam: Pick<EnvGeneratedExam, 'questionSets'>
|
||||
): boolean {
|
||||
// Check all question sets and questions are in generated exam
|
||||
for (const generatedQuestionSet of generatedExam.questionSets) {
|
||||
const attemptQuestionSet = questionSets.find(
|
||||
q => q.id === generatedQuestionSet.id
|
||||
);
|
||||
if (!attemptQuestionSet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const generatedQuestion of generatedQuestionSet.questions) {
|
||||
const attemptQuestion = attemptQuestionSet.questions.find(
|
||||
q => q.id === generatedQuestion.id
|
||||
);
|
||||
if (!attemptQuestion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const atLeastOneAnswer = attemptQuestion.answers.length > 0;
|
||||
if (!atLeastOneAnswer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All answers in attempt must be in generated exam
|
||||
const allAnswersInGeneratedExam = attemptQuestion.answers.every(a =>
|
||||
generatedQuestion.answers.includes(a)
|
||||
);
|
||||
if (!allAnswersInGeneratedExam) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the current time submission time to all questions in the attempt if the question answer has changed.
|
||||
*/
|
||||
export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
>,
|
||||
latestAttempt: EnvExamAttempt
|
||||
): EnvExamAttempt['questionSets'] {
|
||||
const databaseAttemptQuestionSets: EnvExamAttempt['questionSets'] = [];
|
||||
|
||||
for (const questionSet of userAttempt.questionSets) {
|
||||
const latestQuestionSet = latestAttempt.questionSets.find(
|
||||
qs => qs.id === questionSet.id
|
||||
);
|
||||
|
||||
// If no latest attempt, add submission time to all questions
|
||||
if (!latestQuestionSet) {
|
||||
databaseAttemptQuestionSets.push({
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
})
|
||||
});
|
||||
} else {
|
||||
const databaseAttemptQuestionSet = {
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
const latestQuestion = latestQuestionSet.questions.find(
|
||||
lq => lq.id === q.id
|
||||
);
|
||||
|
||||
// If no latest question, add submission time
|
||||
if (!latestQuestion) {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
}
|
||||
|
||||
// If answers have changed, add submission time
|
||||
if (
|
||||
JSON.stringify(q.answers) !== JSON.stringify(latestQuestion.answers)
|
||||
) {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
}
|
||||
|
||||
return latestQuestion;
|
||||
})
|
||||
};
|
||||
|
||||
databaseAttemptQuestionSets.push(databaseAttemptQuestionSet);
|
||||
}
|
||||
}
|
||||
|
||||
return databaseAttemptQuestionSets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an exam for the user, based on the exam configuration.
|
||||
*/
|
||||
export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
|
||||
const examCopy = structuredClone(exam);
|
||||
|
||||
const TIMEOUT_IN_MS = 5_000;
|
||||
const START_TIME = Date.now();
|
||||
|
||||
const shuffledQuestionSets = shuffleArray(examCopy.questionSets).map(qs => {
|
||||
const shuffledQuestions = shuffleArray(
|
||||
qs.questions.filter(q => !q.deprecated)
|
||||
).map(q => {
|
||||
const shuffledAnswers = shuffleArray(q.answers);
|
||||
return {
|
||||
...q,
|
||||
answers: shuffledAnswers
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...qs,
|
||||
questions: shuffledQuestions
|
||||
};
|
||||
});
|
||||
|
||||
// Convert question set config by type: [[all question sets of type], [another type], ...]
|
||||
const typeConvertedQuestionSetsConfig = examCopy.config.questionSets.reduce(
|
||||
(acc, curr) => {
|
||||
// If type is already in accumulator, add to it.
|
||||
const typeIndex = acc.findIndex(a => a[0]?.type === curr.type);
|
||||
acc[typeIndex]?.push(curr) ?? acc.push([curr]);
|
||||
return acc;
|
||||
},
|
||||
[] as unknown as [EnvConfig['questionSets']]
|
||||
);
|
||||
|
||||
// Heuristic:
|
||||
// TODO: The lower the number of questions able to fulfill the criteria, the harder the question set.
|
||||
// TODO: Sort difficulty, push question, sort new difficulty, push question, ...
|
||||
typeConvertedQuestionSetsConfig.forEach(qsc => {
|
||||
// Currently, the sorted order is random as this allows the existing algorithm to be retried until
|
||||
// a successful exam generation.
|
||||
qsc.sort(() => Math.round(Math.random() * 2 - 1));
|
||||
});
|
||||
|
||||
const sortedQuestionSetsConfig = typeConvertedQuestionSetsConfig.flat();
|
||||
|
||||
// Move all questions from set that are used to fulfill tag config.
|
||||
const questionSetsConfigWithQuestions = sortedQuestionSetsConfig.map(qsc => {
|
||||
return {
|
||||
...qsc,
|
||||
questionSets: [] as EnvQuestionSet[]
|
||||
};
|
||||
});
|
||||
|
||||
// Sort tag config by number of tags in descending order.
|
||||
const sortedTagConfig = examCopy.config.tags.sort(
|
||||
(a, b) => b.group.length - a.group.length
|
||||
);
|
||||
|
||||
questionSetsConfigWithQuestionsLoop: for (const questionSetConfig of questionSetsConfigWithQuestions) {
|
||||
sortedTagConfigLoop: for (const tagConfig of sortedTagConfig) {
|
||||
shuffledQuestionSetsLoop: for (const questionSet of shuffledQuestionSets.filter(
|
||||
sqs => sqs.type === questionSetConfig.type
|
||||
)) {
|
||||
// If questionSet does not have enough questions for config, do not consider.
|
||||
if (
|
||||
questionSetConfig.numberOfQuestions > questionSet.questions.length
|
||||
) {
|
||||
continue shuffledQuestionSetsLoop;
|
||||
}
|
||||
// If tagConfig is finished, skip.
|
||||
if (tagConfig.numberOfQuestions === 0) {
|
||||
continue sortedTagConfigLoop;
|
||||
}
|
||||
// If questionSetConfig has been fulfilled, skip.
|
||||
if (isQuestionSetConfigFulfilled(questionSetConfig)) {
|
||||
continue questionSetsConfigWithQuestionsLoop;
|
||||
}
|
||||
|
||||
// Find question with at least all tags in the set.
|
||||
const questions = questionSet.questions.filter(q =>
|
||||
tagConfig.group.every(t => q.tags.some(qt => qt === t))
|
||||
);
|
||||
|
||||
questionsLoop: for (const question of questions) {
|
||||
// Does question fulfill criteria for questionSetConfig:
|
||||
const numberOfCorrectAnswers = question.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = question.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
|
||||
if (
|
||||
questionSetConfig.numberOfCorrectAnswers <=
|
||||
numberOfCorrectAnswers &&
|
||||
questionSetConfig.numberOfIncorrectAnswers <=
|
||||
numberOfIncorrectAnswers
|
||||
) {
|
||||
if (isQuestionSetConfigFulfilled(questionSetConfig)) {
|
||||
continue questionSetsConfigWithQuestionsLoop;
|
||||
}
|
||||
// Push questionSet if it does not exist. Otherwise, just push question
|
||||
const qscqs = questionSetConfig.questionSets.find(
|
||||
qs => qs.id === questionSet.id
|
||||
);
|
||||
const questionWithCorrectNumberOfAnswers = {
|
||||
...question,
|
||||
answers: getRandomAnswers(question, questionSetConfig)
|
||||
};
|
||||
if (!qscqs) {
|
||||
if (
|
||||
questionSetConfig.numberOfSet ===
|
||||
questionSetConfig.questionSets.length
|
||||
) {
|
||||
break questionsLoop;
|
||||
}
|
||||
const newQuestionSetWithQuestion = {
|
||||
...questionSet,
|
||||
questions: [questionWithCorrectNumberOfAnswers]
|
||||
};
|
||||
questionSetConfig.questionSets.push(newQuestionSetWithQuestion);
|
||||
} else {
|
||||
if (
|
||||
qscqs.questions.length === questionSetConfig.numberOfQuestions
|
||||
) {
|
||||
break questionsLoop;
|
||||
}
|
||||
qscqs.questions.push(questionWithCorrectNumberOfAnswers);
|
||||
}
|
||||
|
||||
// TODO: Issue is question set is not being removed. So, one question set is used multiple times to fulfill config.
|
||||
// Just remove question set once used? Evaluate:
|
||||
shuffledQuestionSets.splice(
|
||||
shuffledQuestionSets.findIndex(qs => qs.id === questionSet.id),
|
||||
1
|
||||
);
|
||||
// New issue: Once the set is removed, tag config might not be able to be fulfilled.
|
||||
|
||||
// Remove question from questionSet, decrement tagConfig.numberOfQuestions and `questionSetConfig.numberOfQuestions`
|
||||
questionSet.questions.splice(
|
||||
questionSet.questions.findIndex(q => q.id === question.id),
|
||||
1
|
||||
);
|
||||
tagConfig.numberOfQuestions -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add questions to questionSetsConfigWithQuestions until fulfilled.
|
||||
while (!isQuestionSetConfigFulfilled(questionSetConfig)) {
|
||||
if (Date.now() - START_TIME > TIMEOUT_IN_MS) {
|
||||
throw `Unable to generate exam within ${TIMEOUT_IN_MS}ms`;
|
||||
}
|
||||
// Ensure all questionSets ARE FULL
|
||||
if (
|
||||
questionSetConfig.numberOfSet > questionSetConfig.questionSets.length
|
||||
) {
|
||||
const questionSet = shuffledQuestionSets.find(qs => {
|
||||
if (qs.type === questionSetConfig.type) {
|
||||
if (qs.questions.length >= questionSetConfig.numberOfQuestions) {
|
||||
if (qs.questions.length >= questionSetConfig.numberOfQuestions) {
|
||||
// Find questionSetConfig.numberOfQuestions who have `questionSetConfig.numberOfCorrectAnswers` and `questionSetConfig.numberOfIncorrectAnswers`
|
||||
const questions = qs.questions.filter(q => {
|
||||
const numberOfCorrectAnswers = q.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = q.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
return (
|
||||
numberOfCorrectAnswers >=
|
||||
questionSetConfig.numberOfCorrectAnswers &&
|
||||
numberOfIncorrectAnswers >=
|
||||
questionSetConfig.numberOfIncorrectAnswers
|
||||
);
|
||||
});
|
||||
|
||||
if (questions.length >= questionSetConfig.numberOfQuestions) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!questionSet) {
|
||||
throw `Invalid Exam Configuration for ${examCopy.id}. Not enough questions for question type ${questionSetConfig.type}.`;
|
||||
}
|
||||
// Remove questionSet from shuffledQuestionSets
|
||||
shuffledQuestionSets.splice(
|
||||
shuffledQuestionSets.findIndex(qs => qs.id === questionSet.id),
|
||||
1
|
||||
);
|
||||
|
||||
const questions = questionSet.questions.filter(q => {
|
||||
const numberOfCorrectAnswers = q.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = q.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
return (
|
||||
numberOfCorrectAnswers >=
|
||||
questionSetConfig.numberOfCorrectAnswers &&
|
||||
numberOfIncorrectAnswers >=
|
||||
questionSetConfig.numberOfIncorrectAnswers
|
||||
);
|
||||
});
|
||||
|
||||
const questionSetWithCorrectNumberOfAnswers = {
|
||||
...questionSet,
|
||||
questions: questions.map(q => ({
|
||||
...q,
|
||||
answers: getRandomAnswers(q, questionSetConfig)
|
||||
}))
|
||||
};
|
||||
|
||||
questionSetConfig.questionSets.push(
|
||||
questionSetWithCorrectNumberOfAnswers
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure all existing questionSets have correct number of questions
|
||||
for (const questionSet of questionSetConfig.questionSets) {
|
||||
if (
|
||||
questionSet.questions.length < questionSetConfig.numberOfQuestions
|
||||
) {
|
||||
const questions = shuffledQuestionSets
|
||||
.find(qs => qs.id === questionSet.id)
|
||||
?.questions.filter(
|
||||
q => !questionSet.questions.find(qsq => qsq.id === q.id)
|
||||
);
|
||||
if (!questions) {
|
||||
throw `Invalid Exam Configuration for ${examCopy.id}. Not enough questions for question type ${questionSetConfig.type}.`;
|
||||
}
|
||||
|
||||
const questionsWithEnoughAnswers = questions.filter(q => {
|
||||
const numberOfCorrectAnswers = q.answers.filter(
|
||||
a => a.isCorrect
|
||||
).length;
|
||||
const numberOfIncorrectAnswers = q.answers.filter(
|
||||
a => !a.isCorrect
|
||||
).length;
|
||||
return (
|
||||
numberOfCorrectAnswers >=
|
||||
questionSetConfig.numberOfCorrectAnswers &&
|
||||
numberOfIncorrectAnswers >=
|
||||
questionSetConfig.numberOfIncorrectAnswers
|
||||
);
|
||||
});
|
||||
|
||||
// Push as many questions as needed to fulfill questionSetConfig
|
||||
const questionsToAdd = questionsWithEnoughAnswers.splice(
|
||||
0,
|
||||
questionSetConfig.numberOfQuestions - questionSet.questions.length
|
||||
);
|
||||
|
||||
questionSet.questions.push(
|
||||
...questionsToAdd.map(q => ({
|
||||
...q,
|
||||
answers: getRandomAnswers(q, questionSetConfig)
|
||||
}))
|
||||
);
|
||||
|
||||
// Remove questions from shuffledQuestionSets
|
||||
questionsToAdd.forEach(q => {
|
||||
const index = shuffledQuestionSets
|
||||
.find(qs => qs.id === questionSet.id)!
|
||||
.questions.findIndex(qs => qs.id === q.id);
|
||||
|
||||
shuffledQuestionSets
|
||||
.find(qs => qs.id === questionSet.id)
|
||||
?.questions.splice(index, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tagConfig of sortedTagConfig) {
|
||||
if (tagConfig.numberOfQuestions > 0) {
|
||||
throw `Invalid Exam Configuration for exam "${examCopy.id}". Not enough questions for tag group "${tagConfig.group.join(',')}".`;
|
||||
}
|
||||
}
|
||||
|
||||
const questionSets = questionSetsConfigWithQuestions.flatMap(qsc => {
|
||||
const questionSets = qsc.questionSets;
|
||||
return questionSets.map(qs => {
|
||||
const questions = qs.questions.map(q => {
|
||||
const answers = q.answers.map(a => a.id);
|
||||
return {
|
||||
id: q.id,
|
||||
answers
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: qs.id,
|
||||
questions
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
examId: examCopy.id,
|
||||
questionSets,
|
||||
deprecated: false
|
||||
};
|
||||
}
|
||||
|
||||
function isQuestionSetConfigFulfilled(
|
||||
questionSetConfig: EnvConfig['questionSets'][number] & {
|
||||
questionSets: EnvQuestionSet[];
|
||||
}
|
||||
) {
|
||||
return (
|
||||
questionSetConfig.numberOfSet === questionSetConfig.questionSets.length &&
|
||||
questionSetConfig.questionSets.every(qs => {
|
||||
return qs.questions.length === questionSetConfig.numberOfQuestions;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets random answers for a question.
|
||||
*/
|
||||
function getRandomAnswers(
|
||||
question: EnvMultipleChoiceQuestion,
|
||||
questionSetConfig: EnvConfig['questionSets'][number]
|
||||
): EnvMultipleChoiceQuestion['answers'] {
|
||||
const { numberOfCorrectAnswers, numberOfIncorrectAnswers } =
|
||||
questionSetConfig;
|
||||
|
||||
const randomAnswers = shuffleArray(question.answers);
|
||||
const incorrectAnswers = randomAnswers
|
||||
.filter(a => !a.isCorrect)
|
||||
.splice(0, numberOfIncorrectAnswers);
|
||||
const correctAnswers = randomAnswers
|
||||
.filter(a => a.isCorrect)
|
||||
.splice(0, numberOfCorrectAnswers);
|
||||
|
||||
if (!incorrectAnswers || !correctAnswers) {
|
||||
throw new Error(
|
||||
`Question ${question.id} does not have enough correct/incorrect answers.`
|
||||
);
|
||||
}
|
||||
|
||||
const answers = incorrectAnswers.concat(correctAnswers);
|
||||
return answers;
|
||||
}
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Shuffles an array using the Fisher-Yates algorithm.
|
||||
*
|
||||
* https://bost.ocks.org/mike/shuffle/
|
||||
*/
|
||||
function shuffleArray<T>(array: Array<T>) {
|
||||
let m = array.length;
|
||||
let t;
|
||||
let i;
|
||||
|
||||
// While there remain elements to shuffle…
|
||||
while (m) {
|
||||
// Pick a remaining element…
|
||||
i = Math.floor(Math.random() * m--);
|
||||
|
||||
// And swap it with the current element.
|
||||
t = array[m]!;
|
||||
array[m] = array[i]!;
|
||||
array[i] = t;
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
/* eslint-enable jsdoc/require-description-complete-sentence */
|
||||
+91
-1
@@ -1,10 +1,11 @@
|
||||
import { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import { FastifyPluginCallback, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { type user } from '@prisma/client';
|
||||
|
||||
import { JWT_SECRET } from '../utils/env';
|
||||
import { type Token, isExpired } from '../utils/tokens';
|
||||
import { ERRORS } from '../exam-environment/utils/errors';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
@@ -19,6 +20,10 @@ declare module 'fastify' {
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
authorizeExamEnvironmentToken: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +75,94 @@ const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
req.user = user;
|
||||
};
|
||||
|
||||
async function handleExamEnvironmentTokenAuth(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { 'exam-environment-authorization-token': encodedToken } =
|
||||
req.headers;
|
||||
|
||||
if (!encodedToken || typeof encodedToken !== 'string') {
|
||||
void reply.code(400);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN header is a required string.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(encodedToken, JWT_SECRET);
|
||||
} catch (e) {
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
JSON.stringify(e)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const payload = jwt.decode(encodedToken);
|
||||
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'Unreachable. Decoded token has been verified.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const examEnvironmentAuthorizationToken =
|
||||
payload['examEnvironmentAuthorizationToken'];
|
||||
|
||||
// if (typeof examEnvironmentAuthorizationToken !== 'string') {
|
||||
// // TODO: This code is debatable, because the token would have to have been signed by the api
|
||||
// // which means it is valid, but, somehow, got signed as an object instead of a string.
|
||||
// void reply.code(400+500);
|
||||
// return reply.send(
|
||||
// ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
// 'EXAM-ENVIRONMENT-AUTHORIZATION-TOKEN is not valid.'
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
assertIsString(examEnvironmentAuthorizationToken);
|
||||
|
||||
const token =
|
||||
await fastify.prisma.examEnvironmentAuthorizationToken.findFirst({
|
||||
where: {
|
||||
id: examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
message: 'Token not found'
|
||||
};
|
||||
}
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: token.userId }
|
||||
});
|
||||
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
||||
req.user = user;
|
||||
}
|
||||
|
||||
fastify.decorate('authorize', handleAuth);
|
||||
fastify.decorate(
|
||||
'authorizeExamEnvironmentToken',
|
||||
handleExamEnvironmentTokenAuth
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
function assertIsString(some: unknown): asserts some is string {
|
||||
if (typeof some !== 'string') {
|
||||
throw new Error('Expected a string');
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(auth, { name: 'auth', dependencies: ['cookies'] });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { type PrismaPromise } from '@prisma/client';
|
||||
import { Certification } from '../../../../shared/config/certification-settings';
|
||||
import {
|
||||
defaultUserEmail,
|
||||
@@ -85,7 +84,10 @@ describe('certificate routes', () => {
|
||||
jest
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
|
||||
.mockImplementation(
|
||||
() => Promise.resolve(null) as PrismaPromise<null>
|
||||
() =>
|
||||
Promise.resolve(null) as ReturnType<
|
||||
typeof fastifyTestInstance.prisma.user.findUnique
|
||||
>
|
||||
);
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import _ from 'lodash';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import * as schemas from '../../schemas';
|
||||
import { createResetProperties } from '../../utils/create-user';
|
||||
@@ -16,11 +19,13 @@ import {
|
||||
normalizeTwitter,
|
||||
removeNulls
|
||||
} from '../../utils/normalize';
|
||||
import type { UpdateReqType } from '../../utils';
|
||||
import {
|
||||
getCalendar,
|
||||
getPoints,
|
||||
ProgressTimestamp
|
||||
} from '../../utils/progress';
|
||||
import { JWT_SECRET } from '../../utils/env';
|
||||
|
||||
/**
|
||||
* Helper function to get the api url from the shared transcript link.
|
||||
@@ -71,6 +76,9 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
await fastify.prisma.user.delete({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.examEnvironmentAuthorizationToken.deleteMany({
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
reply.clearOurCookies();
|
||||
|
||||
return {};
|
||||
@@ -356,9 +364,59 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/user/exam-environment/token',
|
||||
{
|
||||
schema: schemas.userExamEnvironmentToken
|
||||
},
|
||||
examEnvironmentTokenHandler
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-param
|
||||
/**
|
||||
* Generate a new authorization token for the given user, and invalidates any existing tokens.
|
||||
*
|
||||
* Requires the user to be authenticated.
|
||||
*/
|
||||
async function examEnvironmentTokenHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.userExamEnvironmentToken>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
throw new Error('Unreachable. User should be authenticated.');
|
||||
}
|
||||
// Delete (invalidate) any existing tokens for the user.
|
||||
await this.prisma.examEnvironmentAuthorizationToken.deleteMany({
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
const token = await this.prisma.examEnvironmentAuthorizationToken.create({
|
||||
data: {
|
||||
createdDate: new Date(),
|
||||
id: customNanoid(),
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
const examEnvironmentAuthorizationToken = jwt.sign(
|
||||
{ examEnvironmentAuthorizationToken: token.id },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
void reply.send({
|
||||
data: {
|
||||
examEnvironmentAuthorizationToken
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin containing GET routes for user account management. They are kept
|
||||
* separate because they do not require CSRF protection.
|
||||
|
||||
@@ -39,3 +39,4 @@ export { postMsUsername } from './schemas/user/post-ms-username';
|
||||
export { reportUser } from './schemas/user/report-user';
|
||||
export { resetMyProgress } from './schemas/user/reset-my-progress';
|
||||
export { submitSurvey } from './schemas/user/submit-survey';
|
||||
export { userExamEnvironmentToken } from './schemas/user/exam-environment-token';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const userExamEnvironmentToken = {
|
||||
response: {
|
||||
200: Type.Object({
|
||||
data: Type.Object({
|
||||
examEnvironmentAuthorizationToken: Type.String()
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,12 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { type TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
type FastifyRequest,
|
||||
type FastifySchema,
|
||||
type RawRequestDefaultExpression,
|
||||
type RawServerDefault,
|
||||
type RouteGenericInterface
|
||||
} from 'fastify';
|
||||
|
||||
/**
|
||||
* Utility to encode a buffer to a base64 URI.
|
||||
@@ -19,3 +27,84 @@ function sha256(buf: Buffer) {
|
||||
return createHash('sha256').update(buf).digest();
|
||||
}
|
||||
export const challenge = base64URLEncode(sha256(Buffer.from(verifier)));
|
||||
|
||||
export type UpdateReqType<Schema extends FastifySchema> = FastifyRequest<
|
||||
RouteGenericInterface,
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression<RawServerDefault>,
|
||||
Schema,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Wrapper around a promise to catch errors and return them as part of the promise.
|
||||
*
|
||||
* This is most useful to prevent callback / try...catch hell.
|
||||
*
|
||||
* ## Example:
|
||||
*
|
||||
* ```ts
|
||||
* const maybeWhatIWant = await mapErr(
|
||||
* this.prisma.whatIWantCollection.create({
|
||||
* data: {}
|
||||
* })
|
||||
* );
|
||||
*
|
||||
* if (maybeWhatIWant.hasError) {
|
||||
* void reply.code(500);
|
||||
* return reply.send('Unable to generate exam, due to: ' +
|
||||
* JSON.stringify(maybeWhatIWant.error)
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* const whatIWant = maybeWhatIWant.data;
|
||||
* ```
|
||||
*
|
||||
* @param promise - any promise to be tried.
|
||||
* @returns a promise with either the data or the caught error
|
||||
*/
|
||||
export async function mapErr<T>(promise: Promise<T>): Promise<Result<T>> {
|
||||
try {
|
||||
return { hasError: false, data: await promise };
|
||||
} catch (error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around a synchronise function to catch throws and return them as part of the value.
|
||||
*
|
||||
* This is most useful to prevent try...catch hell.
|
||||
*
|
||||
* ## Example:
|
||||
*
|
||||
* ```ts
|
||||
* const maybeWhatIWant = await syncMapErr(
|
||||
* () => chai.assert.deepEqual({}, {})
|
||||
* );
|
||||
*
|
||||
* if (maybeWhatIWant.hasError) {
|
||||
* void reply.code(500);
|
||||
* return reply.send('Unable to generate exam, due to: ' +
|
||||
* JSON.stringify(maybeWhatIWant.error)
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* const whatIWant = maybeWhatIWant.data;
|
||||
* ```
|
||||
*
|
||||
* @param fn - any function to be tried.
|
||||
* @returns the data or the caught error
|
||||
*/
|
||||
export function syncMapErr<T>(fn: () => T): Result<T> {
|
||||
try {
|
||||
return { hasError: false, data: fn() };
|
||||
} catch (error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
}
|
||||
|
||||
export type Result<T> =
|
||||
| { hasError: false; data: T }
|
||||
| { hasError: true; error: unknown };
|
||||
|
||||
Reference in New Issue
Block a user