feat(api): add /user/get-session-user (#50557)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2023-07-11 18:28:56 +02:00
committed by GitHub
parent 09ae25aa82
commit 6e787d3336
13 changed files with 1026 additions and 127 deletions

View File

@@ -19,6 +19,7 @@
"fastify-plugin": "^4.3.0",
"jsonwebtoken": "9.0.1",
"nanoid": "3",
"mongodb": "4",
"nodemon": "2.0.22",
"query-string": "^7.1.3"
},

View File

@@ -5,6 +5,7 @@ import {
} from 'fastify';
import { AUTH0_DOMAIN } from '../utils/env';
import { defaultUser } from '../utils/default-user';
declare module 'fastify' {
interface Session {
@@ -14,60 +15,6 @@ declare module 'fastify' {
}
}
// TODO: this probably belongs in a separate file and may not be 100% correct.
// All it's doing is providing the properties required by the current schema.
const defaultUser = {
about: '',
acceptedPrivacyTerms: false,
completedChallenges: [],
currentChallengeId: '',
emailVerified: false,
externalId: '',
is2018DataVisCert: false,
is2018FullStackCert: false,
isApisMicroservicesCert: false,
isBackEndCert: false,
isBanned: false,
isCheater: false,
isDataAnalysisPyCertV7: false,
isDataVisCert: false,
isDonating: false,
isFrontEndCert: false,
isFrontEndLibsCert: false,
isFullStackCert: false,
isHonest: false,
isInfosecCertV7: false,
isInfosecQaCert: false,
isJsAlgoDataStructCert: false,
isMachineLearningPyCertV7: false,
isQaCertV7: false,
isRelationalDatabaseCertV8: false,
isRespWebDesignCert: false,
isSciCompPyCertV7: false,
keyboardShortcuts: false,
location: '',
name: '',
unsubscribeId: '',
picture: '',
profileUI: {
isLocked: false,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
},
progressTimestamps: [],
sendQuincyEmail: false,
theme: 'default',
// TODO: generate a UUID like in api-server
username: ''
};
const getEmailFromAuth0 = async (req: FastifyRequest) => {
const auth0Res = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
headers: {

View File

@@ -2,9 +2,219 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import jwt, { JwtPayload } from 'jsonwebtoken';
import type { Prisma } from '@prisma/client';
import { ObjectId } from 'mongodb';
import _ from 'lodash';
import { defaultUser } from '../utils/default-user';
import { setupServer, superRequest } from '../../jest.utils';
import { JWT_SECRET } from '../utils/env';
import { encodeUserToken } from '../utils/user-token';
// This is used to build a test user.
const testUserData: Prisma.userCreateInput = {
...defaultUser,
email: 'foo@bar.com',
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
}
],
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
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: 'abc123',
lastSavedDate: 123,
files: [
{
contents: 'test-contents',
ext: 'js',
history: ['indexjs'],
key: 'indexjs',
name: 'test-name'
}
]
}
],
sound: true,
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'
};
// These are not part of the schema, but are added to the user object by
// get-session-user's handler
const computedProperties = {
calendar: {},
completedChallengeCount: 0,
completedChallenges: [], // we don't need to provide an empty array, prisma will create it
isEmailVerified: minimalUserData.emailVerified,
points: 1,
portfolio: [],
yearsTopContributor: [],
// This is the default value if profileUI is missing. If individual properties
// are missing from the db, they will be omitted from the response.
profileUI: {
isLocked: true,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
}
};
// This is (most of) what we expect to get back from the API. The remaining
// properties are 'id' and 'joinDate', which are generated by the database.
// We're currently filtering properties with null values, since the old api just
// would not return those.
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: []
}
],
githubProfile: testUserData.githubProfile,
isApisMicroservicesCert: testUserData.isApisMicroservicesCert,
isBackEndCert: testUserData.isBackEndCert,
isCheater: testUserData.isCheater,
isDonating: testUserData.isDonating,
isEmailVerified: testUserData.emailVerified,
is2018DataVisCert: testUserData.is2018DataVisCert,
isDataVisCert: testUserData.isDataVisCert,
isFrontEndCert: testUserData.isFrontEndCert,
isFullStackCert: testUserData.isFullStackCert,
isFrontEndLibsCert: testUserData.isFrontEndLibsCert,
isHonest: testUserData.isHonest,
isInfosecQaCert: testUserData.isInfosecQaCert,
isQaCertV7: testUserData.isQaCertV7,
isInfosecCertV7: testUserData.isInfosecCertV7,
isJsAlgoDataStructCert: testUserData.isJsAlgoDataStructCert,
isRelationalDatabaseCertV8: testUserData.isRelationalDatabaseCertV8,
isRespWebDesignCert: testUserData.isRespWebDesignCert,
isSciCompPyCertV7: testUserData.isSciCompPyCertV7,
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
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,
username: testUserData.usernameDisplay, // It defaults to usernameDisplay
website: testUserData.website,
yearsTopContributor: testUserData.yearsTopContributor,
currentChallengeId: testUserData.currentChallengeId,
email: testUserData.email,
emailVerified: testUserData.emailVerified,
sendQuincyEmail: testUserData.sendQuincyEmail,
theme: testUserData.theme,
twitter: 'https://twitter.com/foobar',
sound: testUserData.sound,
keyboardShortcuts: testUserData.keyboardShortcuts,
completedChallengeCount: 3,
acceptedPrivacyTerms: testUserData.acceptedPrivacyTerms,
savedChallenges: testUserData.savedChallenges
};
const baseProgressData = {
currentChallengeId: '',
@@ -71,6 +281,11 @@ describe('userRoutes', () => {
});
describe('/account/reset-progress', () => {
afterAll(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: { email: 'foo@bar.com' }
});
});
test('POST returns 200 status code with empty object', async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
@@ -94,14 +309,18 @@ describe('userRoutes', () => {
});
});
describe('/user/user-token', () => {
let userId: string | undefined;
beforeEach(async () => {
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
userId = user?.id;
});
afterEach(async () => {
await fastifyTestInstance.prisma.userToken.deleteMany({
where: {
userId: user?.id
userId
}
});
});
@@ -175,6 +394,145 @@ describe('userRoutes', () => {
expect(await fastifyTestInstance.prisma.userToken.count()).toBe(1);
});
});
describe('user/get-user-session', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: testUserData.email },
data: testUserData
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.userToken.deleteMany({
where: { id: 'dummy-id' }
});
});
test('GET rejects with 500 status code if the username is missing', async () => {
await fastifyTestInstance?.prisma.user.updateMany({
where: { email: testUserData.email },
data: { username: '' }
});
const response = await superRequest('/user/get-session-user', {
method: 'GET',
setCookies
});
expect(response.body).toStrictEqual({ user: {}, result: '' });
expect(response.statusCode).toBe(500);
});
test('GET returns username as the result property', async () => {
const response = await superRequest('/user/get-session-user', {
method: 'GET',
setCookies
});
expect(response.body).toMatchObject({
result: testUserData.username
});
expect(response.statusCode).toBe(200);
});
test('GET returns the public user object', async () => {
// TODO: This gets the user from the database so that we can verify the
// joinDate. It feels like there should be a better way to do this.
const testUser = await fastifyTestInstance?.prisma.user.findFirst({
where: { email: testUserData.email }
});
const publicUser = {
...publicUserData,
id: testUser?.id,
joinDate: new ObjectId(testUser?.id).getTimestamp().toISOString()
};
const response = await superRequest('/user/get-session-user', {
method: 'GET',
setCookies
});
const {
user: { foobar }
} = response.body as unknown as {
user: { foobar: typeof publicUser };
};
expect(testUser).not.toBeNull();
expect(testUser?.id).not.toBeNull();
expect(foobar).toEqual(publicUser);
});
test('GET returns the userToken if it exists', async () => {
const testUser = await fastifyTestInstance.prisma.user.findFirstOrThrow(
{
where: { email: testUserData.email }
}
);
const tokenData = {
userId: testUser.id,
ttl: 123,
id: 'dummy-id',
created: new Date()
};
const encodedToken = encodeUserToken(tokenData.id);
await fastifyTestInstance.prisma.userToken.create({
data: tokenData
});
const response = await superRequest('/user/get-session-user', {
method: 'GET',
setCookies
});
const {
user: { foobar }
} = response.body as unknown as {
user: { foobar: unknown };
};
expect(foobar).toMatchObject({ userToken: encodedToken });
});
test('GET returns a minimal user when all optional properties are missing', async () => {
// To get a minimal test user we first delete the existing one...
await fastifyTestInstance.prisma.user.deleteMany({
where: {
email: minimalUserData.email
}
});
// ...then recreate it using only the properties that the schema
// requires. The alternative is to update, but that would require
// a lot of unsets (this is neater)
const testUser = await fastifyTestInstance.prisma.user.create({
data: minimalUserData
});
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
setCookies = res.get('Set-Cookie');
const publicUser = {
..._.omit(minimalUserData, ['externalId', 'unsubscribeId']),
...computedProperties,
id: testUser?.id,
joinDate: new ObjectId(testUser?.id).getTimestamp().toISOString()
};
const response = await superRequest('/user/get-session-user', {
method: 'GET',
setCookies
});
const {
user: { testuser }
} = response.body as unknown as {
user: { testuser: typeof publicUser };
};
expect(testuser).toStrictEqual(publicUser);
});
});
});
describe('Unauthenticated user', () => {
@@ -207,6 +565,17 @@ describe('userRoutes', () => {
});
});
describe('/user/get-user-session', () => {
test('GET returns 401 status code with error message', async () => {
const response = await superRequest('/user/get-session-user', {
method: 'GET',
setCookies
});
expect(response?.statusCode).toBe(401);
});
});
describe('/user/user-token', () => {
test('POST returns 401 status code with error message', async () => {
const response = await superRequest('/user/user-token', {

View File

@@ -1,7 +1,20 @@
import _, { isEmpty } from 'lodash';
import { ObjectId } from 'mongodb';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { customAlphabet } from 'nanoid';
import { schemas } from '../schemas';
import {
type ProgressTimestamp,
getCalendar,
getPoints
} from '../utils/progress';
import {
normalizeTwitter,
removeNulls,
normalizeProfileUI,
normalizeChallenges
} from '../utils/normalize';
import { encodeUserToken } from '../utils/user-token';
// Loopback creates a 64 character string for the user id, this customizes
@@ -101,6 +114,132 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
}
}
);
fastify.get(
'/user/get-session-user',
{
schema: schemas.getSessionUser
},
async (req, res) => {
try {
const userTokenP = fastify.prisma.userToken.findFirst({
where: { userId: req.session.user.id }
});
const userP = fastify.prisma.user.findUnique({
where: { id: req.session.user.id },
select: {
about: true,
acceptedPrivacyTerms: true,
completedChallenges: true,
currentChallengeId: true,
email: true,
emailVerified: true,
githubProfile: true,
id: true,
is2018DataVisCert: true,
isApisMicroservicesCert: true,
isBackEndCert: true,
isCheater: true,
isCollegeAlgebraPyCertV8: true,
isDataAnalysisPyCertV7: true,
isDataVisCert: true,
isDonating: true,
isFrontEndCert: true,
isFrontEndLibsCert: true,
isFullStackCert: true,
isHonest: true,
isInfosecCertV7: true,
isInfosecQaCert: true,
isJsAlgoDataStructCert: true,
isMachineLearningPyCertV7: true,
isQaCertV7: true,
isRelationalDatabaseCertV8: true,
isRespWebDesignCert: true,
isSciCompPyCertV7: true,
keyboardShortcuts: true,
linkedin: true,
location: true,
name: true,
partiallyCompletedChallenges: true,
picture: true,
portfolio: true,
profileUI: true,
progressTimestamps: true,
savedChallenges: true,
sendQuincyEmail: true,
sound: true,
theme: true,
twitter: true,
username: true,
usernameDisplay: true,
website: true,
yearsTopContributor: true
}
});
const [userToken, user] = await Promise.all([userTokenP, userP]);
if (!user?.username) {
void res.code(500);
return { user: {}, result: '' };
}
const encodedToken = userToken
? encodeUserToken(userToken.id)
: undefined;
const {
username,
usernameDisplay,
completedChallenges,
progressTimestamps,
twitter,
profileUI,
savedChallenges,
partiallyCompletedChallenges,
...publicUser
} = user;
return {
user: {
[username]: {
...removeNulls(publicUser),
completedChallenges: normalizeChallenges(completedChallenges),
completedChallengeCount: completedChallenges.length,
// This assertion is necessary until the database is normalized.
calendar: getCalendar(
progressTimestamps as ProgressTimestamp[] | null
),
partiallyCompletedChallenges: isEmpty(
partiallyCompletedChallenges
)
? undefined
: partiallyCompletedChallenges,
// This assertion is necessary until the database is normalized.
points: getPoints(
progressTimestamps as ProgressTimestamp[] | null
),
profileUI: normalizeProfileUI(profileUI),
savedChallenges: isEmpty(savedChallenges)
? undefined
: savedChallenges,
// TODO(Post-MVP) remove this and just use emailVerified
isEmailVerified: user.emailVerified,
joinDate: new ObjectId(user.id).getTimestamp().toISOString(),
twitter: normalizeTwitter(twitter),
username: usernameDisplay || username,
userToken: encodedToken
}
},
result: user.username
};
} catch (err) {
fastify.log.error(err);
void res.code(500);
return { user: {}, result: '' };
}
}
);
// TODO(Post-MVP): POST -> PUT
fastify.post('/user/user-token', async req => {

View File

@@ -7,38 +7,43 @@ import { schemas } from './schemas';
const ajv = new Ajv({ strictTypes: false });
const isSchemaSecure = ajv.compile(secureSchema);
// These schemas will fail the tests, so can only be checked by hand.
const ignoredSchemas = ['getSessionUser'];
describe('Schemas do not use obviously dangerous validation', () => {
Object.entries(schemas).forEach(([name, schema]) => {
describe(`schema ${name} is okay`, () => {
if ('body' in schema) {
test('body is secure', () => {
expect(isSchemaSecure(schema.body)).toBeTruthy();
});
}
Object.entries(schemas)
.filter(([schema]) => !ignoredSchemas.includes(schema))
.forEach(([name, schema]) => {
describe(`schema ${name} is okay`, () => {
if ('body' in schema) {
test('body is secure', () => {
expect(isSchemaSecure(schema.body)).toBeTruthy();
});
}
if ('querystring' in schema) {
test('querystring is secure', () => {
expect(isSchemaSecure(schema.querystring)).toBeTruthy();
});
}
if ('querystring' in schema) {
test('querystring is secure', () => {
expect(isSchemaSecure(schema.querystring)).toBeTruthy();
});
}
if ('params' in schema) {
test('params is secure', () => {
expect(isSchemaSecure(schema.params)).toBeTruthy();
});
}
if ('params' in schema) {
test('params is secure', () => {
expect(isSchemaSecure(schema.params)).toBeTruthy();
});
}
if ('headers' in schema) {
test('headers is secure', () => {
expect(isSchemaSecure(schema.headers)).toBeTruthy();
});
}
if ('headers' in schema) {
test('headers is secure', () => {
expect(isSchemaSecure(schema.headers)).toBeTruthy();
});
}
Object.entries(schema.response).forEach(([code, codeSchema]) => {
test(`response ${code} is secure`, () => {
expect(isSchemaSecure(codeSchema)).toBeTruthy();
Object.entries(schema.response).forEach(([code, codeSchema]) => {
test(`response ${code} is secure`, () => {
expect(isSchemaSecure(codeSchema)).toBeTruthy();
});
});
});
});
});
});

View File

@@ -184,6 +184,134 @@ export const schemas = {
})
}
},
getSessionUser: {
response: {
200: Type.Object({
user: Type.Record(
Type.String(),
Type.Object({
about: Type.String(),
acceptedPrivacyTerms: Type.Boolean(),
calendar: Type.Record(Type.Number(), Type.Literal(1)),
completedChallenges: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
solution: Type.Optional(Type.String()),
githubLink: Type.Optional(Type.String()),
challengeType: Type.Optional(Type.Number()),
// Technically, files is optional, but the db default was [] and
// the client treats null, undefined and [] equivalently.
// TODO(Post-MVP): make this optional.
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
path: Type.Optional(Type.String())
})
),
isManuallyApproved: Type.Optional(Type.Boolean())
})
),
completedChallengeCount: Type.Number(),
currentChallengeId: Type.Optional(Type.String()),
email: Type.String(),
emailVerified: Type.Boolean(),
githubProfile: Type.Optional(Type.String()),
id: Type.String(),
isApisMicroservicesCert: Type.Optional(Type.Boolean()),
isBackEndCert: Type.Optional(Type.Boolean()),
isCheater: Type.Optional(Type.Boolean()),
isDonating: Type.Boolean(),
is2018DataVisCert: Type.Optional(Type.Boolean()),
isDataVisCert: Type.Optional(Type.Boolean()),
isFrontEndCert: Type.Optional(Type.Boolean()),
isFullStackCert: Type.Optional(Type.Boolean()),
isFrontEndLibsCert: Type.Optional(Type.Boolean()),
isHonest: Type.Optional(Type.Boolean()),
isInfosecCertV7: Type.Optional(Type.Boolean()),
isInfosecQaCert: Type.Optional(Type.Boolean()),
isQaCertV7: Type.Optional(Type.Boolean()),
isJsAlgoDataStructCert: Type.Optional(Type.Boolean()),
isRelationalDatabaseCertV8: Type.Optional(Type.Boolean()),
isRespWebDesignCert: Type.Optional(Type.Boolean()),
isSciCompPyCertV7: Type.Optional(Type.Boolean()),
isDataAnalysisPyCertV7: Type.Optional(Type.Boolean()),
isMachineLearningPyCertV7: Type.Optional(Type.Boolean()),
isCollegeAlgebraPyCertV8: Type.Optional(Type.Boolean()),
keyboardShortcuts: Type.Optional(Type.Boolean()),
linkedin: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
name: Type.Optional(Type.String()),
partiallyCompletedChallenges: Type.Optional(
Type.Array(
Type.Object({ id: Type.String(), completedDate: Type.Number() })
)
),
picture: Type.String(), // TODO(Post-MVP): format as url/uri?
points: Type.Number(),
portfolio: Type.Array(
Type.Object({
description: Type.String(),
id: Type.String(),
image: Type.String(),
title: Type.String(),
url: Type.String()
})
),
profileUI: Type.Optional(
Type.Object({
isLocked: Type.Optional(Type.Boolean()),
showAbout: Type.Optional(Type.Boolean()),
showCerts: Type.Optional(Type.Boolean()),
showDonation: Type.Optional(Type.Boolean()),
showHeatMap: Type.Optional(Type.Boolean()),
showLocation: Type.Optional(Type.Boolean()),
showName: Type.Optional(Type.Boolean()),
showPoints: Type.Optional(Type.Boolean()),
showPortfolio: Type.Optional(Type.Boolean()),
showTimeLine: Type.Optional(Type.Boolean())
})
),
sendQuincyEmail: Type.Boolean(),
theme: Type.Optional(Type.String()),
twitter: Type.Optional(Type.String()),
website: Type.Optional(Type.String()),
yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number?
sound: Type.Optional(Type.Boolean()),
isEmailVerified: Type.Boolean(),
joinDate: Type.String(),
savedChallenges: Type.Optional(
Type.Array(
Type.Object({
id: Type.String(),
lastSavedDate: Type.Number(),
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
history: Type.Array(Type.String())
})
)
})
)
),
username: Type.String(),
userToken: Type.Optional(Type.String())
})
),
result: Type.String()
}),
500: Type.Object({
user: Type.Object({}),
result: Type.Literal('')
})
}
},
// Deprecated endpoints:
deprecatedEndpoints: {
response: {

View File

@@ -0,0 +1,55 @@
// TODO: audit this object to find out which properties need to be updated.
import { type Prisma } from '@prisma/client';
export const defaultUser: Omit<Prisma.userCreateInput, 'email'> = {
about: '',
acceptedPrivacyTerms: false,
completedChallenges: [],
currentChallengeId: '',
emailVerified: true, // this should be true until a user changes their email address
externalId: '',
is2018DataVisCert: false,
is2018FullStackCert: false,
isApisMicroservicesCert: false,
isBackEndCert: false,
isBanned: false,
isCheater: false,
isDataAnalysisPyCertV7: false,
isDataVisCert: false,
isDonating: false,
isFrontEndCert: false,
isFrontEndLibsCert: false,
isFullStackCert: false,
isHonest: false,
isInfosecCertV7: false,
isInfosecQaCert: false,
isJsAlgoDataStructCert: false,
isMachineLearningPyCertV7: false,
isQaCertV7: false,
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
isRespWebDesignCert: false,
isSciCompPyCertV7: false,
keyboardShortcuts: false,
location: '',
name: '',
unsubscribeId: '',
picture: '',
profileUI: {
isLocked: false,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
},
progressTimestamps: [],
sendQuincyEmail: false,
theme: 'default',
// TODO: generate a UUID like in api-server
username: ''
};

View File

@@ -0,0 +1,140 @@
import {
normalizeTwitter,
normalizeProfileUI,
normalizeChallenges
} from './normalize';
describe('normalize', () => {
describe('normalizeTwitter', () => {
it('returns the input if it is a url', () => {
const url = 'https://twitter.com/a_generic_user';
expect(normalizeTwitter(url)).toEqual(url);
});
it('adds the handle to twitter.com if it is not a url', () => {
const handle = '@a_generic_user';
expect(normalizeTwitter(handle)).toEqual(
'https://twitter.com/a_generic_user'
);
});
it('returns undefined if that is the input', () => {
expect(normalizeTwitter('')).toBeUndefined();
});
});
const profileUIInput = {
isLocked: true,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
};
const defaultProfileUI = {
isLocked: true,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
};
describe('normalizeProfileUI', () => {
it('should return the input if it is not null', () => {
expect(normalizeProfileUI(profileUIInput)).toEqual(profileUIInput);
});
it('should return the default profileUI if the input is null', () => {
const input = null;
expect(normalizeProfileUI(input)).toEqual(defaultProfileUI);
});
it('should convert all "null" values to "undefined"', () => {
const input = {
isLocked: null,
showAbout: false,
showCerts: null,
showDonation: null,
showHeatMap: null,
showLocation: null,
showName: null,
showPoints: null,
showPortfolio: null,
showTimeLine: null
};
expect(normalizeProfileUI(input)).toEqual({
isLocked: undefined,
showAbout: false,
showCerts: undefined,
showDonation: undefined,
showHeatMap: undefined,
showLocation: undefined,
showName: undefined,
showPoints: undefined,
showPortfolio: undefined,
showTimeLine: undefined
});
});
});
describe('normalizeChallenges', () => {
it('should remove null values from the input', () => {
const completedChallenges = [
{
id: 'a6b0bb188d873cb2c8729495',
completedDate: 1520002973119,
challengeType: 5,
solution: null,
githubLink: null,
isManuallyApproved: null,
files: [
{
contents: 'test',
ext: 'js',
key: 'indexjs',
name: 'test',
path: 'path-test'
},
{
contents: 'test2',
ext: 'html',
key: 'html-test',
name: 'test2',
path: null
}
]
}
];
expect(normalizeChallenges(completedChallenges)).toEqual([
{
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'
}
]
}
]);
});
});
});

View File

@@ -0,0 +1,83 @@
/* This module's job is to parse the database output and prepare it for
serialization */
import { ProfileUI, CompletedChallenge } from '@prisma/client';
import _ from 'lodash';
type NullToUndefined<T> = T extends null ? undefined : T;
type NoNullProperties<T> = {
[P in keyof T]: NullToUndefined<T[P]>;
};
export const normalizeTwitter = (
handleOrUrl: string | null
): string | undefined => {
if (!handleOrUrl) return undefined;
let url;
try {
new URL(handleOrUrl);
} catch {
url = `https://twitter.com/${handleOrUrl.replace(/^@/, '')}`;
}
return url ?? handleOrUrl;
};
export const normalizeProfileUI = (
maybeProfileUI: ProfileUI | null
): NoNullProperties<ProfileUI> => {
return maybeProfileUI
? removeNulls(maybeProfileUI)
: {
isLocked: true,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
};
};
export const removeNulls = <T extends Record<string, unknown>>(
obj: T
): NoNullProperties<T> =>
_.pickBy(obj, value => value !== null) as NoNullProperties<T>;
type NormalizedFile = {
contents: string;
ext: string;
key: string;
name: string;
path?: string;
};
type NormalizedChallenge = {
challengeType?: number;
completedDate: number;
files: NormalizedFile[];
githubLink?: string;
id: string;
isManuallyApproved?: boolean;
solution?: string;
};
export const normalizeChallenges = (
completedChallenges: CompletedChallenge[]
): NormalizedChallenge[] => {
const noNullProps = completedChallenges.map(challenge =>
removeNulls(challenge)
);
// files.path is optional
const noNullPath = noNullProps.map(challenge => {
const { files, ...rest } = challenge;
const noNullFiles = files?.map(file => removeNulls(file));
return { ...rest, files: noNullFiles };
});
return noNullPath;
};

View File

@@ -0,0 +1,39 @@
import { getCalendar, getPoints } from './progress';
describe('utils/progress', () => {
describe('getCalendar', () => {
it('should return an empty object if no timestamps are passed', () => {
expect(getCalendar([])).toEqual({});
expect(getCalendar(null)).toEqual({});
});
it('should take timestamps and return a calendar object', () => {
const timestamps = [-1111001, 0, 1111000, 1111500, 1113000, 9999999];
expect(getCalendar(timestamps)).toEqual({
'-1112': 1,
0: 1,
1111: 1,
1113: 1,
9999: 1
});
});
it('should handle null, { timestamp: number } and float entries', () => {
const timestamps = [null, { timestamp: 1113000 }, 1111000.5];
expect(getCalendar(timestamps)).toEqual({
1111: 1,
1113: 1
});
});
});
describe('getPoints', () => {
it('should return 1 if there are no progressTimestamps', () => {
expect(getPoints(null)).toEqual(1);
});
it('should return then number of progressTimestamps if there are any', () => {
expect(getPoints([0, 1, 2])).toEqual(3);
});
});
});

24
api/src/utils/progress.ts Normal file
View File

@@ -0,0 +1,24 @@
export type ProgressTimestamp = number | { timestamp: number } | null;
export const getCalendar = (
progressTimestamps: ProgressTimestamp[] | null
): Record<string, 1> => {
const calendar: Record<string, 1> = {};
progressTimestamps?.forEach(progress => {
if (progress === null) return;
if (typeof progress === 'number') {
calendar[Math.floor(progress / 1000)] = 1;
} else {
calendar[Math.floor(progress.timestamp / 1000)] = 1;
}
});
return calendar;
};
export const getPoints = (
progressTimestamps: ProgressTimestamp[] | null
): number => {
return progressTimestamps?.length ?? 1;
};

View File

@@ -38,7 +38,9 @@ export function standardizeRequestBody({
return {
contents,
ext,
history,
history, // TODO(Post-MVP): stop sending history, if possible. The client
// already gets it from the curriculum, so it should not be necessary to
// save it in the db.
key: fileKey,
name
};

57
pnpm-lock.yaml generated
View File

@@ -213,6 +213,9 @@ importers:
jsonwebtoken:
specifier: 9.0.1
version: 9.0.1
mongodb:
specifier: '4'
version: 4.16.0
nanoid:
specifier: '3'
version: 3.3.4
@@ -2679,13 +2682,13 @@ packages:
dependencies:
'@babel/core': 7.20.12
'@babel/helper-annotate-as-pure': 7.18.6
'@babel/helper-environment-visitor': 7.22.5
'@babel/helper-function-name': 7.22.5
'@babel/helper-environment-visitor': 7.22.1
'@babel/helper-function-name': 7.21.0
'@babel/helper-member-expression-to-functions': 7.22.3
'@babel/helper-optimise-call-expression': 7.18.6
'@babel/helper-replace-supers': 7.22.1
'@babel/helper-skip-transparent-expression-wrappers': 7.20.0
'@babel/helper-split-export-declaration': 7.22.6
'@babel/helper-split-export-declaration': 7.18.6
transitivePeerDependencies:
- supports-color
@@ -2697,13 +2700,13 @@ packages:
dependencies:
'@babel/core': 7.22.8
'@babel/helper-annotate-as-pure': 7.18.6
'@babel/helper-environment-visitor': 7.22.5
'@babel/helper-function-name': 7.22.5
'@babel/helper-environment-visitor': 7.22.1
'@babel/helper-function-name': 7.21.0
'@babel/helper-member-expression-to-functions': 7.22.3
'@babel/helper-optimise-call-expression': 7.18.6
'@babel/helper-replace-supers': 7.22.1
'@babel/helper-skip-transparent-expression-wrappers': 7.20.0
'@babel/helper-split-export-declaration': 7.22.6
'@babel/helper-split-export-declaration': 7.18.6
transitivePeerDependencies:
- supports-color
@@ -2895,7 +2898,6 @@ packages:
/@babel/helper-environment-visitor@7.22.1:
resolution: {integrity: sha512-Z2tgopurB/kTbidvzeBrc2To3PUP/9i5MUe+fU6QJCQDyPwSH2oRapkLw3KGECDYSjhQZCNxEvNvZlLw8JjGwA==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-environment-visitor@7.22.5:
resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==}
@@ -2907,7 +2909,6 @@ packages:
dependencies:
'@babel/template': 7.22.5
'@babel/types': 7.22.5
dev: true
/@babel/helper-function-name@7.22.5:
resolution: {integrity: sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==}
@@ -3092,7 +3093,6 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.22.5
dev: true
/@babel/helper-split-export-declaration@7.22.5:
resolution: {integrity: sha512-thqK5QFghPKWLhAV321lxF95yCg2K3Ob5yw+M3VHWfdia0IkPXUtoLH8x/6Fh486QUvzhb8YOWHChTVen2/PoQ==}
@@ -15168,7 +15168,7 @@ packages:
express-session: ^1.17.1
dependencies:
express-session: 1.17.3
mongodb: 3.7.3
mongodb: 3.6.9
transitivePeerDependencies:
- aws4
- bson-ext
@@ -23819,7 +23819,7 @@ packages:
bson: 1.1.6
debug: 3.2.7(supports-color@8.1.1)
loopback-connector: 4.11.1
mongodb: 3.7.3
mongodb: 3.6.9
strong-globalize: 4.1.3
transitivePeerDependencies:
- aws4
@@ -25267,39 +25267,6 @@ packages:
saslprep: 1.0.3
dev: false
/mongodb@3.7.3:
resolution: {integrity: sha512-Psm+g3/wHXhjBEktkxXsFMZvd3nemI0r3IPsE0bU+4//PnvNWKkzhZcEsbPcYiWqe8XqXJJEg4Tgtr7Raw67Yw==}
engines: {node: '>=4'}
peerDependencies:
aws4: '*'
bson-ext: '*'
kerberos: '*'
mongodb-client-encryption: '*'
mongodb-extjson: '*'
snappy: '*'
peerDependenciesMeta:
aws4:
optional: true
bson-ext:
optional: true
kerberos:
optional: true
mongodb-client-encryption:
optional: true
mongodb-extjson:
optional: true
snappy:
optional: true
dependencies:
bl: 2.2.1
bson: 1.1.6
denque: 1.5.1
optional-require: 1.1.8
safe-buffer: 5.2.1
optionalDependencies:
saslprep: 1.0.3
dev: false
/mongodb@4.16.0:
resolution: {integrity: sha512-0EB113Fsucaq1wsY0dOhi1fmZOwFtLOtteQkiqOXGklvWMnSH3g2QS53f0KTP+/6qOkuoXE2JksubSZNmxeI+g==}
engines: {node: '>=12.9.0'}
@@ -28410,7 +28377,7 @@ packages:
/rate-limit-mongo@2.3.2:
resolution: {integrity: sha512-dLck0j5N/AX9ycVHn5lX9Ti2Wrrwi1LfbXitu/mMBZOo2nC26RgYKJVbcb2mYgb9VMaPI2IwJVzIa2hAQrMaDA==}
dependencies:
mongodb: 3.7.3
mongodb: 3.6.9
twostep: 0.4.2
underscore: 1.12.1
transitivePeerDependencies: