chore: import upstream snapshot with attribution
CD - Docker - GHCR Images / Build and Push Images (push) Waiting to run
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Waiting to run
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable filenames-simple/naming-convention */
|
||||
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
|
||||
|
||||
export default createLintStagedConfig(import.meta.dirname);
|
||||
@@ -0,0 +1,400 @@
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
ExamEnvironmentConfig,
|
||||
ExamEnvironmentQuestionType,
|
||||
ExamEnvironmentExamAttempt,
|
||||
ExamEnvironmentExam,
|
||||
ExamEnvironmentGeneratedExam,
|
||||
ExamEnvironmentQuestionSet,
|
||||
ExamEnvironmentChallenge
|
||||
} from '@prisma/client';
|
||||
import { ObjectId } from 'bson';
|
||||
import { examEnvironmentPostExamAttempt } from '../src/exam-environment/schemas/index.js';
|
||||
|
||||
const defaultUserId = '5bd30e0f1caf6ac3ddddddb5';
|
||||
|
||||
export const oid = () => new ObjectId().toString();
|
||||
|
||||
export const examId = oid();
|
||||
|
||||
export const config = {
|
||||
totalTimeInS: 2 * 60 * 60,
|
||||
tags: [],
|
||||
name: 'Test Exam',
|
||||
note: 'Some exam note...',
|
||||
passingPercent: 80,
|
||||
questionSets: [
|
||||
{
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 2,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: ExamEnvironmentQuestionType.Dialogue,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 2,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
}
|
||||
],
|
||||
retakeTimeInS: 24 * 60 * 60
|
||||
} satisfies ExamEnvironmentConfig;
|
||||
|
||||
export const questionSets: ExamEnvironmentQuestionSet[] = [
|
||||
{
|
||||
id: oid(),
|
||||
type: ExamEnvironmentQuestionType.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: ExamEnvironmentQuestionType.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: ExamEnvironmentQuestionType.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: ExamEnvironmentGeneratedExam = {
|
||||
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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
version: 2
|
||||
};
|
||||
|
||||
export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
examId,
|
||||
generatedExamId: generatedExam.id,
|
||||
examModerationId: null,
|
||||
id: oid(),
|
||||
questionSets: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[0]!.questions[0]!.answers[0]!],
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[1]!.questions[0]!.answers[1]!],
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.id,
|
||||
questions: [
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[0]!.answers[1]!],
|
||||
submissionTime: new Date()
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[1]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[1]!.answers[0]!],
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTime: new Date(),
|
||||
userId: defaultUserId,
|
||||
version: 2
|
||||
};
|
||||
|
||||
export const examAttemptSansSubmissionTime: 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 = {
|
||||
id: examId,
|
||||
config,
|
||||
questionSets,
|
||||
prerequisites: ['67112fe1c994faa2c26d0b1d'],
|
||||
deprecated: false,
|
||||
version: 2
|
||||
} satisfies ExamEnvironmentExam;
|
||||
|
||||
export const examEnvironmentChallenge: ExamEnvironmentChallenge = {
|
||||
id: oid(),
|
||||
examId,
|
||||
// Id of the certified full stack developer exam challenge page
|
||||
challengeId: '645147516c245de4d11eb7ba',
|
||||
version: 1
|
||||
};
|
||||
|
||||
export async function seedEnvExam() {
|
||||
await clearEnvExam();
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.create({
|
||||
data: exam
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.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.examEnvironmentGeneratedExam.create({
|
||||
// data: generatedExam
|
||||
// });
|
||||
// numberOfExamsGenerated++;
|
||||
// } catch (_e) {
|
||||
// //
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
export async function clearEnvExam() {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.deleteMany({});
|
||||
}
|
||||
|
||||
export async function seedEnvExamAttempt() {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedExamEnvExamAuthToken() {
|
||||
return fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.create({
|
||||
data: { userId: defaultUserId, expireAt: new Date(Date.now() + 60000) }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export const examChallengeId = '647e22d18acb466c97ccbef8';
|
||||
|
||||
export const examJson = {
|
||||
id: examChallengeId,
|
||||
title: 'Exam Certification',
|
||||
numberOfQuestionsInExam: 3,
|
||||
passingPercent: 10,
|
||||
prerequisites: [
|
||||
{
|
||||
id: '647f85d407d29547b3bee1bb',
|
||||
title: 'challenge-title'
|
||||
}
|
||||
],
|
||||
questions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
wrongAnswers: [
|
||||
{ id: 'ex7hii9zup', answer: 'Q1: Wrong Answer 1' },
|
||||
{ id: 'lmr1ew7m67', answer: 'Q1: Wrong Answer 2' },
|
||||
{ id: 'qh5sz9qdiq', answer: 'Q1: Wrong Answer 3' },
|
||||
{ id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' },
|
||||
{ id: '7vu84wl4lc', answer: 'Q1: Wrong Answer 5' },
|
||||
{ id: 'em59kw6avu', answer: 'Q1: Wrong Answer 6' }
|
||||
],
|
||||
correctAnswers: [
|
||||
{ id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' },
|
||||
{ id: 'f5gk39ske9', answer: 'Q1: Correct Answer 2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
wrongAnswers: [
|
||||
{ id: 'ojhnoxh5r5', answer: 'Q2: Wrong Answer 1' },
|
||||
{ id: 'onx06if0uh', answer: 'Q2: Wrong Answer 2' },
|
||||
{ id: 'zbxnsko712', answer: 'Q2: Wrong Answer 3' },
|
||||
{ id: 'bqv5y68jyp', answer: 'Q2: Wrong Answer 4' },
|
||||
{ id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' },
|
||||
{ id: 'wycrnloajd', answer: 'Q2: Wrong Answer 6' }
|
||||
],
|
||||
correctAnswers: [
|
||||
{ id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' },
|
||||
{ id: 'agert35dk0', answer: 'Q2: Correct Answer 2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
wrongAnswers: [
|
||||
{ id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' },
|
||||
{ id: 'onx06if0ub', answer: 'Q3: Wrong Answer 2' },
|
||||
{ id: 'zbxnsko71c', answer: 'Q3: Wrong Answer 3' },
|
||||
{ id: 'bqv5y68jyd', answer: 'Q3: Wrong Answer 4' },
|
||||
{ id: 'i5xipitise', answer: 'Q3: Wrong Answer 5' },
|
||||
{ id: 'wycrnloajf', answer: 'Q3: Wrong Answer 6' }
|
||||
],
|
||||
correctAnswers: [
|
||||
{ id: 't9ezcsupda', answer: 'Q3: Correct Answer 1' },
|
||||
{ id: 'agert35dkb', answer: 'Q3: Correct Answer 2' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const completedTrophyChallenges = [
|
||||
{
|
||||
id: '647f85d407d29547b3bee1bb',
|
||||
solution: 'challenge-solution',
|
||||
completedDate: 1695064765244,
|
||||
files: []
|
||||
}
|
||||
];
|
||||
|
||||
export type ExamSubmission = {
|
||||
userExamQuestions: {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: {
|
||||
id: string;
|
||||
answer: string;
|
||||
};
|
||||
}[];
|
||||
examTimeInSeconds: number;
|
||||
};
|
||||
|
||||
// failed: 0 correct
|
||||
export const examWithZeroCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
// passed: 1 correct
|
||||
export const examWithOneCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
// passed: 2 correct
|
||||
export const examWithTwoCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
// passed: 3 correct
|
||||
export const examWithAllCorrect: ExamSubmission = {
|
||||
userExamQuestions: [
|
||||
{
|
||||
id: '3bbl2mx2mq',
|
||||
question: 'Question 1?',
|
||||
answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0h',
|
||||
question: 'Question 2?',
|
||||
answer: { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' }
|
||||
},
|
||||
{
|
||||
id: 'oqis5gzs0a',
|
||||
question: 'Question 3?',
|
||||
answer: { id: 'agert35dkb', answer: 'Q3: Correct Answer 2' }
|
||||
}
|
||||
],
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsZeroCorrect = {
|
||||
numberOfCorrectAnswers: 0,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 0,
|
||||
passingPercent: 10,
|
||||
passed: false,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsOneCorrect = {
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 33.3,
|
||||
passingPercent: 10,
|
||||
passed: true,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsTwoCorrect = {
|
||||
numberOfCorrectAnswers: 2,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 66.7,
|
||||
passingPercent: 10,
|
||||
passed: true,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
export const mockResultsAllCorrect = {
|
||||
numberOfCorrectAnswers: 3,
|
||||
numberOfQuestionsInExam: 3,
|
||||
percentCorrect: 100,
|
||||
passingPercent: 10,
|
||||
passed: true,
|
||||
examTimeInSeconds: 20
|
||||
};
|
||||
|
||||
const completedExamChallenge = {
|
||||
id: examChallengeId,
|
||||
challengeType: 17,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
completedDate: expect.any(Number)
|
||||
};
|
||||
|
||||
export const completedExamChallengeOneCorrect = {
|
||||
...completedExamChallenge,
|
||||
examResults: mockResultsOneCorrect
|
||||
};
|
||||
|
||||
export const completedExamChallengeTwoCorrect = {
|
||||
...completedExamChallenge,
|
||||
examResults: mockResultsTwoCorrect
|
||||
};
|
||||
|
||||
export const completedExamChallengeAllCorrect = {
|
||||
...completedExamChallenge,
|
||||
examResults: mockResultsAllCorrect
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { configTypeChecked, tsFiles } from '@freecodecamp/eslint-config/base';
|
||||
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
|
||||
/**
|
||||
* A shared ESLint configuration for the repository.
|
||||
*
|
||||
* @type {import("eslint").Linter.Config[]}
|
||||
* */
|
||||
export default [
|
||||
...configTypeChecked,
|
||||
{
|
||||
...jsdoc.configs['flat/recommended-typescript-error'],
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
require: {
|
||||
ArrowFunctionExpression: true,
|
||||
ClassDeclaration: true,
|
||||
ClassExpression: true,
|
||||
FunctionDeclaration: true,
|
||||
FunctionExpression: true,
|
||||
MethodDefinition: true
|
||||
},
|
||||
|
||||
publicOnly: true
|
||||
}
|
||||
],
|
||||
|
||||
'jsdoc/require-description-complete-sentence': 'error',
|
||||
'jsdoc/tag-lines': 'off'
|
||||
},
|
||||
files: tsFiles
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"author": "freeCodeCamp <team@freecodecamp.org>",
|
||||
"bugs": {
|
||||
"url": "https://github.com/freeCodeCamp/freeCodeCamp/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/csrf-protection": "7.1.0",
|
||||
"@fastify/oauth2": "8.2.0",
|
||||
"@fastify/swagger": "9.7.0",
|
||||
"@fastify/swagger-ui": "5.2.6",
|
||||
"@fastify/type-provider-typebox": "6.1.0",
|
||||
"@freecodecamp/shared": "workspace:*",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@prisma/client": "6.19.3",
|
||||
"@sentry/node": "10.55.0",
|
||||
"@sentry/profiling-node": "10.55.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"bson": "7.2.0",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns-tz": "3.2.0",
|
||||
"dotenv": "16.6.1",
|
||||
"fast-uri": "2.4.0",
|
||||
"fastify": "5.8.5",
|
||||
"fastify-plugin": "5.1.0",
|
||||
"joi": "17.13.3",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"nanoid": "3",
|
||||
"no-profanity": "1.5.1",
|
||||
"nodemailer": "6.10.1",
|
||||
"pino": "9.14.0",
|
||||
"pino-pretty": "10.3.1",
|
||||
"query-string": "7.1.3",
|
||||
"stripe": "16.12.0",
|
||||
"typebox": "1.1.35",
|
||||
"validator": "13.15.35"
|
||||
},
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
"devDependencies": {
|
||||
"@freecodecamp/curriculum": "workspace:*",
|
||||
"@freecodecamp/eslint-config": "workspace:*",
|
||||
"@freecodecamp/shared": "workspace:*",
|
||||
"@total-typescript/ts-reset": "0.6.1",
|
||||
"@types/jsonwebtoken": "9.0.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/nodemailer": "6.4.23",
|
||||
"@types/supertest": "2.0.16",
|
||||
"@types/validator": "13.15.10",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"dotenv-cli": "7.4.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-jsdoc": "48.11.0",
|
||||
"msw": "^2.12.10",
|
||||
"prisma": "6.19.3",
|
||||
"supertest": "6.3.4",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^4.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=8"
|
||||
},
|
||||
"homepage": "https://github.com/freeCodeCamp/freeCodeCamp#readme",
|
||||
"license": "BSD-3-Clause",
|
||||
"main": "none",
|
||||
"name": "@freecodecamp/api",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/freeCodeCamp/freeCodeCamp.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"clean": "rm -rf dist",
|
||||
"develop": "tsx watch --clear-screen=false src/server.ts",
|
||||
"start": "FREECODECAMP_NODE_ENV=production node dist/server.js",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma": "dotenv -e ../.env prisma",
|
||||
"postinstall": "prisma generate",
|
||||
"exam-env:seed": "tsx tools/exam-environment/seed/index.ts",
|
||||
"exam-env:test": "tsx tools/exam-environment/test/index.ts"
|
||||
},
|
||||
"version": "0.0.1"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PrismaConfig } from 'prisma';
|
||||
|
||||
export default {
|
||||
schema: 'prisma'
|
||||
} satisfies PrismaConfig;
|
||||
@@ -0,0 +1,58 @@
|
||||
/// A copy of `ExamEnvironmentExam` used as a staging collection for updates to the curriculum.
|
||||
///
|
||||
/// This collection schema must be kept in sync with `ExamEnvironmentExam`.
|
||||
model ExamCreatorExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(3)
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store authZ users.
|
||||
///
|
||||
/// Currently, this is manually created in order to grant access to the application.
|
||||
model ExamCreatorUser {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
email String
|
||||
/// Unique id from GitHub for an account.
|
||||
///
|
||||
/// Currently, this is unused. Consider removing.
|
||||
github_id Int?
|
||||
name String
|
||||
picture String?
|
||||
settings ExamCreatorUserSettings
|
||||
version Int @default(2)
|
||||
|
||||
ExamCreatorSession ExamCreatorSession[]
|
||||
}
|
||||
|
||||
type ExamCreatorUserSettings {
|
||||
databaseEnvironment ExamCreatorDatabaseEnvironment
|
||||
}
|
||||
|
||||
enum ExamCreatorDatabaseEnvironment {
|
||||
Production
|
||||
Staging
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store auth sessions.
|
||||
model ExamCreatorSession {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
user_id String @db.ObjectId
|
||||
session_id String
|
||||
/// Expiration date for record.
|
||||
expires_at DateTime
|
||||
|
||||
version Int @default(1)
|
||||
|
||||
ExamCreatorUser ExamCreatorUser @relation(fields: [user_id], references: [id])
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/// An exam for the Exam Environment App as designed by the examiners
|
||||
model ExamEnvironmentExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(3)
|
||||
|
||||
// Relations
|
||||
generatedExams ExamEnvironmentGeneratedExam[]
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
ExamEnvironmentChallenge ExamEnvironmentChallenge[]
|
||||
}
|
||||
|
||||
/// A grouping of one or more questions of a given type
|
||||
type ExamEnvironmentQuestionSet {
|
||||
/// Unique question type id
|
||||
id String @db.ObjectId
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Content related to all questions in set
|
||||
context String?
|
||||
questions ExamEnvironmentMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
/// A multiple choice question for the Exam Environment App
|
||||
type ExamEnvironmentMultipleChoiceQuestion {
|
||||
/// 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 ExamEnvironmentAudio?
|
||||
/// Available possible answers for an exam
|
||||
answers ExamEnvironmentAnswer[]
|
||||
/// 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 ExamEnvironmentAudio {
|
||||
/// Optional text for audio
|
||||
captions String?
|
||||
/// URL to audio file
|
||||
///
|
||||
/// Expected in the format: `<url>#t=<start_time_in_seconds>,<end_time_in_seconds>`
|
||||
/// Where `start_time_in_seconds` and `end_time_in_seconds` are optional floats.
|
||||
url String
|
||||
}
|
||||
|
||||
/// Type of question for the Exam Environment App
|
||||
enum ExamEnvironmentQuestionType {
|
||||
/// Single question with one or more answers
|
||||
MultipleChoice
|
||||
/// Mass text
|
||||
Dialogue
|
||||
}
|
||||
|
||||
/// Answer for an Exam Environment App multiple choice question
|
||||
type ExamEnvironmentAnswer {
|
||||
/// 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 ExamEnvironmentConfig {
|
||||
/// Human-readable exam name
|
||||
name String
|
||||
/// Notes given about exam
|
||||
note String
|
||||
/// Category configuration for question selection
|
||||
tags ExamEnvironmentTagConfig[]
|
||||
/// Total time allocated for exam in seconds
|
||||
totalTimeInS Int
|
||||
/// Configuration for sets of questions
|
||||
questionSets ExamEnvironmentQuestionSetConfig[]
|
||||
/// Duration after exam completion before a retake is allowed in seconds
|
||||
retakeTimeInS Int
|
||||
/// Passing percent for the exam
|
||||
passingPercent Float
|
||||
}
|
||||
|
||||
/// Configuration for a set of questions in the Exam Environment App
|
||||
type ExamEnvironmentQuestionSetConfig {
|
||||
type ExamEnvironmentQuestionType
|
||||
/// 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 ExamEnvironmentTagConfig {
|
||||
/// 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 ExamEnvironmentExamAttempt {
|
||||
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
|
||||
/// Un-enforced foreign key to moderation
|
||||
examModerationId String? @db.ObjectId
|
||||
|
||||
questionSets ExamEnvironmentQuestionSetAttempt[]
|
||||
/// Time exam was started
|
||||
startTime DateTime
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(4)
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
generatedExam ExamEnvironmentGeneratedExam @relation(fields: [generatedExamId], references: [id])
|
||||
// Ideally, there could be a way to add a one-way optional relation here, but Prisma does not allow that:
|
||||
// Error parsing attribute "@relation": The relation fields `examAttempt` on Model `ExamEnvironmentExamModeration` and `examModeration` on Model `ExamEnvironmentExamAttempt` both provide the `references` argument in the @relation attribute. You have to provide it only on one of the two fields.
|
||||
// examModeration ExamEnvironmentExamModeration? @relation(fields: [examModerationId], references: [id])
|
||||
examEnvironmentExamModeration ExamEnvironmentExamModeration?
|
||||
}
|
||||
|
||||
type ExamEnvironmentQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentMultipleChoiceQuestionAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentMultipleChoiceQuestionAttempt {
|
||||
/// 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
|
||||
///
|
||||
/// If the question is later revisited, this field is updated
|
||||
submissionTime DateTime
|
||||
}
|
||||
|
||||
/// A generated exam for the Exam Environment App
|
||||
///
|
||||
/// This is the user-facing information for an exam.
|
||||
model ExamEnvironmentGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
questionSets ExamEnvironmentGeneratedQuestionSet[]
|
||||
/// If `deprecated`, the generation should not longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
EnvExamAttempt ExamEnvironmentExamAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentGeneratedMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedMultipleChoiceQuestion {
|
||||
/// Foreign key to question id
|
||||
id String @db.ObjectId
|
||||
/// Each item is a foreign key to an answer
|
||||
answers String[] @db.ObjectId
|
||||
}
|
||||
|
||||
/// A map between challenge ids and exam ids
|
||||
///
|
||||
/// This is expected to be used for relating challenge pages AND/OR certifications to exams
|
||||
model ExamEnvironmentChallenge {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
examId String @db.ObjectId
|
||||
challengeId String @db.ObjectId
|
||||
|
||||
version Int @default(1)
|
||||
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ExamEnvironmentAuthorizationToken {
|
||||
/// An ObjectId is used to provide access to the created timestamp
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Used to set an `expireAt` index to delete documents
|
||||
expireAt DateTime @db.Date
|
||||
userId String @unique @db.ObjectId
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ExamEnvironmentExamModeration {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Whether or not the item is approved
|
||||
status ExamEnvironmentExamModerationStatus
|
||||
/// Foreign key to exam attempt
|
||||
examAttemptId String @unique @db.ObjectId
|
||||
/// Optional feedback/note about the moderation decision
|
||||
feedback String?
|
||||
/// Date the exam attempt was moderated
|
||||
moderationDate DateTime?
|
||||
/// Foreign key to moderator. This is `null` until the item is moderated.
|
||||
moderatorId String? @db.ObjectId
|
||||
|
||||
/// Date the exam attempt expired
|
||||
submissionDate DateTime @default(now()) @db.Date
|
||||
/// Whether the `challengeId` for the `ExamEnvironmentChallenge` has been awarded to the user
|
||||
challengesAwarded Boolean @default(false)
|
||||
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(2)
|
||||
|
||||
// Relations
|
||||
examAttempt ExamEnvironmentExamAttempt @relation(fields: [examAttemptId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ExamEnvironmentExamModerationStatus {
|
||||
/// Attempt is determined to be valid
|
||||
Approved
|
||||
/// Attempt is determined to be invalid
|
||||
Denied
|
||||
/// Attempt has yet to be moderated
|
||||
Pending
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mongodb"
|
||||
url = env("MONGOHQ_URL")
|
||||
}
|
||||
|
||||
// USER COLLECTION ---------------------
|
||||
|
||||
type File {
|
||||
contents String
|
||||
ext String
|
||||
key String
|
||||
name String
|
||||
path String? // Undefined | Null
|
||||
}
|
||||
|
||||
type CompletedChallenge {
|
||||
challengeType Json? // Null | Undefined | String | Int
|
||||
completedDate Json // DateTime | Float, but not, as far as we know, Null
|
||||
files File[]
|
||||
githubLink String? // Undefined
|
||||
id String
|
||||
isManuallyApproved Boolean? // Undefined
|
||||
solution String? // Null | Undefined
|
||||
examResults ExamResults? // Undefined
|
||||
}
|
||||
|
||||
enum DailyCodingChallengeLanguage {
|
||||
javascript
|
||||
python
|
||||
}
|
||||
|
||||
type CompletedDailyCodingChallenge {
|
||||
id String @db.ObjectId
|
||||
/// Date in milliseconds since epoch
|
||||
/// This is not a DateTime, because DateTime does not serialize directly to JSON
|
||||
completedDate Int
|
||||
languages DailyCodingChallengeLanguage[]
|
||||
}
|
||||
|
||||
type PartiallyCompletedChallenge {
|
||||
id String
|
||||
completedDate Float
|
||||
}
|
||||
|
||||
type Portfolio {
|
||||
description String
|
||||
id String
|
||||
image String
|
||||
title String
|
||||
url String
|
||||
}
|
||||
|
||||
type Experience {
|
||||
id String
|
||||
title String
|
||||
company String
|
||||
location String?
|
||||
startDate String
|
||||
endDate String?
|
||||
description String
|
||||
}
|
||||
|
||||
type ProfileUI {
|
||||
isLocked Boolean? // Undefined
|
||||
showAbout Boolean? // Undefined
|
||||
showCerts Boolean? // Undefined
|
||||
showDonation Boolean? // Undefined
|
||||
showHeatMap Boolean? // Undefined
|
||||
showLocation Boolean? // Undefined
|
||||
showName Boolean? // Undefined
|
||||
showPoints Boolean? // Undefined
|
||||
showPortfolio Boolean? // Undefined
|
||||
showExperience Boolean? // Undefined
|
||||
showTimeLine Boolean? // Undefined
|
||||
}
|
||||
|
||||
type SavedChallengeFile {
|
||||
contents String
|
||||
ext String
|
||||
history String[]
|
||||
key String
|
||||
name String
|
||||
}
|
||||
|
||||
type SavedChallenge {
|
||||
files SavedChallengeFile[]
|
||||
id String
|
||||
lastSavedDate Float
|
||||
}
|
||||
|
||||
type QuizAttempt {
|
||||
challengeId String
|
||||
quizId String
|
||||
timestamp Float
|
||||
}
|
||||
|
||||
/// Corresponds to the `user` collection.
|
||||
model user {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
about String
|
||||
acceptedPrivacyTerms Boolean
|
||||
completedChallenges CompletedChallenge[]
|
||||
completedDailyCodingChallenges CompletedDailyCodingChallenge[]
|
||||
completedExams CompletedExam[] // Undefined
|
||||
quizAttempts QuizAttempt[] // Undefined
|
||||
currentChallengeId String?
|
||||
donationEmails String[] // Undefined | String[] (only possible for built in Types like String)
|
||||
email String?
|
||||
emailAuthLinkTTL DateTime? // Null | Undefined
|
||||
emailVerified Boolean?
|
||||
emailVerifyTTL DateTime? // Null | Undefined
|
||||
externalId String
|
||||
githubProfile String? // Undefined
|
||||
isA2EnglishCert Boolean? // Undefined
|
||||
isApisMicroservicesCert Boolean? // Undefined
|
||||
isBackEndCert Boolean? // Undefined
|
||||
isBanned Boolean? // Undefined
|
||||
isCheater Boolean? // Undefined
|
||||
isDataAnalysisPyCertV7 Boolean? // Undefined
|
||||
isDataVisCert Boolean? // Undefined
|
||||
isDonating Boolean
|
||||
isFoundationalCSharpCertV8 Boolean? // Undefined
|
||||
isFrontEndCert Boolean? // Undefined
|
||||
isFrontEndLibsCert Boolean? // Undefined
|
||||
isFullStackCert Boolean? // Undefined
|
||||
isHonest Boolean?
|
||||
isInfosecCertV7 Boolean? // Undefined
|
||||
isInfosecQaCert Boolean? // Undefined
|
||||
isJavascriptCertV9 Boolean? // Undefined
|
||||
isJsAlgoDataStructCert Boolean? // Undefined
|
||||
isJsAlgoDataStructCertV8 Boolean? // Undefined
|
||||
isMachineLearningPyCertV7 Boolean? // Undefined
|
||||
isPythonCertV9 Boolean? // Undefined
|
||||
isQaCertV7 Boolean? // Undefined
|
||||
isRelationalDatabaseCertV8 Boolean? // Undefined
|
||||
isRelationalDatabaseCertV9 Boolean? // Undefined
|
||||
isRespWebDesignCert Boolean? // Undefined
|
||||
isRespWebDesignCertV9 Boolean? // Undefined
|
||||
isSciCompPyCertV7 Boolean? // Undefined
|
||||
is2018DataVisCert Boolean? // Undefined
|
||||
is2018FullStackCert Boolean? // Undefined
|
||||
isCollegeAlgebraPyCertV8 Boolean? // Undefined
|
||||
isFrontEndLibsCertV9 Boolean? // Undefined
|
||||
isBackEndDevApisCertV9 Boolean? // Undefined
|
||||
isFullStackDeveloperCertV9 Boolean? // Undefined
|
||||
isB1EnglishCert Boolean? // Undefined
|
||||
isA2SpanishCert Boolean? // Undefined
|
||||
isA2ChineseCert Boolean? // Undefined
|
||||
isA1ChineseCert Boolean? // Undefined
|
||||
// isUpcomingPythonCertV8 Boolean? // Undefined. It is in the db but has never been used.
|
||||
keyboardShortcuts Boolean? // Undefined
|
||||
linkedin String? // Null | Undefined
|
||||
location String? // Null
|
||||
name String? // Null
|
||||
needsModeration Boolean? // Undefined
|
||||
newEmail String? // Null | Undefined
|
||||
partiallyCompletedChallenges PartiallyCompletedChallenge[] // Undefined | PartiallyCompletedChallenge[]
|
||||
password String? // Undefined
|
||||
picture String?
|
||||
portfolio Portfolio[]
|
||||
experience Experience[]
|
||||
profileUI ProfileUI? // Undefined
|
||||
progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE
|
||||
/// A random number between 0 and 1.
|
||||
///
|
||||
/// Valuable for selectively performing random logic.
|
||||
rand Float?
|
||||
savedChallenges SavedChallenge[] // Undefined | SavedChallenge[]
|
||||
// Nullable tri-state: null (likely new user), true (subscribed), false (unsubscribed)
|
||||
sendQuincyEmail Boolean?
|
||||
socrates Boolean?
|
||||
theme String? // Undefined
|
||||
timezone String? // Undefined
|
||||
twitter String? // Null | Undefined
|
||||
bluesky String? // Null | Undefined
|
||||
unsubscribeId String
|
||||
/// Used to track the number of times the user's record was written to.
|
||||
///
|
||||
/// This has the main benefit of allowing concurrent ops to check for race conditions.
|
||||
updateCount Int? @default(0)
|
||||
username String // TODO(Post-MVP): make this unique
|
||||
usernameDisplay String? // Undefined
|
||||
verificationToken String? // Undefined
|
||||
website String? // Undefined
|
||||
yearsTopContributor String[] // Undefined | String[]
|
||||
isClassroomAccount Boolean? // Undefined
|
||||
|
||||
// Relations
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
examEnvironmentAuthorizationToken ExamEnvironmentAuthorizationToken?
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
|
||||
model AccessToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
ttl Int
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model AuthToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
ttl Int
|
||||
userId String @db.ObjectId
|
||||
}
|
||||
|
||||
model Donation {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
amount Int @db.Int
|
||||
customerId String
|
||||
duration String?
|
||||
email String
|
||||
endDate DonationEndDate?
|
||||
provider String
|
||||
startDate DonationStartDate
|
||||
subscriptionId String
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([email], map: "email_1")
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model SocratesUsage {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
/// UTC date representing the day of usage (midnight).
|
||||
date DateTime @db.Date
|
||||
/// Number of hints used on this day.
|
||||
count Int @default(0)
|
||||
|
||||
@@unique([userId, date])
|
||||
}
|
||||
|
||||
model UserToken {
|
||||
id String @id @map("_id")
|
||||
created DateTime @db.Date
|
||||
ttl Float
|
||||
userId String @db.ObjectId
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model sessions {
|
||||
id String @id @map("_id")
|
||||
expires DateTime @db.Date
|
||||
session String
|
||||
|
||||
@@index([expires], map: "expires_1")
|
||||
}
|
||||
|
||||
model MsUsername {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
ttl Int
|
||||
msUsername String
|
||||
|
||||
@@index([userId, id], map: "userId_1__id_1")
|
||||
@@index([msUsername], map: "msUsername_1")
|
||||
}
|
||||
|
||||
model Exam {
|
||||
id String @id @map("_id") @db.ObjectId
|
||||
numberOfQuestionsInExam Int @db.Int
|
||||
passingPercent Int @db.Int
|
||||
prerequisites Prerequisite[] // undefined | Prerequisite[]
|
||||
title String
|
||||
questions Question[]
|
||||
}
|
||||
|
||||
type CompletedExam {
|
||||
id String
|
||||
challengeType Int
|
||||
completedDate Float // TODO(Post-MVP): Change to DateTime?
|
||||
examResults ExamResults
|
||||
}
|
||||
|
||||
type ExamResults {
|
||||
numberOfCorrectAnswers Int
|
||||
numberOfQuestionsInExam Int
|
||||
percentCorrect Float
|
||||
passingPercent Int
|
||||
passed Boolean
|
||||
examTimeInSeconds Int
|
||||
}
|
||||
|
||||
type Question {
|
||||
id String
|
||||
question String
|
||||
wrongAnswers Answer[]
|
||||
correctAnswers Answer[]
|
||||
deprecated Boolean? // undefined
|
||||
}
|
||||
|
||||
type Answer {
|
||||
id String
|
||||
answer String
|
||||
deprecated Boolean? // undefined
|
||||
}
|
||||
|
||||
type Prerequisite {
|
||||
id String @db.ObjectId
|
||||
title String
|
||||
}
|
||||
|
||||
type DonationEndDate {
|
||||
date DateTime @map("_date") @db.Date
|
||||
when String @map("_when")
|
||||
}
|
||||
|
||||
type DonationStartDate {
|
||||
date DateTime @map("_date") @db.Date
|
||||
when String @map("_when")
|
||||
}
|
||||
|
||||
model Survey {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
title String
|
||||
responses SurveyResponse[]
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
type SurveyResponse {
|
||||
question String
|
||||
response String
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
|
||||
model DailyCodingChallenges {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
challengeNumber Int
|
||||
date DateTime
|
||||
title String
|
||||
description String
|
||||
javascript DailyCodingChallengeApiLanguage
|
||||
python DailyCodingChallengeApiLanguage
|
||||
}
|
||||
|
||||
type DailyCodingChallengeApiLanguage {
|
||||
tests DailyCodingChallengeApiLanguageTests[]
|
||||
challengeFiles DailyCodingChallengeApiLanguageChallengeFiles[]
|
||||
}
|
||||
|
||||
type DailyCodingChallengeApiLanguageTests {
|
||||
text String
|
||||
testString String
|
||||
}
|
||||
|
||||
type DailyCodingChallengeApiLanguageChallengeFiles {
|
||||
contents String
|
||||
fileKey String
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
|
||||
model DripCampaign {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
userId String @db.ObjectId
|
||||
creationDate DateTime @default(now()) @db.Date
|
||||
email String
|
||||
variant String
|
||||
|
||||
@@index([userId], map: "userId_1")
|
||||
@@index([email], map: "email_1")
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import fastifyAccepts from '@fastify/accepts';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUI from '@fastify/swagger-ui';
|
||||
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
|
||||
import uriResolver from 'fast-uri';
|
||||
import Fastify, {
|
||||
FastifyBaseLogger,
|
||||
FastifyHttpOptions,
|
||||
FastifyInstance,
|
||||
RawReplyDefaultExpression,
|
||||
RawRequestDefaultExpression,
|
||||
RawServerDefault
|
||||
} from 'fastify';
|
||||
import { Ajv } from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
import prismaPlugin from './db/prisma.js';
|
||||
import cookies from './plugins/cookies.js';
|
||||
import cors from './plugins/cors.js';
|
||||
import { createMailProvider } from './plugins/mail-providers/nodemailer.js';
|
||||
import mailer from './plugins/mailer.js';
|
||||
import redirectWithMessage from './plugins/redirect-with-message.js';
|
||||
import security from './plugins/security.js';
|
||||
import auth from './plugins/auth.js';
|
||||
import bouncer from './plugins/bouncer.js';
|
||||
import errorHandling from './plugins/error-handling.js';
|
||||
import runtimeMetrics from './plugins/runtime-metrics.js';
|
||||
import csrf from './plugins/csrf.js';
|
||||
import notFound from './plugins/not-found.js';
|
||||
import shadowCapture from './plugins/shadow-capture.js';
|
||||
import growthBook from './plugins/growth-book.js';
|
||||
import serviceBearerAuth from './plugins/service-bearer-auth.js';
|
||||
|
||||
import * as publicRoutes from './routes/public/index.js';
|
||||
import * as protectedRoutes from './routes/protected/index.js';
|
||||
import { classroomRoutes } from './routes/apps/classroom.js';
|
||||
|
||||
import {
|
||||
API_LOCATION,
|
||||
FCC_ENABLE_DEV_LOGIN_MODE,
|
||||
FCC_ENABLE_SWAGGER_UI,
|
||||
FCC_ENABLE_SHADOW_CAPTURE,
|
||||
FCC_ENABLE_SENTRY_ROUTES,
|
||||
FCC_ENABLE_CLASSROOM,
|
||||
FREECODECAMP_NODE_ENV,
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from './utils/env.js';
|
||||
import { isObjectID } from './utils/validation.js';
|
||||
import { bindRouteToLogger, genReqId, getLogger } from './utils/logger.js';
|
||||
import { recordHttpMetrics } from './utils/http-metrics.js';
|
||||
import {
|
||||
examEnvironmentOpenRoutes,
|
||||
examEnvironmentValidatedTokenRoutes
|
||||
} from './exam-environment/routes/exam-environment.js';
|
||||
import { dailyCodingChallengeRoutes } from './daily-coding-challenge/routes/daily-coding-challenge.js';
|
||||
|
||||
type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression,
|
||||
RawReplyDefaultExpression,
|
||||
FastifyBaseLogger,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
// Options that fastify uses
|
||||
const ajv = new Ajv({
|
||||
coerceTypes: 'array', // change data type of data to match type keyword
|
||||
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
|
||||
removeAdditional: 'all', // remove additional properties
|
||||
uriResolver,
|
||||
addUsedSchema: false,
|
||||
// Explicitly set allErrors to `false`.
|
||||
// When set to `true`, a DoS attack is possible.
|
||||
allErrors: false
|
||||
});
|
||||
|
||||
// add the default formatters from avj-formats
|
||||
addFormats.default(ajv);
|
||||
ajv.addFormat('objectid', {
|
||||
type: 'string',
|
||||
validate: (str: string) => isObjectID(str)
|
||||
});
|
||||
|
||||
export const buildOptions: FastifyHttpOptions<
|
||||
RawServerDefault,
|
||||
FastifyBaseLogger
|
||||
> = {
|
||||
loggerInstance: getLogger(),
|
||||
genReqId,
|
||||
// destroy all connections on close to avoid EADDRINUSE
|
||||
// on restart, in development. Leave default in production.
|
||||
forceCloseConnections:
|
||||
FREECODECAMP_NODE_ENV === 'production' ? ('idle' as const) : true
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level wrapper to instantiate the API server. This is where all middleware and
|
||||
* routes should be mounted.
|
||||
*
|
||||
* @param options The options to pass to the Fastify constructor.
|
||||
* @returns The instantiated Fastify server, with TypeBox.
|
||||
*/
|
||||
export const build = async (
|
||||
options: FastifyHttpOptions<RawServerDefault, FastifyBaseLogger> = {}
|
||||
): Promise<FastifyInstanceWithTypeProvider> => {
|
||||
// TODO: Old API returns 403s for failed validation. We now return 400 (default) from AJV.
|
||||
// Watch when implementing in client
|
||||
const fastify = Fastify(options).withTypeProvider<TypeBoxTypeProvider>();
|
||||
|
||||
fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema));
|
||||
|
||||
fastify.addHook('onRequest', bindRouteToLogger);
|
||||
fastify.addHook('onResponse', recordHttpMetrics);
|
||||
|
||||
void fastify.register(redirectWithMessage);
|
||||
void fastify.register(security);
|
||||
void fastify.register(fastifyAccepts);
|
||||
void fastify.register(errorHandling);
|
||||
void fastify.register(runtimeMetrics);
|
||||
|
||||
await fastify.register(cors);
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(csrf);
|
||||
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
|
||||
void fastify.register(mailer, { provider: createMailProvider() });
|
||||
|
||||
// Swagger plugin
|
||||
if (FCC_ENABLE_SWAGGER_UI ?? fastify.gb.isOn('swagger-ui')) {
|
||||
void fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'freeCodeCamp API',
|
||||
version: '1.0.0' // API version
|
||||
}
|
||||
}
|
||||
});
|
||||
void fastify.register(fastifySwaggerUI, {
|
||||
uiConfig: {
|
||||
// Convert csrf_token cookie to csrf-token header
|
||||
requestInterceptor: req => {
|
||||
const csrfTokenCookie = document.cookie
|
||||
.split(';')
|
||||
.find(str => str.includes('csrf_token'));
|
||||
const [_key, csrfToken] = csrfTokenCookie?.split('=') ?? [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (csrfToken) req.headers['csrf-token'] = csrfToken.trim();
|
||||
return req;
|
||||
}
|
||||
}
|
||||
});
|
||||
fastify.log.info(`Swagger UI available at ${API_LOCATION}/documentation`);
|
||||
}
|
||||
|
||||
if (FCC_ENABLE_SHADOW_CAPTURE ?? fastify.gb.isOn('shadow-capture')) {
|
||||
void fastify.register(shadowCapture);
|
||||
}
|
||||
|
||||
void fastify.register(auth);
|
||||
void fastify.register(notFound);
|
||||
void fastify.register(prismaPlugin);
|
||||
void fastify.register(bouncer);
|
||||
await fastify.register(serviceBearerAuth);
|
||||
|
||||
// Routes requiring authentication:
|
||||
void fastify.register(async function (fastify, _opts) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
// CSRF protection enabled:
|
||||
await fastify.register(async function (fastify, _opts) {
|
||||
// TODO: bounce unauthed requests before checking CSRF token. This will
|
||||
// mean moving csrfProtection into custom plugin and testing separately,
|
||||
// because it's a pain to mess around with other cookies/hook order.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
|
||||
await fastify.register(protectedRoutes.challengeRoutes);
|
||||
await fastify.register(protectedRoutes.donateRoutes);
|
||||
await fastify.register(protectedRoutes.socratesRoutes);
|
||||
await fastify.register(protectedRoutes.protectedCertificateRoutes);
|
||||
await fastify.register(protectedRoutes.settingRoutes);
|
||||
await fastify.register(protectedRoutes.userRoutes);
|
||||
});
|
||||
|
||||
// Routes that redirect if access is denied:
|
||||
await fastify.register(async function (fastify, _opts) {
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
|
||||
await fastify.register(protectedRoutes.settingRedirectRoutes);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: The route should not handle its own AuthZ
|
||||
await fastify.register(protectedRoutes.challengeTokenRoutes);
|
||||
|
||||
// CSRF protection disabled:
|
||||
// Routes that work for both authenticated and unauthenticated users:
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
await fastify.register(protectedRoutes.userGetRoutes);
|
||||
});
|
||||
|
||||
// Routes for signed out users:
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
// TODO(Post-MVP): add the redirectIfSignedIn hook here, rather than in the
|
||||
// mobileAuth0Routes and authRoutes plugins.
|
||||
await fastify.register(publicRoutes.mobileAuth0Routes);
|
||||
if (FCC_ENABLE_DEV_LOGIN_MODE) {
|
||||
await fastify.register(publicRoutes.devAuthRoutes);
|
||||
} else {
|
||||
await fastify.register(publicRoutes.authRoutes);
|
||||
}
|
||||
});
|
||||
|
||||
void fastify.register(function (fastify, _opts, done) {
|
||||
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
|
||||
void fastify.register(examEnvironmentValidatedTokenRoutes);
|
||||
done();
|
||||
});
|
||||
void fastify.register(examEnvironmentOpenRoutes);
|
||||
|
||||
// Service-to-service app routes (API key auth), gated by the classroom flag:
|
||||
if (FCC_ENABLE_CLASSROOM ?? fastify.gb.isOn('classroom-mode')) {
|
||||
void fastify.register(async function (fastify) {
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
await fastify.register(classroomRoutes, { prefix: '/apps/classroom' });
|
||||
});
|
||||
}
|
||||
|
||||
if (FCC_ENABLE_SENTRY_ROUTES ?? fastify.gb.isOn('sentry-routes')) {
|
||||
void fastify.register(publicRoutes.sentryRoutes);
|
||||
}
|
||||
|
||||
void fastify.register(publicRoutes.chargeStripeRoute);
|
||||
void fastify.register(publicRoutes.signoutRoute);
|
||||
void fastify.register(publicRoutes.emailSubscribtionRoutes);
|
||||
void fastify.register(publicRoutes.userPublicGetRoutes);
|
||||
void fastify.register(publicRoutes.unprotectedCertificateRoutes);
|
||||
void fastify.register(publicRoutes.deprecatedEndpoints);
|
||||
void fastify.register(publicRoutes.statusRoute);
|
||||
void fastify.register(publicRoutes.unsubscribeDeprecated);
|
||||
void fastify.register(dailyCodingChallengeRoutes);
|
||||
|
||||
return fastify;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
Endpoints to get daily coding challenge info. Daily challenge submission still lives in the main part of the API.
|
||||
@@ -0,0 +1,647 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
|
||||
function dateToDateParam(date: Date): string {
|
||||
return date.toISOString().split('T')[0] as string;
|
||||
}
|
||||
|
||||
const todayUsCentral = new Date(Date.UTC(2025, 9, 2, 5)); // 2025-10-02 00:00:00 in US Central
|
||||
const todayUtcMidnight = new Date(Date.UTC(2025, 9, 2, 0, 0, 0));
|
||||
|
||||
const todayDateParam = dateToDateParam(todayUtcMidnight);
|
||||
|
||||
const yesterdayUtcMidnight = addDays(todayUtcMidnight, -1);
|
||||
|
||||
const twoDaysAgoUtcMidnight = addDays(todayUtcMidnight, -2);
|
||||
const twoDaysAgoDateParam = dateToDateParam(twoDaysAgoUtcMidnight);
|
||||
|
||||
const tomorrowUtcMidnight = addDays(todayUtcMidnight, 1);
|
||||
const tomorrowDateParam = dateToDateParam(tomorrowUtcMidnight);
|
||||
|
||||
const yesterdaysChallenge = {
|
||||
id: '111111111111111111111111',
|
||||
challengeNumber: 1,
|
||||
date: yesterdayUtcMidnight,
|
||||
title: "Yesterday's Challenge",
|
||||
description: "Yesterday's Description",
|
||||
javascript: {
|
||||
tests: [{ text: 'JS Test Yesterday', testString: 'jsTestYesterday()' }],
|
||||
challengeFiles: [{ contents: 'JS Files Yesterday', fileKey: 'scriptjs' }]
|
||||
},
|
||||
python: {
|
||||
tests: [{ text: 'Py Test Yesterday', testString: 'py_test_yesterday()' }],
|
||||
challengeFiles: [{ contents: 'Py Files Yesterday', fileKey: 'mainpy' }]
|
||||
}
|
||||
};
|
||||
|
||||
const todaysChallenge = {
|
||||
id: '222222222222222222222222',
|
||||
challengeNumber: 2,
|
||||
date: todayUtcMidnight,
|
||||
title: "Today's Challenge",
|
||||
description: "Today's Description",
|
||||
javascript: {
|
||||
tests: [{ text: 'JS Test Today', testString: 'jsTestToday()' }],
|
||||
challengeFiles: [{ contents: 'JS Files Today', fileKey: 'scriptjs' }]
|
||||
},
|
||||
python: {
|
||||
tests: [{ text: 'Py Test Today', testString: 'py_test_today()' }],
|
||||
challengeFiles: [{ contents: 'Py Files Today', fileKey: 'mainpy' }]
|
||||
}
|
||||
};
|
||||
|
||||
const tomorrowsChallenge = {
|
||||
id: '333333333333333333333333',
|
||||
challengeNumber: 3,
|
||||
date: tomorrowUtcMidnight,
|
||||
title: "Tomorrow's Challenge",
|
||||
description: "Tomorrow's Description",
|
||||
javascript: {
|
||||
tests: [{ text: 'JS Test Tomorrow', testString: 'jsTestTomorrow()' }],
|
||||
challengeFiles: [{ contents: 'JS Files Tomorrow', fileKey: 'scriptjs' }]
|
||||
},
|
||||
python: {
|
||||
tests: [{ text: 'Py Test Tomorrow', testString: 'py_test_tomorrow()' }],
|
||||
challengeFiles: [{ contents: 'Py Files Tomorrow', fileKey: 'mainpy' }]
|
||||
}
|
||||
};
|
||||
|
||||
const mockChallenges = [
|
||||
tomorrowsChallenge,
|
||||
todaysChallenge,
|
||||
yesterdaysChallenge
|
||||
];
|
||||
|
||||
describe('/daily-coding-challenge', () => {
|
||||
setupServer();
|
||||
// This has to happen after setupServer since it needs real timers.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: todayUsCentral });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/date/:date', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid date format', async () => {
|
||||
const invalidFormats = [
|
||||
'invalid-format',
|
||||
'2025-07',
|
||||
'07-18-2025',
|
||||
'25-07-18',
|
||||
'2025-7-18',
|
||||
'2025-07-8'
|
||||
];
|
||||
|
||||
for (const invalidFormat of invalidFormats) {
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${invalidFormat}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM-DD.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 404 for a date without a challenge', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${twoDaysAgoDateParam}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return a challenge for a valid date', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${todayDateParam}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
...todaysChallenge,
|
||||
date: todaysChallenge.date.toISOString()
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should not return a challenge for a future date relative to US Central', async () => {
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${tomorrowDateParam}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/today', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it("should return today's challenge", async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/today', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
...todaysChallenge,
|
||||
date: todaysChallenge.date.toISOString()
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 404 when no challenge exists for today', async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/today', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Challenge not found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/month/:month', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid month format', async () => {
|
||||
const invalidFormats = ['invalid-month', '2025-13', '2025-1', '25-07'];
|
||||
|
||||
for (const invalidFormat of invalidFormats) {
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/month/${invalidFormat}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should return two challenges on the second day of the month', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest(`/daily-coding-challenge/month/2025-10`, {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
// Should include yesterday's and today's challenges, but not tomorrow's
|
||||
const expectedResponse = [
|
||||
{
|
||||
id: todaysChallenge.id,
|
||||
challengeNumber: todaysChallenge.challengeNumber,
|
||||
date: todaysChallenge.date.toISOString(),
|
||||
title: todaysChallenge.title
|
||||
},
|
||||
{
|
||||
id: yesterdaysChallenge.id,
|
||||
challengeNumber: yesterdaysChallenge.challengeNumber,
|
||||
date: yesterdaysChallenge.date.toISOString(),
|
||||
title: yesterdaysChallenge.title
|
||||
}
|
||||
];
|
||||
|
||||
expect(res.body).toEqual(expectedResponse);
|
||||
expect(res.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return one challenge on the first day of the month', async () => {
|
||||
vi.setSystemTime(new Date(Date.UTC(2025, 9, 1, 5))); // 2025-10-01 00:00:00 in US Central
|
||||
|
||||
const res = await superRequest(`/daily-coding-challenge/month/2025-10`, {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
// Should include yesterday's challenges
|
||||
const expectedResponse = [
|
||||
{
|
||||
id: yesterdaysChallenge.id,
|
||||
challengeNumber: yesterdaysChallenge.challengeNumber,
|
||||
date: yesterdaysChallenge.date.toISOString(),
|
||||
title: yesterdaysChallenge.title
|
||||
}
|
||||
];
|
||||
|
||||
expect(res.body).toEqual(expectedResponse);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 404 when no challenges exist for the given month', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/month/2024-01', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/all', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: mockChallenges
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return { _id, date, challengeNumber, title } for all challenges up to today US Central', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/all', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
|
||||
// Should include yesterday's and today's challenges, but not tomorrow's
|
||||
const expectedResponse = [
|
||||
{
|
||||
id: todaysChallenge.id,
|
||||
challengeNumber: todaysChallenge.challengeNumber,
|
||||
date: todaysChallenge.date.toISOString(),
|
||||
title: todaysChallenge.title
|
||||
},
|
||||
{
|
||||
id: yesterdaysChallenge.id,
|
||||
challengeNumber: yesterdaysChallenge.challengeNumber,
|
||||
date: yesterdaysChallenge.date.toISOString(),
|
||||
title: yesterdaysChallenge.title
|
||||
}
|
||||
];
|
||||
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body).toEqual(expectedResponse);
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 404 when no challenges exist', async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/all', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /daily-coding-challenge/newest', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({
|
||||
data: [yesterdaysChallenge, todaysChallenge, tomorrowsChallenge]
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
});
|
||||
|
||||
it('should return { date } of the newest challenge in the database', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/newest', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
date: tomorrowsChallenge.date.toISOString()
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 404 when no challenges exist', async () => {
|
||||
await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany();
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/newest', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({
|
||||
type: 'error',
|
||||
message: 'No challenges found.'
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry Issue reporting', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting a challenge by date', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findFirst'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest(
|
||||
`/daily-coding-challenge/date/${todayDateParam}`,
|
||||
{ method: 'GET' }
|
||||
).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it("captures unexpected errors when getting today's challenge", async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findFirst'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/today', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting a month of challenges', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findMany'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/month/2025-10', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting all challenges', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findMany'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/all', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('captures unexpected errors when getting the newest challenge', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
vi.spyOn(
|
||||
fastifyTestInstance.prisma.dailyCodingChallenges,
|
||||
'findFirst'
|
||||
).mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const res = await superRequest('/daily-coding-challenge/newest', {
|
||||
method: 'GET'
|
||||
}).send({});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import * as schemas from '../schemas/index.js';
|
||||
import {
|
||||
getNowUsCentral,
|
||||
getUtcMidnight,
|
||||
dateStringToUtcMidnight
|
||||
} from '../utils/helpers.js';
|
||||
|
||||
/**
|
||||
* Plugin containing public GET routes for the daily coding challenges.
|
||||
* Note that they are only for getting challenge info, challenges are still
|
||||
* submitted via the main challenge completion routes.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/date/:date',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.date
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info(
|
||||
{ date: req.params.date },
|
||||
'Received request for daily coding challenge'
|
||||
);
|
||||
|
||||
const { date } = req.params;
|
||||
|
||||
try {
|
||||
const parsedDate = dateStringToUtcMidnight(date);
|
||||
|
||||
if (!parsedDate) {
|
||||
req.log.warn({ date }, 'Invalid date format requested');
|
||||
return reply.status(400).send({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM-DD.'
|
||||
});
|
||||
}
|
||||
|
||||
const challenge = await fastify.prisma.dailyCodingChallenges.findFirst({
|
||||
where: {
|
||||
date: parsedDate
|
||||
}
|
||||
});
|
||||
|
||||
// don't return challenges > today US Central
|
||||
if (!challenge || challenge.date > getUtcMidnight(getNowUsCentral())) {
|
||||
req.log.warn({ date: parsedDate }, 'Challenge not found for date');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'Challenge not found.' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
return reply.send({
|
||||
...challenge,
|
||||
date: challenge.date.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get daily coding challenge.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/date/:date' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/today',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.today
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info("Received request for today's daily coding challenge");
|
||||
|
||||
const today = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
try {
|
||||
const todaysChallenge =
|
||||
await fastify.prisma.dailyCodingChallenges.findFirst({
|
||||
where: {
|
||||
date: today
|
||||
}
|
||||
});
|
||||
|
||||
if (!todaysChallenge) {
|
||||
req.log.warn({ date: today }, 'Challenge not found for today');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'Challenge not found.' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
return reply.send({
|
||||
...todaysChallenge,
|
||||
date: todaysChallenge.date.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
req.log.error(error, "Failed to get today's daily coding challenge.");
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/today' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/month/:month',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.month
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info(
|
||||
{ month: req.params.month },
|
||||
'Received request for month of daily coding challenges'
|
||||
);
|
||||
|
||||
const { month } = req.params;
|
||||
|
||||
try {
|
||||
// Month is guaranteed YYYY-MM format from schema validation
|
||||
const parts = month.split('-');
|
||||
const parsedYear = parseInt(parts[0]!, 10);
|
||||
const parsedMonth = parseInt(parts[1]!, 10);
|
||||
|
||||
// Validate month range
|
||||
if (parsedMonth < 1 || parsedMonth > 12) {
|
||||
req.log.warn({ month }, 'Invalid month value requested');
|
||||
return reply.status(400).send({
|
||||
type: 'error',
|
||||
message: 'Invalid date format. Please use YYYY-MM.'
|
||||
});
|
||||
}
|
||||
|
||||
const monthStart = new Date(Date.UTC(parsedYear, parsedMonth - 1, 1));
|
||||
const monthEnd = new Date(Date.UTC(parsedYear, parsedMonth, 1));
|
||||
const todayUsCentral = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
const challenges = await fastify.prisma.dailyCodingChallenges.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
lte: todayUsCentral
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
challengeNumber: true,
|
||||
date: true,
|
||||
title: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!challenges || challenges.length === 0) {
|
||||
req.log.warn({ month }, 'No challenges found for month');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'No challenges found.' });
|
||||
}
|
||||
|
||||
const response = challenges.map(challenge => ({
|
||||
...challenge,
|
||||
date: challenge.date.toISOString()
|
||||
}));
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get monthly daily coding challenges.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/month/:month' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/all',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.all
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info('Received request for all daily coding challenges');
|
||||
|
||||
const today = getUtcMidnight(getNowUsCentral());
|
||||
|
||||
try {
|
||||
const allChallenges =
|
||||
await fastify.prisma.dailyCodingChallenges.findMany({
|
||||
// only where date <= today US Central
|
||||
where: {
|
||||
date: {
|
||||
lte: today
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
challengeNumber: true,
|
||||
date: true,
|
||||
title: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!allChallenges || allChallenges.length === 0) {
|
||||
req.log.warn({ date: today }, 'No challenges found.');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'No challenges found.' });
|
||||
}
|
||||
|
||||
const response = allChallenges.map(challenge => ({
|
||||
...challenge,
|
||||
date: challenge.date.toISOString()
|
||||
}));
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get all daily coding challenges.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/all' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/daily-coding-challenge/newest',
|
||||
{
|
||||
schema: schemas.dailyCodingChallenge.newest
|
||||
},
|
||||
async (req, reply) => {
|
||||
req.log.info('Received request for newest daily coding challenge');
|
||||
|
||||
try {
|
||||
const newestChallenge =
|
||||
await fastify.prisma.dailyCodingChallenges.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
select: {
|
||||
date: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!newestChallenge) {
|
||||
req.log.warn('No challenges found.');
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_not_found', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ type: 'error', message: 'No challenges found.' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('dcc.challenge_viewed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
return reply.send({ date: newestChallenge.date.toISOString() });
|
||||
} catch (error) {
|
||||
req.log.error(error, 'Failed to get newest daily coding challenge.');
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('dcc.request_failed', 1, {
|
||||
attributes: { route: '/daily-coding-challenge/newest' }
|
||||
});
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ type: 'error', message: 'Internal server error.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
const challengeLanguage = Type.Object({
|
||||
tests: Type.Array(
|
||||
Type.Object({
|
||||
text: Type.String(),
|
||||
testString: Type.String()
|
||||
})
|
||||
),
|
||||
challengeFiles: Type.Array(
|
||||
Type.Object({
|
||||
contents: Type.String(),
|
||||
fileKey: Type.String()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
const singleChallengeResponse = Type.Object({
|
||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||
date: Type.String({ format: 'date-time' }),
|
||||
challengeNumber: Type.Number(),
|
||||
title: Type.String(),
|
||||
description: Type.String(),
|
||||
javascript: challengeLanguage,
|
||||
python: challengeLanguage
|
||||
});
|
||||
|
||||
const date = {
|
||||
params: Type.Object({
|
||||
date: Type.String({ format: 'date' })
|
||||
}),
|
||||
response: {
|
||||
200: singleChallengeResponse,
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Invalid date format. Please use YYYY-MM-DD.')
|
||||
}),
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Challenge not found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const today = {
|
||||
response: {
|
||||
200: singleChallengeResponse,
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Challenge not found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const manyChallengesResponse = Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
date: Type.String({ format: 'date-time' }),
|
||||
challengeNumber: Type.Number(),
|
||||
title: Type.String()
|
||||
})
|
||||
);
|
||||
|
||||
const month = {
|
||||
params: Type.Object({
|
||||
month: Type.String({ pattern: '^\\d{4}-\\d{2}$' })
|
||||
}),
|
||||
response: {
|
||||
200: manyChallengesResponse,
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Invalid date format. Please use YYYY-MM.')
|
||||
}),
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('No challenges found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const all = {
|
||||
response: {
|
||||
200: manyChallengesResponse,
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('No challenges found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const newest = {
|
||||
response: {
|
||||
200: Type.Object({
|
||||
date: Type.String({ format: 'date-time' })
|
||||
}),
|
||||
404: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('No challenges found.')
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal('Internal server error.')
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export const dailyCodingChallenge = {
|
||||
date,
|
||||
today,
|
||||
month,
|
||||
all,
|
||||
newest
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { dailyCodingChallenge } from './daily-coding-challenge.js';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getTimezoneOffset } from 'date-fns-tz';
|
||||
|
||||
/**
|
||||
* @returns Now US Central time.
|
||||
*/
|
||||
export function getNowUsCentral() {
|
||||
const offset = getTimezoneOffset('America/Chicago', new Date());
|
||||
return new Date(Date.now() + offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Date object set to UTC midnight of the given date.
|
||||
* @param date - Date Object.
|
||||
* @returns UTC midnight of the given date.
|
||||
*/
|
||||
export function getUtcMidnight(date: Date): Date {
|
||||
return new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date string in the format "YYYY-MM-DD" and returns a Date object set to UTC midnight.
|
||||
* Returns null if the input is not in the correct format.
|
||||
* @param dateStr - Date string in "YYYY-MM-DD" format.
|
||||
* @returns Date object set to UTC midnight or null if invalid.
|
||||
*/
|
||||
export function dateStringToUtcMidnight(dateStr: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [year, month, day] = dateStr.split('-').map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
];
|
||||
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import { defaultUserEmail, setupServer } from '../../vitest.utils.js';
|
||||
|
||||
import { createUserInput } from '../utils/create-user.js';
|
||||
|
||||
describe('prisma client extensions', () => {
|
||||
setupServer();
|
||||
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCount', () => {
|
||||
it('should default to 0', async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.create({
|
||||
data: createUserInput(defaultUserEmail)
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment by one for updates and creates', async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.create({
|
||||
data: createUserInput(defaultUserEmail)
|
||||
});
|
||||
|
||||
const updateUser = await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username: 'any-change' }
|
||||
});
|
||||
|
||||
expect(updateUser).toMatchObject({
|
||||
username: 'any-change',
|
||||
updateCount: 1
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { id: user.id },
|
||||
// Even no change to values updates the updateCount
|
||||
data: { username: 'any-change' }
|
||||
});
|
||||
|
||||
const updateManyUser = await fastifyTestInstance.prisma.user.findUnique({
|
||||
where: { id: user.id }
|
||||
});
|
||||
|
||||
expect(updateManyUser).toMatchObject({
|
||||
username: 'any-change',
|
||||
updateCount: 2
|
||||
});
|
||||
|
||||
const upsertUser = await fastifyTestInstance.prisma.user.upsert({
|
||||
where: { id: user.id },
|
||||
create: createUserInput(defaultUserEmail),
|
||||
update: { username: 'upser-user' }
|
||||
});
|
||||
|
||||
expect(upsertUser).toMatchObject({
|
||||
username: 'upser-user',
|
||||
updateCount: 3
|
||||
});
|
||||
});
|
||||
|
||||
it("should not increment for 'find' queries", async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.create({
|
||||
data: createUserInput(defaultUserEmail)
|
||||
});
|
||||
|
||||
const findUniqueUser = await fastifyTestInstance.prisma.user.findUnique({
|
||||
where: { id: user.id }
|
||||
});
|
||||
|
||||
expect(findUniqueUser).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
|
||||
const findManyUsers = await fastifyTestInstance.prisma.user.findMany();
|
||||
|
||||
expect(findManyUsers).toHaveLength(1);
|
||||
expect(findManyUsers[0]).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
|
||||
const findFirstUser = await fastifyTestInstance.prisma.user.findFirst();
|
||||
|
||||
expect(findFirstUser).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
|
||||
const findRawUser = await fastifyTestInstance.prisma.user.findRaw({
|
||||
filter: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(findRawUser[0]).toMatchObject({
|
||||
updateCount: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
// importing MONGOHQ_URL so we can mock it in testing.
|
||||
import { MONGOHQ_URL } from '../utils/env.js';
|
||||
import { timeOperation } from './query-timing.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: ReturnType<typeof extendClient>;
|
||||
}
|
||||
}
|
||||
|
||||
const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => {
|
||||
const prisma = extendClient(
|
||||
new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: MONGOHQ_URL
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$connect().catch((err: unknown) => {
|
||||
Sentry.metrics.count('db.connect_failed', 1);
|
||||
server.log.error(err, 'Prisma connection failed');
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.decorate('prisma', prisma);
|
||||
|
||||
server.addHook('onClose', async server => {
|
||||
await server.prisma.$disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: It would be nice to split this up into multiple update functions,
|
||||
// but the types are a pain.
|
||||
// TODO: Multiple extended clients can be used for different restrictions (e.g. session vs non-session users)
|
||||
// TODO: Could be used to add other _easily forgotten_ fields like `progressTimestamp`
|
||||
function extendClient(prisma: PrismaClient) {
|
||||
return prisma
|
||||
.$extends({
|
||||
query: {
|
||||
user: {
|
||||
async update({ args, query }) {
|
||||
args.data.updateCount = { increment: 1 };
|
||||
return query(args);
|
||||
},
|
||||
async updateMany({ args, query }) {
|
||||
args.data.updateCount = { increment: 1 };
|
||||
return query(args);
|
||||
},
|
||||
async upsert({ args, query }) {
|
||||
args.update.updateCount = { increment: 1 };
|
||||
return query(args);
|
||||
}
|
||||
// NOTE: raw ops are untouched, as it is meant to be a direct passthrough to mongodb
|
||||
// async findRaw({ model, operation, args, query }) {}
|
||||
// async aggregateRaw({ model, operation, args, query }) {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
$allOperations({ model, operation, args, query }) {
|
||||
return timeOperation(
|
||||
() => query(args),
|
||||
(result, durationMs) =>
|
||||
Sentry.metrics.distribution(
|
||||
'db.query_duration_ms',
|
||||
durationMs,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { model: model ?? 'raw', operation, result }
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default prismaPlugin;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { timeOperation } from './query-timing.js';
|
||||
|
||||
describe('timeOperation', () => {
|
||||
it('returns the result and emits success with a numeric duration', async () => {
|
||||
const emit = vi.fn();
|
||||
|
||||
const result = await timeOperation(() => Promise.resolve('ok'), emit);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(emit).toHaveBeenCalledWith('success', expect.any(Number));
|
||||
});
|
||||
|
||||
it('re-throws and emits failure when the operation rejects', async () => {
|
||||
const emit = vi.fn();
|
||||
const boom = new Error('boom');
|
||||
|
||||
await expect(timeOperation(() => Promise.reject(boom), emit)).rejects.toBe(
|
||||
boom
|
||||
);
|
||||
expect(emit).toHaveBeenCalledWith('failure', expect.any(Number));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const timeOperation = async <T>(
|
||||
op: () => Promise<T>,
|
||||
emit: (result: 'success' | 'failure', durationMs: number) => void
|
||||
): Promise<T> => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await op();
|
||||
emit('success', performance.now() - start);
|
||||
return result;
|
||||
} catch (err) {
|
||||
emit('failure', performance.now() - start);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
// import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentGetExamChallenge = {
|
||||
querystring: Type.Object({
|
||||
challengeId: Type.Optional(Type.String({ format: 'objectid' })),
|
||||
examId: Type.Optional(Type.String({ format: 'objectid' }))
|
||||
})
|
||||
// response: {
|
||||
// 200: examEnvAttempt,
|
||||
// default: STANDARD_ERROR
|
||||
// }
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
|
||||
export const examEnvironmentPostExamAttempt = {
|
||||
body: Type.Object({
|
||||
attempt: Type.Object({
|
||||
examId: Type.String({ format: 'objectid' }),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({ format: 'objectid' }),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({ format: 'objectid' }),
|
||||
answers: Type.Array(Type.String({ format: 'objectid' }))
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export enum ExamAttemptStatus {
|
||||
// Attempt has not expired yet.
|
||||
InProgress = 'InProgress',
|
||||
// Moderation record is not created for practice exam. Also, it might not exist until exam service cron is run.
|
||||
Expired = 'Expired',
|
||||
// Attempt has expired && moderation record has been created but not yet moderated
|
||||
PendingModeration = 'PendingModeration',
|
||||
// Attempt has been approved
|
||||
Approved = 'Approved',
|
||||
// Attempt has been denied
|
||||
Denied = 'Denied'
|
||||
}
|
||||
|
||||
const examEnvAttempt = Type.Object({
|
||||
id: Type.String(),
|
||||
examId: Type.String(),
|
||||
startTime: Type.String({ format: 'date-time' }),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
answers: Type.Array(Type.String()),
|
||||
submissionTime: Type.String({ format: 'date-time' })
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
result: Type.Union([
|
||||
Type.Null(),
|
||||
Type.Object({
|
||||
score: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
})
|
||||
]),
|
||||
version: Type.Number(),
|
||||
status: Type.Enum(ExamAttemptStatus)
|
||||
});
|
||||
|
||||
export const examEnvironmentGetExamAttempts = {
|
||||
headers: Type.Object({
|
||||
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base
|
||||
// If it is missing, auth will catch.
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(examEnvAttempt),
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export const examEnvironmentGetExamAttempt = {
|
||||
params: Type.Object({
|
||||
attemptId: Type.String({ format: 'objectid' })
|
||||
}),
|
||||
headers: Type.Object({
|
||||
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base.
|
||||
// If it is missing, auth will catch.
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: examEnvAttempt,
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export const examEnvironmentGetExamAttemptsByExamId = {
|
||||
params: Type.Object({
|
||||
examId: Type.String({ format: 'objectid' })
|
||||
}),
|
||||
headers: Type.Object({
|
||||
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base.
|
||||
// If it is missing, auth will catch.
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(examEnvAttempt)
|
||||
// default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
|
||||
export const examEnvironmentPostExamGeneratedExam = {
|
||||
body: Type.Object({
|
||||
examId: Type.String()
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: 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,24 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
export const examEnvironmentExams = {
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
config: Type.Object({
|
||||
name: Type.String(),
|
||||
note: Type.String(),
|
||||
totalTimeInS: Type.Number(),
|
||||
retakeTimeInS: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
}),
|
||||
canTake: Type.Boolean(),
|
||||
prerequisites: Type.Array(Type.String())
|
||||
})
|
||||
),
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
examEnvironmentPostExamAttempt,
|
||||
examEnvironmentGetExamAttempts,
|
||||
examEnvironmentGetExamAttempt,
|
||||
examEnvironmentGetExamAttemptsByExamId
|
||||
} from './exam-environment-exam-attempt.js';
|
||||
export { examEnvironmentPostExamGeneratedExam } from './exam-environment-exam-generated-exam.js';
|
||||
export { examEnvironmentTokenMeta } from './token-meta.js';
|
||||
export { examEnvironmentExams } from './exam-environment-exams.js';
|
||||
export { examEnvironmentGetExamChallenge } from './challenges.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors.js';
|
||||
|
||||
export const examEnvironmentTokenMeta = {
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
expireAt: Type.String({ format: 'date-time' })
|
||||
}),
|
||||
404: STANDARD_ERROR,
|
||||
418: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
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_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_ENOENT_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'),
|
||||
FCC_ERR_UNKNOWN_STATE: createError('FCC_ERR_UNKNOWN_STATE', '%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,440 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import {
|
||||
ExamEnvironmentAnswer,
|
||||
ExamEnvironmentQuestionType
|
||||
} from '@prisma/client';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
exam,
|
||||
examAttempt,
|
||||
generatedExam,
|
||||
oid
|
||||
} from '../../../__fixtures__/exam-environment-exam.js';
|
||||
import * as schemas from '../schemas/index.js';
|
||||
import { setupServer } from '../../../vitest.utils.js';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
checkPrerequisites,
|
||||
constructUserExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt,
|
||||
compareAnswers,
|
||||
shuffleArray
|
||||
} from './exam-environment.js';
|
||||
|
||||
// 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 mocked Math.random', () => {
|
||||
let spy: MockInstance;
|
||||
beforeAll(() => {
|
||||
spy = vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
|
||||
});
|
||||
afterAll(() => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPrequisites()', () => {
|
||||
it("should return true if all items in the second argument exist in the first argument's `.completedChallenges[].id`", () => {
|
||||
const user = {
|
||||
completedChallenges: [{ id: '1' }, { id: '2' }],
|
||||
isHonest: true
|
||||
};
|
||||
const prerequisites = ['1', '2'];
|
||||
|
||||
expect(checkPrerequisites(user, prerequisites)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if any items in the second argument do not exist in the first argument's `.completedChallenges[].id`", () => {
|
||||
const user = {
|
||||
completedChallenges: [{ id: '2' }],
|
||||
isHonest: false
|
||||
};
|
||||
const prerequisites = ['1', '2'];
|
||||
|
||||
expect(checkPrerequisites(user, prerequisites)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttempt()', () => {
|
||||
it('should validate a correct attempt', () => {
|
||||
expect(() =>
|
||||
validateAttempt(generatedExam, examAttempt.questionSets)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
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.submissionTime)).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.submissionTime)
|
||||
);
|
||||
|
||||
const sameAttempt = userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt,
|
||||
{ ...latestAttempt, questionSets: databaseAttemptQuestionSets }
|
||||
);
|
||||
|
||||
const sameSubmissionTimes = sameAttempt.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTime)
|
||||
);
|
||||
|
||||
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]?.submissionTime
|
||||
).not.toEqual(
|
||||
databaseAttemptQuestionSets[0]?.questions[0]?.submissionTime
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareAnswers()', () => {
|
||||
it('should return true when only all correct answers are attempted', () => {
|
||||
const examAnswers: ExamEnvironmentAnswer[] = [
|
||||
{
|
||||
id: '0',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const generatedAnswers = ['0', '1', '2', '3'];
|
||||
const attemptAnswers = ['0', '1'];
|
||||
const isCorrect = compareAnswers(
|
||||
examAnswers,
|
||||
generatedAnswers,
|
||||
attemptAnswers
|
||||
);
|
||||
|
||||
expect(isCorrect).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when any incorrect answers are attempted', () => {
|
||||
const examAnswers: ExamEnvironmentAnswer[] = [
|
||||
{
|
||||
id: '0',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const generatedAnswers = ['0', '1', '2', '3'];
|
||||
const attemptAnswers = ['0', '2'];
|
||||
const isCorrect = compareAnswers(
|
||||
examAnswers,
|
||||
generatedAnswers,
|
||||
attemptAnswers
|
||||
);
|
||||
|
||||
expect(isCorrect).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam Environment', () => {
|
||||
describe('constructUserExam()', () => {
|
||||
it('should not provide the answers', () => {
|
||||
const userExam = constructUserExam(generatedExam, exam);
|
||||
expect(userExam).not.toHaveProperty('answers.isCorrect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleArray()', () => {
|
||||
it('reasonably shuffles an array', () => {
|
||||
const unshuff = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const shuff = shuffleArray(unshuff);
|
||||
|
||||
expect(shuff).not.toEqual(unshuff);
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const unshuff = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
shuffleArray(unshuff);
|
||||
|
||||
expect(unshuff).toEqual(unshuff);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam Environment Schema', () => {
|
||||
setupServer();
|
||||
describe('ExamEnvironmentExam', () => {
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.deleteMany({});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
it("If this test fails and you've deliberately altered the schema, then increment the `version` field by 1", async () => {
|
||||
const configQuestionSets = [
|
||||
{
|
||||
numberOfCorrectAnswers: 0,
|
||||
numberOfIncorrectAnswers: 0,
|
||||
numberOfQuestions: 0,
|
||||
numberOfSet: 0,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice
|
||||
}
|
||||
];
|
||||
const tags = [
|
||||
{
|
||||
group: [''],
|
||||
numberOfQuestions: 0
|
||||
}
|
||||
];
|
||||
const config = {
|
||||
name: '',
|
||||
note: '',
|
||||
passingPercent: 0.0,
|
||||
questionSets: configQuestionSets,
|
||||
retakeTimeInS: 0,
|
||||
tags,
|
||||
totalTimeInS: 0
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
answers: [
|
||||
{
|
||||
id: oid(),
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
],
|
||||
audio: { captions: '', url: '' },
|
||||
deprecated: false,
|
||||
id: oid(),
|
||||
tags: [''],
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const questionSets = [
|
||||
{
|
||||
context: '',
|
||||
id: oid(),
|
||||
questions,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice
|
||||
}
|
||||
];
|
||||
const data = {
|
||||
config,
|
||||
deprecated: false,
|
||||
prerequisites: [oid()],
|
||||
questionSets
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.create({
|
||||
data
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ExamEnvironmentGeneratedExam', () => {
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.deleteMany(
|
||||
{}
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
it("If this test fails and you've deliberately altered the schema, then increment the `version` field by 1", async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
data: {
|
||||
deprecated: false,
|
||||
examId: oid(),
|
||||
questionSets: [
|
||||
{ id: oid(), questions: [{ answers: [oid()], id: oid() }] }
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ExamEnvironmentExamAttempt', () => {
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany(
|
||||
{}
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line vitest/expect-expect
|
||||
it("If this test fails and you've deliberately altered the schema, then increment the `version` field by 1", async () => {
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
examId: oid(),
|
||||
generatedExamId: oid(),
|
||||
examModerationId: null,
|
||||
questionSets: [
|
||||
{
|
||||
id: oid(),
|
||||
questions: [
|
||||
{
|
||||
answers: [oid()],
|
||||
id: oid(),
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTime: new Date(),
|
||||
userId: oid()
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,627 @@
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
// TODO: enable this, since strings don't make good errors.
|
||||
import {
|
||||
ExamEnvironmentAnswer,
|
||||
ExamEnvironmentExam,
|
||||
ExamEnvironmentExamAttempt,
|
||||
ExamEnvironmentExamModerationStatus,
|
||||
ExamEnvironmentGeneratedExam,
|
||||
ExamEnvironmentGeneratedMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestionAttempt,
|
||||
ExamEnvironmentQuestionSet,
|
||||
ExamEnvironmentQuestionSetAttempt
|
||||
} from '@prisma/client';
|
||||
import type { FastifyBaseLogger, FastifyInstance } from 'fastify';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import { omit } from 'lodash-es';
|
||||
import * as schemas from '../schemas/index.js';
|
||||
import { mapErr } from '../../utils/index.js';
|
||||
import { ExamAttemptStatus } from '../schemas/exam-environment-exam-attempt.js';
|
||||
import { ERRORS } from './errors.js';
|
||||
|
||||
interface PartialUser {
|
||||
completedChallenges: { id: string }[];
|
||||
isHonest: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all exam prerequisites have been met by the user:
|
||||
* - completed challenges linked to exam
|
||||
* - user is required to have accepted the academic honesty policy
|
||||
*/
|
||||
export function checkPrerequisites(
|
||||
user: PartialUser,
|
||||
prerequisites: ExamEnvironmentExam['prerequisites']
|
||||
) {
|
||||
return (
|
||||
user.isHonest &&
|
||||
prerequisites.every(p => user.completedChallenges.some(c => c.id === p))
|
||||
);
|
||||
}
|
||||
|
||||
export type UserExam = Omit<
|
||||
ExamEnvironmentExam,
|
||||
'questionSets' | 'config' | 'id' | 'prerequisites' | 'deprecated' | 'version'
|
||||
> & {
|
||||
config: Omit<ExamEnvironmentExam['config'], 'tags' | 'questionSets'>;
|
||||
questionSets: (Omit<ExamEnvironmentQuestionSet, 'questions'> & {
|
||||
questions: (Omit<
|
||||
ExamEnvironmentMultipleChoiceQuestion,
|
||||
'answers' | 'tags' | 'deprecated'
|
||||
> & {
|
||||
answers: Omit<ExamEnvironmentAnswer, 'isCorrect'>[];
|
||||
})[];
|
||||
})[];
|
||||
} & { generatedExamId: string; examId: string };
|
||||
|
||||
/**
|
||||
* Takes the generated exam and the original exam, and creates the user-facing exam.
|
||||
*/
|
||||
export function constructUserExam(
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
exam: ExamEnvironmentExam
|
||||
): 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);
|
||||
if (!examQuestionSet) {
|
||||
throw new Error(
|
||||
`Unreachable. Generated question set id ${gqs.id} not found in exam ${exam.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { questions } = examQuestionSet;
|
||||
|
||||
const userQuestions = gqs.questions.map(gq => {
|
||||
const examQuestion = questions.find(eq => eq.id === gq.id);
|
||||
if (!examQuestion) {
|
||||
throw new Error(
|
||||
`Unreachable. Generated question id ${gq.id} not found in exam question set ${examQuestionSet.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Remove `isCorrect` from question answers
|
||||
const answers = gq.answers.map(generatedAnswerId => {
|
||||
const examAnswer = examQuestion.answers.find(
|
||||
ea => ea.id === generatedAnswerId
|
||||
);
|
||||
if (!examAnswer) {
|
||||
throw new Error(
|
||||
`Unreachable. Generated answer id ${generatedAnswerId} not found in exam question ${examQuestion.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { isCorrect: _, ...answer } = examAnswer;
|
||||
return answer;
|
||||
});
|
||||
|
||||
// NOTE: Shuffling here means when saved attempt is re-fetched, answers will be in different order.
|
||||
const shuffledAnswers = shuffleArray(answers);
|
||||
|
||||
return {
|
||||
id: examQuestion.id,
|
||||
audio: examQuestion.audio,
|
||||
text: examQuestion.text,
|
||||
answers: shuffledAnswers
|
||||
};
|
||||
});
|
||||
|
||||
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 = {
|
||||
totalTimeInS: exam.config.totalTimeInS,
|
||||
name: exam.config.name,
|
||||
note: exam.config.note,
|
||||
retakeTimeInS: exam.config.retakeTimeInS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
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: ExamEnvironmentGeneratedExam,
|
||||
questionSets: ExamEnvironmentExamAttempt['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: ExamEnvironmentQuestionSetAttempt[],
|
||||
generatedExam: Pick<ExamEnvironmentGeneratedExam, '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: ExamEnvironmentExamAttempt
|
||||
): ExamEnvironmentExamAttempt['questionSets'] {
|
||||
const databaseAttemptQuestionSets: ExamEnvironmentExamAttempt['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,
|
||||
submissionTime: new Date()
|
||||
};
|
||||
})
|
||||
});
|
||||
} 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,
|
||||
submissionTime: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// If answers have changed, add submission time
|
||||
if (
|
||||
JSON.stringify(q.answers) !== JSON.stringify(latestQuestion.answers)
|
||||
) {
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
return latestQuestion;
|
||||
})
|
||||
};
|
||||
|
||||
databaseAttemptQuestionSets.push(databaseAttemptQuestionSet);
|
||||
}
|
||||
}
|
||||
|
||||
return databaseAttemptQuestionSets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of correct questions over the number of the total questions given for an attempt.
|
||||
* @returns The score of the exam attempt as a percentage.
|
||||
*/
|
||||
export function calculateScore(
|
||||
exam: ExamEnvironmentExam,
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
attempt: ExamEnvironmentExamAttempt
|
||||
) {
|
||||
const attemptQuestionSets = attempt.questionSets;
|
||||
const generatedQuestionSets = generatedExam.questionSets;
|
||||
|
||||
const totalQuestions = generatedQuestionSets.reduce(
|
||||
(total, attemptQuestionSet) => total + attemptQuestionSet.questions.length,
|
||||
0
|
||||
);
|
||||
let correctQuestions = 0;
|
||||
for (const attemptQuestionSet of attemptQuestionSets) {
|
||||
const examQuestionSet = exam.questionSets.find(
|
||||
({ id }) => id === attemptQuestionSet.id
|
||||
);
|
||||
if (!examQuestionSet) {
|
||||
throw new Error(
|
||||
`Attempt question set ${attemptQuestionSet.id} must exist in exam ${exam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const generatedQuestionSet = generatedQuestionSets.find(
|
||||
({ id }) => id === attemptQuestionSet.id
|
||||
);
|
||||
if (!generatedQuestionSet) {
|
||||
throw new Error(
|
||||
`Generated question set ${attemptQuestionSet.id} must exist in generated exam ${generatedExam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const attemptQuestions = attemptQuestionSet.questions;
|
||||
const examQuestions = examQuestionSet.questions;
|
||||
const generatedQuestions = generatedQuestionSet.questions;
|
||||
for (const attemptQuestion of attemptQuestions) {
|
||||
const examQuestion = examQuestions.find(
|
||||
({ id }) => id === attemptQuestion.id
|
||||
);
|
||||
if (!examQuestion) {
|
||||
throw new Error(
|
||||
`Attempt question ${attemptQuestion.id} must exist in exam ${exam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const generatedQuestion = generatedQuestions.find(
|
||||
({ id }) => id === attemptQuestion.id
|
||||
);
|
||||
if (!generatedQuestion) {
|
||||
throw new Error(
|
||||
`Generated question ${attemptQuestion.id} must exist in generated exam ${generatedExam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const isQuestionCorrect = compareAnswers(
|
||||
examQuestion.answers,
|
||||
generatedQuestion.answers,
|
||||
attemptQuestion.answers
|
||||
);
|
||||
|
||||
if (isQuestionCorrect) {
|
||||
correctQuestions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (correctQuestions / totalQuestions) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: The answers of an attempt is an array for future-proofing when
|
||||
* checkbox questions are needed.
|
||||
*
|
||||
* This calculation takes x / y , x < y as wholey incorrect.
|
||||
*/
|
||||
export function compareAnswers(
|
||||
examAnswers: ExamEnvironmentAnswer[],
|
||||
generatedAnswers: ExamEnvironmentGeneratedMultipleChoiceQuestion['answers'],
|
||||
attemptAnswers: ExamEnvironmentMultipleChoiceQuestionAttempt['answers']
|
||||
): boolean {
|
||||
const correctGeneratedAnswers = generatedAnswers.filter(generatedAnswer => {
|
||||
return examAnswers.some(
|
||||
examAnswer => examAnswer.isCorrect && examAnswer.id === generatedAnswer
|
||||
);
|
||||
});
|
||||
// Check every attempt question answer == every generated question answer
|
||||
const isQuestionCorrect =
|
||||
correctGeneratedAnswers.every(correctAnswer =>
|
||||
attemptAnswers.includes(correctAnswer)
|
||||
) && correctGeneratedAnswers.length == attemptAnswers.length;
|
||||
|
||||
return isQuestionCorrect;
|
||||
}
|
||||
|
||||
/* eslint-disable jsdoc/require-description-complete-sentence */
|
||||
/**
|
||||
* Shuffles an array using the Fisher-Yates algorithm.
|
||||
*
|
||||
* https://bost.ocks.org/mike/shuffle/
|
||||
*/
|
||||
export function shuffleArray<T>(array: Array<T>) {
|
||||
const arr = structuredClone(array);
|
||||
let m = arr.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 = arr[m]!;
|
||||
arr[m] = arr[i]!;
|
||||
arr[i] = t;
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
/* eslint-enable jsdoc/require-description-complete-sentence */
|
||||
|
||||
/**
|
||||
* From an exam attempt, construct the attempt with result (if ready).
|
||||
*
|
||||
* @param fastify - Fastify instance.
|
||||
* @param attempt - The exam attempt.
|
||||
* @param logger - Logger instance.
|
||||
* @returns The exam attempt with result or an error.
|
||||
*/
|
||||
export async function constructEnvExamAttempt(
|
||||
fastify: FastifyInstance,
|
||||
attempt: ExamEnvironmentExamAttempt,
|
||||
logger: FastifyBaseLogger
|
||||
) {
|
||||
const maybeExam = await mapErr(
|
||||
fastify.prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: attempt.examId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
fastify.Sentry?.captureException(maybeExam.error);
|
||||
logger.error(
|
||||
{ err: maybeExam.error, attemptId: attempt.id, examId: attempt.examId },
|
||||
'Unable to query exam.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
fastify.Sentry?.captureException({
|
||||
data: { examId: attempt.examId, attemptId: attempt.id },
|
||||
message: 'Unreachable. Invalid exam id in attempt.'
|
||||
});
|
||||
logger.error(
|
||||
{ examId: attempt.examId, attemptId: attempt.id },
|
||||
'Unreachable. Invalid exam id in attempt.'
|
||||
);
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(
|
||||
'Unreachable. Invalid exam id in attempt.'
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is still in progress, return without result
|
||||
const attemptStartTimeInMS = attempt.startTime.getTime();
|
||||
const examTotalTimeInMS = exam.config.totalTimeInS * 1000;
|
||||
const isAttemptExpired =
|
||||
attemptStartTimeInMS + examTotalTimeInMS < Date.now();
|
||||
if (!isAttemptExpired) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.InProgress
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const maybeMod = await mapErr(
|
||||
fastify.prisma.examEnvironmentExamModeration.findFirst({
|
||||
where: {
|
||||
examAttemptId: attempt.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeMod.hasError) {
|
||||
fastify.Sentry?.captureException(maybeMod.error);
|
||||
logger.error(
|
||||
{ err: maybeMod.error, attemptId: attempt.id },
|
||||
'Unable to query exam moderation.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeMod.error))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const moderation = maybeMod.data;
|
||||
|
||||
// Attempt has expired, but moderation record does not exist
|
||||
if (moderation === null) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.Expired
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is completed, but has not been graded, return without result
|
||||
if (moderation.status === ExamEnvironmentExamModerationStatus.Pending) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.PendingModeration
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is completed, but has been determined to need a retake
|
||||
// TODO: Send moderation.feedback?
|
||||
if (moderation.status === ExamEnvironmentExamModerationStatus.Denied) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null,
|
||||
status: ExamAttemptStatus.Denied
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
fastify.prisma.examEnvironmentGeneratedExam.findUnique({
|
||||
where: {
|
||||
id: attempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
fastify.Sentry?.captureException(maybeGeneratedExam.error);
|
||||
logger.error(
|
||||
{ err: maybeGeneratedExam.error, attemptId: attempt.id },
|
||||
'Unable to query generated exam.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
JSON.stringify(maybeGeneratedExam.error)
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (!generatedExam) {
|
||||
fastify.Sentry?.captureException({
|
||||
data: {
|
||||
attemptId: attempt.id,
|
||||
generatedExamId: attempt.generatedExamId
|
||||
},
|
||||
message:
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
});
|
||||
logger.error(
|
||||
{ attemptId: attempt.id, generatedExamId: attempt.generatedExamId },
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt.'
|
||||
);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const score = calculateScore(exam, generatedExam, attempt);
|
||||
|
||||
const result = {
|
||||
score,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result,
|
||||
status: ExamAttemptStatus.Approved
|
||||
};
|
||||
return { error: null, examEnvironmentExamAttempt };
|
||||
}
|
||||
|
||||
function omitAttemptReferenceIds(attempt: ExamEnvironmentExamAttempt) {
|
||||
return omit(attempt, ['generatedExamId', 'userId']);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||
|
||||
import {
|
||||
DEPLOYMENT_VERSION,
|
||||
SENTRY_DSN,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_SERVER_NAME,
|
||||
SENTRY_LOGS_DEBUG_SAMPLE_RATE,
|
||||
SENTRY_LOGS_INFO_SAMPLE_RATE,
|
||||
SENTRY_PROFILE_SESSION_SAMPLE_RATE,
|
||||
SENTRY_TRACES_SAMPLE_RATE
|
||||
} from './utils/env.js';
|
||||
import {
|
||||
makeShouldSendLog,
|
||||
makeTracesSampler,
|
||||
scrubRedundantLogAttributes,
|
||||
scrubRequestPii
|
||||
} from './utils/sentry.js';
|
||||
|
||||
const shouldSendLog = makeShouldSendLog(
|
||||
SENTRY_LOGS_DEBUG_SAMPLE_RATE,
|
||||
SENTRY_LOGS_INFO_SAMPLE_RATE
|
||||
);
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
serverName: SENTRY_SERVER_NAME,
|
||||
maxValueLength: 8192, // the default is 250, which is too small.
|
||||
release: DEPLOYMENT_VERSION,
|
||||
tracesSampler: makeTracesSampler(SENTRY_TRACES_SAMPLE_RATE),
|
||||
profileSessionSampleRate: SENTRY_PROFILE_SESSION_SAMPLE_RATE,
|
||||
profileLifecycle: 'trace',
|
||||
enableLogs: true,
|
||||
integrations: [
|
||||
nodeProfilingIntegration(),
|
||||
Sentry.pinoIntegration({
|
||||
log: { levels: ['info', 'warn', 'error', 'fatal', 'debug'] }
|
||||
}),
|
||||
Sentry.requestDataIntegration({ include: { cookies: false } })
|
||||
],
|
||||
beforeSend: event => scrubRequestPii(event),
|
||||
beforeSendLog: log =>
|
||||
shouldSendLog(log) ? scrubRedundantLogAttributes(log) : null
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
import { nanoidCharSet } from '../../utils/create-user.js';
|
||||
|
||||
const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
const unsubscribeIdRe = new RegExp(`^[${nanoidCharSet}]{21}$`);
|
||||
const mongodbIdRe = /^[a-f0-9]{24}$/;
|
||||
|
||||
// eslint-disable-next-line jsdoc/require-jsdoc
|
||||
export const newUser = (email: string) => ({
|
||||
about: '',
|
||||
acceptedPrivacyTerms: false,
|
||||
completedChallenges: [],
|
||||
completedDailyCodingChallenges: [],
|
||||
completedExams: [],
|
||||
quizAttempts: [],
|
||||
currentChallengeId: '',
|
||||
donationEmails: [],
|
||||
email,
|
||||
emailAuthLinkTTL: null,
|
||||
emailVerified: true,
|
||||
emailVerifyTTL: null,
|
||||
experience: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
externalId: expect.stringMatching(uuidRe),
|
||||
githubProfile: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
id: expect.stringMatching(mongodbIdRe),
|
||||
is2018DataVisCert: false,
|
||||
is2018FullStackCert: false,
|
||||
isA2EnglishCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isBackEndCert: false,
|
||||
isBanned: false,
|
||||
isCheater: false,
|
||||
isClassroomAccount: null,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isDonating: false,
|
||||
isFoundationalCSharpCertV8: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isFullStackCert: false,
|
||||
isHonest: false,
|
||||
isInfosecCertV7: false,
|
||||
isInfosecQaCert: false,
|
||||
isJavascriptCertV9: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isJsAlgoDataStructCertV8: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
isPythonCertV9: false,
|
||||
isQaCertV7: false,
|
||||
isRelationalDatabaseCertV8: false,
|
||||
isRelationalDatabaseCertV9: false,
|
||||
isCollegeAlgebraPyCertV8: false,
|
||||
isRespWebDesignCert: false,
|
||||
isRespWebDesignCertV9: false,
|
||||
isSciCompPyCertV7: false,
|
||||
isFrontEndLibsCertV9: false,
|
||||
isBackEndDevApisCertV9: false,
|
||||
isFullStackDeveloperCertV9: false,
|
||||
isB1EnglishCert: false,
|
||||
isA2SpanishCert: false,
|
||||
isA2ChineseCert: false,
|
||||
isA1ChineseCert: false,
|
||||
keyboardShortcuts: false,
|
||||
linkedin: null,
|
||||
location: '',
|
||||
name: '',
|
||||
needsModeration: false,
|
||||
newEmail: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
unsubscribeId: expect.stringMatching(unsubscribeIdRe),
|
||||
partiallyCompletedChallenges: [],
|
||||
password: null,
|
||||
picture: '',
|
||||
portfolio: [],
|
||||
profileUI: {
|
||||
isLocked: false,
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showExperience: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
},
|
||||
progressTimestamps: [expect.any(Number)],
|
||||
rand: null, // TODO(Post-MVP): delete from schema (it's not used or required).
|
||||
savedChallenges: [],
|
||||
sendQuincyEmail: null,
|
||||
socrates: null,
|
||||
theme: 'default',
|
||||
timezone: null,
|
||||
twitter: null,
|
||||
bluesky: null,
|
||||
updateCount: 0, // see extendClient in prisma.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
username: expect.stringMatching(fccUuidRe),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
usernameDisplay: expect.stringMatching(fccUuidRe),
|
||||
verificationToken: null,
|
||||
website: null,
|
||||
yearsTopContributor: []
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
|
||||
import { checkCanConnectToDb, defaultUserEmail } from '../../vitest.utils.js';
|
||||
import {
|
||||
HOME_LOCATION,
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../utils/env.js';
|
||||
import { devAuth } from '../plugins/auth-dev.js';
|
||||
import prismaPlugin from '../db/prisma.js';
|
||||
import auth from './auth.js';
|
||||
import cookies from './cookies.js';
|
||||
import growthBook from './growth-book.js';
|
||||
|
||||
import { newUser } from './__fixtures__/user.js';
|
||||
|
||||
describe('dev login', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
await fastify.register(devAuth);
|
||||
await fastify.register(prismaPlugin);
|
||||
await checkCanConnectToDb(fastify.prisma);
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await fastify.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('GET /signin', () => {
|
||||
test('should create an account if one does not exist', async () => {
|
||||
const before = await fastify.prisma.user.count({});
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
const after = await fastify.prisma.user.count({});
|
||||
|
||||
expect(before).toBe(0);
|
||||
expect(after).toBe(before + 1);
|
||||
});
|
||||
|
||||
test('should populate the user with the correct data', async () => {
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(user).toEqual(newUser(defaultUserEmail));
|
||||
expect(user.username).toBe(user.usernameDisplay);
|
||||
});
|
||||
|
||||
test('should set the jwt_access_token cookie', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
|
||||
expect(res.cookies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'jwt_access_token' })
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test.todo('should create a session');
|
||||
|
||||
test('should redirect to the Referer (if it is a valid origin)', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/some-path/or/other'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/some-path/or/other'
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect to /valid-language/learn when signing in from /valid-language', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/espanol'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/espanol/learn'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle referers with trailing slahes', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/espanol/'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/espanol/learn'
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect to /learn by default', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
getRedirectParams,
|
||||
getPrefixedLandingPath,
|
||||
haveSamePath
|
||||
} from '../utils/redirection.js';
|
||||
import { findOrCreateUser } from '../routes/helpers/auth-helpers.js';
|
||||
import { createAccessToken } from '../utils/tokens.js';
|
||||
|
||||
const trimTrailingSlash = (str: string) =>
|
||||
str.endsWith('/') ? str.slice(0, -1) : str;
|
||||
|
||||
async function handleRedirects(req: FastifyRequest, reply: FastifyReply) {
|
||||
const params = getRedirectParams(req);
|
||||
const { origin, pathPrefix } = params;
|
||||
const returnTo = trimTrailingSlash(params.returnTo);
|
||||
const landingUrl = getPrefixedLandingPath(origin, pathPrefix);
|
||||
|
||||
return await reply.redirect(
|
||||
haveSamePath(landingUrl, returnTo) ? `${returnTo}/learn` : returnTo
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify plugin for dev authentication.
|
||||
*
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param _options - The plugin options.
|
||||
* @param done - The callback function.
|
||||
*/
|
||||
export const devAuth: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get('/signin', async (req, reply) => {
|
||||
const email = 'foo@bar.com';
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
|
||||
await handleRedirects(req, reply);
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,605 @@
|
||||
import { Writable } from 'stream';
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import { pino } from 'pino';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env.js';
|
||||
import { type Token, createAccessToken } from '../utils/tokens.js';
|
||||
import { getLoggerOptions } from '../utils/logger.js';
|
||||
import cookies, {
|
||||
sign as signCookie,
|
||||
unsign as unsignCookie
|
||||
} from './cookies.js';
|
||||
import auth from './auth.js';
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
return fastify;
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_IN_SECONDS = 2592000;
|
||||
|
||||
describe('auth', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('setAccessTokenCookie', () => {
|
||||
// We won't need to keep doubly signing the cookie when we migrate the
|
||||
// authentication, but for the MVP we have to be able to read the cookies
|
||||
// set by the api-server. So, double signing:
|
||||
test('should doubly sign the cookie', async () => {
|
||||
const token = createAccessToken('test-id');
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
reply.setAccessTokenCookie(token);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
const { value, ...rest } = res.cookies[0]!;
|
||||
const unsignedOnce = unsignCookie(value);
|
||||
const unsignedTwice = jwt.verify(unsignedOnce.value!, JWT_SECRET) as {
|
||||
accessToken: Token;
|
||||
};
|
||||
expect(unsignedTwice.accessToken).toEqual(token);
|
||||
expect(rest).toEqual({
|
||||
name: 'jwt_access_token',
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
domain: COOKIE_DOMAIN,
|
||||
maxAge: THIRTY_DAYS_IN_SECONDS,
|
||||
httpOnly: true,
|
||||
secure: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorize', () => {
|
||||
beforeEach(() => {
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
});
|
||||
|
||||
test('should deny if the access token is missing', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Access token is required for this request'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the access token is not signed', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Access token is required for this request'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the access token is invalid', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Your access token is invalid'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the access token has expired', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Access token is no longer valid'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should deny if the user is not found', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
fastify.addHook('onRequest', (req, _reply, done) => {
|
||||
expect(req.accessDeniedMessage).toEqual({
|
||||
type: 'info',
|
||||
content: 'Your access token is invalid'
|
||||
});
|
||||
expect(req.user).toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => null } };
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('should populate the request with the user if the token is valid', async () => {
|
||||
const fakeUser = { id: '123', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
fastify.get('/test-user', req => {
|
||||
expect(req.user).toEqual(fakeUser);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-user',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
test('identifies the Sentry user by id only, never email', async () => {
|
||||
const setUser = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { setUser };
|
||||
const fakeUser = {
|
||||
id: '123',
|
||||
username: 'test-user',
|
||||
email: 'foo@bar.com'
|
||||
};
|
||||
// @ts-expect-error prisma isn't built in this minimal test app.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
fastify.get('/test-pii', () => ({ ok: true }));
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-pii',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(setUser).toHaveBeenLastCalledWith({ id: '123' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('req.getAuthedUser', () => {
|
||||
test('returns message when access token is missing', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Access token is required for this request'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when access token is not signed', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Access token is required for this request'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when access token is invalid', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Your access token is invalid'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when access token has expired', async () => {
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Access token is no longer valid'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns message when user is not found', async () => {
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => null } };
|
||||
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'Your access token is invalid'
|
||||
});
|
||||
});
|
||||
|
||||
test('returns user when token is valid', async () => {
|
||||
const fakeUser = { id: '123', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
|
||||
fastify.get('/test', async req => {
|
||||
return req.getAuthedUser();
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
user: fakeUser
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeExamEnvironmentToken', () => {
|
||||
beforeEach(() => {
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.addHook('onRequest', fastify.authorizeExamEnvironmentToken);
|
||||
});
|
||||
|
||||
test('captures an Error if the decoded payload is not an object', async () => {
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
const token = jwt.sign('just-a-string-payload', JWT_SECRET);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: { 'exam-environment-authorization-token': token }
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message:
|
||||
'Unreachable: exam-environment token decoded payload is not an object'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('does not capture an exception for expected token verification failures', async () => {
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: { 'exam-environment-authorization-token': 'invalid-token' }
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onRequest Hook', () => {
|
||||
test('should update the jwt_access_token to httpOnly and secure', async () => {
|
||||
const rawValue = 'should-not-change';
|
||||
fastify.get('/test', (req, reply) => {
|
||||
reply.send({ ok: true });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(rawValue)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.cookies[0]).toMatchObject({
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
value: signCookie(rawValue),
|
||||
maxAge: THIRTY_DAYS_IN_SECONDS
|
||||
});
|
||||
|
||||
expect(res.json()).toStrictEqual({ ok: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should do nothing if there is no jwt_access_token', async () => {
|
||||
fastify.get('/test', (req, reply) => {
|
||||
reply.send({ ok: true });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.cookies).toHaveLength(0);
|
||||
expect(res.json()).toStrictEqual({ ok: true });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request logging', () => {
|
||||
test('binds the userId onto logs for authed requests', async () => {
|
||||
const lines: string[] = [];
|
||||
const sink = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
lines.push(chunk.toString());
|
||||
cb();
|
||||
}
|
||||
});
|
||||
const app = Fastify({
|
||||
loggerInstance: pino(getLoggerOptions('info'), sink)
|
||||
});
|
||||
await app.register(cookies);
|
||||
await app.register(auth);
|
||||
const fakeUser = { id: 'user-42', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't built in this minimal test app.
|
||||
app.prisma = { user: { findUnique: () => fakeUser } };
|
||||
app.addHook('onRequest', app.authorize);
|
||||
app.get('/me', () => ({ ok: true }));
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('user-42') },
|
||||
JWT_SECRET
|
||||
);
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/me',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
await app.close();
|
||||
|
||||
const completed = lines
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
.find(entry => entry.msg === 'request completed');
|
||||
expect(completed?.userId).toBe('user-42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth.access_denied metric', () => {
|
||||
let count: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
count = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { metrics: { count } };
|
||||
fastify.get('/user/session-user', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.get('/other', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
});
|
||||
|
||||
test('skips the metric for the anonymous session-user poll', async () => {
|
||||
await fastify.inject({ method: 'GET', url: '/user/session-user' });
|
||||
|
||||
expect(count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('still counts an invalid token on the session-user route', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/user/session-user',
|
||||
cookies: { jwt_access_token: signCookie(token) }
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledExactlyOnceWith('auth.access_denied', 1, {
|
||||
attributes: { reason: 'Your access token is invalid' }
|
||||
});
|
||||
});
|
||||
|
||||
test('still counts an expired token on the session-user route', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/user/session-user',
|
||||
cookies: { jwt_access_token: signCookie(token) }
|
||||
});
|
||||
|
||||
expect(count).toHaveBeenCalledExactlyOnceWith('auth.access_denied', 1, {
|
||||
attributes: { reason: 'Access token is no longer valid' }
|
||||
});
|
||||
});
|
||||
|
||||
test('counts a missing token on other routes', async () => {
|
||||
await fastify.inject({ method: 'GET', url: '/other' });
|
||||
|
||||
expect(count).toHaveBeenCalledExactlyOnceWith('auth.access_denied', 1, {
|
||||
attributes: { reason: 'Access token is required for this request' }
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
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.js';
|
||||
import { type Token, isExpired } from '../utils/tokens.js';
|
||||
import { ERRORS } from '../exam-environment/utils/errors.js';
|
||||
|
||||
type AuthResult =
|
||||
| {
|
||||
message: string;
|
||||
user?: never;
|
||||
}
|
||||
| {
|
||||
message?: never;
|
||||
user: user;
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
setAccessTokenCookie: (this: FastifyReply, accessToken: Token) => void;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
// TODO: is the full user the correct type here?
|
||||
user: user | null;
|
||||
accessDeniedMessage: { type: 'info'; content: string } | null;
|
||||
getAuthedUser: () => Promise<AuthResult>;
|
||||
}
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
authorizeExamEnvironmentToken: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const auth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const cookieOpts = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: 2592000 // thirty days in seconds
|
||||
};
|
||||
fastify.decorateReply('setAccessTokenCookie', function (accessToken: Token) {
|
||||
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
|
||||
void this.setCookie('jwt_access_token', signedToken, cookieOpts);
|
||||
});
|
||||
|
||||
// update existing jwt_access_token cookie properties
|
||||
fastify.addHook('onRequest', (req, reply, done) => {
|
||||
const rawCookie = req.cookies['jwt_access_token'];
|
||||
if (rawCookie) {
|
||||
const jwtAccessToken = req.unsignCookie(rawCookie);
|
||||
if (jwtAccessToken.valid) {
|
||||
reply.setCookie('jwt_access_token', jwtAccessToken.value, cookieOpts);
|
||||
}
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
fastify.decorateRequest('accessDeniedMessage', null);
|
||||
fastify.decorateRequest('user', null);
|
||||
|
||||
const TOKEN_REQUIRED = 'Access token is required for this request';
|
||||
const TOKEN_INVALID = 'Your access token is invalid';
|
||||
const TOKEN_EXPIRED = 'Access token is no longer valid';
|
||||
|
||||
const setAccessDenied = (req: FastifyRequest, content: string) => {
|
||||
const isAnonymousPoll =
|
||||
req.routeOptions?.url === '/user/session-user' &&
|
||||
content === TOKEN_REQUIRED;
|
||||
if (!isAnonymousPoll) {
|
||||
fastify.Sentry?.metrics?.count('auth.access_denied', 1, {
|
||||
attributes: { reason: content }
|
||||
});
|
||||
}
|
||||
req.accessDeniedMessage = { type: 'info', content };
|
||||
};
|
||||
|
||||
async function getAuthedUser(this: FastifyRequest): Promise<AuthResult> {
|
||||
const tokenCookie = this.cookies.jwt_access_token;
|
||||
if (!tokenCookie) return { message: TOKEN_REQUIRED };
|
||||
|
||||
const unsignedToken = this.unsignCookie(tokenCookie);
|
||||
if (!unsignedToken.valid) return { message: TOKEN_REQUIRED };
|
||||
|
||||
const jwtAccessToken = unsignedToken.value;
|
||||
|
||||
try {
|
||||
jwt.verify(jwtAccessToken, JWT_SECRET);
|
||||
} catch {
|
||||
return { message: TOKEN_INVALID };
|
||||
}
|
||||
|
||||
const { accessToken } = jwt.decode(jwtAccessToken) as {
|
||||
accessToken: Token;
|
||||
};
|
||||
|
||||
if (isExpired(accessToken)) return { message: TOKEN_EXPIRED };
|
||||
// We're using token.userId since it's possible for the user record to be
|
||||
// malformed and for prisma to throw while trying to find the user.
|
||||
fastify.Sentry?.setUser({
|
||||
id: accessToken.userId
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: accessToken.userId }
|
||||
});
|
||||
if (user) {
|
||||
fastify.Sentry?.setUser({ id: user.id });
|
||||
}
|
||||
|
||||
return user ? { user } : { message: TOKEN_INVALID };
|
||||
}
|
||||
|
||||
fastify.decorateRequest('getAuthedUser', getAuthedUser);
|
||||
|
||||
const handleAuth = async (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> => {
|
||||
const { message, user } = await req.getAuthedUser();
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
req.log = reply.log = req.log.child({ userId: user.id });
|
||||
} else {
|
||||
req.log.debug({ reason: message }, 'Request not authenticated');
|
||||
setAccessDenied(req, message);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
req.log.warn({ err: e }, 'Exam environment token verification failed');
|
||||
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) {
|
||||
req.log.error(
|
||||
'Unreachable: exam-environment token verified but decoded payload is not an object'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(
|
||||
'Unreachable: exam-environment token decoded payload is not an object'
|
||||
)
|
||||
);
|
||||
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) {
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_AUTHORIZATION_TOKEN(
|
||||
'Provided token is revoked.'
|
||||
)
|
||||
);
|
||||
}
|
||||
// We're using token.userId since it's possible for the user record to be
|
||||
// malformed and for prisma to throw while trying to find the user.
|
||||
|
||||
fastify.Sentry?.setUser({
|
||||
id: token.userId
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: token.userId }
|
||||
});
|
||||
if (!user) return setAccessDenied(req, TOKEN_INVALID);
|
||||
fastify.Sentry?.setUser({ id: user.id });
|
||||
req.user = user;
|
||||
req.log = reply.log = req.log.child({ userId: user.id });
|
||||
}
|
||||
|
||||
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'] });
|
||||
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
MockInstance
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
|
||||
import { createUserInput } from '../utils/create-user.js';
|
||||
import {
|
||||
AUTH0_DOMAIN,
|
||||
HOME_LOCATION,
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../utils/env.js';
|
||||
import prismaPlugin from '../db/prisma.js';
|
||||
import cookies, { sign, unsign } from './cookies.js';
|
||||
import { auth0Client } from './auth0.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
import auth from './auth.js';
|
||||
import bouncer from './bouncer.js';
|
||||
import growthBook from './growth-book.js';
|
||||
import { newUser } from './__fixtures__/user.js';
|
||||
|
||||
const COOKIE_DOMAIN = 'test.com';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../utils/env.js')>()),
|
||||
COOKIE_DOMAIN: 'test.com'
|
||||
}));
|
||||
|
||||
describe('auth0 plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException: () => '' };
|
||||
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(auth);
|
||||
await fastify.register(bouncer);
|
||||
await fastify.register(auth0Client);
|
||||
await fastify.register(prismaPlugin);
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /signin/google', () => {
|
||||
test('should redirect directly to Google via Auth0 with connection param', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin/google'
|
||||
});
|
||||
const redirectUrl = new URL(res.headers.location!);
|
||||
expect(redirectUrl.host).toMatch(AUTH0_DOMAIN);
|
||||
expect(redirectUrl.pathname).toBe('/authorize');
|
||||
expect(redirectUrl.searchParams.get('connection')).toBe('google-oauth2');
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('sets a login-returnto cookie', async () => {
|
||||
const returnTo = 'http://localhost:3000/learn';
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin/google',
|
||||
headers: { referer: returnTo }
|
||||
});
|
||||
const cookie = res.cookies.find(c => c.name === 'login-returnto');
|
||||
expect(unsign(cookie!.value).value).toBe(returnTo);
|
||||
expect(cookie).toMatchObject({
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('GET /signin', () => {
|
||||
test('should redirect to the auth0 login page', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin'
|
||||
});
|
||||
|
||||
const redirectUrl = new URL(res.headers.location!);
|
||||
expect(redirectUrl.host).toMatch(AUTH0_DOMAIN);
|
||||
expect(redirectUrl.pathname).toBe('/authorize');
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('sets a login-returnto cookie', async () => {
|
||||
const returnTo = 'http://localhost:3000/learn';
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: returnTo
|
||||
}
|
||||
});
|
||||
|
||||
const cookie = res.cookies.find(c => c.name === 'login-returnto');
|
||||
expect(unsign(cookie!.value).value).toBe(returnTo);
|
||||
expect(cookie).toMatchObject({
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/auth0/callback', () => {
|
||||
const email = 'new@user.com';
|
||||
let getAccessTokenFromAuthorizationCodeFlowSpy: MockInstance;
|
||||
let userinfoSpy: MockInstance;
|
||||
let captureException: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockAuthSuccess = () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockResolvedValueOnce(Promise.resolve({ email }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy = vi.spyOn(
|
||||
fastify.auth0OAuth,
|
||||
'getAccessTokenFromAuthorizationCodeFlow'
|
||||
);
|
||||
userinfoSpy = vi.spyOn(fastify.auth0OAuth, 'userinfo');
|
||||
captureException = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fastify.prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
test('should redirect to the client if authentication fails', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
'any error'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
`${HOME_LOCATION}/?${formatMessage({ type: 'danger', content: 'flash.generic-error' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('should redirect to the client if the state is invalid', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
`${HOME_LOCATION}/?${formatMessage({ type: 'danger', content: 'flash.generic-error' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
test('should log a warning if the state is invalid', async () => {
|
||||
vi.spyOn(fastify.log, 'warn');
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.warn).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
'Auth failed: invalid state'
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should log expected Auth0 errors', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
const auth0Error = Error('Response Error: 403 Forbidden');
|
||||
// @ts-expect-error - mocking a hapi/boom error
|
||||
auth0Error.data = {
|
||||
payload: {
|
||||
error: 'invalid_grant'
|
||||
}
|
||||
};
|
||||
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
auth0Error
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.error).toHaveBeenCalledWith(
|
||||
auth0Error,
|
||||
'Auth failed: invalid_grant'
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should capture Auth0 errors with reason invalid_request', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
const auth0Error = Error('Response Error: 400 Bad Request');
|
||||
// @ts-expect-error - mocking a hapi/boom error
|
||||
auth0Error.data = {
|
||||
payload: {
|
||||
error: 'invalid_request'
|
||||
}
|
||||
};
|
||||
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
auth0Error
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.error).toHaveBeenCalledWith(
|
||||
auth0Error,
|
||||
'Auth failed: invalid_request'
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('should capture unexpected Auth0 errors', async () => {
|
||||
vi.spyOn(fastify.log, 'error');
|
||||
const auth0Error = Error('Response Error: 500 Internal Server Error');
|
||||
// @ts-expect-error - mocking a hapi/boom error
|
||||
auth0Error.data = {
|
||||
payload: {
|
||||
error: 'server_error'
|
||||
}
|
||||
};
|
||||
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockRejectedValueOnce(
|
||||
auth0Error
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(fastify.log.error).toHaveBeenCalledWith(
|
||||
auth0Error,
|
||||
'Auth failed: server_error'
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('should not create a user if the state is invalid', async () => {
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=invalid'
|
||||
});
|
||||
|
||||
expect(await fastify.prisma.user.count()).toBe(0);
|
||||
});
|
||||
|
||||
test('should block requests with "access_denied" error', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?error=access_denied&error_description=Access denied from your location'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toMatch(`${HOME_LOCATION}/blocked`);
|
||||
|
||||
const resWithoutDescription = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?error=access_denied'
|
||||
});
|
||||
|
||||
expect(resWithoutDescription.statusCode).toBe(302);
|
||||
expect(resWithoutDescription.headers.location).toMatch(
|
||||
`${HOME_LOCATION}/learn?messages=`
|
||||
);
|
||||
});
|
||||
|
||||
test('creates a user if the state is valid', async () => {
|
||||
mockAuthSuccess();
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
expect(await fastify.prisma.user.count()).toBe(1);
|
||||
});
|
||||
|
||||
test('handles userinfo errors', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockResolvedValueOnce(Promise.reject(Error('any error')));
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count, distribution } };
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': sign(returnTo) }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
returnTo +
|
||||
`?${formatMessage({ type: 'danger', content: 'flash.generic-error' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(await fastify.prisma.user.count()).toBe(0);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'auth.login_latency_ms',
|
||||
expect.any(Number),
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'failure' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('captures userinfo errors carrying innerError', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockRejectedValueOnce(
|
||||
Object.assign(new Error('upstream'), { innerError: new Error('inner') })
|
||||
);
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': sign(returnTo) }
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('handles invalid userinfo responses', async () => {
|
||||
getAccessTokenFromAuthorizationCodeFlowSpy.mockResolvedValueOnce({
|
||||
token: 'any token'
|
||||
});
|
||||
userinfoSpy.mockResolvedValueOnce(Promise.resolve({}));
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': sign(returnTo) }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
returnTo +
|
||||
`?${formatMessage({ type: 'danger', content: 'flash.no-email-in-userinfo' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(await fastify.prisma.user.count()).toBe(0);
|
||||
});
|
||||
|
||||
test('redirects with the signin-success message on success', async () => {
|
||||
mockAuthSuccess();
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count, distribution } };
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(
|
||||
`?${formatMessage({ type: 'success', content: 'flash.signin-success' })}`
|
||||
);
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(count).toHaveBeenCalledWith('auth.login_succeeded', 1, {
|
||||
attributes: { provider: 'auth0' }
|
||||
});
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'auth.login_latency_ms',
|
||||
expect.any(Number),
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'success' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should set the jwt_access_token cookie', async () => {
|
||||
mockAuthSuccess();
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
expect(res.headers['set-cookie']).toEqual(
|
||||
expect.stringMatching(/jwt_access_token=/)
|
||||
);
|
||||
});
|
||||
|
||||
test('should use the login-returnto cookie if present and valid', async () => {
|
||||
mockAuthSuccess();
|
||||
await fastify.prisma.user.create({
|
||||
data: { ...createUserInput(email), acceptedPrivacyTerms: true }
|
||||
});
|
||||
const returnTo = 'https://www.freecodecamp.org/espanol/learn';
|
||||
// /signin sets the cookie
|
||||
const req = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: returnTo
|
||||
}
|
||||
});
|
||||
const returnToCookie = req.cookies.find(c => c.name === 'login-returnto');
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': returnToCookie!.value }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${returnTo}?${formatMessage({ type: 'success', content: 'flash.signin-success' })}`
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect to learn if the user has signed in from the landing page', async () => {
|
||||
mockAuthSuccess();
|
||||
|
||||
const returnTo = 'https://www.freecodecamp.org/';
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: {
|
||||
'login-returnto': sign(returnTo)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toEqual(
|
||||
expect.stringContaining('https://www.freecodecamp.org/learn?')
|
||||
);
|
||||
});
|
||||
|
||||
test('should redirect home if the login-returnto cookie is invalid', async () => {
|
||||
mockAuthSuccess();
|
||||
const returnTo = 'https://www.evilcodecamp.org/espanol/learn';
|
||||
// /signin sets the cookie
|
||||
const req = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/signin',
|
||||
headers: {
|
||||
referer: returnTo
|
||||
}
|
||||
});
|
||||
const returnToCookie = req.cookies.find(c => c.name === 'login-returnto');
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid',
|
||||
cookies: { 'login-returnto': returnToCookie!.value }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(HOME_LOCATION);
|
||||
});
|
||||
|
||||
test('should populate the user with the correct data', async () => {
|
||||
mockAuthSuccess();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/auth/auth0/callback?state=valid'
|
||||
});
|
||||
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
expect(user).toEqual(newUser(email));
|
||||
expect(user.username).toBe(user.usernameDisplay);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import fastifyOauth2, { type OAuth2Namespace } from '@fastify/oauth2';
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { Type } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { isError } from 'lodash-es';
|
||||
import {
|
||||
API_LOCATION,
|
||||
AUTH0_CLIENT_ID,
|
||||
AUTH0_CLIENT_SECRET,
|
||||
AUTH0_DOMAIN,
|
||||
COOKIE_DOMAIN,
|
||||
HOME_LOCATION
|
||||
} from '../utils/env.js';
|
||||
import { findOrCreateUser } from '../routes/helpers/auth-helpers.js';
|
||||
import { createAccessToken } from '../utils/tokens.js';
|
||||
import { getLoginRedirectParams } from '../utils/redirection.js';
|
||||
import { clientNetInfo } from '../utils/logger.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
auth0OAuth: OAuth2Namespace;
|
||||
}
|
||||
}
|
||||
|
||||
const Auth0ErrorSchema = Type.Object({
|
||||
data: Type.Object({
|
||||
payload: Type.Object({
|
||||
error: Type.String()
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Fastify plugin for Auth0 authentication. This uses fastify-plugin to expose
|
||||
* the auth0OAuth decorator (for easier testing), but to maintain encapsulation
|
||||
* it should be registered in a plugin. That prevents auth0OAuth from being
|
||||
* available globally.
|
||||
*
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param _options - The plugin options.
|
||||
* @param done - The callback function.
|
||||
*/
|
||||
export const auth0Client: FastifyPluginCallbackTypebox = fp(
|
||||
(fastify, _options, done) => {
|
||||
void fastify.register(fastifyOauth2, {
|
||||
name: 'auth0OAuth',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
credentials: {
|
||||
client: {
|
||||
id: AUTH0_CLIENT_ID,
|
||||
secret: AUTH0_CLIENT_SECRET
|
||||
}
|
||||
},
|
||||
discovery: { issuer: `https://${AUTH0_DOMAIN}` },
|
||||
callbackUri: `${API_LOCATION}/auth/auth0/callback`,
|
||||
cookie: {
|
||||
// It's important not to sign the cookie, since the OAuth2 plugin will
|
||||
// not unsign it.
|
||||
signed: false
|
||||
}
|
||||
});
|
||||
|
||||
void fastify.register(function (fastify, _options, done) {
|
||||
// TODO(Post-MVP): move this into the app, so that we add this hook once for
|
||||
// all auth routes.
|
||||
fastify.addHook('onRequest', fastify.redirectIfSignedIn);
|
||||
|
||||
fastify.get('/signin', async function (request, reply) {
|
||||
const returnTo = request.headers.referer ?? `${HOME_LOCATION}/learn`;
|
||||
void reply.setCookie('login-returnto', returnTo, {
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
signed: true,
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
const redirectUrl = await this.auth0OAuth.generateAuthorizationUri(
|
||||
request,
|
||||
reply
|
||||
);
|
||||
void reply.redirect(redirectUrl);
|
||||
});
|
||||
|
||||
fastify.get('/signin/google', async function (request, reply) {
|
||||
const returnTo = request.headers.referer ?? `${HOME_LOCATION}/learn`;
|
||||
void reply.setCookie('login-returnto', returnTo, {
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
signed: true,
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
const authorizationEndpoint =
|
||||
await this.auth0OAuth.generateAuthorizationUri(request, reply);
|
||||
|
||||
const url = new URL(authorizationEndpoint);
|
||||
url.searchParams.set('connection', 'google-oauth2');
|
||||
|
||||
void reply.redirect(url.toString());
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
// TODO: use a schema to validate the query params.
|
||||
fastify.get('/auth/auth0/callback', async function (req, reply) {
|
||||
const { error, error_description } = req.query as Record<string, string>;
|
||||
if (error === 'access_denied') {
|
||||
const blockedByLaw =
|
||||
error_description === 'Access denied from your location';
|
||||
if (blockedByLaw) {
|
||||
req.log.info('Access denied due to user location');
|
||||
return reply.redirect(`${HOME_LOCATION}/blocked`);
|
||||
} else {
|
||||
req.log.info(
|
||||
{ errorDescription: error_description, ...clientNetInfo(req) },
|
||||
'Authentication failed for user'
|
||||
);
|
||||
return reply.redirectWithMessage(`${HOME_LOCATION}/learn`, {
|
||||
type: 'info',
|
||||
content: error_description ?? 'Authentication failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { returnTo, origin } = getLoginRedirectParams(req);
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = (
|
||||
await this.auth0OAuth.getAccessTokenFromAuthorizationCodeFlow(req)
|
||||
).token;
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.count('auth.failed', 1, {
|
||||
attributes: { stage: 'token' }
|
||||
});
|
||||
// This is the plugin's error message. If it changes, we will either
|
||||
// have to update the test or write custom state create/verify
|
||||
// functions.
|
||||
if (error instanceof Error && error.message === 'Invalid state') {
|
||||
req.log.warn(error, 'Auth failed: invalid state');
|
||||
} else if (Value.Check(Auth0ErrorSchema, error)) {
|
||||
const errorType = error.data.payload.error;
|
||||
const expectedErrorTypes = ['invalid_grant', 'access_denied'];
|
||||
if (!expectedErrorTypes.includes(errorType)) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
}
|
||||
req.log.error(error, 'Auth failed: ' + errorType);
|
||||
} else {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Failed to get access token from Auth0');
|
||||
}
|
||||
// It's important _not_ to redirect to /signin here, as that could
|
||||
// create an infinite loop.
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: 'flash.generic-error'
|
||||
});
|
||||
}
|
||||
|
||||
let email;
|
||||
const __userinfoStart = performance.now();
|
||||
try {
|
||||
const userinfo = (await fastify.auth0OAuth.userinfo(token)) as {
|
||||
email: string;
|
||||
};
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'auth.login_latency_ms',
|
||||
performance.now() - __userinfoStart,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'success' }
|
||||
}
|
||||
);
|
||||
req.log.debug(
|
||||
{ hasEmail: !!userinfo.email },
|
||||
'Received Auth0 userinfo'
|
||||
);
|
||||
email = userinfo.email;
|
||||
if (typeof email !== 'string') {
|
||||
req.log.warn('Auth0 userinfo missing email');
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: 'flash.no-email-in-userinfo'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'auth.login_latency_ms',
|
||||
performance.now() - __userinfoStart,
|
||||
{
|
||||
unit: 'millisecond',
|
||||
attributes: { provider: 'auth0', result: 'failure' }
|
||||
}
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('auth.failed', 1, {
|
||||
attributes: { stage: 'userinfo' }
|
||||
});
|
||||
if (isError(error) && 'innerError' in error) {
|
||||
// This is a specific error from the @fastify/oauth2 plugin.
|
||||
const innerError = error.innerError as Error;
|
||||
innerError.message = `Auth0 userinfo error: ${innerError.message}`;
|
||||
fastify.Sentry?.captureException(innerError);
|
||||
req.log.error(innerError, 'Failed to get userinfo from Auth0');
|
||||
} else {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(error, 'Failed to get userinfo from Auth0');
|
||||
}
|
||||
return reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: 'flash.generic-error'
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.login_succeeded', 1, {
|
||||
attributes: { provider: 'auth0' }
|
||||
});
|
||||
|
||||
const returnPath = new URL(returnTo).pathname;
|
||||
const returnURL = returnPath === '/' ? `${origin}/learn` : returnTo;
|
||||
|
||||
void reply.redirectWithMessage(returnURL, {
|
||||
type: 'success',
|
||||
content: 'flash.signin-success'
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
},
|
||||
// TODO(Post-MVP): remove bouncer dependency when moving redirectIfSignedIn
|
||||
// out of this plugin.
|
||||
{ dependencies: ['redirect-with-message', 'bouncer'] }
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
MockInstance
|
||||
} from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { type user } from '@prisma/client';
|
||||
|
||||
import { HOME_LOCATION } from '../utils/env.js';
|
||||
import bouncer from './bouncer.js';
|
||||
import auth from './auth.js';
|
||||
import cookies from './cookies.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
|
||||
let authorizeSpy: MockInstance<FastifyInstance['authorize']>;
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(auth);
|
||||
authorizeSpy = vi.spyOn(fastify, 'authorize');
|
||||
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(bouncer);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
fastify.get('/', (_req, reply) => {
|
||||
void reply.send({ foo: 'bar' });
|
||||
});
|
||||
return fastify;
|
||||
}
|
||||
|
||||
describe('bouncer', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('send401IfNoUser', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.send401IfNoUser);
|
||||
});
|
||||
|
||||
test('should return 401 if NO user is present', async () => {
|
||||
const message = {
|
||||
type: 'info' as const,
|
||||
content: 'Something undesirable occurred'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.accessDeniedMessage = message;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toStrictEqual({
|
||||
type: message.type,
|
||||
message: message.content
|
||||
});
|
||||
expect(res.statusCode).toEqual(401);
|
||||
});
|
||||
|
||||
test('should not alter the response if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.user = { id: '123' } as user;
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectIfNoUser', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.redirectIfNoUser);
|
||||
});
|
||||
const redirectLocation = `${HOME_LOCATION}?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
|
||||
|
||||
// TODO(Post-MVP): make the redirects consistent between redirectIfNoUser
|
||||
// and redirectIfSignedIn. Either both should redirect to the referer or
|
||||
// both should redirect to HOME_LOCATION.
|
||||
test('should redirect to HOME_LOCATION if NO user is present', async () => {
|
||||
const message = {
|
||||
type: 'info' as const,
|
||||
content: 'At the moment, content is ignored'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.accessDeniedMessage = message;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should not alter the response if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.user = { id: '123' } as user;
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectIfSignedIn', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.redirectIfSignedIn);
|
||||
});
|
||||
|
||||
test('should redirect to the referer if a user is present', async () => {
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.user = { id: '123' } as user;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/some/other/path'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/some/other/path'
|
||||
);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should not alter the response if NO user is present', async () => {
|
||||
const message = {
|
||||
type: 'info' as const,
|
||||
content: 'At the moment, content is ignored'
|
||||
};
|
||||
authorizeSpy.mockImplementationOnce(async req => {
|
||||
req.accessDeniedMessage = message;
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
FastifyPluginCallback,
|
||||
FastifyRequest,
|
||||
FastifyReply
|
||||
} from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { getRedirectParams } from '../utils/redirection.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
send401IfNoUser: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
redirectIfNoUser: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
redirectIfSignedIn: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorate(
|
||||
'send401IfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
req.log.trace(
|
||||
'Protected route accessed by unauthenticated user. Sent 401.'
|
||||
);
|
||||
|
||||
await reply.status(401).send({
|
||||
type: req.accessDeniedMessage?.type,
|
||||
message: req.accessDeniedMessage?.content
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.decorate(
|
||||
'redirectIfNoUser',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.user) {
|
||||
req.log.trace(
|
||||
'Protected route accessed by unauthenticated user. Redirecting to login.'
|
||||
);
|
||||
const { origin } = getRedirectParams(req);
|
||||
await reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
'Only authenticated users can access this route. Please sign in and try again.'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.decorate(
|
||||
'redirectIfSignedIn',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
if (req.user) {
|
||||
const { returnTo } = getRedirectParams(req);
|
||||
|
||||
req.log.trace({ returnTo }, 'Signed-in user redirected');
|
||||
|
||||
await reply.redirect(returnTo);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin, {
|
||||
dependencies: ['auth', 'redirect-with-message'],
|
||||
name: 'bouncer'
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import cookies, { type CookieSerializeOptions, sign } from './cookies.js';
|
||||
import { cookieUpdate } from './cookie-update.js';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
COOKIE_DOMAIN: 'www.example.com',
|
||||
FREECODECAMP_NODE_ENV: 'not-development'
|
||||
};
|
||||
});
|
||||
|
||||
describe('Cookie updates', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
const setup = async (attributes: CookieSerializeOptions) => {
|
||||
// Since register creates a new scope, we need to create a route inside the
|
||||
// scope for the plugin to be applied.
|
||||
await fastify.register(cookieUpdate, fastify => {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
fastify.get('/', async () => {
|
||||
return { hello: 'world' };
|
||||
});
|
||||
return {
|
||||
cookies: ['cookie_name'],
|
||||
attributes
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should not set cookies that are not in the request', async () => {
|
||||
await setup({});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: 'cookie_name_two=cookie_value'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should update the cookie's attributes without changing the value", async () => {
|
||||
await setup({ sameSite: 'strict' });
|
||||
const signedCookie = sign('cookie_value');
|
||||
const encodedCookie = encodeURIComponent(signedCookie);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: `cookie_name=${signedCookie}`
|
||||
}
|
||||
});
|
||||
|
||||
const updatedCookie = res.headers['set-cookie'] as string;
|
||||
expect(updatedCookie).toEqual(
|
||||
expect.stringContaining(`cookie_name=${encodedCookie}`)
|
||||
);
|
||||
expect(updatedCookie).toEqual(expect.stringContaining('SameSite=Strict'));
|
||||
});
|
||||
|
||||
test('should unsign the cookie if required', async () => {
|
||||
await setup({ signed: false });
|
||||
const signedCookie = sign('cookie_value');
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: `cookie_name=${signedCookie}`
|
||||
}
|
||||
});
|
||||
|
||||
const updatedCookie = res.headers['set-cookie'] as string;
|
||||
expect(updatedCookie).toEqual(
|
||||
expect.stringContaining('cookie_name=cookie_value')
|
||||
);
|
||||
});
|
||||
|
||||
test('should respect the default cookie config if not overriden', async () => {
|
||||
await setup({});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
headers: {
|
||||
cookie: 'cookie_name=anything'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.cookies[0]).toEqual({
|
||||
domain: 'www.example.com',
|
||||
httpOnly: true,
|
||||
name: 'cookie_name',
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
value: expect.any(String),
|
||||
secure: true
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import type { CookieSerializeOptions } from './cookies.js';
|
||||
|
||||
type Options = { cookies: string[]; attributes: CookieSerializeOptions };
|
||||
|
||||
/**
|
||||
* Plugin that updates the attributes of cookies in the response, without
|
||||
* changing the value.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param options Options passed to the plugin via `fastify.register(plugin,
|
||||
* options)`.
|
||||
* @param options.cookies The names of the cookies to update.
|
||||
* @param options.attributes The attributes to update the cookies with. NOTE:
|
||||
* The attributes are merged with the default values given to \@fastify/cookie.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const cookieUpdate: FastifyPluginCallback<Options> = (
|
||||
fastify,
|
||||
options,
|
||||
done
|
||||
) => {
|
||||
fastify.addHook('onSend', (request, reply, _payload, next) => {
|
||||
for (const cookie of options.cookies) {
|
||||
const oldCookie = request.cookies[cookie];
|
||||
if (!oldCookie) continue;
|
||||
|
||||
const unsigned = reply.unsignCookie(oldCookie);
|
||||
const raw = unsigned.valid ? unsigned.value : oldCookie;
|
||||
void reply.setCookie(cookie, raw, options.attributes);
|
||||
}
|
||||
|
||||
request.log.trace('Updated cookies');
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
|
||||
import { COOKIE_SECRET } from '../utils/env.js';
|
||||
import cookies from './cookies.js';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
COOKIE_DOMAIN: 'www.example.com',
|
||||
FREECODECAMP_NODE_ENV: 'not-development'
|
||||
};
|
||||
});
|
||||
|
||||
describe('cookies', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should prefix signed cookies with "s:" (url-encoded)', async () => {
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
void reply.setCookie('test', 'value', { signed: true });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.headers['set-cookie']).toMatch(/test=s%3Avalue\.\w*/);
|
||||
});
|
||||
|
||||
test('should be able to unsign cookies', async () => {
|
||||
const signedCookie = `test=s%3A${fastifyCookie.sign('value', COOKIE_SECRET)}`;
|
||||
fastify.get('/test', (req, reply) => {
|
||||
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
cookie: signedCookie
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
unsigned: { value: 'value', renew: false, valid: true }
|
||||
});
|
||||
});
|
||||
|
||||
test('should reject cookies not prefixed with "s:"', async () => {
|
||||
const signedCookie = `test=${fastifyCookie.sign('value', COOKIE_SECRET)}`;
|
||||
fastify.get('/test', (req, reply) => {
|
||||
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
cookie: signedCookie
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
unsigned: { value: null, renew: false, valid: false }
|
||||
});
|
||||
});
|
||||
|
||||
test('should have reasonable defaults', async () => {
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
void reply.setCookie('test', 'value');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
// No max-age, so we default to a session cookie.
|
||||
expect(res.cookies[0]).toEqual({
|
||||
name: 'test',
|
||||
// defaults:
|
||||
domain: 'www.example.com',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
secure: true,
|
||||
// sign by default:
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
value: expect.stringMatching(/s:value\.\w*/)
|
||||
});
|
||||
});
|
||||
|
||||
// TODO(Post-MVP): Clear all cookies rather than just three specific ones?
|
||||
// Then it should be called something like clearAllCookies.
|
||||
test('clearOurCookies should clear cookies that we set', async () => {
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
void reply.clearOurCookies();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.cookies).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'jwt_access_token',
|
||||
expires: new Date(0),
|
||||
value: ''
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: '_csrf',
|
||||
expires: new Date(0),
|
||||
value: ''
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'csrf_token',
|
||||
expires: new Date(0),
|
||||
value: ''
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import fastifyCookie, { type UnsignResult } from '@fastify/cookie';
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import {
|
||||
COOKIE_DOMAIN,
|
||||
COOKIE_SECRET,
|
||||
FREECODECAMP_NODE_ENV
|
||||
} from '../utils/env.js';
|
||||
import { CSRF_COOKIE, CSRF_SECRET_COOKIE } from './csrf.js';
|
||||
|
||||
export { type CookieSerializeOptions } from '@fastify/cookie';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
clearOurCookies: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a cookie value by prefixing it with "s:" and using the COOKIE_SECRET.
|
||||
*
|
||||
* @param value The value to sign.
|
||||
* @returns The signed cookie value.
|
||||
*/
|
||||
export const sign = (value: string) =>
|
||||
's:' + fastifyCookie.sign(value, COOKIE_SECRET);
|
||||
|
||||
/**
|
||||
* Unsigns a cookie value by removing the "s:" prefix and using the COOKIE_SECRET.
|
||||
*
|
||||
* @param rawValue The signed cookie value.
|
||||
* @returns The unsigned cookie value.
|
||||
*/
|
||||
export const unsign = (rawValue: string): UnsignResult => {
|
||||
const prefix = rawValue.slice(0, 2);
|
||||
if (prefix !== 's:') return { valid: false, renew: false, value: null };
|
||||
|
||||
const value = rawValue.slice(2);
|
||||
return fastifyCookie.unsign(value, COOKIE_SECRET);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compatibility plugin for using cookies signed by express. By prefixing with
|
||||
* "s:" and removing it when unsigning, we can continue to use the same cookies
|
||||
* in Fastify.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
const cookies: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
void fastify.register(fastifyCookie, {
|
||||
secret: {
|
||||
sign,
|
||||
unsign
|
||||
},
|
||||
parseOptions: {
|
||||
domain: COOKIE_DOMAIN,
|
||||
httpOnly: FREECODECAMP_NODE_ENV !== 'development',
|
||||
// Path is necessary to ensure that only one cookie is set and it is valid
|
||||
// for all routes.
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: FREECODECAMP_NODE_ENV !== 'development',
|
||||
signed: true
|
||||
}
|
||||
});
|
||||
|
||||
void fastify.decorateReply('clearOurCookies', function () {
|
||||
void this.clearCookie('jwt_access_token');
|
||||
void this.clearCookie(CSRF_SECRET_COOKIE);
|
||||
void this.clearCookie(CSRF_COOKIE);
|
||||
|
||||
this.request.log.trace('Clearing cookies for user');
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(cookies, { name: 'cookies' });
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance, LogLevel } from 'fastify';
|
||||
import cors from './cors.js';
|
||||
|
||||
const NON_DEBUG_LOG_LEVELS: LogLevel[] = [
|
||||
'fatal',
|
||||
'error',
|
||||
'warn',
|
||||
'info',
|
||||
'trace'
|
||||
];
|
||||
|
||||
describe('cors', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeAll(async () => {
|
||||
fastify = Fastify({ disableRequestLogging: true });
|
||||
await fastify.register(cors);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should only debug log for /status/* routes', async () => {
|
||||
const spies = NON_DEBUG_LOG_LEVELS.map(level =>
|
||||
vi.spyOn(fastify.log, level)
|
||||
);
|
||||
const debugSpy = vi.spyOn(fastify.log, 'debug');
|
||||
await fastify.inject({
|
||||
url: '/status/ping'
|
||||
});
|
||||
|
||||
spies.forEach(spy => {
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should debug log if the origin is undefined', async () => {
|
||||
const spies = NON_DEBUG_LOG_LEVELS.map(level =>
|
||||
vi.spyOn(fastify.log, level)
|
||||
);
|
||||
const debugSpy = vi.spyOn(fastify.log, 'debug');
|
||||
await fastify.inject({
|
||||
url: '/api/some-endpoint'
|
||||
});
|
||||
|
||||
spies.forEach(spy => {
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(debugSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should warn on a request from a disallowed origin', async () => {
|
||||
const warnSpy = vi.spyOn(fastify.log, 'warn');
|
||||
await fastify.inject({
|
||||
url: '/api/some-endpoint',
|
||||
headers: { origin: 'https://disallowed.example.com' }
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
{ origin: 'https://disallowed.example.com' },
|
||||
'Received request from disallowed origin'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { HOME_LOCATION } from '../utils/env.js';
|
||||
import { allowedOrigins } from '../utils/allowed-origins.js';
|
||||
|
||||
const cors: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.options('*', (_req, reply) => {
|
||||
void reply.send();
|
||||
});
|
||||
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
req.log.debug({ origin }, 'Allowing access to origin');
|
||||
void reply.header('Access-Control-Allow-Origin', origin);
|
||||
} else {
|
||||
// TODO: Discuss if this is the correct approach. Standard practice is to
|
||||
// reflect one of a list of allowed origins and handle development
|
||||
// separately. If we switch to that approach we can replace use
|
||||
// @fastify/cors instead.
|
||||
void reply.header('Access-Control-Allow-Origin', HOME_LOCATION);
|
||||
|
||||
if (origin && !req.url?.startsWith('/status/')) {
|
||||
req.log.warn({ origin }, 'Received request from disallowed origin');
|
||||
} else {
|
||||
req.log.debug({ origin }, 'Unknown or missing origin');
|
||||
}
|
||||
}
|
||||
|
||||
void reply
|
||||
.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Csrf-Token, Coderoad-User-Token, Exam-Environment-Authorization-Token'
|
||||
)
|
||||
.header('Access-Control-Allow-Credentials', true)
|
||||
// These 4 are the only methods we use
|
||||
.header('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE')
|
||||
// Vary: Origin to prevent cache poisoning
|
||||
// TODO: do we need Vary: Accept-Encoding?
|
||||
.header('Vary', 'Origin, Accept-Encoding');
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(cors);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import { COOKIE_DOMAIN } from '../utils/env.js';
|
||||
import cookies from './cookies.js';
|
||||
import csrf, { CSRF_COOKIE, CSRF_SECRET_COOKIE } from './csrf.js';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
COOKIE_DOMAIN: 'www.example.com',
|
||||
FREECODECAMP_NODE_ENV: 'production'
|
||||
};
|
||||
});
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify({ logger: true, disableRequestLogging: true });
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(csrf);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
|
||||
fastify.get('/', (_req, reply) => {
|
||||
void reply.send({ foo: 'bar' });
|
||||
});
|
||||
return fastify;
|
||||
}
|
||||
|
||||
describe('CSRF protection', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
test('should receive a new CSRF token with the expected properties', async () => {
|
||||
const response = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
const newCookies = response.cookies;
|
||||
const csrfTokenCookie = newCookies.find(
|
||||
cookie => cookie.name === CSRF_COOKIE
|
||||
);
|
||||
|
||||
const { value, ...rest } = csrfTokenCookie!;
|
||||
|
||||
// The value is a random string - it's enough to check that it's not empty
|
||||
expect(value).toHaveLength(52);
|
||||
|
||||
expect(rest).toStrictEqual({
|
||||
name: CSRF_COOKIE,
|
||||
path: '/',
|
||||
sameSite: 'Strict',
|
||||
domain: COOKIE_DOMAIN,
|
||||
secure: true
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 403 if the _csrf secret is missing', async () => {
|
||||
const response = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(403);
|
||||
// The response body is determined by the error-handling plugin, so we don't
|
||||
// check it here.
|
||||
});
|
||||
|
||||
test('should return 403 if the csrf_token is invalid', async () => {
|
||||
const response = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
cookies: {
|
||||
_csrf: 'foo',
|
||||
csrf_token: 'bar'
|
||||
}
|
||||
});
|
||||
expect(response.statusCode).toEqual(403);
|
||||
});
|
||||
|
||||
test('should allow the request if the csrf_token is valid', async () => {
|
||||
const csrfResponse = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
const csrfTokenCookie = csrfResponse.cookies.find(
|
||||
cookie => cookie.name === CSRF_COOKIE
|
||||
);
|
||||
const csrfSecretCookie = csrfResponse.cookies.find(
|
||||
cookie => cookie.name === CSRF_SECRET_COOKIE
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
cookies: {
|
||||
_csrf: csrfSecretCookie!.value
|
||||
},
|
||||
headers: {
|
||||
'csrf-token': csrfTokenCookie!.value
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ foo: 'bar' });
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import fastifyCsrfProtection from '@fastify/csrf-protection';
|
||||
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
export const CSRF_COOKIE = 'csrf_token';
|
||||
export const CSRF_HEADER = 'csrf-token';
|
||||
export const CSRF_SECRET_COOKIE = '_csrf';
|
||||
|
||||
/**
|
||||
* Plugin for preventing CSRF attacks.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
const csrf: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
void fastify.register(fastifyCsrfProtection, {
|
||||
// TODO: consider signing cookies. We don't on the api-server, but we could
|
||||
// as an extra layer of security.
|
||||
|
||||
///Ignore all other possible sources of CSRF
|
||||
// tokens since we know we can provide this one
|
||||
getToken: req => req.headers[CSRF_HEADER] as string,
|
||||
cookieOpts: { signed: false, sameSite: 'strict' },
|
||||
logLevel: 'silent'
|
||||
});
|
||||
|
||||
// All routes except signout should add a CSRF token to the response
|
||||
fastify.addHook('onRequest', (req, reply, done) => {
|
||||
const isSignout = req.url === '/signout' || req.url === '/signout/';
|
||||
|
||||
if (!isSignout) {
|
||||
req.log.trace('Adding CSRF token to response');
|
||||
const token = reply.generateCsrf();
|
||||
void reply.setCookie(CSRF_COOKIE, token, {
|
||||
sameSite: 'strict',
|
||||
signed: false,
|
||||
// it needs to be read by the client, so that it can be sent in the
|
||||
// header of the next request:
|
||||
httpOnly: false
|
||||
});
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(csrf, { dependencies: ['cookies'] });
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyError, type FastifyInstance } from 'fastify';
|
||||
import accepts from '@fastify/accepts';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
vi.mock('../utils/env.js', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
SENTRY_DSN: 'https://anything@goes/123'
|
||||
};
|
||||
});
|
||||
|
||||
import '../instrument';
|
||||
import errorHandling, { isExpectedClientError } from './error-handling.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
describe('errorHandling', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(accepts);
|
||||
await fastify.register(errorHandling);
|
||||
|
||||
fastify.get('/test', async (_req, _reply) => {
|
||||
const error = Error('a very bad thing happened') as FastifyError;
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
});
|
||||
fastify.get('/test-bad-request', async (_req, _reply) => {
|
||||
const error = Error('a very bad thing happened') as FastifyError;
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
});
|
||||
fastify.get('/test-csrf-token', async (_req, _reply) => {
|
||||
const error = Error() as FastifyError;
|
||||
error.code = 'FST_CSRF_INVALID_TOKEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
});
|
||||
|
||||
fastify.get('/test-csrf-secret', async (_req, _reply) => {
|
||||
const error = Error() as FastifyError;
|
||||
error.code = 'FST_CSRF_MISSING_SECRET';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should redirect to the referer if the request does not Accept json', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
accept: 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should add a generic flash message if it is a server error (i.e. 500+)', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
accept: 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers['location']).toEqual(
|
||||
'https://www.freecodecamp.org/anything?' +
|
||||
formatMessage({
|
||||
type: 'danger',
|
||||
content: 'flash.generic-error'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should return a json response if the request does Accept json', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
accept: 'application/json,text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(500);
|
||||
expect(res.json()).toEqual({
|
||||
message: 'flash.generic-error',
|
||||
type: 'danger'
|
||||
});
|
||||
});
|
||||
|
||||
test('should redirect if the request prefers text/html to json', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
// this does accept json, (via the */*), but prefers text/html
|
||||
accept: 'text/html,*/*'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should respect the error status code', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-bad-request'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(400);
|
||||
});
|
||||
|
||||
test('should return the error message if the status is not 500', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-bad-request'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
message: 'a very bad thing happened',
|
||||
type: 'danger'
|
||||
});
|
||||
});
|
||||
|
||||
test('should convert CSRF errors to a generic error message', async () => {
|
||||
const resToken = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-csrf-token'
|
||||
});
|
||||
const resSecret = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-csrf-secret'
|
||||
});
|
||||
|
||||
expect(resToken.json()).toEqual({
|
||||
message: 'flash.generic-error',
|
||||
type: 'danger'
|
||||
});
|
||||
expect(resSecret.json()).toEqual({
|
||||
message: 'flash.generic-error',
|
||||
type: 'danger'
|
||||
});
|
||||
});
|
||||
|
||||
test('should call fastify.log.error when an unhandled error occurs', async () => {
|
||||
const logSpy = vi.spyOn(fastify.log, 'error');
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.7',
|
||||
'cf-ipcountry': 'US'
|
||||
}
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({
|
||||
message: 'a very bad thing happened'
|
||||
}) as unknown,
|
||||
ip: '203.0.113.7',
|
||||
country: 'US'
|
||||
}),
|
||||
'Error in request'
|
||||
);
|
||||
});
|
||||
|
||||
test('should call fastify.log.warn when a bad request error occurs', async () => {
|
||||
const logSpy = vi.spyOn(fastify.log, 'warn');
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-bad-request'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({
|
||||
message: 'a very bad thing happened'
|
||||
}) as unknown
|
||||
}),
|
||||
'Client error in request'
|
||||
);
|
||||
});
|
||||
|
||||
test('should NOT log when a CSRF error is thrown', async () => {
|
||||
const errorLogSpy = vi.spyOn(fastify.log, 'error');
|
||||
const warnLogSpy = vi.spyOn(fastify.log, 'warn');
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-csrf-token'
|
||||
});
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warnLogSpy).not.toHaveBeenCalled();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-csrf-secret'
|
||||
});
|
||||
|
||||
expect(errorLogSpy).not.toHaveBeenCalled();
|
||||
expect(warnLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('counts a security.csrf_rejected metric with the error code as reason', async () => {
|
||||
const count = vi.fn();
|
||||
fastify.Sentry = {
|
||||
...fastify.Sentry,
|
||||
metrics: { ...fastify.Sentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastify.inject({ method: 'GET', url: '/test-csrf-token' });
|
||||
|
||||
expect(count).toHaveBeenCalledWith('security.csrf_rejected', 1, {
|
||||
attributes: { reason: 'FST_CSRF_INVALID_TOKEN' }
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentry integration', () => {
|
||||
let mockServer: ReturnType<typeof setupServer>;
|
||||
|
||||
beforeAll(() => {
|
||||
// The assumption is that Sentry is the only library making requests. Also, we
|
||||
// only want to know if a request was made, not what it was.
|
||||
const sentryHandler = http.post('*', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
);
|
||||
mockServer = setupServer(sentryHandler);
|
||||
mockServer.listen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockServer.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockServer.close();
|
||||
});
|
||||
|
||||
const createRequestListener = () =>
|
||||
new Promise(resolve => {
|
||||
mockServer.events.on('request:start', () => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.todo('should capture the error with Sentry', async () => {
|
||||
const receivedRequest = createRequestListener();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(await Promise.race([receivedRequest, delay(2000)])).toBe(true);
|
||||
});
|
||||
|
||||
test('should NOT capture CSRF token errors with Sentry', async () => {
|
||||
const receivedRequest = createRequestListener();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-csrf-token'
|
||||
});
|
||||
|
||||
expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should NOT capture CSRF secret errors with Sentry', async () => {
|
||||
const receivedRequest = createRequestListener();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-csrf-secret'
|
||||
});
|
||||
|
||||
expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should NOT capture bad requests with Sentry', async () => {
|
||||
const receivedRequest = createRequestListener();
|
||||
|
||||
await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-bad-request'
|
||||
});
|
||||
|
||||
expect(await Promise.race([receivedRequest, delay(200)])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpectedClientError', () => {
|
||||
test('should return true for a 404 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 404 })).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for a 400 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 400 })).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for a 500 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for a 503 status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: 503 })).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for an error with no status code', () => {
|
||||
expect(isExpectedClientError(new Error())).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for null', () => {
|
||||
expect(isExpectedClientError(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for undefined', () => {
|
||||
expect(isExpectedClientError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for a non-numeric status code', () => {
|
||||
expect(isExpectedClientError({ statusCode: '404' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FastifyError, FastifyPluginCallback } from 'fastify';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { getRedirectParams } from '../utils/redirection.js';
|
||||
import { clientNetInfo } from '../utils/logger.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
Sentry: typeof Sentry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to handle errors and send them to Sentry.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const isExpectedClientError = (error: unknown): boolean =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'statusCode' in error &&
|
||||
typeof (error as { statusCode?: unknown }).statusCode === 'number' &&
|
||||
(error as { statusCode: number }).statusCode < 500;
|
||||
|
||||
const errorHandling: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
Sentry.setupFastifyErrorHandler(fastify, {
|
||||
shouldHandleError: error => !isExpectedClientError(error)
|
||||
});
|
||||
|
||||
fastify.decorate('Sentry', Sentry);
|
||||
|
||||
fastify.setErrorHandler((error: FastifyError, request, reply) => {
|
||||
const accepts = request.accepts().type(['json', 'html']);
|
||||
const { returnTo } = getRedirectParams(request);
|
||||
|
||||
if (!reply.statusCode || reply.statusCode === 200) {
|
||||
const statusCode =
|
||||
error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
reply.code(statusCode);
|
||||
}
|
||||
|
||||
const isCSRFError =
|
||||
error.code === 'FST_CSRF_INVALID_TOKEN' ||
|
||||
error.code === 'FST_CSRF_MISSING_SECRET';
|
||||
|
||||
if (!isCSRFError) {
|
||||
const context = { err: error, ...clientNetInfo(request) };
|
||||
if (reply.statusCode >= 500) {
|
||||
request.log.error(context, 'Error in request');
|
||||
} else {
|
||||
request.log.warn(context, 'Client error in request');
|
||||
}
|
||||
} else {
|
||||
fastify.Sentry?.metrics?.count('security.csrf_rejected', 1, {
|
||||
attributes: { reason: error.code }
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
reply.statusCode === 500 || isCSRFError
|
||||
? 'flash.generic-error'
|
||||
: error.message;
|
||||
if (accepts === 'json') {
|
||||
void reply.send({
|
||||
message,
|
||||
type: 'danger'
|
||||
});
|
||||
} else {
|
||||
void reply.status(302);
|
||||
void reply.redirectWithMessage(returnTo, {
|
||||
type: 'danger',
|
||||
content: message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(errorHandling, {
|
||||
dependencies: ['redirect-with-message', '@fastify/accepts']
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import growthBook from './growth-book.js';
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
|
||||
describe('growth-book', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeAll(() => {
|
||||
fastify = Fastify();
|
||||
// @ts-expect-error we're mocking the Sentry plugin
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should log and capture the error if the GrowthBook initialization fails', async () => {
|
||||
const spy = vi.spyOn(fastify.log, 'error');
|
||||
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: 'invalid-url',
|
||||
clientKey: 'invalid-key'
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(captureException).toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith('growthbook.init_failed', 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { GrowthBook, Options } from '@growthbook/growthbook';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
gb: GrowthBook;
|
||||
}
|
||||
}
|
||||
|
||||
const growthBook: FastifyPluginAsync<Options> = async (fastify, options) => {
|
||||
const gb = new GrowthBook(options);
|
||||
|
||||
const hasRequiredConfig = Boolean(options.clientKey && options.apiHost);
|
||||
|
||||
if (hasRequiredConfig) {
|
||||
const res = await gb.init({ timeout: 3000 });
|
||||
|
||||
if (res.error) {
|
||||
fastify.log.error(res.error, 'Failed to initialize GrowthBook');
|
||||
fastify.Sentry?.captureException(res.error);
|
||||
fastify.Sentry?.metrics?.count('growthbook.init_failed', 1);
|
||||
}
|
||||
}
|
||||
|
||||
fastify.decorate('gb', gb);
|
||||
|
||||
fastify.addHook('onClose', () => {
|
||||
gb.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
export default fp(growthBook);
|
||||
@@ -0,0 +1,78 @@
|
||||
import nodemailer, { Transporter } from 'nodemailer';
|
||||
|
||||
import { MailProvider, SendEmailArgs } from '../mailer.js';
|
||||
import {
|
||||
EMAIL_PROVIDER,
|
||||
MAILPIT_HOST,
|
||||
SES_SMTP_HOST,
|
||||
SES_SMTP_USERNAME,
|
||||
SES_SMTP_PASSWORD
|
||||
} from '../../utils/env.js';
|
||||
|
||||
export type NodemailerConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
* NodemailerProvider is a wrapper around nodemailer that provides a clean
|
||||
* interface for sending email.
|
||||
*/
|
||||
export class NodemailerProvider implements MailProvider {
|
||||
private transporter: Transporter;
|
||||
|
||||
/**
|
||||
* Sets up nodemailer with the provided configuration.
|
||||
*
|
||||
* @param config - The nodemailer transport configuration.
|
||||
*/
|
||||
constructor(config: NodemailerConfig) {
|
||||
this.transporter = nodemailer.createTransport(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email using nodemailer.
|
||||
*
|
||||
* @param param Email options.
|
||||
* @param param.to Email address to send to.
|
||||
* @param param.from Email address to send from.
|
||||
* @param param.subject Email subject.
|
||||
* @param param.text Email body (raw text only).
|
||||
* @param param.cc [Optional] Email address to CC.
|
||||
*/
|
||||
async send({ to, from, subject, text, cc }: SendEmailArgs) {
|
||||
await this.transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
cc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mail provider based on the EMAIL_PROVIDER environment variable.
|
||||
*/
|
||||
export function createMailProvider(): NodemailerProvider {
|
||||
return EMAIL_PROVIDER === 'ses'
|
||||
? new NodemailerProvider({
|
||||
host: SES_SMTP_HOST,
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: SES_SMTP_USERNAME ?? '',
|
||||
pass: SES_SMTP_PASSWORD ?? ''
|
||||
}
|
||||
})
|
||||
: new NodemailerProvider({
|
||||
host: MAILPIT_HOST,
|
||||
port: 1025,
|
||||
secure: false,
|
||||
auth: { user: 'test', pass: 'test' },
|
||||
tls: { rejectUnauthorized: false }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
|
||||
import mailer from './mailer.js';
|
||||
|
||||
describe('mailer', () => {
|
||||
test('should send an email via the provider', async () => {
|
||||
const fastify = Fastify();
|
||||
const send = vi.fn();
|
||||
await fastify.register(mailer, { provider: { send } });
|
||||
|
||||
const data = {
|
||||
to: 'test@add.ress',
|
||||
from: 'team@freecodecamp.org',
|
||||
subject: 'test',
|
||||
text: 'test'
|
||||
};
|
||||
|
||||
await fastify.sendEmail(data);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
test('should emit a Sentry counter and re-throw when the provider fails to send', async () => {
|
||||
const fastify = Fastify();
|
||||
const sendError = new Error('send failed');
|
||||
const send = vi.fn().mockRejectedValue(sendError);
|
||||
await fastify.register(mailer, { provider: { send } });
|
||||
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { metrics: { count } };
|
||||
|
||||
const data = {
|
||||
to: 'test@add.ress',
|
||||
from: 'team@freecodecamp.org',
|
||||
subject: 'test',
|
||||
text: 'test'
|
||||
};
|
||||
|
||||
await expect(fastify.sendEmail(data)).rejects.toThrow(sendError);
|
||||
|
||||
expect(count).toHaveBeenCalledWith('mailer.send_failed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
sendEmail: SendEmail;
|
||||
}
|
||||
}
|
||||
|
||||
export type SendEmailArgs = {
|
||||
to: string;
|
||||
from: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
cc?: string;
|
||||
};
|
||||
|
||||
type SendEmail = (args: SendEmailArgs) => Promise<void>;
|
||||
|
||||
export interface MailProvider {
|
||||
send: SendEmail;
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback<{ provider: MailProvider }> = (
|
||||
fastify,
|
||||
options,
|
||||
done
|
||||
) => {
|
||||
const { provider } = options;
|
||||
|
||||
fastify.decorate('sendEmail', async (args: SendEmailArgs) => {
|
||||
fastify.log.info({ subject: args.subject }, 'Sending email');
|
||||
try {
|
||||
return await provider.send(args);
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.count('mailer.send_failed', 1, {
|
||||
attributes: { result: 'error' }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, beforeEach, afterEach, it, expect } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import accepts from '@fastify/accepts';
|
||||
|
||||
import notFound from './not-found.js';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message.js';
|
||||
|
||||
describe('fourOhFour', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(accepts);
|
||||
await fastify.register(notFound);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
it('should redirect to origin/404 if the request does not Accept json', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
accept: 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers['location']).toEqual(
|
||||
'https://www.freecodecamp.org/404?' +
|
||||
formatMessage({
|
||||
type: 'danger',
|
||||
content: "We couldn't find path /test"
|
||||
})
|
||||
);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
it('should return a 404 json response if the request does Accept json', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
accept: 'application/json,text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ error: 'path not found' });
|
||||
expect(res.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('should redirect if the request prefers text/html to json', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
referer: 'https://www.freecodecamp.org/anything',
|
||||
// this does accept json, (via the */*), but prefers text/html
|
||||
accept: 'text/html,*/*'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers['location']).toEqual(
|
||||
'https://www.freecodecamp.org/404?' +
|
||||
formatMessage({
|
||||
type: 'danger',
|
||||
content: "We couldn't find path /test"
|
||||
})
|
||||
);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { getRedirectParams } from '../utils/redirection.js';
|
||||
|
||||
/**
|
||||
* Plugin for handling missing endpoints.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
const fourOhFour: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
// If the request accepts JSON and does not specifically prefer text/html,
|
||||
// this will return a 404 JSON response. Everything else will be redirected.
|
||||
fastify.setNotFoundHandler((req, reply) => {
|
||||
req.log.debug('User requested path that does not exist');
|
||||
|
||||
const accepted = req.accepts().type(['json', 'html']);
|
||||
if (accepted == 'json') {
|
||||
void reply.code(404).send({ error: 'path not found' });
|
||||
} else {
|
||||
const { origin } = getRedirectParams(req);
|
||||
void reply.status(302);
|
||||
void reply.redirectWithMessage(`${origin}/404`, {
|
||||
type: 'danger',
|
||||
content: `We couldn't find path ${req.url}`
|
||||
});
|
||||
}
|
||||
});
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(fourOhFour, {
|
||||
dependencies: ['redirect-with-message', '@fastify/accepts']
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect, beforeEach } from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import qs from 'query-string';
|
||||
|
||||
import redirectWithMessage from './redirect-with-message.js';
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(redirectWithMessage);
|
||||
return fastify;
|
||||
}
|
||||
|
||||
const isString = (value: unknown): value is string => {
|
||||
return typeof value === 'string';
|
||||
};
|
||||
|
||||
describe('redirectWithMessage plugin', () => {
|
||||
test('should decorate reply object with redirectWithMessage method', async () => {
|
||||
expect.assertions(3);
|
||||
|
||||
const fastify = await setupServer();
|
||||
|
||||
fastify.get('/', (_req, reply) => {
|
||||
expect(reply).toHaveProperty('redirectWithMessage');
|
||||
expect(reply.redirectWithMessage).toBeInstanceOf(Function);
|
||||
return { foo: 'bar' };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
describe('redirectWithMessage method', () => {
|
||||
let fastify: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
test('should redirect to the first argument', async () => {
|
||||
fastify.get('/', (_req, reply) => {
|
||||
return reply.redirectWithMessage('/target', {
|
||||
type: 'info',
|
||||
content: 'foo'
|
||||
});
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(/^\/target/);
|
||||
expect(res.statusCode).toEqual(302);
|
||||
});
|
||||
|
||||
test('should convert the second argument into a query string', async () => {
|
||||
fastify.get('/', (_req, reply) => {
|
||||
return reply.redirectWithMessage('/target', {
|
||||
type: 'info',
|
||||
content: 'foo'
|
||||
});
|
||||
});
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toMatch(/^\/target\?messages=info/);
|
||||
});
|
||||
|
||||
test('should encode the message twice when creating the query string', async () => {
|
||||
const expectedMessage = { danger: ['foo bar'] };
|
||||
|
||||
fastify.get('/', (_req, reply) => {
|
||||
return reply.redirectWithMessage('/target', {
|
||||
type: 'danger',
|
||||
content: 'foo bar'
|
||||
});
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/'
|
||||
});
|
||||
if (!isString(res.headers.location))
|
||||
throw new Error('Location is not a string');
|
||||
const { search } = new URL(res.headers.location, 'http://localhost');
|
||||
|
||||
// The query string itself is encoded:
|
||||
const { messages } = qs.parse(search, { arrayFormat: 'index' });
|
||||
if (!isString(messages)) throw new Error('Messages is not a string');
|
||||
|
||||
// As is the message embedded in it:
|
||||
expect(qs.parse(messages, { arrayFormat: 'index' })).toEqual(
|
||||
expectedMessage
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { FastifyPluginCallback, type FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
// TODO: (POST MVP)use node's querystring and just JSON stringify the message.
|
||||
// No need for query-string on either side.
|
||||
import qs from 'query-string';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
redirectWithMessage: typeof redirectWithMessage;
|
||||
}
|
||||
}
|
||||
|
||||
type Message = {
|
||||
type: 'info' | 'danger' | 'success' | 'errors';
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MessageQuery = {
|
||||
info?: string[];
|
||||
danger?: string[];
|
||||
success?: string[];
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
// The client expects a message like { info: ['foo'] }, { danger: ['bar'] } etc.
|
||||
const prepareMessage = (message: Message): MessageQuery => ({
|
||||
[message.type]: [message.content]
|
||||
});
|
||||
|
||||
function redirectWithMessage(
|
||||
this: FastifyReply,
|
||||
url: string,
|
||||
message: Message
|
||||
) {
|
||||
return this.redirect(`${url}?${formatMessage(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the message into a querystring.
|
||||
* @param message The message to format.
|
||||
* @returns The formatted message string.
|
||||
*/
|
||||
export function formatMessage(message: Message): string {
|
||||
return qs.stringify(
|
||||
{
|
||||
messages: qs.stringify(prepareMessage(message), {
|
||||
arrayFormat: 'index'
|
||||
})
|
||||
},
|
||||
{ arrayFormat: 'index' }
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorateReply('redirectWithMessage', redirectWithMessage);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin, { name: 'redirect-with-message' });
|
||||
@@ -0,0 +1,33 @@
|
||||
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 15_000;
|
||||
|
||||
const runtimeMetrics: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||
loopDelay.enable();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
fastify.Sentry?.metrics?.gauge(
|
||||
'runtime.memory_rss_bytes',
|
||||
process.memoryUsage().rss
|
||||
);
|
||||
fastify.Sentry?.metrics?.gauge(
|
||||
'runtime.event_loop_delay_p99_ms',
|
||||
loopDelay.percentile(99) / 1e6
|
||||
);
|
||||
loopDelay.reset();
|
||||
}, SAMPLE_INTERVAL_MS);
|
||||
timer.unref();
|
||||
|
||||
fastify.addHook('onClose', (_instance, hookDone) => {
|
||||
clearInterval(timer);
|
||||
loopDelay.disable();
|
||||
hookDone();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(runtimeMetrics, { name: 'runtime-metrics' });
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { FREECODECAMP_NODE_ENV } from '../utils/env.js';
|
||||
|
||||
const securityHeaders: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
// OWASP recommended headers
|
||||
fastify.addHook('onRequest', async (req, reply) => {
|
||||
void reply
|
||||
.header('Cache-Control', 'no-store')
|
||||
.header('Content-Security-Policy', "frame-ancestors 'none'")
|
||||
.header('X-Content-Type-Options', 'nosniff')
|
||||
.header('X-Frame-Options', 'DENY');
|
||||
// TODO: Increase this gradually to 2 years. Include preload once it is
|
||||
// at least 1 year.
|
||||
if (FREECODECAMP_NODE_ENV === 'production') {
|
||||
void reply.header(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=300; includeSubDomains'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(securityHeaders);
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
vi.mock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
TPA_API_BEARER_TOKEN: 'test-api-secret-key'
|
||||
};
|
||||
});
|
||||
|
||||
import serviceBearerAuth from './service-bearer-auth.js';
|
||||
|
||||
describe('service-bearer-auth plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
let captureException: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(serviceBearerAuth);
|
||||
captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
test('should allow request with valid bearer token', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer test-api-secret-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 401 when authorization header is missing', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
|
||||
test('should return 401 when authorization header has no Bearer prefix', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'test-api-secret-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
|
||||
test('should return 401 when bearer token is empty', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer '
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
|
||||
test('should return 401 when bearer token is wrong', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer wrong-key'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
|
||||
test('should return 401 when bearer token is the same length but wrong', async () => {
|
||||
const sameLengthWrongToken = 'x'.repeat('test-api-secret-key'.length);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: `Bearer ${sameLengthWrongToken}`
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.json()).toEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('service-bearer-auth plugin without a configured token', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../utils/env');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test('should return 500 when TPA_API_BEARER_TOKEN is not configured', async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../utils/env.js')>();
|
||||
return { ...actual, TPA_API_BEARER_TOKEN: '' };
|
||||
});
|
||||
|
||||
const { default: plugin } = await import('./service-bearer-auth.js');
|
||||
const fastify = Fastify();
|
||||
await fastify.register(plugin);
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error Sentry isn't decorated in this minimal test app.
|
||||
fastify.Sentry = { captureException };
|
||||
fastify.addHook('onRequest', fastify.validateBearerToken);
|
||||
fastify.get('/test', (_req, reply) => {
|
||||
void reply.send({ ok: true });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
authorization: 'Bearer anything'
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(500);
|
||||
expect(res.json()).toEqual({
|
||||
error: 'Service authentication not configured'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledTimes(1);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
new Error('TPA_API_BEARER_TOKEN is not configured')
|
||||
);
|
||||
|
||||
await fastify.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type {
|
||||
FastifyPluginCallback,
|
||||
FastifyRequest,
|
||||
FastifyReply
|
||||
} from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { TPA_API_BEARER_TOKEN } from '../utils/env.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
validateBearerToken: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorate(
|
||||
'validateBearerToken',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
const secret = TPA_API_BEARER_TOKEN ?? '';
|
||||
if (secret.length === 0) {
|
||||
req.log.error('TPA_API_BEARER_TOKEN is not configured');
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('TPA_API_BEARER_TOKEN is not configured')
|
||||
);
|
||||
await reply
|
||||
.status(500)
|
||||
.send({ error: 'Service authentication not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
await reply.status(401).send({ error: 'Bearer token is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const tokenBuf = Buffer.from(token);
|
||||
const secretBuf = Buffer.from(secret);
|
||||
if (
|
||||
tokenBuf.length !== secretBuf.length ||
|
||||
!crypto.timingSafeEqual(tokenBuf, secretBuf)
|
||||
) {
|
||||
await reply.status(401).send({ error: 'Invalid bearer token' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin, { name: 'service-bearer-auth' });
|
||||
@@ -0,0 +1,139 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { appendFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type {
|
||||
FastifyPluginCallback,
|
||||
FastifyReply,
|
||||
FastifyRequest
|
||||
} from 'fastify';
|
||||
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
const LOGS_DIRECTORY = 'logs';
|
||||
const REQUEST_CAPTURE_FILE = 'request-capture.jsonl';
|
||||
const RESPONSE_CAPTURE_FILE = 'response-capture.jsonl';
|
||||
|
||||
let REQUEST_BUFFER: unknown[] = [];
|
||||
let RESPONSE_BUFFER: unknown[] = [];
|
||||
|
||||
/**
|
||||
* Plugin for capturing requests and responses to allow shadow testing.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
const shadowCapture: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
mkdirSync(LOGS_DIRECTORY, { recursive: true });
|
||||
fastify.addHook('onRequest', (req, rep, done) => {
|
||||
// Attach timestamp at beginning of lifecycle
|
||||
// @ts-expect-error Exists
|
||||
req.__timestamp = Date.now();
|
||||
|
||||
// Give request and response same id to match.
|
||||
const id = randomUUID();
|
||||
// @ts-expect-error Exists
|
||||
req.__id = id;
|
||||
// @ts-expect-error Exists
|
||||
rep.__id = id;
|
||||
done();
|
||||
});
|
||||
|
||||
// Body is only included after `Parsing` lifecycle
|
||||
fastify.addHook('preValidation', (req, rep, done) => {
|
||||
captureRequest(req);
|
||||
done();
|
||||
});
|
||||
|
||||
fastify.addHook('onSend', async (_req, rep, payload) => {
|
||||
// @ts-expect-error Exists
|
||||
rep.__payload = payload;
|
||||
return payload;
|
||||
});
|
||||
|
||||
fastify.addHook('onResponse', (_req, rep, done) => {
|
||||
captureReply(rep);
|
||||
done();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
function captureRequest(req: FastifyRequest) {
|
||||
const savedRequest = {
|
||||
// @ts-expect-error Exists
|
||||
id: req.__id,
|
||||
// @ts-expect-error Exists
|
||||
timestamp: req.__timestamp,
|
||||
url: req.url,
|
||||
headers: omit(req.headers, 'cookie'),
|
||||
cookies: include(req.cookies, '_csrf', 'csrf_token', 'jwt_access_token'),
|
||||
user: req.user,
|
||||
body: req.body
|
||||
};
|
||||
|
||||
if (REQUEST_BUFFER.length > 10) {
|
||||
appendFileSync(
|
||||
join(LOGS_DIRECTORY, REQUEST_CAPTURE_FILE),
|
||||
REQUEST_BUFFER.map(rb => JSON.stringify(rb)).join('\n') + '\n'
|
||||
);
|
||||
REQUEST_BUFFER = [savedRequest];
|
||||
} else {
|
||||
REQUEST_BUFFER.push(savedRequest);
|
||||
}
|
||||
}
|
||||
|
||||
function captureReply(rep: FastifyReply) {
|
||||
const savedReply = {
|
||||
// @ts-expect-error Exists
|
||||
id: rep.__id,
|
||||
headers: rep.getHeaders(),
|
||||
timestamp: Date.now(),
|
||||
// @ts-expect-error Exists
|
||||
payload: rep.__payload,
|
||||
statusCode: rep.statusCode
|
||||
};
|
||||
|
||||
if (RESPONSE_BUFFER.length > 10) {
|
||||
appendFileSync(
|
||||
join(LOGS_DIRECTORY, RESPONSE_CAPTURE_FILE),
|
||||
RESPONSE_BUFFER.map(rb => JSON.stringify(rb)).join('\n') + '\n'
|
||||
);
|
||||
RESPONSE_BUFFER = [savedReply];
|
||||
} else {
|
||||
RESPONSE_BUFFER.push(savedReply);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a subset of the given object with the values or properties given removed.
|
||||
* @param obj - An array or an object literal.
|
||||
* @param vals - Items or properties to exclude from `obj`.
|
||||
* @returns Subset of `obj`.
|
||||
*/
|
||||
function omit(obj: Record<string, unknown> | unknown[], ...vals: unknown[]) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.filter(o => !vals.includes(o));
|
||||
} else {
|
||||
return Object.keys(obj)
|
||||
.filter(k => {
|
||||
return !vals.includes(k);
|
||||
})
|
||||
.reduce((acc, curr) => ({ ...acc, [curr]: obj[curr] }), {});
|
||||
}
|
||||
}
|
||||
|
||||
function include(obj: Record<string, unknown> | unknown[], ...vals: unknown[]) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.filter(o => vals.includes(o));
|
||||
} else {
|
||||
return Object.keys(obj)
|
||||
.filter(k => {
|
||||
return vals.includes(k);
|
||||
})
|
||||
.reduce((acc, curr) => ({ ...acc, [curr]: obj[curr] }), {});
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(shadowCapture);
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import '@total-typescript/ts-reset';
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, test, expect, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../utils/env', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../utils/env.js')>();
|
||||
return {
|
||||
...actual,
|
||||
TPA_API_BEARER_TOKEN: 'test-classroom-api-secret',
|
||||
FCC_ENABLE_CLASSROOM: true
|
||||
};
|
||||
});
|
||||
|
||||
import request from 'supertest';
|
||||
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
import {
|
||||
defaultUserEmail,
|
||||
defaultUserId,
|
||||
resetDefaultUser,
|
||||
setupServer
|
||||
} from '../../../vitest.utils.js';
|
||||
|
||||
const BEARER_TOKEN = 'test-classroom-api-secret';
|
||||
|
||||
const classroomUserEmail = 'student1@example.com';
|
||||
const nonClassroomUserEmail = 'student2@example.com';
|
||||
const classroomUserId = '000000000000000000000001';
|
||||
const nonClassroomUserId = '000000000000000000000002';
|
||||
|
||||
function post(url: string) {
|
||||
return request(fastifyTestInstance.server)
|
||||
.post(url)
|
||||
.set('authorization', `Bearer ${BEARER_TOKEN}`);
|
||||
}
|
||||
|
||||
describe('classroom routes', () => {
|
||||
setupServer();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: { in: [classroomUserEmail, nonClassroomUserEmail] } }
|
||||
});
|
||||
|
||||
await resetDefaultUser();
|
||||
});
|
||||
|
||||
describe('Without bearer token', () => {
|
||||
test('POST get-user-id returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-id')
|
||||
.send({ email: 'someone@example.com' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
|
||||
test('POST get-user-data returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-data')
|
||||
.send({ userIds: [defaultUserId] });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Bearer token is required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('With wrong bearer token', () => {
|
||||
test('POST get-user-id returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-id')
|
||||
.set('authorization', 'Bearer wrong-key')
|
||||
.send({ email: 'someone@example.com' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
|
||||
test('POST get-user-data returns 401', async () => {
|
||||
const res = await request(fastifyTestInstance.server)
|
||||
.post('/apps/classroom/get-user-data')
|
||||
.set('authorization', 'Bearer wrong-key')
|
||||
.send({ userIds: [defaultUserId] });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toStrictEqual({ error: 'Invalid bearer token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authenticated with API key', () => {
|
||||
describe('POST /apps/classroom/get-user-id', () => {
|
||||
test('returns 400 for missing email', async () => {
|
||||
const res = await post('/apps/classroom/get-user-id').send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 400 for invalid email format', async () => {
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: 'not-an-email'
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 200 with empty userId when no classroom account matches email', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: defaultUserEmail
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toStrictEqual({ userId: '' });
|
||||
expect(count).toHaveBeenCalledWith('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 200 with userId for a classroom account', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isClassroomAccount: true }
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: defaultUserEmail
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toStrictEqual({ userId: defaultUserId });
|
||||
expect(count).toHaveBeenCalledWith('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'found' }
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 500 and captures when the database query fails', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const original = fastifyTestInstance.prisma.user.findFirst;
|
||||
fastifyTestInstance.prisma.user.findFirst = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('test')) as typeof original;
|
||||
|
||||
const res = await post('/apps/classroom/get-user-id').send({
|
||||
email: defaultUserEmail
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.findFirst = original;
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toStrictEqual({
|
||||
error: 'Failed to retrieve user id'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'test' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /apps/classroom/get-user-data', () => {
|
||||
test('returns 400 when more than 50 userIds are provided', async () => {
|
||||
const tooMany = Array.from(
|
||||
{ length: 51 },
|
||||
(_, i) => `${String(i).padStart(24, '0')}`
|
||||
);
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: tooMany
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 200 with empty data for empty userIds array', async () => {
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: []
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toStrictEqual({ data: {} });
|
||||
});
|
||||
|
||||
test('returns data only for classroom accounts', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
isClassroomAccount: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'challenge-default',
|
||||
completedDate: now,
|
||||
files: []
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...createUserInput(classroomUserEmail),
|
||||
id: classroomUserId,
|
||||
isClassroomAccount: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'challenge-student',
|
||||
completedDate: now + 1,
|
||||
files: []
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.user.create({
|
||||
data: {
|
||||
...createUserInput(nonClassroomUserEmail),
|
||||
id: nonClassroomUserId,
|
||||
isClassroomAccount: false,
|
||||
completedChallenges: []
|
||||
}
|
||||
});
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: [defaultUserId, classroomUserId, nonClassroomUserId]
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const responseBody = res.body as {
|
||||
data: Record<
|
||||
string,
|
||||
Array<{ id: string; completedDate: number }> | undefined
|
||||
>;
|
||||
};
|
||||
expect(Object.keys(responseBody.data)).toEqual(
|
||||
expect.arrayContaining([defaultUserId, classroomUserId])
|
||||
);
|
||||
expect(responseBody.data).not.toHaveProperty(nonClassroomUserId);
|
||||
|
||||
expect(responseBody.data[defaultUserId]?.[0]).toStrictEqual({
|
||||
id: 'challenge-default',
|
||||
completedDate: now
|
||||
});
|
||||
expect(responseBody.data[classroomUserId]?.[0]).toStrictEqual({
|
||||
id: 'challenge-student',
|
||||
completedDate: now + 1
|
||||
});
|
||||
});
|
||||
|
||||
test('response contains only id and completedDate', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
isClassroomAccount: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'challenge-shape-test',
|
||||
completedDate: now,
|
||||
solution: 'http://example.com/solution',
|
||||
files: [
|
||||
{
|
||||
contents: 'some code',
|
||||
ext: 'js',
|
||||
key: 'indexjs',
|
||||
name: 'index'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: [defaultUserId]
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const responseBody = res.body as {
|
||||
data: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
const challenge = responseBody.data[defaultUserId]![0]!;
|
||||
expect(Object.keys(challenge)).toStrictEqual(['id', 'completedDate']);
|
||||
});
|
||||
|
||||
test('returns 500 and captures when the database query fails', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const original = fastifyTestInstance.prisma.user.findMany;
|
||||
fastifyTestInstance.prisma.user.findMany = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('test')) as typeof original;
|
||||
|
||||
const res = await post('/apps/classroom/get-user-data').send({
|
||||
userIds: [defaultUserId]
|
||||
});
|
||||
|
||||
fastifyTestInstance.prisma.user.findMany = original;
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toStrictEqual({
|
||||
error: 'Failed to retrieve user data'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ message: 'test' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { normalizeDate } from '../../utils/normalize.js';
|
||||
import * as schemas from '../../schemas.js';
|
||||
|
||||
/**
|
||||
* Routes for the classroom app integration.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const classroomRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.post(
|
||||
'/get-user-id',
|
||||
{
|
||||
schema: schemas.classroomGetUserIdSchema
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findFirst({
|
||||
where: { email, isClassroomAccount: true },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
fastify.Sentry?.metrics?.count('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
return reply.send({ userId: '' });
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('classroom.user_looked_up', 1, {
|
||||
attributes: { result: 'found' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
userId: user.id
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
request.log.error(error, 'Failed to retrieve user id');
|
||||
return reply.code(500).send({ error: 'Failed to retrieve user id' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/get-user-data',
|
||||
{
|
||||
schema: schemas.classroomGetUserDataSchema
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { userIds } = request.body;
|
||||
|
||||
try {
|
||||
const users = await fastify.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
isClassroomAccount: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
completedChallenges: true
|
||||
}
|
||||
});
|
||||
|
||||
const userData: Record<
|
||||
string,
|
||||
{ id: string; completedDate: number }[]
|
||||
> = {};
|
||||
|
||||
users.forEach(user => {
|
||||
userData[user.id] = user.completedChallenges.map(challenge => ({
|
||||
id: challenge.id,
|
||||
completedDate: normalizeDate(challenge.completedDate)
|
||||
}));
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
data: userData
|
||||
});
|
||||
} catch (error) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
request.log.error(error, 'Failed to retrieve user data');
|
||||
return reply.code(500).send({ error: 'Failed to retrieve user data' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
|
||||
import db from '../../db/prisma.js';
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
import { checkCanConnectToDb } from '../../../vitest.utils.js';
|
||||
import { findOrCreateUser } from './auth-helpers.js';
|
||||
import { assignVariantBucket } from '../../utils/drip-campaign.js';
|
||||
import growthBook from '../../plugins/growth-book.js';
|
||||
import {
|
||||
GROWTHBOOK_FASTIFY_API_HOST,
|
||||
GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
} from '../../utils/env.js';
|
||||
|
||||
async function setupServer() {
|
||||
const fastify = Fastify();
|
||||
await fastify.register(db);
|
||||
await checkCanConnectToDb(fastify.prisma);
|
||||
await fastify.register(growthBook, {
|
||||
apiHost: GROWTHBOOK_FASTIFY_API_HOST,
|
||||
clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY
|
||||
});
|
||||
return fastify;
|
||||
}
|
||||
|
||||
describe('findOrCreateUser', () => {
|
||||
let fastify: FastifyInstance;
|
||||
const email = 'test@user.com';
|
||||
beforeAll(async () => {
|
||||
fastify = await setupServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.prisma.$runCommandRaw({ dropDatabase: 1 });
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.prisma.user.deleteMany({ where: { email } });
|
||||
await fastify.prisma.dripCampaign.deleteMany({ where: { email } });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('should log an error and capture an exception if there are multiple users with the same email', async () => {
|
||||
const user1 = await fastify.prisma.user.create({
|
||||
data: createUserInput(email)
|
||||
});
|
||||
const user2 = await fastify.prisma.user.create({
|
||||
data: createUserInput(email)
|
||||
});
|
||||
|
||||
const userIds = [user1.id, user2.id];
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ audit: true, userIds, email },
|
||||
'Multiple user records found'
|
||||
);
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
new Error('Multiple user records found for: ' + userIds.join(', '))
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith('user.duplicate_email_detected', 1);
|
||||
});
|
||||
|
||||
test('should NOT log an error or capture an exception if there is only one user with the email', async () => {
|
||||
await fastify.prisma.user.create({ data: createUserInput(email) });
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should NOT log an error or capture an exception if there are no users with the email', async () => {
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith('user.created', 1);
|
||||
});
|
||||
|
||||
describe('drip campaign logic', () => {
|
||||
test('should create a drip campaign record when a new user is created and feature flag is enabled', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { ...fastify.Sentry, metrics: { count } };
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
expect(dripCampaign).toBeDefined();
|
||||
expect(dripCampaign?.userId).toBe(user.id);
|
||||
expect(dripCampaign?.email).toBe(email);
|
||||
expect(['A', 'B']).toContain(dripCampaign?.variant);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'growthbook.signup_flag_evaluated',
|
||||
1,
|
||||
{
|
||||
attributes: { flag: 'drip-campaign', result: 'success' }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should assign a consistent variant based on userId', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
const expectedVariant = assignVariantBucket(user.id);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
expect(dripCampaign?.variant).toBe(expectedVariant);
|
||||
});
|
||||
|
||||
test('should not create a drip campaign record when feature flag is disabled', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => false);
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
const dripCampaign = await fastify.prisma.dripCampaign.findFirst({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
|
||||
expect(dripCampaign).toBeNull();
|
||||
});
|
||||
|
||||
test('should not prevent user creation if drip campaign record creation fails', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
// @ts-expect-error - Only mocks part of the Sentry object.
|
||||
fastify.Sentry = { captureException, metrics: { count } };
|
||||
|
||||
const originalCreate = fastify.prisma.dripCampaign.create;
|
||||
|
||||
fastify.prisma.dripCampaign.create = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Database error'));
|
||||
|
||||
const logError = vi.spyOn(fastify.log, 'error');
|
||||
|
||||
const user = await findOrCreateUser(fastify, email);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBeTruthy();
|
||||
|
||||
const dbError: unknown = expect.objectContaining({
|
||||
message: 'Database error'
|
||||
});
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ err: dbError, userId: user.id },
|
||||
'Failed to create drip campaign record for user'
|
||||
);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'growthbook.signup_flag_evaluated',
|
||||
1,
|
||||
{
|
||||
attributes: { flag: 'drip-campaign', result: 'failed' }
|
||||
}
|
||||
);
|
||||
|
||||
fastify.prisma.dripCampaign.create = originalCreate;
|
||||
});
|
||||
|
||||
test('should not create drip campaign for existing users', async () => {
|
||||
vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true);
|
||||
|
||||
// Create user first
|
||||
await fastify.prisma.user.create({ data: createUserInput(email) });
|
||||
|
||||
// Call findOrCreateUser for existing user
|
||||
await findOrCreateUser(fastify, email);
|
||||
|
||||
// Verify no drip campaign record was created
|
||||
const dripCampaigns = await fastify.prisma.dripCampaign.findMany({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
expect(dripCampaigns).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
import { assignVariantBucket } from '../../utils/drip-campaign.js';
|
||||
|
||||
/**
|
||||
* Finds an existing user with the given email or creates a new user if none exists.
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param email - The email of the user.
|
||||
* @returns The existing or newly created user.
|
||||
*/
|
||||
export const findOrCreateUser = async (
|
||||
fastify: FastifyInstance,
|
||||
email: string
|
||||
): Promise<{ id: string; acceptedPrivacyTerms: boolean }> => {
|
||||
// TODO: handle the case where there are multiple users with the same email.
|
||||
// e.g. use findMany and throw an error if more than one is found.
|
||||
const existingUser = await fastify.prisma.user.findMany({
|
||||
where: { email },
|
||||
select: { id: true, acceptedPrivacyTerms: true }
|
||||
});
|
||||
if (existingUser.length > 1) {
|
||||
const userIds = existingUser.map(user => user.id);
|
||||
fastify.log.error(
|
||||
{ audit: true, userIds, email },
|
||||
'Multiple user records found'
|
||||
);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('Multiple user records found for: ' + userIds.join(', '))
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('user.duplicate_email_detected', 1);
|
||||
}
|
||||
|
||||
if (existingUser[0]) {
|
||||
return existingUser[0];
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const newUser = await fastify.prisma.user.create({
|
||||
data: createUserInput(email),
|
||||
select: { id: true, acceptedPrivacyTerms: true }
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.count('user.created', 1);
|
||||
|
||||
// Create drip campaign record if feature flag is enabled
|
||||
if (fastify.gb.isOn('drip-campaign')) {
|
||||
try {
|
||||
const variant = assignVariantBucket(newUser.id);
|
||||
await fastify.prisma.dripCampaign.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
email,
|
||||
variant
|
||||
}
|
||||
});
|
||||
fastify.log.info(
|
||||
{ userId: newUser.id, variant },
|
||||
'Drip campaign record created for user'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
|
||||
attributes: { flag: 'drip-campaign', result: 'success' }
|
||||
});
|
||||
} catch (err) {
|
||||
fastify.Sentry?.captureException(err);
|
||||
fastify.log.error(
|
||||
{ err, userId: newUser.id },
|
||||
'Failed to create drip campaign record for user'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('growthbook.signup_flag_evaluated', 1, {
|
||||
attributes: { flag: 'drip-campaign', result: 'failed' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUser;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import { getFallbackFullStackDate } from './certificate-utils.js';
|
||||
|
||||
const fullStackChallenges = [
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '5a553ca864b52e1d8bceea14'
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561acd10cb82ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1685210952511,
|
||||
id: '561abd10cb81ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17523bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561add10cb82ac38a17213bc'
|
||||
}
|
||||
];
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('getFallbackFullStackDate', () => {
|
||||
test('should return the date of the latest completed challenge', () => {
|
||||
expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe(
|
||||
1685210952511
|
||||
);
|
||||
});
|
||||
|
||||
test('should fall back to completedDate if no certifications are provided', () => {
|
||||
expect(getFallbackFullStackDate([], 123)).toBe(123);
|
||||
});
|
||||
|
||||
test('should fall back to completedDate if none of the certifications have been completed', () => {
|
||||
expect(
|
||||
getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123)
|
||||
).toBe(123);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
certSlugTypeMap,
|
||||
certToIdMap,
|
||||
Certification
|
||||
} from '@freecodecamp/shared/config/certification-settings';
|
||||
import { normalizeDate } from '../../utils/normalize.js';
|
||||
|
||||
const fullStackCertificateIds = [
|
||||
certToIdMap[Certification.RespWebDesign],
|
||||
certToIdMap[Certification.JsAlgoDataStruct],
|
||||
certToIdMap[Certification.FrontEndDevLibs],
|
||||
certToIdMap[Certification.DataVis],
|
||||
certToIdMap[Certification.BackEndDevApis],
|
||||
certToIdMap[Certification.LegacyInfoSecQa]
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if the given certification slug is known.
|
||||
*
|
||||
* @param certSlug - The certification slug to check.
|
||||
* @returns True if the certification slug is known, otherwise false.
|
||||
*/
|
||||
export function isKnownCertSlug(certSlug: string): certSlug is Certification {
|
||||
return certSlug in certSlugTypeMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the completion date for the full stack certification, if it exists.
|
||||
*
|
||||
* @param completedChallenges - The array of completed challenges.
|
||||
* @param completedDate - The fallback completed date.
|
||||
* @returns The latest certification date or the completed date if no certification is found.
|
||||
*/
|
||||
export function getFallbackFullStackDate(
|
||||
completedChallenges: { id: string; completedDate: Prisma.JsonValue }[],
|
||||
completedDate: Prisma.JsonValue
|
||||
): number {
|
||||
const latestCertDate = completedChallenges
|
||||
.filter(chal => fullStackCertificateIds.includes(chal.id))
|
||||
.map(chal => ({
|
||||
...chal,
|
||||
completedDate: normalizeDate(chal.completedDate)
|
||||
}))
|
||||
.sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate;
|
||||
|
||||
return latestCertDate ?? normalizeDate(completedDate);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, test, expect, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
PartiallyCompletedChallenge,
|
||||
CompletedChallenge
|
||||
} from '@prisma/client';
|
||||
|
||||
import { createFetchMock } from '../../../vitest.utils.js';
|
||||
import {
|
||||
canSubmitCodeRoadCertProject,
|
||||
verifyTrophyWithMicrosoft,
|
||||
decodeFiles,
|
||||
decodeBase64,
|
||||
encodeBase64
|
||||
} from './challenge-helpers.js';
|
||||
|
||||
const id = 'abc';
|
||||
|
||||
const partiallyCompletedChallenges: PartiallyCompletedChallenge[] = [
|
||||
{
|
||||
id,
|
||||
completedDate: 1
|
||||
}
|
||||
];
|
||||
const completedChallenges: CompletedChallenge[] = [
|
||||
{
|
||||
id,
|
||||
completedDate: 1,
|
||||
challengeType: 1,
|
||||
files: [],
|
||||
githubLink: null,
|
||||
solution: null,
|
||||
isManuallyApproved: false,
|
||||
examResults: null
|
||||
}
|
||||
];
|
||||
|
||||
describe('Challenge Helpers', () => {
|
||||
describe('canSubmitCodeRoadCertProject', () => {
|
||||
test('returns true if the user has completed the required challenges or partially completed them', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges: []
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false if the user has not completed the required challenges', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges: []
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false if the id is undefined', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(undefined, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTrophyWithMicrosoft', () => {
|
||||
const userId = 'abc123';
|
||||
const msUsername = 'ANRandom';
|
||||
const msTrophyId = 'learn.wwl.get-started-c-sharp-part-3.trophy';
|
||||
const verifyData = { msUsername, msTrophyId };
|
||||
const achievementsUrl = `https://learn.microsoft.com/api/achievements/user/${userId}`;
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("handles failure to reach Microsoft's profile api", async () => {
|
||||
const notOk = createFetchMock({ ok: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(notOk);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.profile.err',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("handles failure to reach Microsoft's achievements api", async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({ ok: false });
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-3'
|
||||
});
|
||||
});
|
||||
|
||||
test('handles the case where the user has no achievements', async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({ body: { achievements: [] } });
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-6'
|
||||
});
|
||||
});
|
||||
|
||||
test("handles failure to find the trophy in the user's achievements", async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({
|
||||
body: { achievements: [{ typeId: 'fake-id' }] }
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-4',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('returns msUserAchievementsApiUrl on success', async () => {
|
||||
const fetchProfile = createFetchMock({ body: { userId } });
|
||||
const fetchAchievements = createFetchMock({
|
||||
body: { achievements: [{ typeId: msTrophyId }] }
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockImplementationOnce(fetchProfile)
|
||||
.mockImplementationOnce(fetchAchievements);
|
||||
|
||||
const verification = await verifyTrophyWithMicrosoft(verifyData);
|
||||
|
||||
expect(verification).toEqual({
|
||||
type: 'success',
|
||||
msUserAchievementsApiUrl: achievementsUrl
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFiles', () => {
|
||||
test('decodes base64 encoded file contents', () => {
|
||||
const encodedFiles = [
|
||||
{
|
||||
contents: btoa('console.log("Hello, world!");')
|
||||
},
|
||||
{
|
||||
contents: btoa('<h1>Hello, world!</h1>')
|
||||
}
|
||||
];
|
||||
|
||||
const decodedFiles = decodeFiles(encodedFiles);
|
||||
|
||||
expect(decodedFiles).toEqual([
|
||||
{
|
||||
contents: 'console.log("Hello, world!");'
|
||||
},
|
||||
{
|
||||
contents: '<h1>Hello, world!</h1>'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves all other file properties unchanged', () => {
|
||||
const encodedFiles = [
|
||||
{
|
||||
contents: btoa('console.log("Hello, world!");'),
|
||||
ext: '.js',
|
||||
history: [],
|
||||
key: 'file1',
|
||||
name: 'hello.js'
|
||||
}
|
||||
];
|
||||
|
||||
const decodedFiles = decodeFiles(encodedFiles);
|
||||
|
||||
expect(decodedFiles).toEqual([
|
||||
{
|
||||
contents: 'console.log("Hello, world!");',
|
||||
ext: '.js',
|
||||
history: [],
|
||||
key: 'file1',
|
||||
name: 'hello.js'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('can handle unicode characters', () => {
|
||||
const encodedFiles = [
|
||||
{
|
||||
contents: encodeBase64('console.log("Hello, ✅🚀!");')
|
||||
}
|
||||
];
|
||||
|
||||
const decodedFiles = decodeFiles(encodedFiles);
|
||||
|
||||
expect(decodedFiles).toEqual([
|
||||
{
|
||||
contents: 'console.log("Hello, ✅🚀!");'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeBase64', () => {
|
||||
test('decodes a base64 encoded string', () => {
|
||||
const encoded = encodeBase64('Hello, world!');
|
||||
const decoded = decodeBase64(encoded);
|
||||
expect(decoded).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
test('can handle unicode characters', () => {
|
||||
const original = 'Hello, ✅🚀!';
|
||||
const encoded = encodeBase64(original);
|
||||
const decoded = decodeBase64(encoded);
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Confirm that a user can submit a CodeRoad project.
|
||||
*
|
||||
* @param id The id of the project.
|
||||
* @param param The challenges the user has completed.
|
||||
* @param param.partiallyCompletedChallenges The partially completed challenges.
|
||||
* @param param.completedChallenges The completed challenges.
|
||||
* @returns A boolean indicating if the user can submit the project.
|
||||
*/
|
||||
export const canSubmitCodeRoadCertProject = (
|
||||
id: string | undefined,
|
||||
{
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
}: {
|
||||
partiallyCompletedChallenges: { id: string }[];
|
||||
completedChallenges: { id: string }[];
|
||||
}
|
||||
) => {
|
||||
if (partiallyCompletedChallenges.some(c => c.id === id)) return true;
|
||||
if (completedChallenges.some(c => c.id === id)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
type MSProfileError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.profile.err';
|
||||
variables: { msUsername: string };
|
||||
};
|
||||
|
||||
type MSProfileSuccess = {
|
||||
type: 'success';
|
||||
userId: string;
|
||||
};
|
||||
|
||||
async function getMSProfile(msUsername: string) {
|
||||
const error: MSProfileError = {
|
||||
type: 'error',
|
||||
message: 'flash.ms.profile.err',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
};
|
||||
|
||||
const msProfileApi = `https://learn.microsoft.com/api/profiles/${msUsername}`;
|
||||
const msProfileApiRes = await fetch(msProfileApi);
|
||||
|
||||
if (!msProfileApiRes.ok) return error;
|
||||
|
||||
const { userId } = (await msProfileApiRes.json()) as {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const success: MSProfileSuccess = {
|
||||
type: 'success',
|
||||
userId
|
||||
};
|
||||
|
||||
return userId ? success : error;
|
||||
}
|
||||
|
||||
type AchievementsError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.trophy.err-3';
|
||||
};
|
||||
|
||||
type NoAchievementsError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.trophy.err-6';
|
||||
};
|
||||
|
||||
type NoTrophyError = {
|
||||
type: 'error';
|
||||
message: 'flash.ms.trophy.err-4';
|
||||
variables: { msUsername: string };
|
||||
};
|
||||
|
||||
type Validated = {
|
||||
type: 'success';
|
||||
msUserAchievementsApiUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles all communication with the Microsoft Learn APIs.
|
||||
*
|
||||
* @param requestData The data needed by the Microsoft Learn APIs.
|
||||
* @param requestData.msUsername The Microsoft username used to get the profile.
|
||||
* @param requestData.msTrophyId The Microsoft trophy ID to verify.
|
||||
* @returns An object with 'type' of success|error and information about the success or failure.
|
||||
*/
|
||||
export async function verifyTrophyWithMicrosoft({
|
||||
msUsername,
|
||||
msTrophyId
|
||||
}: {
|
||||
msUsername: string;
|
||||
msTrophyId: string;
|
||||
}) {
|
||||
const msProfile = await getMSProfile(msUsername);
|
||||
|
||||
if (msProfile.type === 'error') return msProfile;
|
||||
|
||||
const msUserAchievementsApiUrl = `https://learn.microsoft.com/api/achievements/user/${msProfile.userId}`;
|
||||
const msUserAchievementsApiRes = await fetch(msUserAchievementsApiUrl);
|
||||
|
||||
if (!msUserAchievementsApiRes.ok) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-3'
|
||||
} as AchievementsError;
|
||||
}
|
||||
|
||||
const { achievements } = (await msUserAchievementsApiRes.json()) as {
|
||||
achievements?: { typeId: string }[];
|
||||
};
|
||||
|
||||
if (!achievements?.length)
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-6'
|
||||
} as NoAchievementsError;
|
||||
|
||||
// TODO: handle the case where there are achievements, but the `typeId` is not
|
||||
// a property of the achievements. This suggests that Microsoft has changed
|
||||
// their API and, to aid debugging, we should report a different error
|
||||
// message.
|
||||
const earnedTrophy = achievements?.some(a => a.typeId === msTrophyId);
|
||||
|
||||
if (earnedTrophy) {
|
||||
return {
|
||||
type: 'success',
|
||||
msUserAchievementsApiUrl
|
||||
} as Validated;
|
||||
} else {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'flash.ms.trophy.err-4',
|
||||
variables: {
|
||||
msUsername
|
||||
}
|
||||
} as NoTrophyError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to decode an array of base64 encoded file objects.
|
||||
*
|
||||
* @param files Array of file-like objects each having a base64 encoded `contents` string.
|
||||
* @returns The same array shape with `contents` decoded.
|
||||
*/
|
||||
export function decodeFiles<T extends { contents: string }>(files: T[]): T[] {
|
||||
return files.map(file => ({
|
||||
...file,
|
||||
contents: decodeBase64(file.contents)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 encoded string into a UTF-8 string.
|
||||
*
|
||||
* @param str The base64 encoded string to decode.
|
||||
* @returns The decoded UTF-8 string.
|
||||
*/
|
||||
export function decodeBase64(str: string): string {
|
||||
return Buffer.from(str, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a UTF-8 string into a base64 encoded string.
|
||||
*
|
||||
* @param str The UTF-8 string to encode.
|
||||
* @returns The base64 encoded string.
|
||||
*/
|
||||
export function encodeBase64(str: string): string {
|
||||
return Buffer.from(str, 'utf8').toString('base64');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isProfane } from 'no-profanity';
|
||||
|
||||
import { blocklistedUsernames } from '@freecodecamp/shared/config/constants';
|
||||
|
||||
/**
|
||||
* Checks if a username is restricted (i.e. It's profane or reserved).
|
||||
* @param username - The username to check.
|
||||
* @returns True if the username is restricted, false otherwise.
|
||||
*/
|
||||
export const isRestricted = (username: string): boolean => {
|
||||
return isProfane(username) || blocklistedUsernames.includes(username);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { pick, omit } from 'lodash-es';
|
||||
|
||||
// user flags that the api-server returns as false if they're missing in the
|
||||
// user document. Since Prisma returns null for missing fields, we need to
|
||||
// normalize them to false.
|
||||
// TODO(Post-MVP): remove this when the database is normalized.
|
||||
const nullableFlags = [
|
||||
'is2018DataVisCert',
|
||||
'is2018FullStackCert',
|
||||
'isA2EnglishCert',
|
||||
'isApisMicroservicesCert',
|
||||
'isBackEndCert',
|
||||
'isCheater',
|
||||
'isCollegeAlgebraPyCertV8',
|
||||
'isDataAnalysisPyCertV7',
|
||||
'isDataVisCert',
|
||||
// isDonating doesn't need fixing because it's not nullable
|
||||
'isFoundationalCSharpCertV8',
|
||||
'isFrontEndCert',
|
||||
'isFullStackCert',
|
||||
'isFrontEndLibsCert',
|
||||
'isJavascriptCertV9',
|
||||
'isClassroomAccount',
|
||||
'isHonest',
|
||||
'isInfosecCertV7',
|
||||
'isInfosecQaCert',
|
||||
'isJsAlgoDataStructCert',
|
||||
'isJsAlgoDataStructCertV8',
|
||||
'isMachineLearningPyCertV7',
|
||||
'isPythonCertV9',
|
||||
'isQaCertV7',
|
||||
'isRelationalDatabaseCertV8',
|
||||
'isRelationalDatabaseCertV9',
|
||||
'isRespWebDesignCert',
|
||||
'isRespWebDesignCertV9',
|
||||
'isSciCompPyCertV7',
|
||||
'isFrontEndLibsCertV9',
|
||||
'isBackEndDevApisCertV9',
|
||||
'isFullStackDeveloperCertV9',
|
||||
'isB1EnglishCert',
|
||||
'isA2SpanishCert',
|
||||
'isA2ChineseCert',
|
||||
'isA1ChineseCert',
|
||||
// isUpcomingPythonCertV8 exists in the db, but is not returned by the api-server
|
||||
// TODO(Post-MVP): delete it from the db?
|
||||
'keyboardShortcuts'
|
||||
] as const;
|
||||
|
||||
type NullableFlags = (typeof nullableFlags)[number];
|
||||
|
||||
/**
|
||||
* Splits a user object into two objects: one with nullable flags and one without.
|
||||
*
|
||||
* @param user - The user object to split.
|
||||
* @returns A tuple where the first element is an object with nullable flags and the second element is an object with the remaining properties.
|
||||
*/
|
||||
export function splitUser<U extends Record<NullableFlags, unknown>>(
|
||||
user: U
|
||||
): [Pick<U, NullableFlags>, Omit<U, NullableFlags>] {
|
||||
return [pick(user, nullableFlags), omit(user, nullableFlags)];
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { Certification } from '@freecodecamp/shared/config/certification-settings';
|
||||
import {
|
||||
defaultUserEmail,
|
||||
defaultUserId,
|
||||
devLogin,
|
||||
resetDefaultUser,
|
||||
setupServer,
|
||||
superRequest
|
||||
} from '../../../vitest.utils.js';
|
||||
import { getChallenges } from '../../utils/get-challenges.js';
|
||||
import { createCertLookup } from './certificate.js';
|
||||
|
||||
describe('certificate routes', () => {
|
||||
setupServer();
|
||||
describe('Authenticated user', () => {
|
||||
let setCookies: string[];
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
setCookies = await devLogin();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('PUT /certificate/verify', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDefaultUser();
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
name: 'fcc',
|
||||
username: 'fcc',
|
||||
completedChallenges: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 if no certSlug', async () => {
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({});
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
response: {
|
||||
message: 'flash.wrong-name',
|
||||
variables: { name: '' }
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should return 400 if certSlug is invalid', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: 'non-existant'
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
response: {
|
||||
message: 'flash.wrong-name',
|
||||
variables: { name: 'non-existant' }
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: { reason: 'unknown_slug' }
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Revisit this test after deciding if we need/want to fetch the
|
||||
// entire user during authorization or just the user id.
|
||||
test('should return 500 and capture an exception if user not found in db', async () => {
|
||||
const findUniqueForAuth =
|
||||
fastifyTestInstance.prisma.user.findUnique.bind(
|
||||
fastifyTestInstance.prisma.user
|
||||
);
|
||||
|
||||
vi.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
|
||||
.mockImplementationOnce(findUniqueForAuth)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
message: 'flash.went-wrong',
|
||||
type: 'danger'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_user_missing', 1);
|
||||
});
|
||||
|
||||
test('should return 400 if user has not set a `name`', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
name: null
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
response: {
|
||||
type: 'info',
|
||||
message: 'flash.name-needed'
|
||||
},
|
||||
isCertMap: {
|
||||
is2018DataVisCert: false,
|
||||
isA2EnglishCert: false,
|
||||
isB1EnglishCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isBackEndCert: false,
|
||||
isCollegeAlgebraPyCertV8: false,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isFoundationalCSharpCertV8: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isFullStackCert: false,
|
||||
isInfosecCertV7: false,
|
||||
isInfosecQaCert: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
isPythonCertV9: false,
|
||||
isQaCertV7: false,
|
||||
isRelationalDatabaseCertV8: false,
|
||||
isRelationalDatabaseCertV9: false,
|
||||
isRespWebDesignCert: false,
|
||||
isSciCompPyCertV7: false,
|
||||
isJavascriptCertV9: false,
|
||||
isRespWebDesignCertV9: false
|
||||
},
|
||||
completedChallenges: []
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: {
|
||||
certSlug: Certification.RespWebDesign,
|
||||
reason: 'name_missing'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 200 if user already claimed cert', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
isRespWebDesignCert: true
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.body.response).toStrictEqual({
|
||||
type: 'info',
|
||||
message: 'flash.already-claimed',
|
||||
variables: {
|
||||
name: 'Legacy Responsive Web Design V8'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: {
|
||||
certSlug: Certification.RespWebDesign,
|
||||
reason: 'already_claimed'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 if not all requirements have been met to claim', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||
],
|
||||
isRespWebDesignCert: false
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.body.response).toStrictEqual({
|
||||
message: 'flash.incomplete-steps',
|
||||
type: 'info',
|
||||
variables: { name: 'Legacy Responsive Web Design V8' }
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('certificate.claim_blocked', 1, {
|
||||
attributes: {
|
||||
certSlug: Certification.RespWebDesign,
|
||||
reason: 'incomplete_steps'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Email does not actually send (work) in development, but status should still be 200.
|
||||
test('should send the certified email when full stack developer v9 is claimed', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||
],
|
||||
isFullStackDeveloperCertV9: true
|
||||
}
|
||||
});
|
||||
|
||||
const spy = vi.spyOn(fastifyTestInstance, 'sendEmail');
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should capture an exception if the congratulations email fails to send', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||
],
|
||||
isFullStackDeveloperCertV9: true
|
||||
}
|
||||
});
|
||||
|
||||
vi.spyOn(fastifyTestInstance, 'sendEmail').mockRejectedValueOnce(
|
||||
new Error('send failed')
|
||||
);
|
||||
const captureException = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = { ...originalSentry, captureException };
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should return 200 if all went well', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||
],
|
||||
isRespWebDesignCert: false
|
||||
}
|
||||
});
|
||||
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({ isRespWebDesignCert: true });
|
||||
expect(response.body).toStrictEqual({
|
||||
response: {
|
||||
message: 'flash.cert-claim-success',
|
||||
type: 'success',
|
||||
variables: {
|
||||
name: 'Legacy Responsive Web Design V8',
|
||||
username: 'fcc'
|
||||
}
|
||||
},
|
||||
isCertMap: {
|
||||
is2018DataVisCert: false,
|
||||
isA1ChineseCert: false,
|
||||
isA2ChineseCert: false,
|
||||
isA2EnglishCert: false,
|
||||
isA2SpanishCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isB1EnglishCert: false,
|
||||
isBackEndCert: false,
|
||||
isBackEndDevApisCertV9: false,
|
||||
isCollegeAlgebraPyCertV8: false,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isFoundationalCSharpCertV8: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isFrontEndLibsCertV9: false,
|
||||
isFullStackCert: false,
|
||||
isFullStackDeveloperCertV9: false,
|
||||
isInfosecCertV7: false,
|
||||
isInfosecQaCert: false,
|
||||
isJavascriptCertV9: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isJsAlgoDataStructCertV8: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
isPythonCertV9: false,
|
||||
isQaCertV7: false,
|
||||
isRelationalDatabaseCertV8: false,
|
||||
isRelationalDatabaseCertV9: false,
|
||||
isRespWebDesignCert: true,
|
||||
isRespWebDesignCertV9: false,
|
||||
isSciCompPyCertV7: false
|
||||
},
|
||||
completedChallenges: [
|
||||
{
|
||||
completedDate: 123456789,
|
||||
files: [],
|
||||
id: 'bd7158d8c442eddfaeb5bd18'
|
||||
},
|
||||
{
|
||||
completedDate: 123456789,
|
||||
files: [],
|
||||
id: '587d78af367417b2b2512b03'
|
||||
},
|
||||
{
|
||||
completedDate: 123456789,
|
||||
files: [],
|
||||
id: '587d78af367417b2b2512b04'
|
||||
},
|
||||
{
|
||||
completedDate: 123456789,
|
||||
files: [],
|
||||
id: '587d78b0367417b2b2512b05'
|
||||
},
|
||||
{
|
||||
completedDate: 123456789,
|
||||
files: [],
|
||||
id: 'bd7158d8c242eddfaeb5bd13'
|
||||
},
|
||||
{
|
||||
challengeType: 7,
|
||||
// TODO: use matcher for date near now
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
completedDate: expect.any(Number),
|
||||
files: [],
|
||||
id: '561add10cb82ac38a17513bc'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('certificate.claimed', 1, {
|
||||
attributes: { certSlug: Certification.RespWebDesign }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
// Tests for all certifications as to what may currently be claimed, and what may no longer be claimed
|
||||
test('should return 400 if certSlug is not allowed', async () => {
|
||||
const claimableCerts = [
|
||||
Certification.RespWebDesign,
|
||||
// TODO: Enable, once these are no longer "upcoming".
|
||||
// Certification.RespWebDesignV9,
|
||||
// Certification.JsV9,
|
||||
Certification.JsAlgoDataStruct,
|
||||
Certification.FrontEndDevLibs,
|
||||
Certification.DataVis,
|
||||
Certification.RelationalDb,
|
||||
Certification.BackEndDevApis,
|
||||
Certification.QualityAssurance,
|
||||
Certification.SciCompPy,
|
||||
Certification.DataAnalysisPy,
|
||||
Certification.InfoSec,
|
||||
Certification.MachineLearningPy,
|
||||
Certification.CollegeAlgebraPy,
|
||||
Certification.FoundationalCSharp,
|
||||
Certification.LegacyFrontEnd,
|
||||
Certification.LegacyBackEnd,
|
||||
Certification.LegacyDataVis,
|
||||
Certification.LegacyInfoSecQa,
|
||||
Certification.LegacyFullStack
|
||||
];
|
||||
const unclaimableCerts = ['fake-slug'];
|
||||
|
||||
for (const certSlug of claimableCerts) {
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug
|
||||
});
|
||||
|
||||
// `flash.incomplete-steps` comes after the check for whether a certification may be claimed or not.
|
||||
expect(response.body).toMatchObject({
|
||||
response: { message: 'flash.incomplete-steps' }
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
|
||||
for (const certSlug of unclaimableCerts) {
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug
|
||||
});
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
response: {
|
||||
variables: { name: certSlug },
|
||||
message: 'flash.wrong-name'
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
// This has to be the last test since vi.mockRestore replaces the original
|
||||
// function with undefined when restoring a prisma function (for some
|
||||
// reason)
|
||||
test('should return 500 if db update fails', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
vi.spyOn(fastifyTestInstance.prisma, 'user', 'get').mockReturnValue({
|
||||
...fastifyTestInstance.prisma.user,
|
||||
update: vi.fn().mockRejectedValueOnce(new Error('test'))
|
||||
});
|
||||
|
||||
const response = await superRequest('/certificate/verify', {
|
||||
method: 'PUT',
|
||||
setCookies
|
||||
}).send({
|
||||
certSlug: Certification.RespWebDesign
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
message: 'flash.generic-error',
|
||||
type: 'danger'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCertLookup', () => {
|
||||
let challenges: ReturnType<typeof getChallenges>;
|
||||
|
||||
beforeAll(() => {
|
||||
// TODO: create a mock challenges array specific to these tests.
|
||||
challenges = getChallenges();
|
||||
});
|
||||
|
||||
test('should create a lookup for all certifications', () => {
|
||||
const certLookup = createCertLookup(challenges);
|
||||
|
||||
for (const cert of Object.values(Certification)) {
|
||||
const certData = certLookup[cert];
|
||||
expect(certData).toHaveProperty('id');
|
||||
expect(certData).toHaveProperty('tests');
|
||||
expect(certData).toHaveProperty('challengeType');
|
||||
}
|
||||
});
|
||||
|
||||
test('each certification should have a unique challenge id', () => {
|
||||
const certLookup = createCertLookup(challenges);
|
||||
const ids = Object.values(certLookup)
|
||||
.map(({ id }) => id)
|
||||
.sort();
|
||||
const uniqueIds = Array.from(new Set(ids)).sort();
|
||||
expect(uniqueIds).toEqual(ids);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { CompletedChallenge } from '@prisma/client';
|
||||
import validator from 'validator';
|
||||
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import { challenges, getChallenges } from '../../utils/get-challenges.js';
|
||||
import {
|
||||
Certification,
|
||||
type CertificationFlags,
|
||||
certSlugTypeMap,
|
||||
certToIdMap,
|
||||
certToTitleMap,
|
||||
currentCertifications,
|
||||
legacyCertifications,
|
||||
legacyFullStackCertification,
|
||||
upcomingCertifications
|
||||
} from '@freecodecamp/shared/config/certification-settings';
|
||||
|
||||
import * as schemas from '../../schemas.js';
|
||||
import { normalizeChallenges, removeNulls } from '../../utils/normalize.js';
|
||||
|
||||
import { SHOW_UPCOMING_CHANGES } from '../../utils/env.js';
|
||||
import { isKnownCertSlug } from '../helpers/certificate-utils.js';
|
||||
|
||||
function isCertAllowed(certSlug: string): boolean {
|
||||
if (
|
||||
currentCertifications.includes(certSlug) ||
|
||||
legacyCertifications.includes(certSlug) ||
|
||||
legacyFullStackCertification.includes(certSlug)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (SHOW_UPCOMING_CHANGES && upcomingCertifications.includes(certSlug)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderCertifiedEmail({
|
||||
username,
|
||||
name
|
||||
}: {
|
||||
username: string;
|
||||
name: string;
|
||||
}) {
|
||||
const certifiedEmailTemplate = `Hi ${name || username},
|
||||
|
||||
Congratulations on completing the Certified Full-Stack Developer Curriculum!
|
||||
|
||||
All of your certifications are now live at: https://www.freecodecamp.org/${username}
|
||||
|
||||
Please tell me a bit more about you and your near-term goals.
|
||||
|
||||
Also, check out https://contribute.freecodecamp.org/ for some fun and convenient ways you can contribute to the community.
|
||||
|
||||
Happy coding,
|
||||
|
||||
- Quincy Larson, teacher at freeCodeCamp
|
||||
`;
|
||||
return certifiedEmailTemplate;
|
||||
}
|
||||
|
||||
function hasCompletedTests(
|
||||
tests: { id: string }[],
|
||||
completedChallenges: CompletedChallenge[]
|
||||
) {
|
||||
return tests.every(({ id }) =>
|
||||
completedChallenges.some(({ id: completedId }) => completedId === id)
|
||||
);
|
||||
}
|
||||
|
||||
function assertTestsExist(
|
||||
tests: ReturnType<typeof getChallenges>[number]['tests']
|
||||
): asserts tests is { id: string }[] {
|
||||
if (!Array.isArray(tests)) {
|
||||
throw new Error('Tests is not an array');
|
||||
}
|
||||
if (!tests.every(test => typeof test === 'object' && test !== null)) {
|
||||
throw new Error('Tests contains non-object values');
|
||||
}
|
||||
if (!tests.every(test => typeof test.id === 'string')) {
|
||||
throw new Error('Tests contain non-string ids');
|
||||
}
|
||||
}
|
||||
|
||||
function getCertBySlug(
|
||||
cert: Certification,
|
||||
challenges: ReturnType<typeof getChallenges>
|
||||
): { id: string; tests: { id: string }[]; challengeType: number } {
|
||||
const challengeId = certToIdMap[cert];
|
||||
const challengeById = challenges.filter(({ id }) => id === challengeId)[0];
|
||||
if (!challengeById) {
|
||||
throw new Error(`Challenge with id '${challengeId}' not found`);
|
||||
}
|
||||
const { id, tests, challengeType } = challengeById;
|
||||
assertTestsExist(tests);
|
||||
return { id, tests, challengeType };
|
||||
}
|
||||
|
||||
type CertLookup = Record<
|
||||
Certification,
|
||||
{ id: string; tests: { id: string }[]; challengeType: number }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Create a lookup from Certification enum values to their corresponding
|
||||
* challenge metadata (id, tests and challengeType) using the provided
|
||||
* challenges array.
|
||||
*
|
||||
* @param challenges - The array returned by getChallenges().
|
||||
* @returns A record mapping each Certification to an object with id, tests and challengeType.
|
||||
*/
|
||||
export function createCertLookup(
|
||||
challenges: ReturnType<typeof getChallenges>
|
||||
): CertLookup {
|
||||
const certLookup = {} as CertLookup;
|
||||
|
||||
for (const cert of Object.values(Certification)) {
|
||||
certLookup[cert] = getCertBySlug(cert, challenges);
|
||||
}
|
||||
return certLookup;
|
||||
}
|
||||
|
||||
function getUserIsCertMap(user: Partial<CertificationFlags>) {
|
||||
const {
|
||||
is2018DataVisCert = false,
|
||||
isA1ChineseCert = false,
|
||||
isA2ChineseCert = false,
|
||||
isA2EnglishCert = false,
|
||||
isA2SpanishCert = false,
|
||||
isApisMicroservicesCert = false,
|
||||
isB1EnglishCert = false,
|
||||
isBackEndCert = false,
|
||||
isBackEndDevApisCertV9 = false,
|
||||
isCollegeAlgebraPyCertV8 = false,
|
||||
isDataAnalysisPyCertV7 = false,
|
||||
isDataVisCert = false,
|
||||
isFoundationalCSharpCertV8 = false,
|
||||
isFrontEndCert = false,
|
||||
isFrontEndLibsCert = false,
|
||||
isFrontEndLibsCertV9 = false,
|
||||
isFullStackCert = false,
|
||||
isFullStackDeveloperCertV9 = false,
|
||||
isInfosecCertV7 = false,
|
||||
isInfosecQaCert = false,
|
||||
isJavascriptCertV9 = false,
|
||||
isJsAlgoDataStructCert = false,
|
||||
isJsAlgoDataStructCertV8 = false,
|
||||
isMachineLearningPyCertV7 = false,
|
||||
isPythonCertV9 = false,
|
||||
isQaCertV7 = false,
|
||||
isRelationalDatabaseCertV8 = false,
|
||||
isRelationalDatabaseCertV9 = false,
|
||||
isRespWebDesignCert = false,
|
||||
isRespWebDesignCertV9 = false,
|
||||
isSciCompPyCertV7 = false
|
||||
} = user;
|
||||
|
||||
return {
|
||||
is2018DataVisCert,
|
||||
isA1ChineseCert,
|
||||
isA2ChineseCert,
|
||||
isA2EnglishCert,
|
||||
isA2SpanishCert,
|
||||
isApisMicroservicesCert,
|
||||
isB1EnglishCert,
|
||||
isBackEndCert,
|
||||
isBackEndDevApisCertV9,
|
||||
isCollegeAlgebraPyCertV8,
|
||||
isDataAnalysisPyCertV7,
|
||||
isDataVisCert,
|
||||
isFoundationalCSharpCertV8,
|
||||
isFrontEndCert,
|
||||
isFrontEndLibsCert,
|
||||
isFrontEndLibsCertV9,
|
||||
isFullStackCert,
|
||||
isFullStackDeveloperCertV9,
|
||||
isInfosecCertV7,
|
||||
isInfosecQaCert,
|
||||
isJavascriptCertV9,
|
||||
isJsAlgoDataStructCert,
|
||||
isJsAlgoDataStructCertV8,
|
||||
isMachineLearningPyCertV7,
|
||||
isPythonCertV9,
|
||||
isQaCertV7,
|
||||
isRelationalDatabaseCertV8,
|
||||
isRelationalDatabaseCertV9,
|
||||
isRespWebDesignCert,
|
||||
isRespWebDesignCertV9,
|
||||
isSciCompPyCertV7
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin for the protected certificate endpoints.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
const certLookup = createCertLookup(challenges);
|
||||
|
||||
// TODO(POST_MVP): Response should not include updated user. If a client wants the updated user, it should make a separate request
|
||||
// OR: Always respond with current user - full user object - not random pieces.
|
||||
fastify.put(
|
||||
'/certificate/verify',
|
||||
{
|
||||
schema: schemas.certificateVerify,
|
||||
errorHandler(error, request, reply) {
|
||||
if (error.validation) {
|
||||
void reply.code(400).send({
|
||||
response: {
|
||||
type: 'danger',
|
||||
message: 'flash.wrong-name',
|
||||
variables: { name: '' }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fastify.errorHandler(error, request, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { certSlug } = req.body;
|
||||
|
||||
if (!isKnownCertSlug(certSlug) || !isCertAllowed(certSlug)) {
|
||||
req.log.warn({ certSlug }, 'Unknown certificate slug');
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { reason: 'unknown_slug' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
response: {
|
||||
type: 'danger',
|
||||
// message: 'Certificate type not found'
|
||||
message: 'flash.wrong-name',
|
||||
variables: { name: certSlug }
|
||||
}
|
||||
} as const;
|
||||
}
|
||||
|
||||
const certType = certSlugTypeMap[certSlug];
|
||||
const certName = certToTitleMap[certSlug];
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
void reply.code(500);
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('User not found when claiming certificate')
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_user_missing', 1);
|
||||
req.log.error('User not found');
|
||||
return {
|
||||
type: 'danger',
|
||||
// message: 'User not found'
|
||||
message: 'flash.went-wrong'
|
||||
} as const;
|
||||
}
|
||||
const { completedChallenges } = user;
|
||||
const isCertMap = getUserIsCertMap(removeNulls(user));
|
||||
|
||||
// TODO: Discuss if this is a requirement still
|
||||
if (!user.name) {
|
||||
req.log.warn('User does not have a name property');
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { certSlug, reason: 'name_missing' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
response: {
|
||||
type: 'info',
|
||||
message: 'flash.name-needed'
|
||||
},
|
||||
isCertMap,
|
||||
completedChallenges: normalizeChallenges(completedChallenges)
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (user[certType]) {
|
||||
req.log.debug({ certName }, 'User has already claimed certificate');
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { certSlug, reason: 'already_claimed' }
|
||||
});
|
||||
void reply.code(200);
|
||||
return {
|
||||
response: {
|
||||
type: 'info',
|
||||
message: 'flash.already-claimed',
|
||||
variables: {
|
||||
name: certName
|
||||
}
|
||||
},
|
||||
isCertMap,
|
||||
completedChallenges: normalizeChallenges(completedChallenges)
|
||||
} as const;
|
||||
}
|
||||
|
||||
const { id, tests, challengeType } = certLookup[certSlug];
|
||||
const hasCompletedTestRequirements = hasCompletedTests(
|
||||
tests,
|
||||
user.completedChallenges
|
||||
);
|
||||
|
||||
if (!hasCompletedTestRequirements) {
|
||||
req.log.warn(
|
||||
{ certName },
|
||||
'User has not completed the tests for certificate'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('certificate.claim_blocked', 1, {
|
||||
attributes: { certSlug, reason: 'incomplete_steps' }
|
||||
});
|
||||
void reply.code(400);
|
||||
return {
|
||||
response: {
|
||||
type: 'info',
|
||||
message: 'flash.incomplete-steps',
|
||||
variables: {
|
||||
name: certName
|
||||
}
|
||||
},
|
||||
isCertMap,
|
||||
completedChallenges: normalizeChallenges(completedChallenges)
|
||||
} as const;
|
||||
}
|
||||
|
||||
const updatedUser = await fastify.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
[certType]: true,
|
||||
completedChallenges: {
|
||||
push: {
|
||||
id,
|
||||
completedDate: Date.now(),
|
||||
challengeType
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
completedChallenges: true,
|
||||
email: true,
|
||||
name: true,
|
||||
username: true,
|
||||
is2018DataVisCert: true,
|
||||
is2018FullStackCert: true,
|
||||
isA1ChineseCert: true,
|
||||
isA2ChineseCert: true,
|
||||
isA2EnglishCert: true,
|
||||
isA2SpanishCert: true,
|
||||
isApisMicroservicesCert: true,
|
||||
isB1EnglishCert: true,
|
||||
isBackEndCert: true,
|
||||
isBackEndDevApisCertV9: true,
|
||||
isCollegeAlgebraPyCertV8: true,
|
||||
isDataAnalysisPyCertV7: true,
|
||||
isDataVisCert: true,
|
||||
isFoundationalCSharpCertV8: true,
|
||||
isFrontEndCert: true,
|
||||
isFrontEndLibsCert: true,
|
||||
isFrontEndLibsCertV9: true,
|
||||
isFullStackCert: true,
|
||||
isFullStackDeveloperCertV9: true,
|
||||
isInfosecCertV7: true,
|
||||
isInfosecQaCert: true,
|
||||
isJavascriptCertV9: true,
|
||||
isJsAlgoDataStructCert: true,
|
||||
isJsAlgoDataStructCertV8: true,
|
||||
isMachineLearningPyCertV7: true,
|
||||
isPythonCertV9: true,
|
||||
isQaCertV7: true,
|
||||
isRelationalDatabaseCertV8: true,
|
||||
isRelationalDatabaseCertV9: true,
|
||||
isRespWebDesignCert: true,
|
||||
isRespWebDesignCertV9: true,
|
||||
isSciCompPyCertV7: true
|
||||
}
|
||||
});
|
||||
|
||||
const email = updatedUser.email;
|
||||
const updatedUserSansNull = removeNulls(updatedUser);
|
||||
const updatedIsCertMap = getUserIsCertMap(updatedUserSansNull);
|
||||
|
||||
// TODO(POST-MVP): Consider sending email based on `user.isEmailVerified` as well
|
||||
const fullStackV9Claimed = updatedIsCertMap.isFullStackDeveloperCertV9;
|
||||
|
||||
const shouldSendCertifiedEmailToCamper =
|
||||
email && validator.default.isEmail(email) && fullStackV9Claimed;
|
||||
|
||||
if (shouldSendCertifiedEmailToCamper) {
|
||||
const notifyUser = {
|
||||
to: email,
|
||||
from: 'quincy@freecodecamp.org',
|
||||
subject:
|
||||
'Congratulations on completing the Certified Full-Stack Developer Curriculum!',
|
||||
text: renderCertifiedEmail({
|
||||
username: updatedUser.username,
|
||||
// Safety: `user.name` is required to exist earlier. TODO: Assert
|
||||
name: updatedUser.name as string
|
||||
})
|
||||
};
|
||||
|
||||
// Failed email should not prevent successful response.
|
||||
try {
|
||||
req.log.debug('Sending congratulations email');
|
||||
// TODO(POST-MVP): Ensure Camper knows they **have** claimed the cert, but the email failed to send.
|
||||
await fastify.sendEmail(notifyUser);
|
||||
} catch (e) {
|
||||
req.log.error(e, 'Failed to send congratulations email');
|
||||
fastify.Sentry?.captureException(e);
|
||||
}
|
||||
}
|
||||
|
||||
req.log.info({ certName, audit: true }, 'User has claimed certificate');
|
||||
fastify.Sentry?.metrics?.count('certificate.claimed', 1, {
|
||||
attributes: { certSlug }
|
||||
});
|
||||
void reply.code(200);
|
||||
return {
|
||||
response: {
|
||||
type: 'success',
|
||||
message: 'flash.cert-claim-success',
|
||||
variables: {
|
||||
username: updatedUser.username,
|
||||
name: certName
|
||||
}
|
||||
},
|
||||
isCertMap: updatedIsCertMap,
|
||||
completedChallenges: normalizeChallenges(
|
||||
updatedUserSansNull.completedChallenges
|
||||
)
|
||||
} as const;
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,679 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
createSuperRequest,
|
||||
devLogin,
|
||||
setupServer,
|
||||
defaultUserEmail,
|
||||
defaultUserId
|
||||
} from '../../../vitest.utils.js';
|
||||
import { createUserInput } from '../../utils/create-user.js';
|
||||
|
||||
const testEWalletEmail = 'baz@bar.com';
|
||||
const testSubscriptionId = 'sub_test_id';
|
||||
const testCustomerId = 'cust_test_id';
|
||||
const userWithoutProgress = createUserInput(defaultUserEmail);
|
||||
const userWithProgress = {
|
||||
...createUserInput(defaultUserEmail),
|
||||
completedChallenges: [
|
||||
{
|
||||
id: 'a6b0bb188d873cb2c8729495',
|
||||
completedDate: 1520002973119,
|
||||
solution: null,
|
||||
challengeType: 5
|
||||
},
|
||||
{
|
||||
id: '33b0bb188d873cb2c8729433',
|
||||
completedDate: 4420002973122,
|
||||
solution: null,
|
||||
challengeType: 5
|
||||
},
|
||||
{
|
||||
id: 'a5229172f011153519423690',
|
||||
completedDate: 1520440323273,
|
||||
solution: null,
|
||||
challengeType: 5
|
||||
},
|
||||
{
|
||||
id: 'a5229172f011153519423692',
|
||||
completedDate: 1520440323274,
|
||||
githubLink: '',
|
||||
challengeType: 5
|
||||
}
|
||||
]
|
||||
};
|
||||
const donationMock = {
|
||||
endDate: null,
|
||||
startDate: {
|
||||
date: '2024-07-17T10:20:56.076Z',
|
||||
when: '2024-07-17T10:20:56.076+00:00'
|
||||
},
|
||||
id: '66979a414748aa2f3ba36d41',
|
||||
amount: 500,
|
||||
customerId: 'cust_test_id',
|
||||
duration: 'month',
|
||||
email: 'foo@bar.com',
|
||||
provider: 'stripe',
|
||||
subscriptionId: 'sub_test_id',
|
||||
userId: defaultUserId
|
||||
};
|
||||
const sharedDonationReqBody = {
|
||||
amount: 500,
|
||||
duration: 'month'
|
||||
};
|
||||
const chargeStripeReqBody = {
|
||||
email: testEWalletEmail,
|
||||
subscriptionId: 'sub_test_id',
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const chargeStripeCardReqBody = {
|
||||
paymentMethodId: 'UID',
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const createStripePaymentIntentReqBody = {
|
||||
email: testEWalletEmail,
|
||||
name: 'Baz Bar',
|
||||
token: { id: 'tok_123' },
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const mockSubCreate = vi.fn();
|
||||
const mockAttachPaymentMethod = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF',
|
||||
object: 'payment_method'
|
||||
})
|
||||
);
|
||||
const mockCustomerCreate = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: testCustomerId,
|
||||
name: 'Jest_User',
|
||||
currency: 'sgd',
|
||||
description: 'Jest User Account created'
|
||||
})
|
||||
);
|
||||
const mockSubRetrieveObj = {
|
||||
id: testSubscriptionId,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
plan: {
|
||||
product: 'prod_GD1GGbJsqQaupl'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 1 Jan 2040
|
||||
current_period_start: Math.floor(Date.now() / 1000),
|
||||
customer: testCustomerId,
|
||||
status: 'active'
|
||||
};
|
||||
const mockSubRetrieve = vi.fn(() => Promise.resolve(mockSubRetrieveObj));
|
||||
const mockCheckoutSessionCreate = vi.fn(() =>
|
||||
Promise.resolve({ id: 'checkout_session_id' })
|
||||
);
|
||||
const mockCustomerUpdate = vi.fn();
|
||||
const generateMockSubCreate = (status: string) => () =>
|
||||
Promise.resolve({
|
||||
id: testSubscriptionId,
|
||||
latest_invoice: {
|
||||
payment_intent: {
|
||||
client_secret: 'superSecret',
|
||||
status
|
||||
}
|
||||
}
|
||||
});
|
||||
const defaultError = () =>
|
||||
Promise.reject(new Error('Stripe encountered an error'));
|
||||
|
||||
const {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
} = vi.hoisted(() => {
|
||||
class StripeError extends Error {}
|
||||
class StripeCardError extends StripeError {}
|
||||
class StripeInvalidRequestError extends StripeError {}
|
||||
class StripeAuthenticationError extends StripeError {}
|
||||
return {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('stripe', () => ({
|
||||
default: class {
|
||||
static errors = {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
|
||||
constructor() {}
|
||||
|
||||
customers = {
|
||||
create: mockCustomerCreate,
|
||||
update: mockCustomerUpdate
|
||||
};
|
||||
|
||||
paymentMethods = {
|
||||
attach: mockAttachPaymentMethod
|
||||
};
|
||||
|
||||
subscriptions = {
|
||||
create: mockSubCreate,
|
||||
retrieve: mockSubRetrieve
|
||||
};
|
||||
|
||||
checkout = {
|
||||
sessions: {
|
||||
create: mockCheckoutSessionCreate
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Donate', () => {
|
||||
let setCookies: string[];
|
||||
setupServer();
|
||||
describe('Authenticated User', () => {
|
||||
let superPost: ReturnType<typeof createSuperRequest>;
|
||||
let superPut: ReturnType<typeof createSuperRequest>;
|
||||
const verifyUpdatedUserAndNewDonation = async (email: string) => {
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email }
|
||||
});
|
||||
const donations = await fastifyTestInstance.prisma.donation.findMany({
|
||||
where: { userId: user?.id }
|
||||
});
|
||||
const donation = donations[0];
|
||||
expect(donations.length).toBe(1);
|
||||
expect(donation?.amount).toBe(sharedDonationReqBody.amount);
|
||||
expect(donation?.duration).toBe(sharedDonationReqBody.duration);
|
||||
expect(typeof donation?.subscriptionId).toBe('string');
|
||||
expect(donation?.customerId).toBe(testCustomerId);
|
||||
expect(donation?.provider).toBe('stripe');
|
||||
};
|
||||
const verifyNoUpdatedUserAndNoNewDonation = async (email: string) => {
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email }
|
||||
});
|
||||
const donations = await fastifyTestInstance.prisma.donation.findMany({});
|
||||
expect(user?.isDonating).toBe(false);
|
||||
expect(donations.length).toBe(0);
|
||||
};
|
||||
const verifyNoNewUserAndNoNewDonation = async () => {
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: testEWalletEmail }
|
||||
});
|
||||
const donations = await fastifyTestInstance.prisma.donation.findMany({});
|
||||
expect(user).toBe(null);
|
||||
expect(donations.length).toBe(0);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
setCookies = await devLogin();
|
||||
superPost = createSuperRequest({ method: 'POST', setCookies });
|
||||
superPut = createSuperRequest({ method: 'PUT', setCookies });
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: userWithProgress.email },
|
||||
data: userWithProgress
|
||||
});
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: testEWalletEmail }
|
||||
});
|
||||
await fastifyTestInstance.prisma.donation.deleteMany({});
|
||||
});
|
||||
|
||||
describe('POST /donate/charge-stripe-card', () => {
|
||||
test('should return 200 and update the user', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('we only care about specific error cases')
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyUpdatedUserAndNewDonation(userWithProgress.email);
|
||||
expect(response.body).toEqual({ isDonating: true, type: 'success' });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should return 402 with client_secret if subscription status requires source action', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('requires_source_action')
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
type: 'UserActionRequired',
|
||||
message: 'Payment requires user action',
|
||||
client_secret: 'superSecret'
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(402);
|
||||
expect(count).toHaveBeenCalledWith('donation.action_required', 1);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return 402 if subscription status requires source', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('requires_source')
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
type: 'PaymentMethodRequired',
|
||||
message: 'Card has been declined'
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(402);
|
||||
});
|
||||
|
||||
test('should return 400 if the user is already donating', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('still does not matter')
|
||||
);
|
||||
const successResponse = await superPost(
|
||||
'/donate/charge-stripe-card'
|
||||
).send(chargeStripeCardReqBody);
|
||||
const failResponse = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
//Verify that only the first call changed the DB
|
||||
await verifyUpdatedUserAndNewDonation(userWithProgress.email);
|
||||
expect(successResponse.status).toBe(200);
|
||||
expect(failResponse.body).toEqual({
|
||||
error: {
|
||||
type: 'AlreadyDonatingError',
|
||||
message: 'User is already donating.'
|
||||
}
|
||||
});
|
||||
expect(failResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should return 403 if the user has no email', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: userWithProgress.email },
|
||||
data: { email: null }
|
||||
});
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
error: {
|
||||
type: 'EmailRequiredError',
|
||||
message: 'User has not provided an email address'
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
test('should return 500 if Stripe encountes an error', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockSubCreate.mockImplementationOnce(defaultError);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should not capture Stripe card decline errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const CardError = Stripe.errors.StripeCardError as unknown as new (
|
||||
m?: string
|
||||
) => Error;
|
||||
mockSubCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new CardError('card_declined'))
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should not capture Stripe invalid request errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const InvalidRequestError = Stripe.errors
|
||||
.StripeInvalidRequestError as unknown as new (m?: string) => Error;
|
||||
mockSubCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new InvalidRequestError('invalid_request'))
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should capture Stripe infra errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const AuthError = Stripe.errors
|
||||
.StripeAuthenticationError as unknown as new (m?: string) => Error;
|
||||
mockSubCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new AuthError('invalid api key'))
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return 400 if user has not completed challenges', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: userWithProgress.email },
|
||||
data: userWithoutProgress
|
||||
});
|
||||
const failResponse = await superPost('/donate/charge-stripe-card').send(
|
||||
chargeStripeCardReqBody
|
||||
);
|
||||
await verifyNoUpdatedUserAndNoNewDonation(userWithProgress.email);
|
||||
expect(failResponse.body).toEqual({
|
||||
error: {
|
||||
type: 'MethodRestrictionError',
|
||||
message: `Donate using another method`
|
||||
}
|
||||
});
|
||||
expect(failResponse.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /donate/add-donation', () => {
|
||||
test('should return 200 and update the user', async () => {
|
||||
const response = await superPost('/donate/add-donation').send({
|
||||
anything: true,
|
||||
itIs: 'ignored'
|
||||
});
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: userWithProgress.email }
|
||||
});
|
||||
expect(user?.isDonating).toBe(true);
|
||||
expect(response.body).toEqual({
|
||||
isDonating: true
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should return 400 if the user is already donating', async () => {
|
||||
const successResponse = await superPost('/donate/add-donation').send(
|
||||
{}
|
||||
);
|
||||
expect(successResponse.status).toBe(200);
|
||||
const failResponse = await superPost('/donate/add-donation').send({});
|
||||
expect(failResponse.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should capture unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
const updateSpy = vi
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'update')
|
||||
.mockRejectedValueOnce(new Error('DB error'));
|
||||
|
||||
const response = await superPost('/donate/add-donation').send({});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
updateSpy.mockRestore();
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /donate/update-stripe-card', () => {
|
||||
test('should return 200 and return session id', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.donation.create({
|
||||
data: donationMock
|
||||
});
|
||||
const response = await superPut('/donate/update-stripe-card').send({});
|
||||
expect(mockCheckoutSessionCreate).toHaveBeenCalledWith({
|
||||
cancel_url: 'http://localhost:8000/update-stripe-card',
|
||||
customer: 'cust_test_id',
|
||||
mode: 'setup',
|
||||
payment_method_types: ['card'],
|
||||
setup_intent_data: {
|
||||
metadata: {
|
||||
customer_id: 'cust_test_id',
|
||||
subscription_id: 'sub_test_id'
|
||||
}
|
||||
},
|
||||
success_url:
|
||||
'http://localhost:8000/update-stripe-card?session_id={CHECKOUT_SESSION_ID}'
|
||||
});
|
||||
expect(response.body).toEqual({ sessionId: 'checkout_session_id' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'donation.card_update_requested',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'success' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
test('should return 404 if there is no donation record', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superPut('/donate/update-stripe-card').send({});
|
||||
expect(response.body).toEqual({
|
||||
message: 'flash.generic-error',
|
||||
type: 'danger'
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'donation.card_update_requested',
|
||||
1,
|
||||
{
|
||||
attributes: { result: 'not_found' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /donate/create-stripe-payment-intent', () => {
|
||||
test('should return 200 and call stripe api properly', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('no-errors')
|
||||
);
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
expect(mockCustomerCreate).toHaveBeenCalledWith({
|
||||
email: testEWalletEmail,
|
||||
name: 'Baz Bar'
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should return 400 when email format is wrong', async () => {
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send({
|
||||
...createStripePaymentIntentReqBody,
|
||||
email: '12raqdcev'
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
error: 'The donation form had invalid values for this submission.'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should return 400 if amount is incorrect', async () => {
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send({
|
||||
...createStripePaymentIntentReqBody,
|
||||
amount: '350'
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
error: 'The donation form had invalid values for this submission.'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should return 500 if Stripe encounters an error', async () => {
|
||||
mockSubCreate.mockImplementationOnce(defaultError);
|
||||
const response = await superPost(
|
||||
'/donate/create-stripe-payment-intent'
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /donate/charge-stripe', () => {
|
||||
test('should return 200 and call stripe api properly', async () => {
|
||||
mockSubCreate.mockImplementationOnce(
|
||||
generateMockSubCreate('no-errors')
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyUpdatedUserAndNewDonation(testEWalletEmail);
|
||||
expect(mockSubRetrieve).toHaveBeenCalledWith('sub_test_id');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should return 500 when if product id is wrong', async () => {
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
...mockSubRetrieveObj,
|
||||
items: {
|
||||
...mockSubRetrieveObj.items,
|
||||
data: [
|
||||
{
|
||||
...mockSubRetrieveObj.items.data[0],
|
||||
plan: {
|
||||
product: 'wrong_product_id'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyNoNewUserAndNoNewDonation();
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
test('should return 500 if subsciption is not active', async () => {
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
...mockSubRetrieveObj,
|
||||
status: 'canceled'
|
||||
})
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyNoNewUserAndNoNewDonation();
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
test('should return 500 if timestamp is old', async () => {
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
...mockSubRetrieveObj,
|
||||
current_period_start: Math.floor(Date.now() / 1000) - 500
|
||||
})
|
||||
);
|
||||
const response = await superPost('/donate/charge-stripe').send(
|
||||
chargeStripeReqBody
|
||||
);
|
||||
await verifyNoNewUserAndNoNewDonation();
|
||||
expect(response.body).toEqual({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import * as schemas from '../../schemas.js';
|
||||
import { donationSubscriptionConfig } from '@freecodecamp/shared/config/donation-settings';
|
||||
import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../../utils/env.js';
|
||||
import { clientNetInfo } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Plugin for the donation endpoints requiring auth.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
// Stripe plugin
|
||||
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2024-06-20',
|
||||
typescript: true
|
||||
});
|
||||
|
||||
fastify.put(
|
||||
'/donate/update-stripe-card',
|
||||
{
|
||||
schema: schemas.updateStripeCard
|
||||
},
|
||||
async (req, reply) => {
|
||||
const donation = await fastify.prisma.donation.findFirst({
|
||||
where: { userId: req.user?.id, provider: 'stripe' }
|
||||
});
|
||||
if (!donation) {
|
||||
req.log.warn(
|
||||
{ userId: req.user?.id },
|
||||
'Stripe donation record not found'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('donation.card_update_requested', 1, {
|
||||
attributes: { result: 'not_found' }
|
||||
});
|
||||
void reply.code(404);
|
||||
return { message: 'flash.generic-error', type: 'danger' } as const;
|
||||
}
|
||||
const { customerId, subscriptionId } = donation;
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
mode: 'setup',
|
||||
customer: customerId,
|
||||
setup_intent_data: {
|
||||
metadata: {
|
||||
customer_id: customerId,
|
||||
subscription_id: subscriptionId
|
||||
}
|
||||
},
|
||||
success_url: `${HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${HOME_LOCATION}/update-stripe-card`
|
||||
});
|
||||
req.log.info('Stripe session created');
|
||||
fastify.Sentry?.metrics?.count('donation.card_update_requested', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
return { sessionId: session.id } as const;
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/donate/add-donation',
|
||||
{
|
||||
schema: schemas.addDonation
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
if (user?.isDonating) {
|
||||
req.log.warn('User is already donating');
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'info',
|
||||
message: 'User is already donating.'
|
||||
} as const;
|
||||
}
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
isDonating: true
|
||||
}
|
||||
});
|
||||
|
||||
req.log.info({ audit: true }, 'User is now donating');
|
||||
|
||||
return {
|
||||
isDonating: true
|
||||
} as const;
|
||||
} catch (error) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(
|
||||
{ err: error, userId: req.user?.id, ...clientNetInfo(req) },
|
||||
'User failed to donate'
|
||||
);
|
||||
void reply.code(500);
|
||||
return {
|
||||
type: 'danger',
|
||||
message: 'Something went wrong.'
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/donate/charge-stripe-card',
|
||||
{
|
||||
schema: schemas.chargeStripeCard
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { paymentMethodId, amount, duration } = req.body;
|
||||
const id = req.user!.id;
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
const { email, name } = user;
|
||||
|
||||
if (!email) {
|
||||
req.log.warn('User has no email');
|
||||
void reply.code(403);
|
||||
return reply.send({
|
||||
error: {
|
||||
type: 'EmailRequiredError',
|
||||
message: 'User has not provided an email address'
|
||||
}
|
||||
});
|
||||
}
|
||||
const threeChallengesCompleted = user.completedChallenges.length >= 3;
|
||||
|
||||
if (!threeChallengesCompleted) {
|
||||
req.log.warn(
|
||||
'User has tried to donate before completing 3 challenges'
|
||||
);
|
||||
void reply.code(400);
|
||||
return {
|
||||
error: {
|
||||
type: 'MethodRestrictionError',
|
||||
message: `Donate using another method`
|
||||
}
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (user.isDonating) {
|
||||
req.log.warn('User is already donating');
|
||||
void reply.code(400);
|
||||
return reply.send({
|
||||
error: {
|
||||
type: 'AlreadyDonatingError',
|
||||
message: 'User is already donating.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create Stripe Customer
|
||||
const { id: customerId } = await stripe.customers.create({
|
||||
email,
|
||||
payment_method: paymentMethodId,
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
...(name && { name })
|
||||
});
|
||||
|
||||
// //Create Stripe Subscription
|
||||
const plan = `${donationSubscriptionConfig.duration[
|
||||
duration
|
||||
].toLowerCase()}-donation-${amount}`;
|
||||
|
||||
const {
|
||||
id: subscriptionId,
|
||||
latest_invoice: {
|
||||
// For older api versions, @ts-ignore is recommended by Stripe. More info: https://github.com/stripe/stripe-node/blob/fe81d1f28064c9b468c7b380ab09f7a93054ba63/README.md?plain=1#L91
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore stripe-version-2019-10-17
|
||||
payment_intent: { client_secret, status }
|
||||
}
|
||||
} = await stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
payment_behavior: 'allow_incomplete',
|
||||
items: [{ plan }],
|
||||
expand: ['latest_invoice.payment_intent']
|
||||
});
|
||||
if (status === 'requires_source_action') {
|
||||
req.log.info('User payment requires user action');
|
||||
fastify.Sentry?.metrics?.count('donation.action_required', 1);
|
||||
void reply.code(402);
|
||||
return reply.send({
|
||||
error: {
|
||||
type: 'UserActionRequired',
|
||||
message: 'Payment requires user action',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
client_secret
|
||||
}
|
||||
});
|
||||
} else if (status === 'requires_source') {
|
||||
req.log.warn('User payment declined');
|
||||
fastify.Sentry?.metrics?.count('donation.declined', 1, {
|
||||
attributes: { flow: 'charge-stripe-card' }
|
||||
});
|
||||
void reply.code(402);
|
||||
return reply.send({
|
||||
error: {
|
||||
type: 'PaymentMethodRequired',
|
||||
message: 'Card has been declined'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// update record in database
|
||||
const donation = {
|
||||
userId: id,
|
||||
email,
|
||||
amount,
|
||||
duration,
|
||||
provider: 'stripe',
|
||||
subscriptionId,
|
||||
customerId: customerId,
|
||||
// TODO(Post-MVP) migrate to startDate: new Date()
|
||||
startDate: {
|
||||
date: new Date().toISOString(),
|
||||
when: new Date().toISOString().replace(/.$/, '+00:00')
|
||||
}
|
||||
};
|
||||
|
||||
await fastify.prisma.donation.create({
|
||||
data: donation
|
||||
});
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isDonating: true
|
||||
}
|
||||
});
|
||||
|
||||
req.log.info(
|
||||
{
|
||||
audit: true,
|
||||
userId: id,
|
||||
email,
|
||||
amount,
|
||||
duration,
|
||||
subscriptionId,
|
||||
...clientNetInfo(req)
|
||||
},
|
||||
'User has successfully donated'
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('donation.created', 1, {
|
||||
attributes: { flow: 'charge-stripe-card' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
type: 'success',
|
||||
isDonating: true
|
||||
});
|
||||
} catch (error) {
|
||||
const ctx = {
|
||||
err: error,
|
||||
userId: req.user?.id,
|
||||
...clientNetInfo(req)
|
||||
};
|
||||
if (
|
||||
error instanceof Stripe.errors.StripeCardError ||
|
||||
error instanceof Stripe.errors.StripeInvalidRequestError
|
||||
) {
|
||||
req.log.warn(ctx, 'Stripe upstream error charging card');
|
||||
} else {
|
||||
fastify.Sentry?.captureException(error);
|
||||
req.log.error(ctx, 'User failed to donate');
|
||||
}
|
||||
void reply.code(500);
|
||||
return reply.send({
|
||||
error: 'Donation failed due to a server error.'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './certificate.js';
|
||||
export * from './challenge.js';
|
||||
export * from './donate.js';
|
||||
export * from './settings.js';
|
||||
export * from './user.js';
|
||||
export * from './socrates.js';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
devLogin,
|
||||
setupServer,
|
||||
createSuperRequest,
|
||||
defaultUserId
|
||||
} from '../../../vitest.utils.js';
|
||||
|
||||
const mockedFetch = vi.fn();
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
|
||||
|
||||
const validPayload = {
|
||||
description: 'Make the text say hello',
|
||||
userInput: 'Hello world',
|
||||
seed: '<h1>Hello</h1>',
|
||||
hints: [{ text: 'Check your spelling', failed: true }]
|
||||
};
|
||||
|
||||
describe('socratesRoutes', () => {
|
||||
setupServer();
|
||||
|
||||
describe('Authenticated user', () => {
|
||||
let superPut: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPut = createSuperRequest({ method: 'PUT', setCookies });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PUT /socrates/get-hint', () => {
|
||||
test('should return 403 when user has socrates explicitly disabled', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: false }
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-no-access',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow access when socrates is null (default)', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: null }
|
||||
});
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ hint: 'Try adding a closing tag.' })
|
||||
)
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('hint');
|
||||
});
|
||||
|
||||
describe('with socrates enabled', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: true }
|
||||
});
|
||||
await fastifyTestInstance.prisma.socratesUsage.deleteMany({
|
||||
where: { userId: defaultUserId }
|
||||
});
|
||||
});
|
||||
|
||||
test('should return hint on successful Socrates API response', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count, distribution }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ hint: 'Try adding a closing tag.' })
|
||||
)
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
hint: 'Try adding a closing tag.',
|
||||
attempts: 1,
|
||||
limit: 3
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('socrates.hint_granted', 1, {
|
||||
attributes: { donorStatus: 'non-donor' }
|
||||
});
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'socrates.upstream_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond', attributes: { result: 'success' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass session userId, not client-supplied userId', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
await superPut('/socrates/get-hint').send({
|
||||
...validPayload
|
||||
});
|
||||
|
||||
const fetchCall = mockedFetch.mock.calls[0]!;
|
||||
const body = JSON.parse(fetchCall[1].body as string) as {
|
||||
userId: string;
|
||||
};
|
||||
expect(body.userId).toBe(defaultUserId);
|
||||
});
|
||||
|
||||
test('should use session userId even when userId is sent in body', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
userId: 'attacker-id'
|
||||
});
|
||||
|
||||
const fetchCall = mockedFetch.mock.calls[0]!;
|
||||
const body = JSON.parse(fetchCall[1].body as string) as {
|
||||
userId: string;
|
||||
};
|
||||
expect(body.userId).toBe(defaultUserId);
|
||||
expect(body.userId).not.toBe('attacker-id');
|
||||
});
|
||||
|
||||
test('should return 429 when Socrates API rate limits', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve('')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-rate-limit',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'upstream', donorStatus: 'non-donor' }
|
||||
});
|
||||
});
|
||||
|
||||
test('should forward upstream error message on 400', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ error: 'Input too short for analysis.' })
|
||||
)
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'Input too short for analysis.',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'bad_status' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should use fallback message on 400 with no upstream error', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve('')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unable-to-generate',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 500 and capture on other Socrates API errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: () => Promise.resolve('Service Unavailable')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message: 'Socrates API returned status 503'
|
||||
})
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'bad_status' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 500 and capture when Socrates API returns invalid JSON', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('not json')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.type).toBe('danger');
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'invalid_response' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 500 and capture when Socrates API returns no hint', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ foo: 'bar' }))
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.type).toBe('danger');
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({
|
||||
message: 'Socrates API did not return a hint'
|
||||
})
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'missing_hint' } }
|
||||
);
|
||||
});
|
||||
|
||||
test('should return 500 when fetch throws', async () => {
|
||||
mockedFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('should not capture a fetch network failure', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
const distribution = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count, distribution }
|
||||
};
|
||||
|
||||
const networkError = Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error('connect ECONNREFUSED'), {
|
||||
code: 'ECONNREFUSED'
|
||||
})
|
||||
});
|
||||
mockedFetch.mockRejectedValueOnce(networkError);
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'network' } }
|
||||
);
|
||||
expect(distribution).toHaveBeenCalledWith(
|
||||
'socrates.upstream_latency_ms',
|
||||
expect.any(Number),
|
||||
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should capture a genuine TypeError bug from the handler', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const bugError = new TypeError(
|
||||
"Cannot read properties of undefined (reading 'foo')"
|
||||
);
|
||||
mockedFetch.mockRejectedValueOnce(bugError);
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledExactlyOnceWith(bugError);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'socrates.upstream_call_failed',
|
||||
1,
|
||||
{ attributes: { reason: 'exception' } }
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
|
||||
describe('daily usage entitlements', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: true, isDonating: false }
|
||||
});
|
||||
await fastifyTestInstance.prisma.socratesUsage.deleteMany({
|
||||
where: { userId: defaultUserId }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should return attempts=1 and limit=3 on first hint for non-donor', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(1);
|
||||
expect(response.body.limit).toBe(3);
|
||||
});
|
||||
|
||||
test('should increment attempts on each request', async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(2);
|
||||
});
|
||||
|
||||
test('should return 429 when non-donor exceeds 3 hints/day', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
}
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.body.attempts).toBe(3);
|
||||
expect(response.body.limit).toBe(3);
|
||||
expect(response.body.error).toBe('socrates-daily-limit');
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(3);
|
||||
expect(count).toHaveBeenCalledWith('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'local', donorStatus: 'non-donor' }
|
||||
});
|
||||
});
|
||||
|
||||
test('should not inflate count beyond limit on repeated 429s', async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
// Exhaust the non-donor limit
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
}
|
||||
|
||||
// Make extra requests that should all be 429
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(res.status).toBe(429);
|
||||
}
|
||||
|
||||
// Upgrade to donor (limit: 10) and verify access is restored
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isDonating: true }
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(4);
|
||||
expect(response.body.limit).toBe(10);
|
||||
});
|
||||
|
||||
test('should allow 10 hints/day for donors', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isDonating: true }
|
||||
});
|
||||
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
let response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
for (let i = 1; i < 10; i++) {
|
||||
response = await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
|
||||
expect(response.body.attempts).toBe(10);
|
||||
expect(response.body.limit).toBe(10);
|
||||
|
||||
response = await superPut('/socrates/get-hint').send(validPayload);
|
||||
expect(response.status).toBe(429);
|
||||
});
|
||||
|
||||
test('should not consume an attempt on upstream API error', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Server Error')
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.attempts).toBe(0);
|
||||
expect(response.body.limit).toBe(3);
|
||||
});
|
||||
|
||||
test('should not count yesterday usage against today limit', async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const yesterdayUTC = new Date(
|
||||
Date.UTC(
|
||||
yesterday.getUTCFullYear(),
|
||||
yesterday.getUTCMonth(),
|
||||
yesterday.getUTCDate()
|
||||
)
|
||||
);
|
||||
|
||||
await fastifyTestInstance.prisma.socratesUsage.create({
|
||||
data: { userId: defaultUserId, date: yesterdayUTC, count: 3 }
|
||||
});
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ hint: 'A hint.' }))
|
||||
});
|
||||
|
||||
const response =
|
||||
await superPut('/socrates/get-hint').send(validPayload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { socrates: true }
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 when userInput is empty string', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
userInput: ''
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toStrictEqual({
|
||||
error: 'socrates-invalid-request',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should accept request without userInput', async () => {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({ hint: 'Try adding a closing tag.' })
|
||||
)
|
||||
});
|
||||
|
||||
const { userInput: _unused, ...payloadWithoutUserInput } =
|
||||
validPayload;
|
||||
const response = await superPut('/socrates/get-hint').send(
|
||||
payloadWithoutUserInput
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('hint');
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should return 400 when seed is empty', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
seed: ''
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 400 when description is empty', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({
|
||||
...validPayload,
|
||||
description: ''
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 400 when required fields are missing', async () => {
|
||||
const response = await superPut('/socrates/get-hint').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthenticated user', () => {
|
||||
test('should not return a hint for unauthenticated requests', async () => {
|
||||
const response = await createSuperRequest({ method: 'PUT' })(
|
||||
'/socrates/get-hint'
|
||||
).send(validPayload);
|
||||
|
||||
// Unauthenticated requests fail before reaching the route handler
|
||||
expect(response.status).not.toBe(200);
|
||||
expect(response.body).not.toHaveProperty('hint');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import * as schemas from '../../schemas.js';
|
||||
import { SOCRATES_API_KEY, SOCRATES_ENDPOINT } from '../../utils/env.js';
|
||||
|
||||
const DAILY_LIMITS = { donor: 10, nonDonor: 3 } as const;
|
||||
|
||||
function getDailyLimit(isDonating: boolean): number {
|
||||
return isDonating ? DAILY_LIMITS.donor : DAILY_LIMITS.nonDonor;
|
||||
}
|
||||
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN'
|
||||
]);
|
||||
|
||||
function isFetchNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof TypeError)) {
|
||||
return false;
|
||||
}
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
const code =
|
||||
cause && typeof cause === 'object' && 'code' in cause
|
||||
? (cause as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (typeof code === 'string') {
|
||||
return code.startsWith('UND_ERR_') || NETWORK_ERROR_CODES.has(code);
|
||||
}
|
||||
return error.message === 'fetch failed';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const socratesRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
// Socrates plugin
|
||||
fastify.put(
|
||||
'/socrates/get-hint',
|
||||
{
|
||||
schema: schemas.askSocrates,
|
||||
errorHandler(error, req, reply) {
|
||||
if (error.validation) {
|
||||
void reply.status(400).send({
|
||||
error: 'socrates-invalid-request',
|
||||
type: 'info',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
} else {
|
||||
fastify.errorHandler(error, req, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
if (!req.user || req.user.socrates === false) {
|
||||
return reply.status(403).send({
|
||||
error: 'socrates-no-access',
|
||||
type: 'danger',
|
||||
attempts: 0,
|
||||
limit: 0
|
||||
});
|
||||
}
|
||||
|
||||
const limit = getDailyLimit(req.user.isDonating);
|
||||
const donorStatus = req.user.isDonating ? 'donor' : 'non-donor';
|
||||
const now = new Date();
|
||||
const todayUTC = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
);
|
||||
|
||||
const existing = await fastify.prisma.socratesUsage.findUnique({
|
||||
where: {
|
||||
userId_date: { userId: req.user.id, date: todayUTC }
|
||||
}
|
||||
});
|
||||
|
||||
if (existing && existing.count >= limit) {
|
||||
fastify.Sentry?.metrics?.count('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'local', donorStatus }
|
||||
});
|
||||
return reply.status(429).send({
|
||||
error: 'socrates-daily-limit',
|
||||
type: 'info',
|
||||
attempts: limit,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
const usage = await fastify.prisma.socratesUsage.upsert({
|
||||
where: {
|
||||
userId_date: { userId: req.user.id, date: todayUTC }
|
||||
},
|
||||
create: {
|
||||
userId: req.user.id,
|
||||
date: todayUTC,
|
||||
count: 1
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
const attempts = usage.count;
|
||||
|
||||
const rollbackUsage = async () => {
|
||||
await fastify.prisma.socratesUsage.update({
|
||||
where: {
|
||||
userId_date: { userId: req.user!.id, date: todayUTC }
|
||||
},
|
||||
data: { count: { decrement: 1 } }
|
||||
});
|
||||
};
|
||||
|
||||
const upstreamFetchStart = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SOCRATES_ENDPOINT}/hint`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': SOCRATES_API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: req.body.description,
|
||||
userInput: req.body.userInput,
|
||||
seed: req.body.seed,
|
||||
hints: req.body.hints,
|
||||
userId: req.user.id
|
||||
})
|
||||
});
|
||||
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'socrates.upstream_latency_ms',
|
||||
performance.now() - upstreamFetchStart,
|
||||
{ unit: 'millisecond', attributes: { result: 'success' } }
|
||||
);
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
req.log.error(
|
||||
{
|
||||
status: response.status,
|
||||
upstreamBody: responseText.slice(0, 500)
|
||||
},
|
||||
'Socrates API returned an error response.'
|
||||
);
|
||||
|
||||
await rollbackUsage();
|
||||
|
||||
if (response.status === 429) {
|
||||
fastify.Sentry?.metrics?.count('socrates.rate_limit_hit', 1, {
|
||||
attributes: { source: 'upstream', donorStatus }
|
||||
});
|
||||
return reply.status(429).send({
|
||||
error: 'socrates-rate-limit',
|
||||
type: 'info',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
let upstreamMessage: string | undefined;
|
||||
try {
|
||||
const parsed = responseText
|
||||
? (JSON.parse(responseText) as { error?: string })
|
||||
: null;
|
||||
upstreamMessage = parsed?.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'bad_status' }
|
||||
});
|
||||
return reply.status(400).send({
|
||||
error: upstreamMessage || 'socrates-unable-to-generate',
|
||||
type: 'info',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
fastify.Sentry?.captureException(
|
||||
new Error(`Socrates API returned status ${response.status}`)
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'bad_status' }
|
||||
});
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = responseText ? JSON.parse(responseText) : null;
|
||||
} catch (error) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'invalid_response' }
|
||||
});
|
||||
req.log.error(
|
||||
{ err: error },
|
||||
'Failed to parse Socrates API response.'
|
||||
);
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
typeof (payload as { hint?: unknown }).hint !== 'string'
|
||||
) {
|
||||
fastify.Sentry?.captureException(
|
||||
new Error('Socrates API did not return a hint')
|
||||
);
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: { reason: 'missing_hint' }
|
||||
});
|
||||
req.log.error(
|
||||
{
|
||||
payloadType: payload === null ? 'null' : typeof payload,
|
||||
hintType: typeof (payload as { hint?: unknown } | null)?.hint
|
||||
},
|
||||
'Socrates API did not return a hint.'
|
||||
);
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
const { hint } = payload as { hint: string };
|
||||
|
||||
fastify.Sentry?.metrics?.count('socrates.hint_granted', 1, {
|
||||
attributes: { donorStatus }
|
||||
});
|
||||
return { hint, attempts, limit } as const;
|
||||
} catch (error) {
|
||||
fastify.Sentry?.metrics?.distribution(
|
||||
'socrates.upstream_latency_ms',
|
||||
performance.now() - upstreamFetchStart,
|
||||
{ unit: 'millisecond', attributes: { result: 'failure' } }
|
||||
);
|
||||
if (!isFetchNetworkError(error)) {
|
||||
fastify.Sentry?.captureException(error);
|
||||
}
|
||||
fastify.Sentry?.metrics?.count('socrates.upstream_call_failed', 1, {
|
||||
attributes: {
|
||||
reason: isFetchNetworkError(error) ? 'network' : 'exception'
|
||||
}
|
||||
});
|
||||
req.log.error(
|
||||
{ err: error },
|
||||
'Failed to fetch hint from Socrates API.'
|
||||
);
|
||||
await rollbackUsage();
|
||||
return reply.status(500).send({
|
||||
error: 'socrates-unavailable',
|
||||
type: 'danger',
|
||||
attempts: attempts - 1,
|
||||
limit
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
done();
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { devAuth } from '../../plugins/auth-dev.js';
|
||||
|
||||
/**
|
||||
* Route handler for development login.
|
||||
*
|
||||
* @deprecated
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin,
|
||||
* options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const devAuthRoutes: FastifyPluginCallback = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
void fastify.register(devAuth);
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
import {
|
||||
setupServer,
|
||||
superRequest,
|
||||
createSuperRequest
|
||||
} from '../../../vitest.utils.js';
|
||||
import { AUTH0_DOMAIN } from '../../utils/env.js';
|
||||
|
||||
const mockedFetch = vi.fn();
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
|
||||
|
||||
const newUserEmail = 'a.n.random@user.com';
|
||||
|
||||
const mockAuth0NotOk = () => ({
|
||||
ok: false
|
||||
});
|
||||
|
||||
const mockAuth0InvalidEmail = () => ({
|
||||
ok: true,
|
||||
json: () => ({ email: 'invalid-email' })
|
||||
});
|
||||
|
||||
const mockAuth0ValidEmail = () => ({
|
||||
ok: true,
|
||||
json: () => ({ email: newUserEmail })
|
||||
});
|
||||
|
||||
vi.mock('../../utils/env', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../utils/env.js')>(
|
||||
'../../utils/env'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
FCC_ENABLE_DEV_LOGIN_MODE: false
|
||||
};
|
||||
});
|
||||
|
||||
describe('auth0 routes', () => {
|
||||
setupServer();
|
||||
describe('GET /signin', () => {
|
||||
it('should redirect to the auth0 login page', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const redirectUrl = new URL(res.headers.location);
|
||||
expect(redirectUrl.host).toMatch(AUTH0_DOMAIN);
|
||||
expect(redirectUrl.pathname).toBe('/authorize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /mobile-login', () => {
|
||||
let superGet: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
beforeAll(() => {
|
||||
superGet = createSuperRequest({ method: 'GET' });
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: newUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 if the authorization header is invalid', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0NotOk());
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer invalid-token'
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
type: 'danger',
|
||||
message: 'We could not log you in, please try again in a moment.'
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
expect(count).toHaveBeenCalledWith('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'no_email' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should return 400 if the email is not valid', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0InvalidEmail());
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer valid-token'
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
type: 'danger',
|
||||
message: 'The email is incorrectly formatted'
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'invalid_format' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should set the jwt_access_token cookie if the authorization header is valid', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail());
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer valid-token'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.get('Set-Cookie')).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/jwt_access_token=/)])
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
it('should create a user if they do not exist', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail());
|
||||
const existingUserCount = await fastifyTestInstance.prisma.user.count();
|
||||
|
||||
const res = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer valid-token'
|
||||
);
|
||||
|
||||
const newUserCount = await fastifyTestInstance.prisma.user.count();
|
||||
|
||||
expect(existingUserCount).toBe(0);
|
||||
expect(newUserCount).toBe(1);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should redirect to returnTo if already logged in', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(mockAuth0ValidEmail());
|
||||
const firstRes = await superGet('/mobile-login').set(
|
||||
'Authorization',
|
||||
'Bearer valid-token'
|
||||
);
|
||||
|
||||
expect(firstRes.status).toBe(200);
|
||||
|
||||
const res = await superRequest('/mobile-login', {
|
||||
method: 'GET',
|
||||
setCookies: firstRes.get('Set-Cookie')
|
||||
})
|
||||
.set('Authorization', 'Bearer does-not-matter')
|
||||
.set('Referer', 'https://www.freecodecamp.org/back-home');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/back-home'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import validator from 'validator';
|
||||
|
||||
import { AUTH0_DOMAIN } from '../../utils/env.js';
|
||||
import { auth0Client } from '../../plugins/auth0.js';
|
||||
import { createAccessToken } from '../../utils/tokens.js';
|
||||
import { findOrCreateUser } from '../helpers/auth-helpers.js';
|
||||
import { clientNetInfo } from '../../utils/logger.js';
|
||||
|
||||
const getEmailFromAuth0 = async (
|
||||
req: FastifyRequest
|
||||
): Promise<string | null> => {
|
||||
const auth0Res = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
|
||||
headers: {
|
||||
Authorization: req.headers.authorization ?? ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!auth0Res.ok) {
|
||||
req.log.warn({ status: auth0Res.status }, 'Auth0 userinfo request failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
// For now, we assume the response is a JSON object. If not, we can't proceed
|
||||
// and the only safe thing to do is to throw.
|
||||
const { email } = (await auth0Res.json()) as { email?: string };
|
||||
return typeof email === 'string' ? email : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Route handler for Mobile authentication.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*
|
||||
*/
|
||||
export const mobileAuth0Routes: FastifyPluginCallback = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
// TODO(Post-MVP): move this into the app, so that we add this hook once for
|
||||
// all auth routes.
|
||||
fastify.addHook('onRequest', fastify.redirectIfSignedIn);
|
||||
|
||||
fastify.get('/mobile-login', async (req, reply) => {
|
||||
const email = await getEmailFromAuth0(req);
|
||||
|
||||
req.log.debug('Mobile app login attempt');
|
||||
|
||||
if (!email) {
|
||||
req.log.error(
|
||||
clientNetInfo(req),
|
||||
'Could not get email from Auth0 to log in'
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'no_email' }
|
||||
});
|
||||
|
||||
return reply.status(401).send({
|
||||
message: 'We could not log you in, please try again in a moment.',
|
||||
type: 'danger'
|
||||
});
|
||||
}
|
||||
if (!validator.default.isEmail(email)) {
|
||||
req.log.warn(
|
||||
clientNetInfo(req),
|
||||
'Email is incorrectly formatted for login'
|
||||
);
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'failure', reason: 'invalid_format' }
|
||||
});
|
||||
|
||||
return reply.status(400).send({
|
||||
message: 'The email is incorrectly formatted',
|
||||
type: 'danger'
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
fastify.Sentry?.metrics?.count('auth.mobile_login_attempted', 1, {
|
||||
attributes: { result: 'success' }
|
||||
});
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Route handler for authentication routes.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const authRoutes: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
// All routes are registered by the auth0 plugin, but we need an extra plugin
|
||||
// (this one) to encapsulate the auth0 decorators. Otherwise auth0OAuth will
|
||||
// be available globally.
|
||||
void fastify.register(auth0Client);
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,421 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
test,
|
||||
expect,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import {
|
||||
defaultUserEmail,
|
||||
defaultUserId,
|
||||
resetDefaultUser,
|
||||
setupServer,
|
||||
superRequest
|
||||
} from '../../../vitest.utils.js';
|
||||
import { getFallbackFullStackDate } from '../helpers/certificate-utils.js';
|
||||
|
||||
const DATE_NOW = Date.now();
|
||||
|
||||
describe('certificate routes', () => {
|
||||
setupServer();
|
||||
|
||||
describe('Unauthenticated user', () => {
|
||||
beforeAll(async () => {
|
||||
await resetDefaultUser();
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(DATE_NOW);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('GET /certificate/showCert/:username/:certSlug', () => {
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: defaultUserEmail },
|
||||
data: {
|
||||
username: 'foobar',
|
||||
name: 'foobar',
|
||||
isHonest: true,
|
||||
isBanned: false,
|
||||
isCheater: false,
|
||||
profileUI: { isLocked: false, showCerts: true, showTimeLine: true }
|
||||
}
|
||||
});
|
||||
});
|
||||
test('should return user not found if the user cannot be found', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/not-a-valid-user-name/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.username-not-found',
|
||||
variables: { username: 'not-a-valid-user-name' }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'certificate.public_view_blocked',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'user_not_found' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
test('should ask user to add name if there is no name', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { name: null }
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.add-name'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
test('should return not eligible if user is banned', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isBanned: true }
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-eligible'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
test('should return not eligible if user is cheater', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isCheater: true }
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-eligible'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
test('should return not honest if user is not honest', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { isHonest: false }
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-honest',
|
||||
variables: { username: 'foobar' }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
test('should return profile private if profile is private', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
// All properties need to be defined, as this op SETs `profileUI`
|
||||
profileUI: { isLocked: true, showTimeLine: true, showCerts: true }
|
||||
}
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.profile-private',
|
||||
variables: { username: 'foobar' }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
test('should return certs private if certs are private', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
profileUI: { showCerts: false, showTimeLine: true, isLocked: false }
|
||||
}
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.certs-private',
|
||||
variables: { username: 'foobar' }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
test('should return timeline private if timeline is private', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
profileUI: { showTimeLine: false, showCerts: true, isLocked: false }
|
||||
}
|
||||
});
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.timeline-private',
|
||||
variables: { username: 'foobar' }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('should not return user full name if `showName` is `false`', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
profileUI: {
|
||||
showTimeLine: true,
|
||||
showCerts: true,
|
||||
isLocked: false,
|
||||
showName: false
|
||||
},
|
||||
isJsAlgoDataStructCert: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: '561abd10cb81ac38a17513bc', // Cert ID
|
||||
completedDate: DATE_NOW
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: delete this assertion once there's only one status 200 response
|
||||
expect(response.body).toHaveProperty('username', 'foobar');
|
||||
expect(response.body).not.toHaveProperty('name');
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('certificate.public_viewed', 1, {
|
||||
attributes: {
|
||||
certSlug: 'javascript-algorithms-and-data-structures',
|
||||
nameVisibility: 'hidden'
|
||||
}
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return user full name if `showName` is `true`', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
profileUI: {
|
||||
showTimeLine: true,
|
||||
showCerts: true,
|
||||
isLocked: false,
|
||||
showName: true
|
||||
},
|
||||
isJsAlgoDataStructCert: true,
|
||||
completedChallenges: [
|
||||
{
|
||||
id: '561abd10cb81ac38a17513bc', // Cert ID
|
||||
completedDate: DATE_NOW
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/javascript-algorithms-and-data-structures',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.body).toHaveProperty('name', 'foobar');
|
||||
expect(response.status).toBe(200);
|
||||
expect(count).toHaveBeenCalledWith('certificate.public_viewed', 1, {
|
||||
attributes: {
|
||||
certSlug: 'javascript-algorithms-and-data-structures',
|
||||
nameVisibility: 'shown'
|
||||
}
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('should return cert-not-found if there is no cert with that slug', async () => {
|
||||
const count = vi.fn();
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(
|
||||
'/certificate/showCert/foobar/not-a-valid-cert-slug',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.cert-not-found',
|
||||
variables: { certSlug: 'not-a-valid-cert-slug' }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(count).toHaveBeenCalledWith(
|
||||
'certificate.public_view_blocked',
|
||||
1,
|
||||
{
|
||||
attributes: { reason: 'unknown_cert_slug' }
|
||||
}
|
||||
);
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const fullStackChallenges = [
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '5a553ca864b52e1d8bceea14'
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561acd10cb82ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1685210952511,
|
||||
id: '561abd10cb81ac38a17513bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1585210952511,
|
||||
id: '561add10cb82ac38a17523bc'
|
||||
},
|
||||
{
|
||||
completedDate: 1588665778679,
|
||||
id: '561add10cb82ac38a17213bc'
|
||||
}
|
||||
];
|
||||
|
||||
describe('helper functions', () => {
|
||||
describe('getFallbackFullStackDate', () => {
|
||||
it('should return the date of the latest completed challenge', () => {
|
||||
expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe(
|
||||
1685210952511
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to completedDate if no certifications are provided', () => {
|
||||
expect(getFallbackFullStackDate([], 123)).toBe(123);
|
||||
});
|
||||
|
||||
it('should fall back to completedDate if none of the certifications have been completed', () => {
|
||||
expect(
|
||||
getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123)
|
||||
).toBe(123);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import { find } from 'lodash-es';
|
||||
import * as schemas from '../../schemas.js';
|
||||
import {
|
||||
certSlugTypeMap,
|
||||
certToTitleMap,
|
||||
certToIdMap,
|
||||
completionHours,
|
||||
oldDataVizId
|
||||
} from '@freecodecamp/shared/config/certification-settings';
|
||||
import {
|
||||
getFallbackFullStackDate,
|
||||
isKnownCertSlug
|
||||
} from '../helpers/certificate-utils.js';
|
||||
import { normalizeDate } from '../../utils/normalize.js';
|
||||
|
||||
/**
|
||||
* Plugin for the unprotected certificate endpoints.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get(
|
||||
'/certificate/showCert/:username/:certSlug',
|
||||
{
|
||||
schema: schemas.certSlug
|
||||
},
|
||||
async (req, reply) => {
|
||||
const username = req.params.username.toLowerCase();
|
||||
const certSlug = req.params.certSlug;
|
||||
|
||||
if (!isKnownCertSlug(certSlug)) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'unknown_cert_slug' }
|
||||
});
|
||||
req.log.warn({ certSlug }, 'Unknown certSlug');
|
||||
void reply.code(404);
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.cert-not-found',
|
||||
variables: { certSlug }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const certType = certSlugTypeMap[certSlug];
|
||||
const certId = certToIdMap[certSlug];
|
||||
const certTitle = certToTitleMap[certSlug];
|
||||
const completionTime = completionHours[certSlug] || 300;
|
||||
const user = await fastify.prisma.user.findFirst({
|
||||
where: { username },
|
||||
select: {
|
||||
isBanned: true,
|
||||
isCheater: true,
|
||||
isA2EnglishCert: true,
|
||||
isFrontEndCert: true,
|
||||
isBackEndCert: true,
|
||||
isFullStackCert: true,
|
||||
isRespWebDesignCert: true,
|
||||
isRespWebDesignCertV9: true,
|
||||
isFrontEndLibsCert: true,
|
||||
isJavascriptCertV9: true,
|
||||
isJsAlgoDataStructCert: true,
|
||||
isJsAlgoDataStructCertV8: true,
|
||||
isDataVisCert: true,
|
||||
is2018DataVisCert: true,
|
||||
isApisMicroservicesCert: true,
|
||||
isInfosecQaCert: true,
|
||||
isPythonCertV9: true,
|
||||
isQaCertV7: true,
|
||||
isInfosecCertV7: true,
|
||||
isSciCompPyCertV7: true,
|
||||
isDataAnalysisPyCertV7: true,
|
||||
isMachineLearningPyCertV7: true,
|
||||
isRelationalDatabaseCertV8: true,
|
||||
isRelationalDatabaseCertV9: true,
|
||||
isCollegeAlgebraPyCertV8: true,
|
||||
isFoundationalCSharpCertV8: true,
|
||||
isFrontEndLibsCertV9: true,
|
||||
isBackEndDevApisCertV9: true,
|
||||
isFullStackDeveloperCertV9: true,
|
||||
isB1EnglishCert: true,
|
||||
isA2SpanishCert: true,
|
||||
isA2ChineseCert: true,
|
||||
isA1ChineseCert: true,
|
||||
isHonest: true,
|
||||
username: true,
|
||||
name: true,
|
||||
completedChallenges: true,
|
||||
profileUI: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user === null) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'user_not_found' }
|
||||
});
|
||||
req.log.debug({ username }, 'User not found');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.username-not-found',
|
||||
variables: { username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isCheater || user.isBanned) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'user_ineligible' }
|
||||
});
|
||||
req.log.debug({ username }, 'User is banned or a cheater');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-eligible'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.isHonest) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'not_honest' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has not accepted honesty policy');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.not-honest',
|
||||
variables: { username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (user.profileUI?.isLocked) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'profile_locked' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has a locked profile');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.profile-private',
|
||||
variables: { username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.name) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'missing_name' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has not added a name');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.add-name'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.profileUI?.showCerts) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'certs_private' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has private certs');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.certs-private',
|
||||
variables: { username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.profileUI?.showTimeLine) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'timeline_private' }
|
||||
});
|
||||
req.log.debug({ username }, 'User has private timeline');
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.timeline-private',
|
||||
variables: { username }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
if (!user[certType]) {
|
||||
fastify.Sentry?.metrics?.count('certificate.public_view_blocked', 1, {
|
||||
attributes: { reason: 'cert_not_completed' }
|
||||
});
|
||||
req.log.debug(
|
||||
{ username, certTitle },
|
||||
'User has not completed the certification'
|
||||
);
|
||||
return reply.send({
|
||||
messages: [
|
||||
{
|
||||
type: 'info',
|
||||
message: 'flash.user-not-certified',
|
||||
variables: { username, cert: certTitle }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const { completedChallenges } = user;
|
||||
const certChallenge = find(
|
||||
completedChallenges,
|
||||
({ id }) => certId === id
|
||||
);
|
||||
|
||||
let { completedDate = Date.now() } = certChallenge || {};
|
||||
|
||||
// the challenge id has been rotated for isDataVisCert
|
||||
if (certType === 'isDataVisCert' && !certChallenge) {
|
||||
const oldDataVisIdChall = find(
|
||||
completedChallenges,
|
||||
({ id }) => oldDataVizId === id
|
||||
);
|
||||
|
||||
if (oldDataVisIdChall) {
|
||||
completedDate = oldDataVisIdChall.completedDate || completedDate;
|
||||
}
|
||||
}
|
||||
|
||||
// if fullcert is not found, return the latest completedDate
|
||||
if (certType === 'isFullStackCert' && !certChallenge) {
|
||||
completedDate = getFallbackFullStackDate(
|
||||
completedChallenges,
|
||||
completedDate
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = user;
|
||||
|
||||
if (!user.profileUI.showName) {
|
||||
req.log.debug({ username }, 'User has private name');
|
||||
fastify.Sentry?.metrics?.count('certificate.public_viewed', 1, {
|
||||
attributes: { certSlug, nameVisibility: 'hidden' }
|
||||
});
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
certSlug,
|
||||
certTitle,
|
||||
username,
|
||||
date: normalizeDate(completedDate),
|
||||
completionTime
|
||||
});
|
||||
}
|
||||
|
||||
fastify.Sentry?.metrics?.count('certificate.public_viewed', 1, {
|
||||
attributes: { certSlug, nameVisibility: 'shown' }
|
||||
});
|
||||
void reply.code(200);
|
||||
return reply.send({
|
||||
certSlug,
|
||||
certTitle,
|
||||
username,
|
||||
name,
|
||||
date: normalizeDate(completedDate),
|
||||
completionTime
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from 'supertest';
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import { setupServer } from '../../../vitest.utils.js';
|
||||
import { endpoints } from './deprecated-endpoints.js';
|
||||
|
||||
describe('Deprecated endpoints', () => {
|
||||
setupServer();
|
||||
|
||||
endpoints.forEach(([endpoint, method]) => {
|
||||
test(`${method} ${endpoint} returns 410 status code with "info" message`, async () => {
|
||||
const response = await request(fastifyTestInstance.server)[
|
||||
method.toLowerCase() as 'get' | 'post'
|
||||
](endpoint);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
message: {
|
||||
type: 'info',
|
||||
message: 'Please reload the app, this feature is no longer available.'
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(410);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import * as schemas from '../../schemas.js';
|
||||
|
||||
type Endpoints = [string, 'GET' | 'POST'][];
|
||||
|
||||
export const endpoints: Endpoints = [
|
||||
['/refetch-user-completed-challenges', 'POST'],
|
||||
['/certificate/verify-can-claim-cert', 'GET'],
|
||||
['/api/github', 'GET'],
|
||||
['/account', 'GET']
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugin for the deprecated endpoints. Instantiates a Fastify route for each
|
||||
* endpoint, returning a 410 status code and a message indicating that the user
|
||||
* should reload the app.
|
||||
*
|
||||
* These endpoints remain active until we can confirm that no requests are being
|
||||
* made to them.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Fastify options I guess?
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const deprecatedEndpoints: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
endpoints.forEach(([endpoint, method]) => {
|
||||
fastify.route({
|
||||
method,
|
||||
url: endpoint,
|
||||
schema: schemas.deprecatedEndpoints,
|
||||
handler: async (_req, reply) => {
|
||||
void reply.status(410);
|
||||
return {
|
||||
message: {
|
||||
type: 'info',
|
||||
message:
|
||||
'Please reload the app, this feature is no longer available.'
|
||||
}
|
||||
} as const;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
|
||||
import { unsubscribeEndpoints } from './deprecated-unsubscribe.js';
|
||||
|
||||
const urlEncodedMessage =
|
||||
'?messages=info%5B0%5D%3DWe%2520are%2520no%2520longer%2520able%2520to%2520process%2520this%2520unsubscription%2520request.%2520Please%2520go%2520to%2520your%2520settings%2520to%2520update%2520your%2520email%2520preferences';
|
||||
|
||||
describe('Deprecated unsubscribeEndpoints', () => {
|
||||
setupServer();
|
||||
|
||||
unsubscribeEndpoints.forEach(([endpoint, method]) => {
|
||||
test(`${method} ${endpoint} redirects to origin with "info" message`, async () => {
|
||||
const response = await superRequest(endpoint, { method }).set(
|
||||
'Referer',
|
||||
'https://www.freecodecamp.org/settings'
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
expect(response.headers.location).toStrictEqual(
|
||||
'https://www.freecodecamp.org' + urlEncodedMessage
|
||||
);
|
||||
expect(response.status).toBe(302);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import { getRedirectParams } from '../../utils/redirection.js';
|
||||
|
||||
type Endpoint = [string, 'GET' | 'POST'];
|
||||
|
||||
export const unsubscribeEndpoints: Endpoint[] = [
|
||||
['/u/:email', 'GET'],
|
||||
['/unsubscribe/:email', 'GET']
|
||||
];
|
||||
|
||||
/**
|
||||
* Plugin for the deprecated unsubscribe endpoints. Each route returns a 302
|
||||
* redirect to the referer with a message explaining how to unsubscribe.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin,
|
||||
* options)`.
|
||||
* @param done The callback to signal that the plugin is ready.
|
||||
*/
|
||||
export const unsubscribeDeprecated: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
unsubscribeEndpoints.forEach(([endpoint, method]) => {
|
||||
fastify.route({
|
||||
method,
|
||||
url: endpoint,
|
||||
handler: async (req, reply) => {
|
||||
const { origin } = getRedirectParams(req);
|
||||
void reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
'We are no longer able to process this unsubscription request. ' +
|
||||
'Please go to your settings to update your email preferences'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
import { describe, test, expect, beforeAll, vi } from 'vitest';
|
||||
import Stripe from 'stripe';
|
||||
import { setupServer, superRequest } from '../../../vitest.utils.js';
|
||||
|
||||
const testEWalletEmail = 'baz@bar.com';
|
||||
const testSubscriptionId = 'sub_test_id';
|
||||
const testCustomerId = 'cust_test_id';
|
||||
|
||||
const sharedDonationReqBody = {
|
||||
amount: 500,
|
||||
duration: 'month'
|
||||
};
|
||||
const chargeStripeReqBody = {
|
||||
email: testEWalletEmail,
|
||||
subscriptionId: 'sub_test_id',
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const createStripePaymentIntentReqBody = {
|
||||
email: testEWalletEmail,
|
||||
name: 'Baz Bar',
|
||||
token: { id: 'tok_123' },
|
||||
...sharedDonationReqBody
|
||||
};
|
||||
const mockSubCreate = vi.fn();
|
||||
const mockAttachPaymentMethod = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF',
|
||||
object: 'payment_method'
|
||||
})
|
||||
);
|
||||
const mockCustomerCreate = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
id: testCustomerId,
|
||||
name: 'Jest_User',
|
||||
currency: 'sgd',
|
||||
description: 'Jest User Account created'
|
||||
})
|
||||
);
|
||||
const mockSubRetrieveObj = {
|
||||
id: testSubscriptionId,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
plan: {
|
||||
product: 'prod_GD1GGbJsqQaupl'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 1 Jan 2040
|
||||
current_period_start: Math.floor(Date.now() / 1000),
|
||||
customer: testCustomerId,
|
||||
status: 'active'
|
||||
};
|
||||
const mockSubRetrieve = vi.fn(() => Promise.resolve(mockSubRetrieveObj));
|
||||
const mockCheckoutSessionCreate = vi.fn(() =>
|
||||
Promise.resolve({ id: 'checkout_session_id' })
|
||||
);
|
||||
const mockCustomerUpdate = vi.fn();
|
||||
const generateMockSubCreate = (status: string) => () =>
|
||||
Promise.resolve({
|
||||
id: testSubscriptionId,
|
||||
latest_invoice: {
|
||||
payment_intent: {
|
||||
client_secret: 'superSecret',
|
||||
status
|
||||
}
|
||||
}
|
||||
});
|
||||
const {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
} = vi.hoisted(() => {
|
||||
class StripeError extends Error {}
|
||||
class StripeCardError extends StripeError {}
|
||||
class StripeInvalidRequestError extends StripeError {}
|
||||
class StripeAuthenticationError extends StripeError {}
|
||||
return {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('stripe', () => ({
|
||||
default: class {
|
||||
static errors = {
|
||||
StripeError,
|
||||
StripeCardError,
|
||||
StripeInvalidRequestError,
|
||||
StripeAuthenticationError
|
||||
};
|
||||
constructor() {}
|
||||
customers = {
|
||||
create: mockCustomerCreate,
|
||||
update: mockCustomerUpdate
|
||||
};
|
||||
paymentMethods = {
|
||||
attach: mockAttachPaymentMethod
|
||||
};
|
||||
subscriptions = {
|
||||
create: mockSubCreate,
|
||||
retrieve: mockSubRetrieve
|
||||
};
|
||||
checkout = {
|
||||
sessions: {
|
||||
create: mockCheckoutSessionCreate
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
describe('Donate', () => {
|
||||
let setCookies: string[];
|
||||
setupServer();
|
||||
|
||||
describe('Unauthenticated User', () => {
|
||||
// Get the CSRF cookies from an unprotected route
|
||||
beforeAll(async () => {
|
||||
const res = await superRequest('/status/ping', { method: 'GET' });
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
|
||||
const endpoints: { path: string; method: 'POST' | 'PUT' }[] = [
|
||||
{ path: '/donate/add-donation', method: 'POST' },
|
||||
{ path: '/donate/charge-stripe-card', method: 'POST' },
|
||||
{ path: '/donate/update-stripe-card', method: 'PUT' }
|
||||
];
|
||||
|
||||
endpoints.forEach(({ path, method }) => {
|
||||
test(`${method} ${path} returns 401 status code with error message`, async () => {
|
||||
const response = await superRequest(path, {
|
||||
method,
|
||||
setCookies
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /donate/create-stripe-payment-intent should return 200', async () => {
|
||||
mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors'));
|
||||
const response = await superRequest(
|
||||
'/donate/create-stripe-payment-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST /donate/charge-stripe should return 200', async () => {
|
||||
mockSubCreate.mockImplementationOnce(generateMockSubCreate('no-errors'));
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
describe('Sentry Issue reporting', () => {
|
||||
test('create-stripe-payment-intent captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockCustomerCreate.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('Stripe unavailable'))
|
||||
);
|
||||
const response = await superRequest(
|
||||
'/donate/create-stripe-payment-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}
|
||||
).send(createStripePaymentIntentReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('create-stripe-payment-intent rejects invalid amount for duration', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const count = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
metrics: { ...originalSentry.metrics, count }
|
||||
};
|
||||
|
||||
const response = await superRequest(
|
||||
'/donate/create-stripe-payment-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}
|
||||
).send({ ...createStripePaymentIntentReqBody, amount: 999 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(count).toHaveBeenCalledWith('donation.intent_rejected', 1, {
|
||||
attributes: { reason: 'invalid_amount' }
|
||||
});
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe captures each subscription-validation failure', async () => {
|
||||
const invalidSubscriptions: unknown[] = [
|
||||
{ ...mockSubRetrieveObj, status: 'incomplete' },
|
||||
{
|
||||
...mockSubRetrieveObj,
|
||||
items: { data: [{ plan: { product: 'not_a_real_product' } }] }
|
||||
},
|
||||
{ ...mockSubRetrieveObj, current_period_start: 0 },
|
||||
{ ...mockSubRetrieveObj, customer: 12345 }
|
||||
];
|
||||
|
||||
for (const sub of invalidSubscriptions) {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.resolve(sub as typeof mockSubRetrieveObj)
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
}
|
||||
});
|
||||
|
||||
test('charge-stripe captures unexpected errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('Stripe unavailable'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe does not capture Stripe card decline errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const CardError = Stripe.errors.StripeCardError as unknown as new (
|
||||
m?: string
|
||||
) => Error;
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new CardError('card_declined'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe does not capture Stripe invalid request errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const InvalidRequestError = Stripe.errors
|
||||
.StripeInvalidRequestError as unknown as new (m?: string) => Error;
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new InvalidRequestError('invalid_request'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
|
||||
test('charge-stripe captures Stripe infra errors', async () => {
|
||||
const originalSentry = fastifyTestInstance.Sentry;
|
||||
const captureException = vi.fn();
|
||||
fastifyTestInstance.Sentry = {
|
||||
...originalSentry,
|
||||
captureException
|
||||
};
|
||||
|
||||
const AuthError = Stripe.errors
|
||||
.StripeAuthenticationError as unknown as new (m?: string) => Error;
|
||||
mockSubRetrieve.mockImplementationOnce(() =>
|
||||
Promise.reject(new AuthError('invalid api key'))
|
||||
);
|
||||
const response = await superRequest('/donate/charge-stripe', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(chargeStripeReqBody);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(captureException).toHaveBeenCalledOnce();
|
||||
|
||||
fastifyTestInstance.Sentry = originalSentry;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user