From 086ff3633302c4702e620da99eb5454c027b1a91 Mon Sep 17 00:00:00 2001 From: Muhammed Mustafa Date: Thu, 11 Apr 2024 08:57:46 +0200 Subject: [PATCH] feat(api): get certslug route (#50515) Co-authored-by: Sboonny Co-authored-by: Shaun Hamilton Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> --- api/src/app.ts | 8 +- api/src/routes/certificate.test.ts | 194 +++++++++++++ api/src/routes/certificate.ts | 269 +++++++++++++++++- api/src/schemas.ts | 131 +++++++++ api/src/utils/common-challenge-functions.ts | 1 + api/src/utils/error-formatting.ts | 27 ++ client/i18n/locales/english/translations.json | 1 + 7 files changed, 626 insertions(+), 5 deletions(-) diff --git a/api/src/app.ts b/api/src/app.ts index 42c6b7865be..29297cb98ac 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -30,6 +30,10 @@ import sessionAuth from './plugins/session-auth'; import codeFlowAuth from './plugins/code-flow-auth'; import { mobileAuth0Routes } from './routes/auth'; import { devAuthRoutes } from './routes/auth-dev'; +import { + protectedCertificateRoutes, + unprotectedCertificateRoutes +} from './routes/certificate'; import { challengeRoutes } from './routes/challenge'; import { deprecatedEndpoints } from './routes/deprecated-endpoints'; import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe'; @@ -49,7 +53,6 @@ import { SESSION_SECRET } from './utils/env'; import { isObjectID } from './utils/validation'; -import { certificateRoutes } from './routes/certificate'; export type FastifyInstanceWithTypeProvider = FastifyInstance< RawServerDefault, @@ -199,11 +202,12 @@ export const build = async ( if (FCC_ENABLE_DEV_LOGIN_MODE) { void fastify.register(devAuthRoutes); } - void fastify.register(certificateRoutes); void fastify.register(challengeRoutes); void fastify.register(settingRoutes); void fastify.register(donateRoutes); void fastify.register(userRoutes); + void fastify.register(protectedCertificateRoutes); + void fastify.register(unprotectedCertificateRoutes); void fastify.register(userGetRoutes); void fastify.register(deprecatedEndpoints); void fastify.register(statusRoute); diff --git a/api/src/routes/certificate.test.ts b/api/src/routes/certificate.test.ts index 198864adec1..2a33fb477c2 100644 --- a/api/src/routes/certificate.test.ts +++ b/api/src/routes/certificate.test.ts @@ -434,4 +434,198 @@ describe('certificate routes', () => { }); }); }); + + describe('Unauthenticated user', () => { + 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 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); + }); + 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); + }); + }); + }); }); diff --git a/api/src/routes/certificate.ts b/api/src/routes/certificate.ts index 0386774b2bd..c117b2556a6 100644 --- a/api/src/routes/certificate.ts +++ b/api/src/routes/certificate.ts @@ -1,5 +1,7 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import isEmail from 'validator/lib/isEmail'; +import { find } from 'lodash'; +import { CompletedChallenge } from '@prisma/client'; import { schemas } from '../schemas'; import { getChallenges } from '../utils/get-challenges'; import { @@ -10,11 +12,14 @@ import { currentCertifications, legacyCertifications, legacyFullStackCertification, + certTypeIdMap, + completionHours, + oldDataVizId, 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'; +import { formatCertificationValidation } from '../utils/error-formatting'; const { legacyFrontEndChallengeId, @@ -40,13 +45,13 @@ const { } = certIds; /** - * Plugin for the certificate endpoints. + * 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 certificateRoutes: FastifyPluginCallbackTypebox = ( +export const protectedCertificateRoutes: FastifyPluginCallbackTypebox = ( fastify, _options, done @@ -270,6 +275,244 @@ export const certificateRoutes: FastifyPluginCallbackTypebox = ( done(); }; +/** + * 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, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400); + return formatCertificationValidation(error.validation); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + let username = req.params.username; + const certSlug = req.params.certSlug; + + username = username.toLowerCase(); + fastify.log.info(`certSlug: ${certSlug}`); + + if (!assertCertSlugIsKeyofCertSlugTypeMap(certSlug)) { + void reply.code(404); + return reply.send({ + type: 'info', + message: 'flash.cert-not-found', + variables: { certSlug } + }); + } + + const certType = certSlugTypeMap[certSlug]; + const certId = certTypeIdMap[certType]; + const certTitle = certTypeTitleMap[certType]; + const completionTime = completionHours[certType] || 300; + const user = await fastify.prisma.user.findFirst({ + where: { username }, + select: { + isBanned: true, + isCheater: true, + isFrontEndCert: true, + isBackEndCert: true, + isFullStackCert: true, + isRespWebDesignCert: true, + isFrontEndLibsCert: true, + isJsAlgoDataStructCert: true, + isJsAlgoDataStructCertV8: true, + isDataVisCert: true, + is2018DataVisCert: true, + isApisMicroservicesCert: true, + isInfosecQaCert: true, + isQaCertV7: true, + isInfosecCertV7: true, + isSciCompPyCertV7: true, + isDataAnalysisPyCertV7: true, + isMachineLearningPyCertV7: true, + isRelationalDatabaseCertV8: true, + isCollegeAlgebraPyCertV8: true, + isFoundationalCSharpCertV8: true, + isUpcomingPythonCertV8: true, + isHonest: true, + username: true, + name: true, + completedChallenges: true, + profileUI: true + } + }); + + if (user === null) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.username-not-found', + variables: { username } + } + ] + }); + } + + if (user.isCheater || user.isBanned) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.not-eligible' + } + ] + }); + } + + if (!user.isHonest) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.not-honest', + variables: { username } + } + ] + }); + } + + if (user.profileUI?.isLocked) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.profile-private', + variables: { username } + } + ] + }); + } + + if (!user.name) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.add-name' + } + ] + }); + } + + if (!user.profileUI?.showCerts) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.certs-private', + variables: { username } + } + ] + }); + } + + if (!user.profileUI?.showTimeLine) { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.timeline-private', + variables: { username } + } + ] + }); + } + + if (user[certType]) { + 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 { username, name } = user; + + if (!user.profileUI.showName) { + void reply.code(200); + return reply.send({ + certSlug, + certTitle, + username, + date: completedDate, + completionTime + }); + } + + void reply.code(200); + return reply.send({ + certSlug, + certTitle, + username, + name, + date: completedDate, + completionTime + }); + } else { + return reply.send({ + messages: [ + { + type: 'info', + message: 'flash.user-not-certified', + variables: { username, cert: certTypeTitleMap[certType] } + } + ] + }); + } + } catch (err) { + fastify.log.error(err); + void reply.code(500); + return reply.send({ + message: + 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.', + type: 'danger' + }); + } + } + ); + + done(); +}; + function isCertAllowed(certSlug: string): boolean { if ( currentCertifications.includes(certSlug) || @@ -467,3 +710,23 @@ function getUserIsCertMap(user: CertI) { isUpcomingPythonCertV8 }; } + +function getFallbackFullStackDate( + completedChallenges: CompletedChallenge[], + completedDate: number +) { + const chalIds = [ + respWebDesignId, + jsAlgoDataStructId, + frontEndDevLibsId, + dataVis2018Id, + apisMicroservicesId, + legacyInfosecQaId + ]; + + const latestCertDate = completedChallenges + .filter(chal => chalIds.includes(chal.id)) + .sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate; + + return latestCertDate ? latestCertDate : completedDate; +} diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 052c7398d81..088875a61b7 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -1,4 +1,8 @@ import { Type } from '@fastify/type-provider-typebox'; +import { Certification } from '../../shared/config/certification-settings'; +// import type { certTypes } from '../../shared/config/certification-settings'; + +// type CertTypes = keyof typeof certTypes; const generic500 = Type.Object({ message: Type.Literal( @@ -785,6 +789,133 @@ export const schemas = { }) } }, + // certification + certSlug: { + params: Type.Object({ + certSlug: Type.String(), + username: Type.String() + }), + response: { + // TODO(POST_MVP): Most of these should not be 200s + 200: Type.Union([ + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.username-not-found'), + variables: Type.Object({ + username: Type.String() + }) + }) + ) + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.not-eligible') + }) + ) + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.not-honest'), + variables: Type.Object({ + username: Type.String() + }) + }) + ) + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.profile-private'), + variables: Type.Object({ + username: Type.String() + }) + }) + ) + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.add-name') + }) + ) + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.certs-private'), + variables: Type.Object({ + username: Type.String() + }) + }) + ) + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.timeline-private'), + variables: Type.Object({ + username: Type.String() + }) + }) + ) + }), + Type.Object({ + certSlug: Type.Enum(Certification), + certTitle: Type.String(), + username: Type.String(), + date: Type.Number(), + completionTime: Type.Number() + }), + Type.Object({ + certSlug: Type.Enum(Certification), + certTitle: Type.String(), + username: Type.String(), + name: Type.String(), + date: Type.Number(), + completionTime: Type.Number() + }), + Type.Object({ + messages: Type.Array( + Type.Object({ + type: Type.Literal('info'), + message: Type.Literal('flash.user-not-certified'), + variables: Type.Object({ + username: Type.String(), + cert: Type.String() + }) + }) + ) + }) + ]), + 400: Type.Object({ + type: Type.Literal('error'), + message: Type.String() + }), + 404: Type.Object({ + message: Type.Literal('flash.cert-not-found'), + type: Type.Literal('info'), + variables: Type.Object({ + certSlug: Type.String() + }) + }), + 500: Type.Object({ + type: Type.Literal('danger'), + message: Type.Literal( + 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.' + ) + }) + } + }, postMsUsername: { body: Type.Object({ msTranscriptUrl: Type.String({ maxLength: 1000 }) diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts index e41e24c8cdf..db759841bfb 100644 --- a/api/src/utils/common-challenge-functions.ts +++ b/api/src/utils/common-challenge-functions.ts @@ -59,6 +59,7 @@ type CompletedChallengeFile = { path?: string | null; }; +// TODO: Should probably prefer `import{CompletedChallenge}from'@prisma/client'` instead of defining it here export type CompletedChallenge = { id: string; solution?: string | null; diff --git a/api/src/utils/error-formatting.ts b/api/src/utils/error-formatting.ts index 43a05b3e5f2..72b5253301c 100644 --- a/api/src/utils/error-formatting.ts +++ b/api/src/utils/error-formatting.ts @@ -1,4 +1,7 @@ import { ErrorObject } from 'ajv'; +import { certTypes } from '../../../shared/config/certification-settings'; + +type CertLogs = (typeof certTypes)[keyof typeof certTypes]; type FormattedError = { type: 'error'; @@ -47,6 +50,30 @@ export const formatProjectCompletedValidation = ( }; }; +/** + * Format validation errors for /project-completed. + * + * @param errors An array of validation errors. + * @returns Formatted errors that can be used in the response. + */ +export const formatCertificationValidation = ( + errors: ErrorObject[] +): FormattedError => { + const error = getError(errors); + + return error.instancePath === '' && + Object.values(certTypes).includes(error.params.missingProperty as CertLogs) + ? ({ + type: 'error', + message: + 'You have not provided the valid param for us to display the certification.' + } as const) + : ({ + type: 'error', + message: 'That does not appear to be a valid certification request.' + } as const); +}; + /** * Format validation errors for /coderoad-challenge-completed. * diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 4a971036d8a..68392edb892 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -804,6 +804,7 @@ "challenge-submit-too-big": "Sorry, you cannot submit your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org", "invalid-update-flag": "You are attempting to access forbidden resources. Please request assistance on https://forum.freecodecamp.org if this is a valid request.", "generate-exam-error": "An error occurred trying to generate your exam.", + "cert-not-found": "The certification {{certSlug}} does not exist.", "ms": { "transcript": { "link-err-1": "Please include a Microsoft transcript URL in the request.",