feat(api): add PUT /certificate/verify (#51507)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2024-02-28 18:01:35 +02:00
committed by GitHub
parent 64bf9defcd
commit bbc1ffa489
10 changed files with 1102 additions and 19 deletions

View File

@@ -34,7 +34,8 @@
"pino-pretty": "10.2.3",
"query-string": "7.1.3",
"rate-limit-mongo": "^2.3.2",
"stripe": "8.222.0"
"stripe": "8.222.0",
"validator": "13.11.0"
},
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"devDependencies": {
@@ -43,6 +44,7 @@
"@types/jsonwebtoken": "9.0.5",
"@types/nodemailer": "6.4.14",
"@types/supertest": "2.0.16",
"@types/validator": "13.11.2",
"dotenv-cli": "7.3.0",
"jest": "29.7.0",
"prisma": "5.5.2",

View File

@@ -97,6 +97,7 @@ model user {
isDataAnalysisPyCertV7 Boolean? // Undefined
isDataVisCert Boolean? // Undefined
isDonating Boolean
isFoundationalCSharpCertV8 Boolean? // Undefined
isFrontEndCert Boolean? // Undefined
isFrontEndLibsCert Boolean? // Undefined
isFullStackCert Boolean? // Undefined
@@ -104,6 +105,7 @@ model user {
isInfosecCertV7 Boolean? // Undefined
isInfosecQaCert Boolean? // Undefined
isJsAlgoDataStructCert Boolean? // Undefined
isJsAlgoDataStructCertV8 Boolean? // Undefined
isMachineLearningPyCertV7 Boolean? // Undefined
isQaCertV7 Boolean? // Undefined
isRelationalDatabaseCertV8 Boolean? // Undefined
@@ -112,6 +114,7 @@ model user {
is2018DataVisCert Boolean? // Undefined
is2018FullStackCert Boolean? // Undefined
isCollegeAlgebraPyCertV8 Boolean? // Undefined
isUpcomingPythonCertV8 Boolean? // Undefined
keyboardShortcuts Boolean? // Undefined
linkedin String? // Null | Undefined
location String? // Null

View File

@@ -51,6 +51,7 @@ import {
SESSION_SECRET
} from './utils/env';
import { isObjectID } from './utils/validation';
import { certificateRoutes } from './routes/certificate';
export type FastifyInstanceWithTypeProvider = FastifyInstance<
RawServerDefault,
@@ -196,6 +197,7 @@ export const build = async (
void fastify.register(devLoginCallback, { prefix: '/auth' });
void fastify.register(devLegacyAuthRoutes);
}
void fastify.register(certificateRoutes);
void fastify.register(challengeRoutes);
void fastify.register(settingRoutes);
void fastify.register(donateRoutes);

View File

@@ -0,0 +1,435 @@
import { type PrismaPromise } from '@prisma/client';
import { Certification } from '../../../shared/config/certification-settings';
import {
defaultUserEmail,
defaultUserId,
devLogin,
setupServer,
superRequest
} from '../../jest.utils';
import { SHOW_UPCOMING_CHANGES } from '../utils/env';
describe('certificate routes', () => {
setupServer();
describe('Authenticated user', () => {
let setCookies: string[];
// Authenticate user
beforeAll(async () => {
setCookies = await devLogin();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('PUT /certificate/verify', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: defaultUserEmail },
data: {
completedChallenges: [],
name: 'fcc',
isRespWebDesignCert: false,
isJsAlgoDataStructCert: false,
isFrontEndLibsCert: false,
is2018DataVisCert: false,
isRelationalDatabaseCertV8: false,
isApisMicroservicesCert: false,
isQaCertV7: false,
isSciCompPyCertV7: false,
isDataAnalysisPyCertV7: false,
isInfosecCertV7: false,
isMachineLearningPyCertV7: false,
isCollegeAlgebraPyCertV8: false,
isFoundationalCSharpCertV8: false,
username: 'fcc'
}
});
});
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 response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: 'non-existant'
});
expect(response.body).toMatchObject({
response: {
message: 'flash.wrong-name',
variables: { name: 'non-existant' }
}
});
expect(response.status).toBe(400);
});
test('should return 500 if user not found in db', async () => {
jest
.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
.mockImplementation(
() => Promise.resolve(null) as PrismaPromise<null>
);
const response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: Certification.RespWebDesign
});
expect(response.body).toStrictEqual({
message: 'flash.went-wrong',
type: 'danger'
});
expect(response.status).toBe(500);
});
test('should return 400 if user has not set a `name`', async () => {
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
data: {
name: null
}
});
const response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: Certification.RespWebDesign
});
expect(response.body).toMatchObject({
response: {
type: 'info',
message: 'flash.name-needed'
},
isCertMap: {
is2018DataVisCert: 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,
isQaCertV7: false,
isRelationalDatabaseCertV8: false,
isRespWebDesignCert: false,
isSciCompPyCertV7: false
},
completedChallenges: []
});
expect(response.status).toBe(400);
});
test('should return 200 if user already claimed cert', async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: defaultUserEmail },
data: {
completedChallenges: [],
isRespWebDesignCert: true
}
});
const response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: Certification.RespWebDesign
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(response.body.response).toStrictEqual({
type: 'info',
message: 'flash.already-claimed',
variables: {
name: 'Responsive Web Design'
}
});
expect(response.status).toBe(200);
});
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 response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: Certification.RespWebDesign
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(response.body.response).toStrictEqual({
message: 'flash.incomplete-steps',
type: 'info',
variables: { name: 'Responsive Web Design' }
});
expect(response.status).toBe(400);
});
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 }
]
}
});
jest
.spyOn(fastifyTestInstance.prisma.user, 'update')
.mockImplementation(() => {
throw new Error('test');
});
const response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: Certification.RespWebDesign
});
expect(response.body).toStrictEqual({
message: 'flash.went-wrong',
type: 'danger'
});
expect(response.status).toBe(500);
});
// Note: Email does not actually send (work) in development, but status should still be 200.
test('should send the certified email, if all current certifications are met', 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,
isJsAlgoDataStructCertV8: true,
isFrontEndLibsCert: true,
is2018DataVisCert: true,
isRelationalDatabaseCertV8: true,
isApisMicroservicesCert: true,
isQaCertV7: true,
isSciCompPyCertV7: true,
isDataAnalysisPyCertV7: true,
isInfosecCertV7: true,
isMachineLearningPyCertV7: true,
isCollegeAlgebraPyCertV8: true,
isFoundationalCSharpCertV8: true
}
});
const spy = jest.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 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 response = await superRequest('/certificate/verify', {
method: 'PUT',
setCookies
}).send({
certSlug: Certification.RespWebDesign
});
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: 'Responsive Web Design',
username: 'fcc'
}
},
isCertMap: {
isRespWebDesignCert: true,
isJsAlgoDataStructCert: false,
isFrontEndLibsCert: false,
is2018DataVisCert: false,
isApisMicroservicesCert: false,
isInfosecQaCert: false,
isQaCertV7: false,
isInfosecCertV7: false,
isFrontEndCert: false,
isBackEndCert: false,
isDataVisCert: false,
isFullStackCert: false,
isSciCompPyCertV7: false,
isDataAnalysisPyCertV7: false,
isMachineLearningPyCertV7: false,
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
isFoundationalCSharpCertV8: 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(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,
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'];
if (SHOW_UPCOMING_CHANGES) {
claimableCerts.push(Certification.UpcomingPython);
} else {
unclaimableCerts.push(Certification.UpcomingPython);
}
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);
}
});
});
});
});

View File

@@ -0,0 +1,469 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import isEmail from 'validator/lib/isEmail';
import { schemas } from '../schemas';
import { getChallenges } from '../utils/get-challenges';
import {
certIds,
certSlugTypeMap,
certTypeTitleMap,
certTypes,
currentCertifications,
legacyCertifications,
legacyFullStackCertification,
upcomingCertifications
} from '../../../shared/config/certification-settings';
import { normalizeChallenges, removeNulls } from '../utils/normalize';
import { CompletedChallenge } from '../utils/common-challenge-functions';
import { SHOW_UPCOMING_CHANGES } from '../utils/env';
const {
legacyFrontEndChallengeId,
legacyBackEndChallengeId,
legacyDataVisId,
legacyInfosecQaId,
legacyFullStackId,
respWebDesignId,
frontEndDevLibsId,
jsAlgoDataStructId,
jsAlgoDataStructV8Id,
dataVis2018Id,
apisMicroservicesId,
qaV7Id,
infosecV7Id,
sciCompPyV7Id,
dataAnalysisPyV7Id,
machineLearningPyV7Id,
relationalDatabaseV8Id,
collegeAlgebraPyV8Id,
foundationalCSharpV8Id,
upcomingPythonV8Id
} = certIds;
/**
* Plugin for the 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 certificateRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
const challenges = getChallenges();
const certTypeIds = createCertTypeIds(challenges);
// @ts-expect-error - @fastify/csrf-protection needs to update their types
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
// 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 (
!assertCertSlugIsKeyofCertSlugTypeMap(certSlug) ||
!isCertAllowed(certSlug)
) {
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 = certTypeTitleMap[certType];
try {
const user = await fastify.prisma.user.findUnique({
where: { id: req.session.user.id }
});
if (!user) {
void reply.code(500);
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) {
void reply.code(400);
return {
response: {
type: 'info',
message: 'flash.name-needed'
},
isCertMap,
completedChallenges: normalizeChallenges(completedChallenges)
} as const;
}
if (user[certType]) {
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 } = certTypeIds[certType];
const hasCompletedTestRequirements = hasCompletedTests(
tests,
user.completedChallenges
);
if (!hasCompletedTestRequirements) {
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: {
username: true,
email: true,
name: true,
completedChallenges: true,
is2018DataVisCert: true,
is2018FullStackCert: true,
isApisMicroservicesCert: true,
isBackEndCert: true,
isDataVisCert: true,
isCollegeAlgebraPyCertV8: true,
isDataAnalysisPyCertV7: true,
isFoundationalCSharpCertV8: true,
isFrontEndCert: true,
isFrontEndLibsCert: true,
isFullStackCert: true,
isInfosecCertV7: true,
isInfosecQaCert: true,
isJsAlgoDataStructCert: true,
isJsAlgoDataStructCertV8: true,
isMachineLearningPyCertV7: true,
isQaCertV7: true,
isRelationalDatabaseCertV8: true,
isRespWebDesignCert: true,
isSciCompPyCertV7: true,
isUpcomingPythonCertV8: true
}
});
const updatedUserSansNull = removeNulls(updatedUser);
const updatedIsCertMap = getUserIsCertMap(updatedUserSansNull);
// TODO(POST-MVP): Consider sending email based on `user.isEmailVerified` as well
const hasCompletedAllCerts = currentCertifications
.map(x => certSlugTypeMap[x])
.every(certType => updatedIsCertMap[certType]);
const shouldSendCertifiedEmailToCamper =
isEmail(updatedUser.email) && hasCompletedAllCerts;
if (shouldSendCertifiedEmailToCamper) {
const notifyUser = {
to: updatedUser.email,
from: 'quincy@freecodecamp.org',
subject:
'Congratulations on completing all of the freeCodeCamp certifications!',
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 {
// TODO(POST-MVP): Ensure Camper knows they **have** claimed the cert, but the email failed to send.
await fastify.sendEmail(notifyUser);
} catch (e) {
fastify.log.error(e);
// TODO: Log to Sentry
}
}
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;
} catch (e) {
fastify.log.error(e);
void reply.code(500);
throw {
type: 'danger',
// message: 'Oops! Something went wrong. Please try again in a moment or contact
message: 'flash.went-wrong'
} as const;
}
}
);
done();
};
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 all of the freeCodeCamp certifications!
All of your certifications are now live at at: https://www.freecodecamp.org/${username}
Please tell me a bit more about you and your near-term goals.
Are you interested in contributing to our open source projects used by nonprofits?
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 assertCertSlugIsKeyofCertSlugTypeMap(
certSlug: string
): certSlug is keyof typeof certSlugTypeMap {
return certSlug in certSlugTypeMap;
}
function createCertTypeIds(challenges: ReturnType<typeof getChallenges>) {
return {
// legacy
[certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, challenges),
[certTypes.jsAlgoDataStruct]: getCertById(jsAlgoDataStructId, challenges),
[certTypes.backEnd]: getCertById(legacyBackEndChallengeId, challenges),
[certTypes.dataVis]: getCertById(legacyDataVisId, challenges),
[certTypes.infosecQa]: getCertById(legacyInfosecQaId, challenges),
[certTypes.fullStack]: getCertById(legacyFullStackId, challenges),
// modern
[certTypes.respWebDesign]: getCertById(respWebDesignId, challenges),
[certTypes.frontEndDevLibs]: getCertById(frontEndDevLibsId, challenges),
[certTypes.dataVis2018]: getCertById(dataVis2018Id, challenges),
[certTypes.jsAlgoDataStructV8]: getCertById(
jsAlgoDataStructV8Id,
challenges
),
[certTypes.apisMicroservices]: getCertById(apisMicroservicesId, challenges),
[certTypes.qaV7]: getCertById(qaV7Id, challenges),
[certTypes.infosecV7]: getCertById(infosecV7Id, challenges),
[certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, challenges),
[certTypes.dataAnalysisPyV7]: getCertById(dataAnalysisPyV7Id, challenges),
[certTypes.machineLearningPyV7]: getCertById(
machineLearningPyV7Id,
challenges
),
[certTypes.relationalDatabaseV8]: getCertById(
relationalDatabaseV8Id,
challenges
),
[certTypes.collegeAlgebraPyV8]: getCertById(
collegeAlgebraPyV8Id,
challenges
),
[certTypes.foundationalCSharpV8]: getCertById(
foundationalCSharpV8Id,
challenges
),
// upcoming
[certTypes.upcomingPythonV8]: getCertById(upcomingPythonV8Id, challenges)
};
}
function getCertById(
challengeId: string,
challenges: ReturnType<typeof getChallenges>
): { id: string; tests: { id: string }[]; challengeType: number } {
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 };
}
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');
}
}
interface CertI {
isRespWebDesignCert?: boolean;
isJsAlgoDataStructCert?: boolean;
isJsAlgoDataStructCertV8?: boolean;
isFrontEndLibsCert?: boolean;
is2018DataVisCert?: boolean;
isApisMicroservicesCert?: boolean;
isInfosecQaCert?: boolean;
isQaCertV7?: boolean;
isInfosecCertV7?: boolean;
isFrontEndCert?: boolean;
isBackEndCert?: boolean;
isDataVisCert?: boolean;
isFullStackCert?: boolean;
isSciCompPyCertV7?: boolean;
isDataAnalysisPyCertV7?: boolean;
isMachineLearningPyCertV7?: boolean;
isRelationalDatabaseCertV8?: boolean;
isCollegeAlgebraPyCertV8?: boolean;
isFoundationalCSharpCertV8?: boolean;
isUpcomingPythonCertV8?: boolean;
}
function getUserIsCertMap(user: CertI) {
const {
isRespWebDesignCert = false,
isJsAlgoDataStructCert = false,
isJsAlgoDataStructCertV8 = false,
isFrontEndLibsCert = false,
is2018DataVisCert = false,
isApisMicroservicesCert = false,
isInfosecQaCert = false,
isQaCertV7 = false,
isInfosecCertV7 = false,
isFrontEndCert = false,
isBackEndCert = false,
isDataVisCert = false,
isFullStackCert = false,
isSciCompPyCertV7 = false,
isDataAnalysisPyCertV7 = false,
isMachineLearningPyCertV7 = false,
isRelationalDatabaseCertV8 = false,
isCollegeAlgebraPyCertV8 = false,
isFoundationalCSharpCertV8 = false,
isUpcomingPythonCertV8 = false
} = user;
return {
isRespWebDesignCert,
isJsAlgoDataStructCert,
isJsAlgoDataStructCertV8,
isFrontEndLibsCert,
is2018DataVisCert,
isApisMicroservicesCert,
isInfosecQaCert,
isQaCertV7,
isInfosecCertV7,
isFrontEndCert,
isBackEndCert,
isDataVisCert,
isFullStackCert,
isSciCompPyCertV7,
isDataAnalysisPyCertV7,
isMachineLearningPyCertV7,
isRelationalDatabaseCertV8,
isCollegeAlgebraPyCertV8,
isFoundationalCSharpCertV8,
isUpcomingPythonCertV8
};
}

View File

@@ -7,6 +7,27 @@ const generic500 = Type.Object({
type: Type.Literal('danger')
});
const isCertMap = Type.Object({
isRespWebDesignCert: Type.Boolean(),
isJsAlgoDataStructCert: Type.Boolean(),
isFrontEndLibsCert: Type.Boolean(),
is2018DataVisCert: Type.Boolean(),
isApisMicroservicesCert: Type.Boolean(),
isInfosecQaCert: Type.Boolean(),
isQaCertV7: Type.Boolean(),
isInfosecCertV7: Type.Boolean(),
isFrontEndCert: Type.Boolean(),
isBackEndCert: Type.Boolean(),
isDataVisCert: Type.Boolean(),
isFullStackCert: Type.Boolean(),
isSciCompPyCertV7: Type.Boolean(),
isDataAnalysisPyCertV7: Type.Boolean(),
isMachineLearningPyCertV7: Type.Boolean(),
isRelationalDatabaseCertV8: Type.Boolean(),
isCollegeAlgebraPyCertV8: Type.Boolean(),
isFoundationalCSharpCertV8: Type.Boolean()
});
const file = Type.Object({
contents: Type.String(),
key: Type.String(),
@@ -760,6 +781,133 @@ export const schemas = {
])
}
},
// /certificate/
certificateVerify: {
// TODO(POST_MVP): Remove partial validation from route for schema validation
body: Type.Object({
certSlug: Type.String({ maxLength: 1024 })
}),
response: {
200: Type.Object({
response: Type.Union([
Type.Object({
type: Type.Literal('info'),
message: Type.Union([Type.Literal('flash.already-claimed')]),
variables: Type.Object({
name: Type.String()
})
}),
Type.Object({
type: Type.Literal('success'),
message: Type.Literal('flash.cert-claim-success'),
variables: Type.Object({
username: Type.String(),
name: Type.String()
})
})
]),
isCertMap,
completedChallenges: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
solution: Type.Optional(Type.String()),
githubLink: Type.Optional(Type.String()),
challengeType: Type.Optional(Type.Number()),
// Technically, files is optional, but the db default was [] and
// the client treats null, undefined and [] equivalently.
// TODO(Post-MVP): make this optional.
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
path: Type.Optional(Type.String())
})
),
isManuallyApproved: Type.Optional(Type.Boolean())
})
)
}),
400: Type.Union([
Type.Object({
response: Type.Object({
type: Type.Literal('info'),
message: Type.Union([Type.Literal('flash.incomplete-steps')]),
variables: Type.Object({
name: Type.String()
})
}),
isCertMap,
completedChallenges: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
solution: Type.Optional(Type.String()),
githubLink: Type.Optional(Type.String()),
challengeType: Type.Optional(Type.Number()),
// Technically, files is optional, but the db default was [] and
// the client treats null, undefined and [] equivalently.
// TODO(Post-MVP): make this optional.
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
path: Type.Optional(Type.String())
})
),
isManuallyApproved: Type.Optional(Type.Boolean())
})
)
}),
Type.Object({
response: Type.Object({
type: Type.Literal('danger'),
message: Type.Union([Type.Literal('flash.wrong-name')]),
variables: Type.Object({
name: Type.String()
})
})
}),
Type.Object({
response: Type.Object({
type: Type.Literal('info'),
message: Type.Union([Type.Literal('flash.name-needed')])
}),
isCertMap,
completedChallenges: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
solution: Type.Optional(Type.String()),
githubLink: Type.Optional(Type.String()),
challengeType: Type.Optional(Type.Number()),
// Technically, files is optional, but the db default was [] and
// the client treats null, undefined and [] equivalently.
// TODO(Post-MVP): make this optional.
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
path: Type.Optional(Type.String())
})
),
isManuallyApproved: Type.Optional(Type.Boolean())
})
)
})
]),
500: Type.Object({
type: Type.Literal('danger'),
message: Type.Literal('flash.went-wrong')
})
}
},
examChallengeCompleted: {
body: Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),

View File

@@ -34,6 +34,7 @@ export function createUserInput(email: string): Prisma.userCreateInput {
isDataAnalysisPyCertV7: false,
isDataVisCert: false,
isDonating: false,
isFoundationalCSharpCertV8: false,
isFrontEndCert: false,
isFrontEndLibsCert: false,
isFullStackCert: false,

View File

@@ -39,6 +39,7 @@ assert.ok(process.env.FCC_ENABLE_SWAGGER_UI);
assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
assert.ok(process.env.JWT_SECRET);
assert.ok(process.env.STRIPE_SECRET_KEY);
assert.ok(process.env.SHOW_UPCOMING_CHANGES);
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
assert.ok(process.env.SES_ID);
@@ -109,4 +110,6 @@ export const SES_ID = process.env.SES_ID;
export const SES_SECRET = process.env.SES_SECRET;
export const SES_REGION = process.env.SES_REGION;
export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER;
export const SHOW_UPCOMING_CHANGES =
process.env.SHOW_UPCOMING_CHANGES === 'true';
export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;

View File

@@ -3,10 +3,15 @@
// redirectToCurrentChallenge and, instead, only report the current challenge id
// via the user object, then we should *not* store this so it can be garbage
// collected.
import curriculum from '../../../shared/config/curriculum.json';
import { SuperBlocks } from '../../../shared/config/superblocks';
import { readFileSync } from 'fs';
import { join } from 'path';
type Curriculum = { [keyValue in SuperBlocks]?: CurriculumProps };
const CURRICULUM_PATH = '../shared/config/curriculum.json';
// Curriculum is read using fs, because it is too large for VSCode's LSP to handle type inference which causes anoying behaviour.
const curriculum = JSON.parse(
readFileSync(join(process.cwd(), CURRICULUM_PATH), 'utf-8')
) as Curriculum;
interface Block {
challenges: {
@@ -18,25 +23,30 @@ interface Block {
}[];
}
interface CurriculumProps {
type SuperBlock = {
blocks: Record<string, Block>;
}
};
type Curriculum = Record<string, SuperBlock>;
/**
* Get all the challenges from the curriculum.
* @returns An array of challenges.
* Get all challenges including all certifications as "challenges" (ids and tests).
* @returns The whole curricula reduced to an array.
*/
export function getChallenges() {
const superBlockKeys = Object.values(SuperBlocks);
const typedCurriculum: Curriculum = curriculum as Curriculum;
export function getChallenges(): Block['challenges'] {
const curricula = Object.values(curriculum);
return superBlockKeys
.map(key => typedCurriculum[key]?.blocks)
.reduce((accumulator: Block['challenges'], superBlock) => {
const blockKeys = Object.keys(superBlock ?? {});
const challengesForBlock = blockKeys.map(
key => superBlock?.[key]?.challenges ?? []
);
return [...accumulator, ...challengesForBlock.flat()];
return curricula
.map(v => v.blocks)
.reduce((acc: Block['challenges'], superBlock) => {
const blockKeys = Object.keys(superBlock);
const challengesForBlock = blockKeys.map(k => {
const block = superBlock[k];
if (!block) {
return [];
}
return block.challenges;
});
return [...acc, ...challengesForBlock.flat()];
}, []);
}

10
pnpm-lock.yaml generated
View File

@@ -261,6 +261,9 @@ importers:
stripe:
specifier: 8.222.0
version: 8.222.0
validator:
specifier: 13.11.0
version: 13.11.0
devDependencies:
'@total-typescript/ts-reset':
specifier: 0.5.1
@@ -277,6 +280,9 @@ importers:
'@types/supertest':
specifier: 2.0.16
version: 2.0.16
'@types/validator':
specifier: 13.11.2
version: 13.11.2
dotenv-cli:
specifier: 7.3.0
version: 7.3.0
@@ -10169,6 +10175,10 @@ packages:
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
dev: true
/@types/validator@13.11.2:
resolution: {integrity: sha512-nIKVVQKT6kGKysnNt+xLobr+pFJNssJRi2s034wgWeFBUx01fI8BeHTW2TcRp7VcFu9QCYG8IlChTuovcm0oKQ==}
dev: true
/@types/validator@13.7.12:
resolution: {integrity: sha512-YVtyAPqpefU+Mm/qqnOANW6IkqKpCSrarcyV269C8MA8Ux0dbkEuQwM/4CjL47kVEM2LgBef/ETfkH+c6+moFA==}
dev: true