mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
refactor: separate public and private plugins (#56359)
This commit is contained in:
committed by
GitHub
parent
b0ab8450f1
commit
274680dbdb
+6
-2
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { setupServer, superRequest } from '../../jest.utils';
|
||||
import { setupServer, superRequest } from '../../../jest.utils';
|
||||
|
||||
import { unsubscribeEndpoints } from './deprecated-unsubscribe';
|
||||
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
+3
-3
@@ -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'.
|
||||
@@ -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();
|
||||
@@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user