feat(api): get certslug route (#50515)

Co-authored-by: Sboonny <muhammed@freecodecamp.org>
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Muhammed Mustafa
2024-04-11 08:57:46 +02:00
committed by GitHub
parent 208fdbd735
commit 086ff36333
7 changed files with 626 additions and 5 deletions
+6 -2
View File
@@ -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);
+194
View File
@@ -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);
});
});
});
});
+266 -3
View File
@@ -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;
}
+131
View File
@@ -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 })
@@ -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;
+27
View File
@@ -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.
*
@@ -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.",