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

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
dist/
+4
View File
@@ -0,0 +1,4 @@
/* eslint-disable filenames-simple/naming-convention */
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
export default createLintStagedConfig(import.meta.dirname);
+400
View File
@@ -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) }
});
}
+233
View File
@@ -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
};
+36
View File
@@ -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
}
];
+95
View File
@@ -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"
}
+5
View File
@@ -0,0 +1,5 @@
import type { PrismaConfig } from 'prisma';
export default {
schema: 'prisma'
} satisfies PrismaConfig;
+58
View File
@@ -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])
}
+253
View File
@@ -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
}
+376
View File
@@ -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
View File
@@ -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;
};
+1
View File
@@ -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));
}
+109
View File
@@ -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
});
});
});
});
+89
View File
@@ -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;
+24
View File
@@ -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));
});
});
+17
View File
@@ -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
}
};
+10
View File
@@ -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
}
};
+64
View File
@@ -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']);
}
+46
View File
@@ -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
});
+109
View File
@@ -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: []
});
+156
View File
@@ -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`);
});
});
});
+48
View File
@@ -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();
};
+605
View File
@@ -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' }
});
});
});
});
+246
View File
@@ -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'] });
+522
View File
@@ -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);
});
});
});
+241
View File
@@ -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'] }
);
+168
View File
@@ -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);
});
});
});
+70
View File
@@ -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'
});
+116
View File
@@ -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
});
});
});
+40
View File
@@ -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();
};
+140
View File
@@ -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: ''
})
])
);
});
});
+81
View File
@@ -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' });
+78
View File
@@ -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'
);
});
});
+47
View File
@@ -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);
+109
View File
@@ -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);
});
});
+50
View File
@@ -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'] });
+356
View File
@@ -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);
});
});
+85
View File
@@ -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']
});
+32
View File
@@ -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);
});
});
+33
View File
@@ -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 }
});
}
+47
View File
@@ -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' }
});
});
});
+46
View File
@@ -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);
+76
View File
@@ -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);
});
});
+37
View File
@@ -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
);
});
});
});
+60
View File
@@ -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' });
+33
View File
@@ -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' });
+27
View File
@@ -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);
+156
View File
@@ -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();
});
});
+59
View File
@@ -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' });
+139
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@
import '@total-typescript/ts-reset';
+334
View File
@@ -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' })
);
});
});
});
});
+97
View File
@@ -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();
};
+217
View File
@@ -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);
});
});
});
+76
View File
@@ -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);
});
});
});
+175
View File
@@ -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');
}
+12
View File
@@ -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);
};
+61
View File
@@ -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);
});
});
+442
View File
@@ -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
+679
View File
@@ -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);
});
});
});
});
+292
View File
@@ -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();
};
+6
View File
@@ -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
+721
View File
@@ -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');
});
});
});
+291
View File
@@ -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
+21
View File
@@ -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();
};
+181
View File
@@ -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'
);
});
});
});
+108
View File
@@ -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();
};
+421
View File
@@ -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);
});
});
});
+294
View File
@@ -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();
};
+343
View File
@@ -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