refactor: separate public and private plugins (#56359)

This commit is contained in:
Oliver Eyton-Williams
2024-10-02 13:52:02 +02:00
committed by GitHub
parent b0ab8450f1
commit 274680dbdb
38 changed files with 2153 additions and 1673 deletions
+6 -2
View File
@@ -205,9 +205,9 @@ export const defaultUserId = '64c7810107dd4782d32baee7';
export const defaultUserEmail = 'foo@bar.com';
export const defaultUsername = 'fcc-test-user';
export async function devLogin(): Promise<string[]> {
export const resetDefaultUser = async (): Promise<void> => {
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: 'foo@bar.com' }
where: { email: defaultUserEmail }
});
await fastifyTestInstance.prisma.user.create({
@@ -217,6 +217,10 @@ export async function devLogin(): Promise<string[]> {
username: defaultUsername
}
});
};
export async function devLogin(): Promise<string[]> {
await resetDefaultUser();
const res = await superRequest('/signin', { method: 'GET' });
expect(res.status).toBe(302);
return res.get('Set-Cookie');
+21 -33
View File
@@ -26,21 +26,9 @@ import security from './plugins/security';
import auth from './plugins/auth';
import bouncer from './plugins/bouncer';
import notFound from './plugins/not-found';
import { authRoutes, 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';
import { donateRoutes, chargeStripeRoute } from './routes/donate';
import { emailSubscribtionRoutes } from './routes/email-subscription';
import { settingRoutes, settingRedirectRoutes } from './routes/settings';
import { statusRoute } from './routes/status';
import { userGetRoutes, userRoutes, userPublicGetRoutes } from './routes/user';
import { signoutRoute } from './routes/signout';
import * as publicRoutes from './routes/public';
import * as protectedRoutes from './routes/protected';
import {
API_LOCATION,
EMAIL_PROVIDER,
@@ -197,25 +185,25 @@ export const build = async (
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.send401IfNoUser);
await fastify.register(challengeRoutes);
await fastify.register(donateRoutes);
await fastify.register(protectedCertificateRoutes);
await fastify.register(settingRoutes);
await fastify.register(userRoutes);
await fastify.register(protectedRoutes.challengeRoutes);
await fastify.register(protectedRoutes.donateRoutes);
await fastify.register(protectedRoutes.protectedCertificateRoutes);
await fastify.register(protectedRoutes.settingRoutes);
await fastify.register(protectedRoutes.userRoutes);
});
// CSRF protection disabled:
await fastify.register(async function (fastify, _opts) {
fastify.addHook('onRequest', fastify.send401IfNoUser);
await fastify.register(userGetRoutes);
await fastify.register(protectedRoutes.userGetRoutes);
});
// Routes that redirect if access is denied:
await fastify.register(async function (fastify, _opts) {
fastify.addHook('onRequest', fastify.redirectIfNoUser);
await fastify.register(settingRedirectRoutes);
await fastify.register(protectedRoutes.settingRedirectRoutes);
});
});
// Routes for signed out users:
@@ -223,22 +211,22 @@ export const build = async (
fastify.addHook('onRequest', fastify.authorize);
// TODO(Post-MVP): add the redirectIfSignedIn hook here, rather than in the
// mobileAuth0Routes and authRoutes plugins.
await fastify.register(mobileAuth0Routes);
await fastify.register(publicRoutes.mobileAuth0Routes);
// TODO: consolidate with LOCAL_MOCK_AUTH
if (FCC_ENABLE_DEV_LOGIN_MODE) {
await fastify.register(devAuthRoutes);
await fastify.register(publicRoutes.devAuthRoutes);
} else {
await fastify.register(authRoutes);
await fastify.register(publicRoutes.authRoutes);
}
});
void fastify.register(chargeStripeRoute);
void fastify.register(signoutRoute);
void fastify.register(emailSubscribtionRoutes);
void fastify.register(userPublicGetRoutes);
void fastify.register(unprotectedCertificateRoutes);
void fastify.register(deprecatedEndpoints);
void fastify.register(statusRoute);
void fastify.register(unsubscribeDeprecated);
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);
return fastify;
};
@@ -0,0 +1,48 @@
import { getFallbackFullStackDate } from './certificate-utils';
const fullStackChallenges = [
{
completedDate: 1585210952511,
id: '5a553ca864b52e1d8bceea14'
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17513bc'
},
{
completedDate: 1588665778679,
id: '561acd10cb82ac38a17513bc'
},
{
completedDate: 1685210952511,
id: '561abd10cb81ac38a17513bc'
},
{
completedDate: 1585210952511,
id: '561add10cb82ac38a17523bc'
},
{
completedDate: 1588665778679,
id: '561add10cb82ac38a17213bc'
}
];
describe('helper functions', () => {
describe('getFallbackFullStackDate', () => {
it('should return the date of the latest completed challenge', () => {
expect(getFallbackFullStackDate(fullStackChallenges, 123)).toBe(
1685210952511
);
});
it('should fall back to completedDate if no certifications are provided', () => {
expect(getFallbackFullStackDate([], 123)).toBe(123);
});
it('should fall back to completedDate if none of the certifications have been completed', () => {
expect(
getFallbackFullStackDate([{ completedDate: 567, id: 'abc' }], 123)
).toBe(123);
});
});
});
@@ -0,0 +1,52 @@
import {
certSlugTypeMap,
certIds
} from '../../../../shared/config/certification-settings';
const {
legacyInfosecQaId,
respWebDesignId,
frontEndDevLibsId,
jsAlgoDataStructId,
dataVis2018Id,
apisMicroservicesId
} = certIds;
const fullStackCertificateIds = [
respWebDesignId,
jsAlgoDataStructId,
frontEndDevLibsId,
dataVis2018Id,
apisMicroservicesId,
legacyInfosecQaId
];
/**
* 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 keyof typeof certSlugTypeMap {
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: number }[],
completedDate: number
) {
const latestCertDate = completedChallenges
.filter(chal => fullStackCertificateIds.includes(chal.id))
.sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate;
return latestCertDate ?? completedDate;
}
+49
View File
@@ -0,0 +1,49 @@
import _ from 'lodash';
// 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',
'isApisMicroservicesCert',
'isBackEndCert',
'isCheater',
'isCollegeAlgebraPyCertV8',
'isDataAnalysisPyCertV7',
'isDataVisCert',
// isDonating doesn't need fixing because it's not nullable
'isFoundationalCSharpCertV8',
'isFrontEndCert',
'isFullStackCert',
'isFrontEndLibsCert',
'isHonest',
'isInfosecCertV7',
'isInfosecQaCert',
'isJsAlgoDataStructCert',
'isJsAlgoDataStructCertV8',
'isMachineLearningPyCertV7',
'isQaCertV7',
'isRelationalDatabaseCertV8',
'isRespWebDesignCert',
'isSciCompPyCertV7',
'isDataAnalysisPyCertV7',
// 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)];
}
@@ -1,14 +1,13 @@
import { type PrismaPromise } from '@prisma/client';
import { Certification } from '../../../shared/config/certification-settings';
import { Certification } from '../../../../shared/config/certification-settings';
import {
defaultUserEmail,
defaultUserId,
devLogin,
setupServer,
superRequest
} from '../../jest.utils';
import { SHOW_UPCOMING_CHANGES } from '../utils/env';
import { getFallbackFullStackDate } from './certificate';
} from '../../../jest.utils';
import { SHOW_UPCOMING_CHANGES } from '../../utils/env';
describe('certificate routes', () => {
setupServer();
@@ -435,264 +434,4 @@ 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);
});
test('should return cert-not-found if there is no cert with that slug', async () => {
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);
});
});
});
});
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);
});
});
});
@@ -1,9 +1,8 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import type { CompletedChallenge } from '@prisma/client';
import isEmail from 'validator/lib/isEmail';
import { find } from 'lodash';
import { CompletedChallenge } from '@prisma/client';
import * as schemas from '../schemas';
import { getChallenges } from '../utils/get-challenges';
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { getChallenges } from '../../utils/get-challenges';
import {
certIds,
certSlugTypeMap,
@@ -12,13 +11,14 @@ import {
currentCertifications,
legacyCertifications,
legacyFullStackCertification,
certTypeIdMap,
completionHours,
oldDataVizId,
upcomingCertifications
} from '../../../shared/config/certification-settings';
import { normalizeChallenges, removeNulls } from '../utils/normalize';
import { SHOW_UPCOMING_CHANGES } from '../utils/env';
} from '../../../../shared/config/certification-settings';
import * as schemas from '../../schemas';
import { normalizeChallenges, removeNulls } from '../../utils/normalize';
import { SHOW_UPCOMING_CHANGES } from '../../utils/env';
import { isKnownCertSlug } from '../helpers/certificate-utils';
const {
legacyFrontEndChallengeId,
@@ -43,6 +43,198 @@ const {
upcomingPythonV8Id
} = certIds;
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 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 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 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)
};
}
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
};
}
/**
* Plugin for the protected certificate endpoints.
*
@@ -257,451 +449,3 @@ export const protectedCertificateRoutes: 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
},
async (req, reply) => {
const username = req.params.username.toLowerCase();
const certSlug = req.params.certSlug;
fastify.log.info(`certSlug: ${certSlug}`);
if (!isKnownCertSlug(certSlug)) {
void reply.code(404);
return reply.send({
messages: [
{
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]) {
return reply.send({
messages: [
{
type: 'info',
message: 'flash.user-not-certified',
variables: { username, cert: certTypeTitleMap[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 { 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
});
}
);
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 isKnownCertSlug(
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
};
}
/**
* 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: number }[],
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 ?? completedDate;
}
@@ -6,7 +6,7 @@ const mockVerifyTrophyWithMicrosoft = jest.fn();
import { omit } from 'lodash';
import { Static } from '@fastify/type-provider-typebox';
import { challengeTypes } from '../../../shared/config/challenge-types';
import { challengeTypes } from '../../../../shared/config/challenge-types';
import {
defaultUserId,
devLogin,
@@ -16,7 +16,7 @@ import {
defaultUserEmail,
createSuperRequest,
defaultUsername
} from '../../jest.utils';
} from '../../../jest.utils';
import {
completedExamChallengeOneCorrect,
completedExamChallengeTwoCorrect,
@@ -31,14 +31,14 @@ import {
examWithTwoCorrect,
examWithAllCorrect,
type ExamSubmission
} from '../../__mocks__/exam';
import { Answer } from '../utils/exam-types';
import type { getSessionUser } from '../schemas/user/get-session-user';
} from '../../../__mocks__/exam';
import { Answer } from '../../utils/exam-types';
import type { getSessionUser } from '../../schemas/user/get-session-user';
jest.mock('./helpers/challenge-helpers', () => {
jest.mock('../helpers/challenge-helpers', () => {
const originalModule = jest.requireActual<
typeof import('./helpers/challenge-helpers')
>('./helpers/challenge-helpers');
typeof import('../helpers/challenge-helpers')
>('../helpers/challenge-helpers');
return {
__esModule: true,
@@ -4,8 +4,8 @@ import { uniqBy } from 'lodash';
import { CompletedExam, ExamResults } from '@prisma/client';
import isURL from 'validator/lib/isURL';
import { challengeTypes } from '../../../shared/config/challenge-types';
import * as schemas from '../schemas';
import { challengeTypes } from '../../../../shared/config/challenge-types';
import * as schemas from '../../schemas';
import {
jsCertProjectIds,
multifileCertProjectIds,
@@ -14,25 +14,25 @@ import {
type CompletedChallenge,
saveUserChallengeData,
msTrophyChallenges
} from '../utils/common-challenge-functions';
import { JWT_SECRET } from '../utils/env';
} from '../../utils/common-challenge-functions';
import { JWT_SECRET } from '../../utils/env';
import {
formatCoderoadChallengeCompletedValidation,
formatProjectCompletedValidation
} from '../utils/error-formatting';
import { getChallenges } from '../utils/get-challenges';
import { ProgressTimestamp, getPoints } from '../utils/progress';
} from '../../utils/error-formatting';
import { getChallenges } from '../../utils/get-challenges';
import { ProgressTimestamp, getPoints } from '../../utils/progress';
import {
validateExamFromDbSchema,
validateGeneratedExamSchema,
validateUserCompletedExamSchema,
validateExamResultsSchema
} from '../utils/exam-schemas';
import { generateRandomExam, createExamResults } from '../utils/exam';
} from '../../utils/exam-schemas';
import { generateRandomExam, createExamResults } from '../../utils/exam';
import {
canSubmitCodeRoadCertProject,
verifyTrophyWithMicrosoft
} from './helpers/challenge-helpers';
} from '../helpers/challenge-helpers';
interface JwtPayload {
userToken: string;
@@ -3,11 +3,10 @@ import {
createSuperRequest,
devLogin,
setupServer,
superRequest,
defaultUserEmail,
defaultUserId
} from '../../jest.utils';
import { createUserInput } from '../utils/create-user';
} from '../../../jest.utils';
import { createUserInput } from '../../utils/create-user';
const testEWalletEmail = 'baz@bar.com';
const testSubscriptionId = 'sub_test_id';
@@ -490,49 +489,4 @@ describe('Donate', () => {
});
});
});
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);
});
});
});
@@ -1,13 +1,9 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import Stripe from 'stripe';
import {
donationSubscriptionConfig,
allStripeProductIdsArray
} from '../../../shared/config/donation-settings';
import * as schemas from '../schemas';
import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../utils/env';
import { inLastFiveMinutes } from '../utils/validate-donation';
import { findOrCreateUser } from './helpers/auth-helpers';
import * as schemas from '../../schemas';
import { donationSubscriptionConfig } from '../../../../shared/config/donation-settings';
import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../../utils/env';
/**
* Plugin for the donation endpoints requiring auth.
@@ -225,159 +221,3 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
done();
};
/**
* Plugin for public donation 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 chargeStripeRoute: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
// Stripe plugin
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
typescript: true
});
fastify.post(
'/donate/create-stripe-payment-intent',
{
schema: schemas.createStripePaymentIntent
},
async (req, reply) => {
const { email, name, amount, duration } = req.body;
if (!donationSubscriptionConfig.plans[duration].includes(amount)) {
void reply.code(400);
return {
error: 'The donation form had invalid values for this submission.'
} as const;
}
try {
const stripeCustomer = await stripe.customers.create({
email,
name
});
const stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}`
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
});
if (
stripeSubscription.latest_invoice &&
typeof stripeSubscription.latest_invoice !== 'string' &&
stripeSubscription.latest_invoice.payment_intent &&
typeof stripeSubscription.latest_invoice.payment_intent !==
'string' &&
stripeSubscription.latest_invoice.payment_intent.client_secret !==
null
) {
const clientSecret =
stripeSubscription.latest_invoice.payment_intent.client_secret;
return reply.send({
subscriptionId: stripeSubscription.id,
clientSecret
});
} else {
throw new Error('Stripe payment intent client secret is missing');
}
} catch (error) {
fastify.log.error(error);
fastify.Sentry.captureException(error);
void reply.code(500);
return reply.send({
error: 'Donation failed due to a server error.'
});
}
}
);
fastify.post(
'/donate/charge-stripe',
{
schema: schemas.chargeStripe
},
async (req, reply) => {
try {
const { email, amount, duration, subscriptionId } = req.body;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const isSubscriptionActive = subscription.status === 'active';
const productId = subscription.items.data[0]?.plan.product?.toString();
const isStartedRecently = inLastFiveMinutes(
subscription.current_period_start
);
const isProductIdValid =
productId && allStripeProductIdsArray.includes(productId);
const isValidCustomer = typeof subscription.customer === 'string';
if (!isSubscriptionActive)
throw new Error(
`Stripe subscription information is invalid: ${subscriptionId}`
);
if (!isProductIdValid)
throw new Error(`Product ID is invalid: ${subscriptionId}`);
if (!isStartedRecently)
throw new Error(`Subscription is not recent: ${subscriptionId}`);
if (!isValidCustomer)
throw new Error(`Customer ID is invalid: ${subscriptionId}`);
else {
// TODO(Post-MVP) new users should not be created if user is not found
const user = await findOrCreateUser(fastify, email);
const donation = {
userId: user.id,
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: subscription.customer as string,
// 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: user.id },
data: {
isDonating: true
}
});
return reply.send({
isDonating: true
});
}
} catch (error) {
fastify.log.error(error);
fastify.Sentry.captureException(error);
void reply.code(500);
return {
error: 'Donation failed due to a server error.'
} as const;
}
}
);
done();
};
+5
View File
@@ -0,0 +1,5 @@
export * from './certificate';
export * from './challenge';
export * from './donate';
export * from './settings';
export * from './user';
@@ -7,10 +7,10 @@ import {
createSuperRequest,
defaultUserId,
defaultUserEmail
} from '../../jest.utils';
import { formatMessage } from '../plugins/redirect-with-message';
import { createUserInput } from '../utils/create-user';
import { API_LOCATION, HOME_LOCATION } from '../utils/env';
} from '../../../jest.utils';
import { formatMessage } from '../../plugins/redirect-with-message';
import { createUserInput } from '../../utils/create-user';
import { API_LOCATION, HOME_LOCATION } from '../../utils/env';
import {
isPictureWithProtocol,
getWaitMessage,
@@ -17,12 +17,12 @@ import { ResolveFastifyReplyType } from 'fastify/types/type-provider';
import { differenceInMinutes } from 'date-fns';
import validator from 'validator';
import { isValidUsername } from '../../../shared/utils/validate';
import * as schemas from '../schemas';
import { createAuthToken, isExpired } from '../utils/tokens';
import { API_LOCATION } from '../utils/env';
import { getRedirectParams } from '../utils/redirection';
import { isRestricted } from './helpers/is-restricted';
import { isValidUsername } from '../../../../shared/utils/validate';
import * as schemas from '../../schemas';
import { createAuthToken, isExpired } from '../../utils/tokens';
import { API_LOCATION } from '../../utils/env';
import { getRedirectParams } from '../../utils/redirection';
import { isRestricted } from '../helpers/is-restricted';
const { isEmail } = validator;
@@ -6,7 +6,7 @@ import type { Prisma } from '@prisma/client';
import { ObjectId } from 'mongodb';
import _ from 'lodash';
import { createUserInput } from '../utils/create-user';
import { createUserInput } from '../../utils/create-user';
import {
defaultUserId,
defaultUserEmail,
@@ -14,9 +14,9 @@ import {
setupServer,
superRequest,
createSuperRequest
} from '../../jest.utils';
import { JWT_SECRET } from '../utils/env';
import { getMsTranscriptApiUrl, replacePrivateData } from './user';
} from '../../../jest.utils';
import { JWT_SECRET } from '../../utils/env';
import { getMsTranscriptApiUrl } from './user';
const mockedFetch = jest.fn();
jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
@@ -1162,228 +1162,6 @@ Thanks and regards,
});
});
});
describe('Public', () => {
let superGet: ReturnType<typeof createSuperRequest>;
beforeEach(() => {
superGet = createSuperRequest({ method: 'GET' });
});
describe('/api/users/get-public-profile', () => {
const profilelessUsername = 'profileless-user';
const lockedUsername = 'locked-user';
const publicUsername = 'public-user';
const lockedUserProfileUI = {
isLocked: true,
showAbout: true,
showPortfolio: false
};
const unlockedUserProfileUI = {
isLocked: false,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
};
const users = [profilelessUsername, lockedUsername, publicUsername];
beforeAll(async () => {
await fastifyTestInstance.prisma.user.create({
data: {
...minimalUserData,
email: profilelessUsername,
username: profilelessUsername
}
});
await fastifyTestInstance.prisma.user.create({
data: {
...minimalUserData,
email: lockedUsername,
username: lockedUsername,
profileUI: lockedUserProfileUI
}
});
await fastifyTestInstance.prisma.user.create({
data: {
...testUserData,
email: publicUsername,
username: publicUsername,
profileUI: unlockedUserProfileUI
}
});
});
afterAll(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: {
OR: users.map(username => ({ username }))
}
});
});
describe('GET', () => {
test('returns 400 status code if the user agent is blocked', async () => {
const response = await superGet(
'/api/users/get-public-profile?username=public-user'
).set('User-Agent', 'curl');
expect(response.text).toBe(
'This endpoint is no longer available outside of the freeCodeCamp ecosystem'
);
expect(response.statusCode).toBe(400);
});
test('returns 400 status code if the username param is missing', async () => {
const res = await superGet('/api/users/get-public-profile');
// TODO(Post-MVP): return something more informative
expect(res.body).toStrictEqual({});
expect(res.statusCode).toBe(400);
});
test('returns 400 status code if the username param is empty', async () => {
const res = await superGet('/api/users/get-public-profile?username=');
// TODO(Post-MVP): return something more informative
expect(res.body).toStrictEqual({});
expect(res.statusCode).toBe(400);
});
test('returns 404 status code for non-existent user', async () => {
const response = await superGet(
'/api/users/get-public-profile?username=non-existent'
);
// TODO(Post-MVP): return something more informative
expect(response.body).toStrictEqual({});
expect(response.statusCode).toBe(404);
});
test('returns 200 status code with a locked profile if the profile is private', async () => {
const response = await superGet(
`/api/users/get-public-profile?username=${lockedUsername}`
);
expect(response.body).toStrictEqual({
entities: {
user: {
[lockedUsername]: {
isLocked: true,
profileUI: lockedUserProfileUI,
username: lockedUsername
}
}
},
result: lockedUsername
});
expect(response.statusCode).toBe(200);
});
test('returns 200 status code locked profile if the profile is missing', async () => {
const response = await superGet(
`/api/users/get-public-profile?username=${profilelessUsername}`
);
expect(response.body).toStrictEqual({
entities: {
user: {
[profilelessUsername]: {
isLocked: true,
profileUI: lockedProfileUI,
username: profilelessUsername
}
}
},
result: profilelessUsername
});
expect(response.statusCode).toBe(200);
});
// TODO: create a list of public properties like the api-server and use that
// to restrict the output of this and get-session-user.
test('returns 200 status code with public user object', async () => {
const testUser =
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: publicUsername }
});
const response = await superGet(
`/api/users/get-public-profile?username=${publicUsername}`
);
// TODO: create a fixture for this without 'completedSurveys', ideally
// it should contain the entire body.
const publicUser = {
// TODO(Post-MVP, maybe): return completedSurveys?
..._.omit(publicUserData, 'completedSurveys'),
username: publicUsername,
joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(),
profileUI: unlockedUserProfileUI
};
expect(response.body).toStrictEqual({
entities: {
user: {
[publicUsername]: publicUser
}
},
result: publicUsername
});
expect(response.statusCode).toBe(200);
});
});
});
describe('GET /api/users/exists', () => {
beforeAll(async () => {
await fastifyTestInstance.prisma.user.create({
data: minimalUserData
});
});
it('should return { exists: true } with a 400 status code if the username param is missing or empty', async () => {
const res = await superGet('/api/users/exists');
expect(res.body).toStrictEqual({ exists: true });
expect(res.statusCode).toBe(400);
const res2 = await superGet('/api/users/exists?username=');
expect(res2.body).toStrictEqual({ exists: true });
expect(res2.statusCode).toBe(400);
});
it('should return { exists: true } if the username exists', async () => {
const res = await superGet('/api/users/exists?username=testuser');
expect(res.body).toStrictEqual({ exists: true });
expect(res.statusCode).toBe(200);
});
it('should ignore case when checking for username existence', async () => {
const res = await superGet('/api/users/exists?username=TeStUsEr');
expect(res.body).toStrictEqual({ exists: true });
expect(res.statusCode).toBe(200);
});
it('should return { exists: false } if the username does not exist', async () => {
const res = await superGet('/api/users/exists?username=nonexistent');
expect(res.body).toStrictEqual({ exists: false });
expect(res.statusCode).toBe(200);
});
it('should return { exists: true } if the username is restricted (ignoring case)', async () => {
const res = await superGet('/api/users/exists?username=pRofIle');
expect(res.body).toStrictEqual({ exists: true });
const res2 = await superGet('/api/users/exists?username=flAnge');
expect(res2.body).toStrictEqual({ exists: true });
});
});
});
});
describe('Microsoft helpers', () => {
@@ -1413,147 +1191,3 @@ describe('Microsoft helpers', () => {
});
});
});
describe('get-public-profile helpers', () => {
describe('replacePrivateData', () => {
const user = {
about: 'about',
calendar: { 1: 1, 2: 1 } as const,
completedChallenges: [
{ id: '123', completedDate: 123, files: [] },
{ id: '456', completedDate: 456, challengeType: 7, files: [] }
],
id: '5f5b1b3b1c9d440000d9e3b4',
isDonating: false,
location: 'location',
joinDate: 'joinDate',
name: 'name',
points: 2,
portfolio: [
{
id: '789',
title: 'portfolio',
url: 'url',
image: 'image',
description: 'description'
}
],
profileUI: {
isLocked: false,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
}
};
test(`returns "" for 'about' if showAbout is not true`, () => {
const userWithoutAbout = {
...user,
profileUI: { ...user.profileUI, showAbout: false }
};
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
about: ''
});
});
test('returns {} for calendar if showHeatMap is not true', () => {
const userWithoutHeatMap = {
...user,
profileUI: { ...user.profileUI, showHeatMap: false }
};
expect(replacePrivateData(userWithoutHeatMap).calendar).toEqual({});
});
test(`returns [] for completeChallenges if showTimeLine is not true`, () => {
const userWithoutTimeLine = {
...user,
profileUI: { ...user.profileUI, showTimeLine: false }
};
expect(replacePrivateData(userWithoutTimeLine)).toMatchObject({
completedChallenges: []
});
});
test('omits certifications from completedChallenges if showCerts is not true', () => {
const userWithoutCerts = {
...user,
profileUI: { ...user.profileUI, showCerts: false }
};
expect(replacePrivateData(userWithoutCerts)).toMatchObject({
completedChallenges: [{ id: '123', completedDate: 123, files: [] }]
});
});
test('returns null for isDonating if showDonation is not true', () => {
const userWithoutDonation = {
...user,
profileUI: { ...user.profileUI, showDonation: false }
};
expect(replacePrivateData(userWithoutDonation)).toMatchObject({
isDonating: null
});
});
test('returns "" for joinDate if showAbout is not true', () => {
const userWithoutAbout = {
...user,
profileUI: { ...user.profileUI, showAbout: false }
};
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
joinDate: ''
});
});
test(`returns "" for 'location' if showLocation is not true`, () => {
const userWithoutLocation = {
...user,
profileUI: { ...user.profileUI, showLocation: false }
};
expect(replacePrivateData(userWithoutLocation)).toMatchObject({
location: ''
});
});
test(`returns "" for 'name' if showName is not true`, () => {
const userWithoutName = {
...user,
profileUI: { ...user.profileUI, showName: false }
};
expect(replacePrivateData(userWithoutName)).toMatchObject({
name: ''
});
});
test('returns null for points if showPoints is not true', () => {
const userWithoutPoints = {
...user,
profileUI: { ...user.profileUI, showPoints: false }
};
expect(replacePrivateData(userWithoutPoints)).toMatchObject({
points: null
});
});
test('returns [] for portfolio if showPortfolio is not true', () => {
const userWithoutPortfolio = {
...user,
profileUI: { ...user.profileUI, showPortfolio: false }
};
expect(replacePrivateData(userWithoutPortfolio)).toMatchObject({
portfolio: []
});
});
test('returns the expected public user object if all showX flags are true', () => {
expect(replacePrivateData(user)).toEqual(
_.omit(user, ['id', 'profileUI'])
);
});
});
});
@@ -1,71 +1,26 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { Portfolio } from '@prisma/client';
import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { ObjectId } from 'mongodb';
import _ from 'lodash';
import * as schemas from '../schemas';
// Loopback creates a 64 character string for the user id, this customizes
// nanoid to do the same. Any unique key _should_ be fine, though.
import { customNanoid } from '../utils/ids';
import * as schemas from '../../schemas';
import { createResetProperties } from '../../utils/create-user';
import { customNanoid } from '../../utils/ids';
import { encodeUserToken } from '../../utils/tokens';
import { trimTags } from '../../utils/validation';
import { generateReportEmail } from '../../utils/email-templates';
import { splitUser } from '../helpers/user-utils';
import {
normalizeChallenges,
normalizeFlags,
normalizeProfileUI,
normalizeTwitter,
removeNulls,
normalizeSurveys,
NormalizedChallenge
} from '../utils/normalize';
normalizeTwitter,
removeNulls
} from '../../utils/normalize';
import {
Calendar,
getCalendar,
getPoints,
type ProgressTimestamp
} from '../utils/progress';
import { encodeUserToken } from '../utils/tokens';
import { trimTags } from '../utils/validation';
import { generateReportEmail } from '../utils/email-templates';
import { createResetProperties } from '../utils/create-user';
import { challengeTypes } from '../../../shared/config/challenge-types';
import { isRestricted } from './helpers/is-restricted';
// 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',
'isApisMicroservicesCert',
'isBackEndCert',
'isCheater',
'isCollegeAlgebraPyCertV8',
'isDataAnalysisPyCertV7',
'isDataVisCert',
// isDonating doesn't need fixing because it's not nullable
'isFoundationalCSharpCertV8',
'isFrontEndCert',
'isFullStackCert',
'isFrontEndLibsCert',
'isHonest',
'isInfosecCertV7',
'isInfosecQaCert',
'isJsAlgoDataStructCert',
'isJsAlgoDataStructCertV8',
'isMachineLearningPyCertV7',
'isQaCertV7',
'isRelationalDatabaseCertV8',
'isRespWebDesignCert',
'isSciCompPyCertV7',
'isDataAnalysisPyCertV7',
// isUpcomingPythonCertV8 exists in the db, but is not returned by the api-server
// TODO(Post-MVP): delete it from the db?
'keyboardShortcuts'
] as const;
const blockedUserAgentParts = ['python', 'google-apps-script', 'curl'];
type NullableFlag = (typeof nullableFlags)[number];
ProgressTimestamp
} from '../../utils/progress';
/**
* Helper function to get the api url from the shared transcript link.
@@ -510,8 +465,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
? encodeUserToken(userToken.id)
: undefined;
const flags = _.pick<typeof user, NullableFlag>(user, nullableFlags);
const rest = _.omit<typeof user, NullableFlag>(user, nullableFlags);
const [flags, rest] = splitUser(user);
const {
username,
@@ -570,220 +524,3 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
done();
};
type ProfileUI = Partial<{
isLocked: boolean;
showAbout: boolean;
showCerts: boolean;
showDonation: boolean;
showHeatMap: boolean;
showLocation: boolean;
showName: boolean;
showPoints: boolean;
showPortfolio: boolean;
showTimeLine: boolean;
}>;
type RawUser = {
about: string;
completedChallenges: NormalizedChallenge[];
calendar: Calendar;
id: string;
isDonating: boolean;
joinDate: string;
location: string;
name: string;
points: number;
portfolio: Portfolio[];
profileUI: ProfileUI;
};
/**
* Creates an object with the properties that are shared with the public.
* @param user The raw user object.
* @returns The shared user object.
*/
export const replacePrivateData = (user: RawUser) => {
const {
showAbout,
showHeatMap,
showCerts,
showDonation,
showLocation,
showName,
showPoints,
showPortfolio,
showTimeLine
} = user.profileUI;
return {
about: showAbout ? user.about : '',
calendar: showHeatMap ? user.calendar : {},
completedChallenges: showTimeLine
? showCerts
? user.completedChallenges
: user.completedChallenges.filter(
c => c.challengeType !== challengeTypes.step
)
: [],
isDonating: showDonation ? user.isDonating : null,
joinDate: showAbout ? user.joinDate : '',
location: showLocation ? user.location : '',
name: showName ? user.name : '',
points: showPoints ? user.points : null,
portfolio: showPortfolio ? user.portfolio : []
};
};
/**
* Plugin containing public GET routes for user account management. They are kept
* separate because they do not require CSRF protection or authorization.
*
* @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 userPublicGetRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
fastify.get(
'/api/users/get-public-profile',
{
schema: schemas.getPublicProfile,
onRequest: (req, reply, done) => {
const userAgent = req.headers['user-agent'];
if (
userAgent &&
blockedUserAgentParts.some(ua => userAgent.toLowerCase().includes(ua))
) {
void reply.code(400);
void reply.send(
'This endpoint is no longer available outside of the freeCodeCamp ecosystem'
);
}
done();
}
},
async (req, reply) => {
// TODO(Post-MVP): look for duplicates unless we can make username unique in the db.
const user = await fastify.prisma.user.findFirst({
where: { username: req.query.username }
// TODO: only select desired fields, then stop 'omit'ing the undesired
// ones.
});
if (!user) {
void reply.code(404);
return reply.send({});
}
const flags = _.pick<typeof user, NullableFlag>(user, nullableFlags);
const rest = _.omit<typeof user, NullableFlag>(user, nullableFlags);
const publicUser = _.omit(rest, [
'currentChallengeId',
'email',
'emailVerified',
'sendQuincyEmail',
'theme',
// keyboardShortcuts is included in flags.
// 'keyboardShortcuts',
'acceptedPrivacyTerms',
'progressTimestamps',
'unsubscribeId',
'donationEmails',
'externalId',
'usernameDisplay',
'isBanned'
]);
const normalizedProfileUI = normalizeProfileUI(user.profileUI);
void reply.code(200);
if (normalizedProfileUI.isLocked) {
// TODO(Post-MVP): just return isLocked: true and either a null user
// or no user at all. (see other TODO in the else branch below)
return reply.send({
entities: {
user: {
[user.username]: {
isLocked: true,
profileUI: normalizedProfileUI,
username: user.username
}
}
},
result: user.username
});
} else {
const progressTimestamps = user.progressTimestamps as
| ProgressTimestamp[]
| null;
const sharedUser = replacePrivateData({
...user,
calendar: getCalendar(progressTimestamps),
completedChallenges: normalizeChallenges(user.completedChallenges),
location: user.location ?? '',
joinDate: new ObjectId(user.id).getTimestamp().toISOString(),
name: user.name ?? '',
points: getPoints(progressTimestamps),
profileUI: normalizedProfileUI
});
const returnedUser = {
...removeNulls(publicUser),
...normalizeFlags(flags),
...sharedUser,
profileUI: normalizedProfileUI,
// TODO: should this always be returned? Shouldn't some privacy
// setting control it? Same applies to website, githubProfile,
// and linkedin.
twitter: normalizeTwitter(user.twitter),
yearsTopContributor: user.yearsTopContributor
};
return reply.send({
// TODO(Post-MVP): just return a user object (i.e. returnedUser) and
// isLocked: false. The there should be no need for Type.Union in the
// schema. Alternatively, have the user object be nullable and don't
// bother with isLocked.
entities: {
user: { [user.username]: returnedUser }
},
result: user.username
});
}
}
);
fastify.get(
'/api/users/exists',
{
schema: schemas.userExists,
attachValidation: true
},
async (req, reply) => {
if (req.validationError) {
void reply.code(400);
// TODO(Post-MVP): return a message telling the requester that their
// request was malformed.
return await reply.send({ exists: true });
}
const username = req.query.username.toLowerCase();
if (isRestricted(username)) return await reply.send({ exists: true });
const exists =
(await fastify.prisma.user.count({
where: { username }
})) > 0;
await reply.send({ exists });
}
);
done();
};
@@ -1,8 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { defaultUserEmail, setupServer, superRequest } from '../../jest.utils';
import { HOME_LOCATION } from '../utils/env';
import { nanoidCharSet } from '../utils/create-user';
import {
defaultUserEmail,
setupServer,
superRequest
} from '../../../jest.utils';
import { HOME_LOCATION } from '../../utils/env';
import { nanoidCharSet } from '../../utils/create-user';
describe('dev login', () => {
setupServer();
@@ -1,12 +1,12 @@
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
import { createAccessToken } from '../utils/tokens';
import { createAccessToken } from '../../utils/tokens';
import {
getPrefixedLandingPath,
getRedirectParams,
haveSamePath
} from '../utils/redirection';
import { findOrCreateUser } from './helpers/auth-helpers';
} from '../../utils/redirection';
import { findOrCreateUser } from '../helpers/auth-helpers';
const trimTrailingSlash = (str: string) =>
str.endsWith('/') ? str.slice(0, -1) : str;
@@ -3,8 +3,8 @@ import {
setupServer,
superRequest,
createSuperRequest
} from '../../jest.utils';
import { AUTH0_DOMAIN } from '../utils/env';
} from '../../../jest.utils';
import { AUTH0_DOMAIN } from '../../utils/env';
const mockedFetch = jest.fn();
jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
@@ -25,10 +25,10 @@ const mockAuth0ValidEmail = () => ({
json: () => ({ email: newUserEmail })
});
jest.mock('../utils/env', () => {
jest.mock('../../utils/env', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...jest.requireActual('../utils/env'),
...jest.requireActual('../../utils/env'),
FCC_ENABLE_DEV_LOGIN_MODE: false
};
});
@@ -9,10 +9,10 @@ import rateLimit, { type FastifyRateLimitStore } from '@fastify/rate-limit';
import MongoStoreRL from 'rate-limit-mongo';
import isEmail from 'validator/lib/isEmail';
import { AUTH0_DOMAIN, MONGOHQ_URL } from '../utils/env';
import { auth0Client } from '../plugins/auth0';
import { createAccessToken } from '../utils/tokens';
import { findOrCreateUser } from './helpers/auth-helpers';
import { AUTH0_DOMAIN, MONGOHQ_URL } from '../../utils/env';
import { auth0Client } from '../../plugins/auth0';
import { createAccessToken } from '../../utils/tokens';
import { findOrCreateUser } from '../helpers/auth-helpers';
const getEmailFromAuth0 = async (
req: FastifyRequest
+274
View File
@@ -0,0 +1,274 @@
import {
defaultUserEmail,
defaultUserId,
resetDefaultUser,
setupServer,
superRequest
} from '../../../jest.utils';
import { getFallbackFullStackDate } from '../helpers/certificate-utils';
describe('certificate routes', () => {
setupServer();
describe('Unauthenticated user', () => {
beforeAll(async () => resetDefaultUser());
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);
});
test('should return cert-not-found if there is no cert with that slug', async () => {
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);
});
});
});
});
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);
});
});
});
+238
View File
@@ -0,0 +1,238 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { find } from 'lodash';
import * as schemas from '../../schemas';
import {
certSlugTypeMap,
certTypeTitleMap,
certTypeIdMap,
completionHours,
oldDataVizId
} from '../../../../shared/config/certification-settings';
import {
getFallbackFullStackDate,
isKnownCertSlug
} from '../helpers/certificate-utils';
/**
* 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;
fastify.log.info(`certSlug: ${certSlug}`);
if (!isKnownCertSlug(certSlug)) {
void reply.code(404);
return reply.send({
messages: [
{
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]) {
return reply.send({
messages: [
{
type: 'info',
message: 'flash.user-not-certified',
variables: { username, cert: certTypeTitleMap[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 { 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
});
}
);
done();
};
@@ -1,6 +1,6 @@
import request from 'supertest';
import { setupServer } from '../../jest.utils';
import { setupServer } from '../../../jest.utils';
import { endpoints } from './deprecated-endpoints';
describe('Deprecated endpoints', () => {
@@ -1,6 +1,6 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import * as schemas from '../schemas';
import * as schemas from '../../schemas';
type Endpoints = [string, 'GET' | 'POST'][];
@@ -1,4 +1,4 @@
import { setupServer, superRequest } from '../../jest.utils';
import { setupServer, superRequest } from '../../../jest.utils';
import { unsubscribeEndpoints } from './deprecated-unsubscribe';
@@ -1,6 +1,6 @@
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { getRedirectParams } from '../utils/redirection';
import { getRedirectParams } from '../../utils/redirection';
type Endpoint = [string, 'GET' | 'POST'];
+139
View File
@@ -0,0 +1,139 @@
import { setupServer, superRequest } from '../../../jest.utils';
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 = jest.fn();
const mockAttachPaymentMethod = jest.fn(() =>
Promise.resolve({
id: 'pm_1MqLiJLkdIwHu7ixUEgbFdYF',
object: 'payment_method'
})
);
const mockCustomerCreate = jest.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 = jest.fn(() => Promise.resolve(mockSubRetrieveObj));
const mockCheckoutSessionCreate = jest.fn(() =>
Promise.resolve({ id: 'checkout_session_id' })
);
const mockCustomerUpdate = jest.fn();
const generateMockSubCreate = (status: string) => () =>
Promise.resolve({
id: testSubscriptionId,
latest_invoice: {
payment_intent: {
client_secret: 'superSecret',
status
}
}
});
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => {
return {
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);
});
});
});
+167
View File
@@ -0,0 +1,167 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '../../utils/env';
import {
donationSubscriptionConfig,
allStripeProductIdsArray
} from '../../../../shared/config/donation-settings';
import * as schemas from '../../schemas';
import { inLastFiveMinutes } from '../../utils/validate-donation';
import { findOrCreateUser } from '../helpers/auth-helpers';
/**
* Plugin for public donation 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 chargeStripeRoute: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
// Stripe plugin
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
typescript: true
});
fastify.post(
'/donate/create-stripe-payment-intent',
{
schema: schemas.createStripePaymentIntent
},
async (req, reply) => {
const { email, name, amount, duration } = req.body;
if (!donationSubscriptionConfig.plans[duration].includes(amount)) {
void reply.code(400);
return {
error: 'The donation form had invalid values for this submission.'
} as const;
}
try {
const stripeCustomer = await stripe.customers.create({
email,
name
});
const stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}`
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
});
if (
stripeSubscription.latest_invoice &&
typeof stripeSubscription.latest_invoice !== 'string' &&
stripeSubscription.latest_invoice.payment_intent &&
typeof stripeSubscription.latest_invoice.payment_intent !==
'string' &&
stripeSubscription.latest_invoice.payment_intent.client_secret !==
null
) {
const clientSecret =
stripeSubscription.latest_invoice.payment_intent.client_secret;
return reply.send({
subscriptionId: stripeSubscription.id,
clientSecret
});
} else {
throw new Error('Stripe payment intent client secret is missing');
}
} catch (error) {
fastify.log.error(error);
fastify.Sentry.captureException(error);
void reply.code(500);
return reply.send({
error: 'Donation failed due to a server error.'
});
}
}
);
fastify.post(
'/donate/charge-stripe',
{
schema: schemas.chargeStripe
},
async (req, reply) => {
try {
const { email, amount, duration, subscriptionId } = req.body;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const isSubscriptionActive = subscription.status === 'active';
const productId = subscription.items.data[0]?.plan.product?.toString();
const isStartedRecently = inLastFiveMinutes(
subscription.current_period_start
);
const isProductIdValid =
productId && allStripeProductIdsArray.includes(productId);
const isValidCustomer = typeof subscription.customer === 'string';
if (!isSubscriptionActive)
throw new Error(
`Stripe subscription information is invalid: ${subscriptionId}`
);
if (!isProductIdValid)
throw new Error(`Product ID is invalid: ${subscriptionId}`);
if (!isStartedRecently)
throw new Error(`Subscription is not recent: ${subscriptionId}`);
if (!isValidCustomer)
throw new Error(`Customer ID is invalid: ${subscriptionId}`);
else {
// TODO(Post-MVP) new users should not be created if user is not found
const user = await findOrCreateUser(fastify, email);
const donation = {
userId: user.id,
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: subscription.customer as string,
// 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: user.id },
data: {
isDonating: true
}
});
return reply.send({
isDonating: true
});
}
} catch (error) {
fastify.log.error(error);
fastify.Sentry.captureException(error);
void reply.code(500);
return {
error: 'Donation failed due to a server error.'
} as const;
}
}
);
done();
};
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Prisma } from '@prisma/client';
import { setupServer, superRequest } from '../../jest.utils';
import { HOME_LOCATION } from '../utils/env';
import { createUserInput } from '../utils/create-user';
import { setupServer, superRequest } from '../../../jest.utils';
import { HOME_LOCATION } from '../../utils/env';
import { createUserInput } from '../../utils/create-user';
const urlEncodedInfoMessage1 =
'?messages=info%5B0%5D%3DWe%2520could%2520not%2520find%2520an%2520account%2520to%2520unsubscribe.';
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import * as schemas from '../schemas';
import { getRedirectParams } from '../utils/redirection';
import * as schemas from '../../schemas';
import { getRedirectParams } from '../../utils/redirection';
/**
* Endpoints to set 'sendQuincyEmail' to true or false using 'unsubscribeId'.
+10
View File
@@ -0,0 +1,10 @@
export * from './auth-dev';
export * from './auth';
export * from './certificate';
export * from './deprecated-endpoints';
export * from './deprecated-unsubscribe';
export * from './donate';
export * from './email-subscription';
export * from './signout';
export * from './status';
export * from './user';
@@ -1,5 +1,5 @@
import { devLogin, setupServer, superRequest } from '../../jest.utils';
import { HOME_LOCATION } from '../utils/env';
import { devLogin, setupServer, superRequest } from '../../../jest.utils';
import { HOME_LOCATION } from '../../utils/env';
describe('GET /signout', () => {
setupServer();
@@ -1,6 +1,6 @@
import type { FastifyPluginCallback } from 'fastify';
import { getRedirectParams } from '../utils/redirection';
import { getRedirectParams } from '../../utils/redirection';
/**
* Route handler for signing out.
@@ -1,4 +1,4 @@
import { setupServer, superRequest } from '../../jest.utils';
import { setupServer, superRequest } from '../../../jest.utils';
describe('/status', () => {
setupServer();
+614
View File
@@ -0,0 +1,614 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { Prisma } from '@prisma/client';
import { ObjectId } from 'mongodb';
import _ from 'lodash';
import { createUserInput } from '../../utils/create-user';
import {
defaultUserEmail,
setupServer,
createSuperRequest
} from '../../../jest.utils';
import { getMsTranscriptApiUrl } from '../protected/user';
import { replacePrivateData } from './user';
const mockedFetch = jest.fn();
jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
// This is used to build a test user.
const testUserData: Prisma.userCreateInput = {
...createUserInput(defaultUserEmail),
username: 'foobar',
usernameDisplay: 'Foo Bar',
progressTimestamps: [1520002973119, 1520440323273],
completedChallenges: [
{
id: 'a6b0bb188d873cb2c8729495',
completedDate: 1520002973119,
solution: null,
challengeType: 5,
files: [
{
contents: 'test',
ext: 'js',
key: 'indexjs',
name: 'test',
path: 'path-test'
},
{
contents: 'test2',
ext: 'html',
key: 'html-test',
name: 'test2'
}
]
},
{
id: 'a5229172f011153519423690',
completedDate: 1520440323273,
solution: null,
challengeType: 5,
files: []
},
{
id: 'a5229172f011153519423692',
completedDate: 1520440323274,
githubLink: '',
challengeType: 5,
examResults: {
numberOfCorrectAnswers: 0,
numberOfQuestionsInExam: 0,
percentCorrect: 0,
passingPercent: 0,
passed: false,
examTimeInSeconds: 0
}
}
],
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
completedExams: [],
githubProfile: 'github.com/foobar',
website: 'https://www.freecodecamp.org',
donationEmails: ['an@add.ress'],
portfolio: [
{
description: 'A portfolio',
id: 'a6b0bb188d873cb2c8729495',
image: 'https://www.freecodecamp.org/cat.png',
title: 'A portfolio',
url: 'https://www.freecodecamp.org'
}
],
savedChallenges: [
{
id: 'a6b0bb188d873cb2c8729495',
lastSavedDate: 123,
files: [
{
contents: 'test-contents',
ext: 'js',
history: ['indexjs'],
key: 'indexjs',
name: 'test-name'
}
]
}
],
yearsTopContributor: ['2018'],
twitter: '@foobar',
linkedin: 'linkedin.com/foobar'
};
const minimalUserData: Prisma.userCreateInput = {
about: 'I am a test user',
acceptedPrivacyTerms: true,
email: testUserData.email,
emailVerified: true,
externalId: '1234567890',
isDonating: false,
picture: 'https://www.freecodecamp.org/cat.png',
sendQuincyEmail: true,
username: 'testuser',
unsubscribeId: '1234567890'
};
const lockedProfileUI = {
isLocked: true,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
};
const publicUserData = {
about: testUserData.about,
calendar: { 1520002973: 1, 1520440323: 1 },
// testUserData.completedChallenges, with nulls removed
completedChallenges: [
{
id: 'a6b0bb188d873cb2c8729495',
completedDate: 1520002973119,
challengeType: 5,
files: [
{
contents: 'test',
ext: 'js',
key: 'indexjs',
name: 'test',
path: 'path-test'
},
{
contents: 'test2',
ext: 'html',
key: 'html-test',
name: 'test2'
}
]
},
{
id: 'a5229172f011153519423690',
completedDate: 1520440323273,
challengeType: 5,
files: []
},
{
id: 'a5229172f011153519423692',
completedDate: 1520440323274,
githubLink: '',
challengeType: 5,
files: [],
examResults: {
numberOfCorrectAnswers: 0,
numberOfQuestionsInExam: 0,
percentCorrect: 0,
passingPercent: 0,
passed: false,
examTimeInSeconds: 0
}
}
],
completedExams: testUserData.completedExams,
completedSurveys: [], // TODO: add surveys
githubProfile: testUserData.githubProfile,
is2018DataVisCert: testUserData.is2018DataVisCert,
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
isApisMicroservicesCert: testUserData.isApisMicroservicesCert,
isBackEndCert: testUserData.isBackEndCert,
isCheater: testUserData.isCheater,
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
isDataVisCert: testUserData.isDataVisCert,
isDonating: testUserData.isDonating,
isFoundationalCSharpCertV8: testUserData.isFoundationalCSharpCertV8,
isFrontEndCert: testUserData.isFrontEndCert,
isFrontEndLibsCert: testUserData.isFrontEndLibsCert,
isFullStackCert: testUserData.isFullStackCert,
isHonest: testUserData.isHonest,
isInfosecCertV7: testUserData.isInfosecCertV7,
isInfosecQaCert: testUserData.isInfosecQaCert,
isJsAlgoDataStructCert: testUserData.isJsAlgoDataStructCert,
isJsAlgoDataStructCertV8: testUserData.isJsAlgoDataStructCertV8,
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
isQaCertV7: testUserData.isQaCertV7,
isRelationalDatabaseCertV8: testUserData.isRelationalDatabaseCertV8,
isRespWebDesignCert: testUserData.isRespWebDesignCert,
isSciCompPyCertV7: testUserData.isSciCompPyCertV7,
linkedin: testUserData.linkedin,
location: testUserData.location,
name: testUserData.name,
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
picture: testUserData.picture,
points: 2,
portfolio: testUserData.portfolio,
profileUI: testUserData.profileUI,
savedChallenges: testUserData.savedChallenges,
twitter: 'https://twitter.com/foobar',
username: testUserData.usernameDisplay, // It defaults to usernameDisplay
website: testUserData.website,
yearsTopContributor: testUserData.yearsTopContributor
};
describe('userRoutes', () => {
setupServer();
describe('Public', () => {
let superGet: ReturnType<typeof createSuperRequest>;
beforeEach(() => {
superGet = createSuperRequest({ method: 'GET' });
});
describe('/api/users/get-public-profile', () => {
const profilelessUsername = 'profileless-user';
const lockedUsername = 'locked-user';
const publicUsername = 'public-user';
const lockedUserProfileUI = {
isLocked: true,
showAbout: true,
showPortfolio: false
};
const unlockedUserProfileUI = {
isLocked: false,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
};
const users = [profilelessUsername, lockedUsername, publicUsername];
beforeAll(async () => {
await fastifyTestInstance.prisma.user.create({
data: {
...minimalUserData,
email: profilelessUsername,
username: profilelessUsername
}
});
await fastifyTestInstance.prisma.user.create({
data: {
...minimalUserData,
email: lockedUsername,
username: lockedUsername,
profileUI: lockedUserProfileUI
}
});
await fastifyTestInstance.prisma.user.create({
data: {
...testUserData,
email: publicUsername,
username: publicUsername,
profileUI: unlockedUserProfileUI
}
});
});
afterAll(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: {
OR: users.map(username => ({ username }))
}
});
});
describe('GET', () => {
test('returns 400 status code if the user agent is blocked', async () => {
const response = await superGet(
'/api/users/get-public-profile?username=public-user'
).set('User-Agent', 'curl');
expect(response.text).toBe(
'This endpoint is no longer available outside of the freeCodeCamp ecosystem'
);
expect(response.statusCode).toBe(400);
});
test('returns 400 status code if the username param is missing', async () => {
const res = await superGet('/api/users/get-public-profile');
// TODO(Post-MVP): return something more informative
expect(res.body).toStrictEqual({});
expect(res.statusCode).toBe(400);
});
test('returns 400 status code if the username param is empty', async () => {
const res = await superGet('/api/users/get-public-profile?username=');
// TODO(Post-MVP): return something more informative
expect(res.body).toStrictEqual({});
expect(res.statusCode).toBe(400);
});
test('returns 404 status code for non-existent user', async () => {
const response = await superGet(
'/api/users/get-public-profile?username=non-existent'
);
// TODO(Post-MVP): return something more informative
expect(response.body).toStrictEqual({});
expect(response.statusCode).toBe(404);
});
test('returns 200 status code with a locked profile if the profile is private', async () => {
const response = await superGet(
`/api/users/get-public-profile?username=${lockedUsername}`
);
expect(response.body).toStrictEqual({
entities: {
user: {
[lockedUsername]: {
isLocked: true,
profileUI: lockedUserProfileUI,
username: lockedUsername
}
}
},
result: lockedUsername
});
expect(response.statusCode).toBe(200);
});
test('returns 200 status code locked profile if the profile is missing', async () => {
const response = await superGet(
`/api/users/get-public-profile?username=${profilelessUsername}`
);
expect(response.body).toStrictEqual({
entities: {
user: {
[profilelessUsername]: {
isLocked: true,
profileUI: lockedProfileUI,
username: profilelessUsername
}
}
},
result: profilelessUsername
});
expect(response.statusCode).toBe(200);
});
// TODO: create a list of public properties like the api-server and use that
// to restrict the output of this and get-session-user.
test('returns 200 status code with public user object', async () => {
const testUser =
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: publicUsername }
});
const response = await superGet(
`/api/users/get-public-profile?username=${publicUsername}`
);
// TODO: create a fixture for this without 'completedSurveys', ideally
// it should contain the entire body.
const publicUser = {
// TODO(Post-MVP, maybe): return completedSurveys?
..._.omit(publicUserData, 'completedSurveys'),
username: publicUsername,
joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(),
profileUI: unlockedUserProfileUI
};
expect(response.body).toStrictEqual({
entities: {
user: {
[publicUsername]: publicUser
}
},
result: publicUsername
});
expect(response.statusCode).toBe(200);
});
});
});
describe('GET /api/users/exists', () => {
beforeAll(async () => {
await fastifyTestInstance.prisma.user.create({
data: minimalUserData
});
});
it('should return { exists: true } with a 400 status code if the username param is missing or empty', async () => {
const res = await superGet('/api/users/exists');
expect(res.body).toStrictEqual({ exists: true });
expect(res.statusCode).toBe(400);
const res2 = await superGet('/api/users/exists?username=');
expect(res2.body).toStrictEqual({ exists: true });
expect(res2.statusCode).toBe(400);
});
it('should return { exists: true } if the username exists', async () => {
const res = await superGet('/api/users/exists?username=testuser');
expect(res.body).toStrictEqual({ exists: true });
expect(res.statusCode).toBe(200);
});
it('should ignore case when checking for username existence', async () => {
const res = await superGet('/api/users/exists?username=TeStUsEr');
expect(res.body).toStrictEqual({ exists: true });
expect(res.statusCode).toBe(200);
});
it('should return { exists: false } if the username does not exist', async () => {
const res = await superGet('/api/users/exists?username=nonexistent');
expect(res.body).toStrictEqual({ exists: false });
expect(res.statusCode).toBe(200);
});
it('should return { exists: true } if the username is restricted (ignoring case)', async () => {
const res = await superGet('/api/users/exists?username=pRofIle');
expect(res.body).toStrictEqual({ exists: true });
const res2 = await superGet('/api/users/exists?username=flAnge');
expect(res2.body).toStrictEqual({ exists: true });
});
});
});
});
describe('Microsoft helpers', () => {
describe('getMsTranscriptApiUrl', () => {
const expectedUrl =
'https://learn.microsoft.com/api/profiles/transcript/share/8u6awert43q1plo';
const urlWithoutSlash =
'https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo';
const urlWithSlash = `${urlWithoutSlash}/`;
const urlWithQueryParams = `${urlWithoutSlash}?foo=bar`;
const urlWithQueryParamsAndSlash = `${urlWithSlash}?foo=bar`;
it('should extract the transcript id from the url', () => {
expect(getMsTranscriptApiUrl(urlWithoutSlash)).toBe(expectedUrl);
});
it('should handle trailing slashes', () => {
expect(getMsTranscriptApiUrl(urlWithSlash)).toBe(expectedUrl);
});
it('should ignore query params', () => {
expect(getMsTranscriptApiUrl(urlWithQueryParams)).toBe(expectedUrl);
expect(getMsTranscriptApiUrl(urlWithQueryParamsAndSlash)).toBe(
expectedUrl
);
});
});
});
describe('get-public-profile helpers', () => {
describe('replacePrivateData', () => {
const user = {
about: 'about',
calendar: { 1: 1, 2: 1 } as const,
completedChallenges: [
{ id: '123', completedDate: 123, files: [] },
{ id: '456', completedDate: 456, challengeType: 7, files: [] }
],
id: '5f5b1b3b1c9d440000d9e3b4',
isDonating: false,
location: 'location',
joinDate: 'joinDate',
name: 'name',
points: 2,
portfolio: [
{
id: '789',
title: 'portfolio',
url: 'url',
image: 'image',
description: 'description'
}
],
profileUI: {
isLocked: false,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
}
};
test(`returns "" for 'about' if showAbout is not true`, () => {
const userWithoutAbout = {
...user,
profileUI: { ...user.profileUI, showAbout: false }
};
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
about: ''
});
});
test('returns {} for calendar if showHeatMap is not true', () => {
const userWithoutHeatMap = {
...user,
profileUI: { ...user.profileUI, showHeatMap: false }
};
expect(replacePrivateData(userWithoutHeatMap).calendar).toEqual({});
});
test(`returns [] for completeChallenges if showTimeLine is not true`, () => {
const userWithoutTimeLine = {
...user,
profileUI: { ...user.profileUI, showTimeLine: false }
};
expect(replacePrivateData(userWithoutTimeLine)).toMatchObject({
completedChallenges: []
});
});
test('omits certifications from completedChallenges if showCerts is not true', () => {
const userWithoutCerts = {
...user,
profileUI: { ...user.profileUI, showCerts: false }
};
expect(replacePrivateData(userWithoutCerts)).toMatchObject({
completedChallenges: [{ id: '123', completedDate: 123, files: [] }]
});
});
test('returns null for isDonating if showDonation is not true', () => {
const userWithoutDonation = {
...user,
profileUI: { ...user.profileUI, showDonation: false }
};
expect(replacePrivateData(userWithoutDonation)).toMatchObject({
isDonating: null
});
});
test('returns "" for joinDate if showAbout is not true', () => {
const userWithoutAbout = {
...user,
profileUI: { ...user.profileUI, showAbout: false }
};
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
joinDate: ''
});
});
test(`returns "" for 'location' if showLocation is not true`, () => {
const userWithoutLocation = {
...user,
profileUI: { ...user.profileUI, showLocation: false }
};
expect(replacePrivateData(userWithoutLocation)).toMatchObject({
location: ''
});
});
test(`returns "" for 'name' if showName is not true`, () => {
const userWithoutName = {
...user,
profileUI: { ...user.profileUI, showName: false }
};
expect(replacePrivateData(userWithoutName)).toMatchObject({
name: ''
});
});
test('returns null for points if showPoints is not true', () => {
const userWithoutPoints = {
...user,
profileUI: { ...user.profileUI, showPoints: false }
};
expect(replacePrivateData(userWithoutPoints)).toMatchObject({
points: null
});
});
test('returns [] for portfolio if showPortfolio is not true', () => {
const userWithoutPortfolio = {
...user,
profileUI: { ...user.profileUI, showPortfolio: false }
};
expect(replacePrivateData(userWithoutPortfolio)).toMatchObject({
portfolio: []
});
});
test('returns the expected public user object if all showX flags are true', () => {
expect(replacePrivateData(user)).toEqual(
_.omit(user, ['id', 'profileUI'])
);
});
});
});
+240
View File
@@ -0,0 +1,240 @@
import { Portfolio } from '@prisma/client';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { ObjectId } from 'mongodb';
import _ from 'lodash';
import { isRestricted } from '../helpers/is-restricted';
import * as schemas from '../../schemas';
import { splitUser } from '../helpers/user-utils';
import {
normalizeChallenges,
NormalizedChallenge,
normalizeFlags,
normalizeProfileUI,
normalizeTwitter,
removeNulls
} from '../../utils/normalize';
import {
Calendar,
getCalendar,
getPoints,
ProgressTimestamp
} from '../../utils/progress';
import { challengeTypes } from '../../../../shared/config/challenge-types';
type ProfileUI = Partial<{
isLocked: boolean;
showAbout: boolean;
showCerts: boolean;
showDonation: boolean;
showHeatMap: boolean;
showLocation: boolean;
showName: boolean;
showPoints: boolean;
showPortfolio: boolean;
showTimeLine: boolean;
}>;
type RawUser = {
about: string;
completedChallenges: NormalizedChallenge[];
calendar: Calendar;
id: string;
isDonating: boolean;
joinDate: string;
location: string;
name: string;
points: number;
portfolio: Portfolio[];
profileUI: ProfileUI;
};
/**
* Creates an object with the properties that are shared with the public.
* @param user The raw user object.
* @returns The shared user object.
*/
export const replacePrivateData = (user: RawUser) => {
const {
showAbout,
showHeatMap,
showCerts,
showDonation,
showLocation,
showName,
showPoints,
showPortfolio,
showTimeLine
} = user.profileUI;
return {
about: showAbout ? user.about : '',
calendar: showHeatMap ? user.calendar : {},
completedChallenges: showTimeLine
? showCerts
? user.completedChallenges
: user.completedChallenges.filter(
c => c.challengeType !== challengeTypes.step
)
: [],
isDonating: showDonation ? user.isDonating : null,
joinDate: showAbout ? user.joinDate : '',
location: showLocation ? user.location : '',
name: showName ? user.name : '',
points: showPoints ? user.points : null,
portfolio: showPortfolio ? user.portfolio : []
};
};
const blockedUserAgentParts = ['python', 'google-apps-script', 'curl'];
/**
* Plugin containing public GET routes for user account management. They are kept
* separate because they do not require CSRF protection or authorization.
*
* @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 userPublicGetRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
fastify.get(
'/api/users/get-public-profile',
{
schema: schemas.getPublicProfile,
onRequest: (req, reply, done) => {
const userAgent = req.headers['user-agent'];
if (
userAgent &&
blockedUserAgentParts.some(ua => userAgent.toLowerCase().includes(ua))
) {
void reply.code(400);
void reply.send(
'This endpoint is no longer available outside of the freeCodeCamp ecosystem'
);
}
done();
}
},
async (req, reply) => {
// TODO(Post-MVP): look for duplicates unless we can make username unique in the db.
const user = await fastify.prisma.user.findFirst({
where: { username: req.query.username }
// TODO: only select desired fields, then stop 'omit'ing the undesired
// ones.
});
if (!user) {
void reply.code(404);
return reply.send({});
}
const [flags, rest] = splitUser(user);
const publicUser = _.omit(rest, [
'currentChallengeId',
'email',
'emailVerified',
'sendQuincyEmail',
'theme',
// keyboardShortcuts is included in flags.
// 'keyboardShortcuts',
'acceptedPrivacyTerms',
'progressTimestamps',
'unsubscribeId',
'donationEmails',
'externalId',
'usernameDisplay',
'isBanned'
]);
const normalizedProfileUI = normalizeProfileUI(user.profileUI);
void reply.code(200);
if (normalizedProfileUI.isLocked) {
// TODO(Post-MVP): just return isLocked: true and either a null user
// or no user at all. (see other TODO in the else branch below)
return reply.send({
entities: {
user: {
[user.username]: {
isLocked: true,
profileUI: normalizedProfileUI,
username: user.username
}
}
},
result: user.username
});
} else {
const progressTimestamps = user.progressTimestamps as
| ProgressTimestamp[]
| null;
const sharedUser = replacePrivateData({
...user,
calendar: getCalendar(progressTimestamps),
completedChallenges: normalizeChallenges(user.completedChallenges),
location: user.location ?? '',
joinDate: new ObjectId(user.id).getTimestamp().toISOString(),
name: user.name ?? '',
points: getPoints(progressTimestamps),
profileUI: normalizedProfileUI
});
const returnedUser = {
...removeNulls(publicUser),
...normalizeFlags(flags),
...sharedUser,
profileUI: normalizedProfileUI,
// TODO: should this always be returned? Shouldn't some privacy
// setting control it? Same applies to website, githubProfile,
// and linkedin.
twitter: normalizeTwitter(user.twitter),
yearsTopContributor: user.yearsTopContributor
};
return reply.send({
// TODO(Post-MVP): just return a user object (i.e. returnedUser) and
// isLocked: false. The there should be no need for Type.Union in the
// schema. Alternatively, have the user object be nullable and don't
// bother with isLocked.
entities: {
user: { [user.username]: returnedUser }
},
result: user.username
});
}
}
);
fastify.get(
'/api/users/exists',
{
schema: schemas.userExists,
attachValidation: true
},
async (req, reply) => {
if (req.validationError) {
void reply.code(400);
// TODO(Post-MVP): return a message telling the requester that their
// request was malformed.
return await reply.send({ exists: true });
}
const username = req.query.username.toLowerCase();
if (isRestricted(username)) return await reply.send({ exists: true });
const exists =
(await fastify.prisma.user.count({
where: { username }
})) > 0;
await reply.send({ exists });
}
);
done();
};